Næste version
This commit is contained in:
@@ -148,21 +148,98 @@ class LibraryWatcher:
|
||||
self._running = False
|
||||
|
||||
def add_library(self, path: str) -> int:
|
||||
"""Tilføj et nyt bibliotek og start overvågning af det med det samme."""
|
||||
"""Tilføj et nyt bibliotek — scanner i baggrundstråd med egen DB-forbindelse."""
|
||||
library_id = add_library(path)
|
||||
|
||||
if self._observer and self._running:
|
||||
handler = MusicLibraryHandler(library_id, self.on_change)
|
||||
self._observer.schedule(handler, path, recursive=True)
|
||||
logger.info(f"Tilføjet bibliotek: {path}")
|
||||
|
||||
# Scan det nye bibliotek i baggrunden
|
||||
threading.Thread(
|
||||
target=self._full_scan_library,
|
||||
args=(library_id, path),
|
||||
daemon=True,
|
||||
).start()
|
||||
# Scan i baggrundstråd med daemon=True så den ikke blokerer programlukning
|
||||
def _scan_in_background(lib_id, lib_path):
|
||||
try:
|
||||
import sqlite3
|
||||
from local.local_db import DB_PATH, is_supported, get_file_modified_at
|
||||
from local.tag_reader import read_tags
|
||||
import os
|
||||
|
||||
# Åbn egen forbindelse — deler ikke med GUI-tråden
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
base = Path(lib_path)
|
||||
if not self._path_accessible(base):
|
||||
conn.close()
|
||||
return
|
||||
|
||||
known = {
|
||||
row["local_path"]: row["file_modified_at"]
|
||||
for row in conn.execute(
|
||||
"SELECT local_path, file_modified_at FROM songs WHERE library_id=?",
|
||||
(lib_id,)
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
processed = 0
|
||||
for dirpath, _, filenames in os.walk(str(base), followlinks=False):
|
||||
for filename in filenames:
|
||||
file_path = Path(dirpath) / filename
|
||||
if not is_supported(file_path):
|
||||
continue
|
||||
path_str = str(file_path)
|
||||
disk_modified = get_file_modified_at(file_path)
|
||||
if path_str not in known or known[path_str] != disk_modified:
|
||||
try:
|
||||
tags = read_tags(file_path)
|
||||
tags["library_id"] = lib_id
|
||||
# Upsert via direkte SQL på denne forbindelse
|
||||
import uuid, json
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM songs WHERE local_path=?",
|
||||
(path_str,)
|
||||
).fetchone()
|
||||
extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False)
|
||||
if existing:
|
||||
conn.execute("""
|
||||
UPDATE songs SET library_id=?, title=?, artist=?,
|
||||
album=?, bpm=?, duration_sec=?, file_format=?,
|
||||
file_modified_at=?, file_missing=0, extra_tags=?
|
||||
WHERE id=?
|
||||
""", (lib_id, tags.get("title",""), tags.get("artist",""),
|
||||
tags.get("album",""), tags.get("bpm",0),
|
||||
tags.get("duration_sec",0), tags.get("file_format",""),
|
||||
disk_modified, extra, existing["id"]))
|
||||
song_id = existing["id"]
|
||||
else:
|
||||
song_id = str(uuid.uuid4())
|
||||
conn.execute("""
|
||||
INSERT INTO songs (id, library_id, local_path, title,
|
||||
artist, album, bpm, duration_sec, file_format,
|
||||
file_modified_at, extra_tags)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (song_id, lib_id, path_str, tags.get("title",""),
|
||||
tags.get("artist",""), tags.get("album",""),
|
||||
tags.get("bpm",0), tags.get("duration_sec",0),
|
||||
tags.get("file_format",""), disk_modified, extra))
|
||||
conn.commit()
|
||||
processed += 1
|
||||
if self.on_change:
|
||||
self.on_change("upserted", path_str, song_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Scan fejl: {file_path}: {e}")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE libraries SET last_full_scan=datetime('now') WHERE id=?",
|
||||
(lib_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Bibliotek scannet: {lib_path} — {processed} filer")
|
||||
except Exception as e:
|
||||
logger.error(f"Baggrunds-scan fejl: {e}")
|
||||
|
||||
t = threading.Thread(target=_scan_in_background, args=(library_id, path), daemon=True)
|
||||
t.start()
|
||||
return library_id
|
||||
|
||||
def remove_library(self, library_id: int):
|
||||
@@ -182,69 +259,159 @@ class LibraryWatcher:
|
||||
self._observer.schedule(handler, str(path), recursive=True)
|
||||
|
||||
def _full_scan_all(self):
|
||||
"""Kør fuld scan på alle aktive biblioteker."""
|
||||
"""Kør fuld scan på alle aktive biblioteker — i baggrundstråde."""
|
||||
for lib in get_libraries(active_only=True):
|
||||
path = Path(lib["path"])
|
||||
if path.exists():
|
||||
self._full_scan_library(lib["id"], str(path))
|
||||
t = threading.Thread(
|
||||
target=self._full_scan_library,
|
||||
args=(lib["id"], str(path)),
|
||||
daemon=True
|
||||
)
|
||||
t.start()
|
||||
|
||||
def _full_scan_library(self, library_id: int, library_path: str):
|
||||
"""
|
||||
Sammenligner filer på disk med SQLite og synkroniserer forskelle.
|
||||
Håndterer utilgængelige mapper og symlinks sikkert.
|
||||
"""
|
||||
"""Scan ét bibliotek med sin egen SQLite-forbindelse — blokerer aldrig GUI."""
|
||||
import sqlite3, uuid, json, os
|
||||
from local.local_db import DB_PATH
|
||||
from local.tag_reader import read_tags, is_supported, get_file_modified_at
|
||||
|
||||
logger.info(f"Fuld scan starter: {library_path}")
|
||||
base = Path(library_path)
|
||||
|
||||
# Tjek at mappen faktisk er tilgængelig — med timeout
|
||||
if not self._path_accessible(base):
|
||||
logger.warning(f"Bibliotek ikke tilgængeligt (timeout eller ingen adgang): {library_path}")
|
||||
logger.warning(f"Bibliotek ikke tilgængeligt: {library_path}")
|
||||
return
|
||||
|
||||
known = get_all_song_paths_for_library(library_id)
|
||||
try:
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Hent kendte stier og modified-tider
|
||||
known = {
|
||||
row["local_path"]: row["file_modified_at"]
|
||||
for row in conn.execute(
|
||||
"SELECT local_path, file_modified_at FROM songs WHERE library_id=?",
|
||||
(library_id,)
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
found_paths = set()
|
||||
processed = 0
|
||||
errors = 0
|
||||
|
||||
import os
|
||||
for dirpath, dirnames, filenames in os.walk(
|
||||
str(base), followlinks=False,
|
||||
onerror=lambda e: logger.warning(f"Adgang nægtet: {e}")
|
||||
):
|
||||
for dirpath, _, filenames in os.walk(str(base), followlinks=False):
|
||||
for filename in filenames:
|
||||
file_path = Path(dirpath) / filename
|
||||
try:
|
||||
if not is_supported(file_path):
|
||||
continue
|
||||
path_str = str(file_path)
|
||||
found_paths.add(path_str)
|
||||
try:
|
||||
disk_modified = get_file_modified_at(file_path)
|
||||
if path_str in known and known[path_str] == disk_modified:
|
||||
continue # uændret — skip
|
||||
|
||||
if path_str not in known or known[path_str] != disk_modified:
|
||||
tags = read_tags(file_path)
|
||||
tags["library_id"] = library_id
|
||||
upsert_song(tags)
|
||||
extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM songs WHERE local_path=?", (path_str,)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
song_id = existing["id"]
|
||||
conn.execute("""
|
||||
UPDATE songs SET library_id=?, title=?, artist=?,
|
||||
album=?, bpm=?, duration_sec=?, file_format=?,
|
||||
file_modified_at=?, file_missing=0, extra_tags=?
|
||||
WHERE id=?
|
||||
""", (library_id, tags.get("title",""), tags.get("artist",""),
|
||||
tags.get("album",""), tags.get("bpm",0),
|
||||
tags.get("duration_sec",0), tags.get("file_format",""),
|
||||
disk_modified, extra, song_id))
|
||||
else:
|
||||
song_id = str(uuid.uuid4())
|
||||
conn.execute("""
|
||||
INSERT INTO songs (id, library_id, local_path, title,
|
||||
artist, album, bpm, duration_sec, file_format,
|
||||
file_modified_at, extra_tags)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (song_id, library_id, path_str,
|
||||
tags.get("title",""), tags.get("artist",""),
|
||||
tags.get("album",""), tags.get("bpm",0),
|
||||
tags.get("duration_sec",0), tags.get("file_format",""),
|
||||
disk_modified, extra))
|
||||
|
||||
# Danse fra fil — merge med eksisterende niveau
|
||||
file_dances = tags.get("dances", [])
|
||||
if file_dances:
|
||||
existing_dances = {
|
||||
row["name"].lower(): row
|
||||
for row in conn.execute("""
|
||||
SELECT d.id, d.name, sd.dance_order
|
||||
FROM song_dances sd JOIN dances d ON d.id=sd.dance_id
|
||||
WHERE sd.song_id=?
|
||||
""", (song_id,)).fetchall()
|
||||
}
|
||||
for i, dance_name in enumerate(file_dances, 1):
|
||||
if not dance_name:
|
||||
continue
|
||||
name_lower = dance_name.lower()
|
||||
if name_lower in existing_dances:
|
||||
conn.execute(
|
||||
"UPDATE song_dances SET dance_order=? "
|
||||
"WHERE song_id=? AND dance_id=?",
|
||||
(i, song_id, existing_dances[name_lower]["id"])
|
||||
)
|
||||
else:
|
||||
# Opret dans uden niveau
|
||||
d = conn.execute(
|
||||
"SELECT id FROM dances WHERE name=? COLLATE NOCASE "
|
||||
"AND level_id IS NULL", (dance_name,)
|
||||
).fetchone()
|
||||
if not d:
|
||||
conn.execute(
|
||||
"INSERT INTO dances (name, level_id, source) "
|
||||
"VALUES (?,NULL,'local')", (dance_name,)
|
||||
)
|
||||
d = conn.execute(
|
||||
"SELECT id FROM dances WHERE name=? COLLATE NOCASE "
|
||||
"AND level_id IS NULL", (dance_name,)
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO song_dances "
|
||||
"(song_id, dance_id, dance_order) VALUES (?,?,?)",
|
||||
(song_id, d["id"], i)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
processed += 1
|
||||
if self.on_change:
|
||||
self.on_change("upserted", path_str, None)
|
||||
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
|
||||
missing_count = 0
|
||||
# Markér forsvundne filer
|
||||
for known_path in known:
|
||||
if known_path not in found_paths:
|
||||
mark_song_missing(known_path)
|
||||
missing_count += 1
|
||||
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)
|
||||
|
||||
update_library_scan_time(library_id)
|
||||
logger.info(
|
||||
f"Scan færdig: {library_path} — "
|
||||
f"{processed} opdateret, {missing_count} mangler, {errors} fejl"
|
||||
conn.execute(
|
||||
"UPDATE libraries SET last_full_scan=datetime('now') WHERE id=?",
|
||||
(library_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Scan færdig: {library_path} — {processed} opdateret")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scan fejl: {library_path}: {e}")
|
||||
|
||||
def _path_accessible(self, path: Path, timeout_sec: float = 5.0) -> bool:
|
||||
"""Tjek om en sti er tilgængelig inden for timeout."""
|
||||
|
||||
@@ -232,6 +232,25 @@ MIGRATIONS: dict[int, list[str]] = {
|
||||
SELECT DISTINCT dance_name, level_id, 'local'
|
||||
FROM song_dances WHERE dance_name IS NOT NULL AND dance_name != ''""",
|
||||
],
|
||||
3: [
|
||||
"ALTER TABLE playlists ADD COLUMN tags TEXT NOT NULL DEFAULT ''",
|
||||
],
|
||||
4: [
|
||||
"ALTER TABLE dances ADD COLUMN choreographer TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE dances ADD COLUMN video_url TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE dances ADD COLUMN stepsheet_url TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE dances ADD COLUMN notes TEXT NOT NULL DEFAULT ''",
|
||||
],
|
||||
5: [
|
||||
# Workshop-markering på sang+dans kombination (ikke dans alene)
|
||||
"""ALTER TABLE song_dances ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""",
|
||||
"""ALTER TABLE song_alt_dances ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""",
|
||||
],
|
||||
6: [
|
||||
# Workshop og dans-valg på selve playlist-sangen
|
||||
"""ALTER TABLE playlist_songs ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""",
|
||||
"""ALTER TABLE playlist_songs ADD COLUMN dance_override TEXT NOT NULL DEFAULT ''""",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -283,11 +302,12 @@ def get_libraries(active_only: bool = True) -> list[sqlite3.Row]:
|
||||
|
||||
def remove_library(library_id: int):
|
||||
with get_db() as conn:
|
||||
# Marker sange som manglende
|
||||
# Marker sange som manglende og løsriv dem fra biblioteket
|
||||
conn.execute(
|
||||
"UPDATE songs SET file_missing=1 WHERE library_id=?", (library_id,)
|
||||
"UPDATE songs SET file_missing=1, library_id=NULL WHERE library_id=?",
|
||||
(library_id,)
|
||||
)
|
||||
# Slet biblioteket helt
|
||||
# Nu kan biblioteket slettes uden FK-konflikt
|
||||
conn.execute("DELETE FROM libraries WHERE id=?", (library_id,))
|
||||
|
||||
|
||||
@@ -452,20 +472,70 @@ def get_all_song_paths_for_library(library_id: int) -> dict[str, str]:
|
||||
|
||||
# ── Afspilningslister ─────────────────────────────────────────────────────────
|
||||
|
||||
def create_playlist(name: str, description: str = "") -> int:
|
||||
def create_playlist(name: str, description: str = "", tags: str = "") -> int:
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO playlists (name, description) VALUES (?,?)",
|
||||
(name, description)
|
||||
"INSERT INTO playlists (name, description, tags) VALUES (?,?,?)",
|
||||
(name, description, tags)
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_playlists() -> list[sqlite3.Row]:
|
||||
def update_playlist_tags(playlist_id: int, tags: str):
|
||||
with get_db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM playlists ORDER BY created_at DESC"
|
||||
conn.execute(
|
||||
"UPDATE playlists SET tags=? WHERE id=?",
|
||||
(tags, playlist_id)
|
||||
)
|
||||
|
||||
|
||||
def get_all_playlist_tags() -> list[str]:
|
||||
"""Returnerer alle unikke tags på tværs af alle playlists, sorteret alfabetisk."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT tags FROM playlists WHERE tags != '' AND name != ?",
|
||||
("__aktiv__",)
|
||||
).fetchall()
|
||||
tags = set()
|
||||
for row in rows:
|
||||
for tag in row["tags"].split(","):
|
||||
t = tag.strip().lower()
|
||||
if t:
|
||||
tags.add(t)
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
def get_playlists(tag_filter: str | None = None) -> list[sqlite3.Row]:
|
||||
"""Hent alle navngivne playlists med sang-antal. Filtrer på tag hvis angivet."""
|
||||
with get_db() as conn:
|
||||
if tag_filter:
|
||||
rows = conn.execute("""
|
||||
SELECT p.*, COUNT(ps.id) as song_count
|
||||
FROM playlists p
|
||||
LEFT JOIN playlist_songs ps ON ps.playlist_id = p.id
|
||||
WHERE p.name != ? AND (
|
||||
p.tags LIKE ? OR p.tags LIKE ? OR
|
||||
p.tags LIKE ? OR p.tags = ?
|
||||
)
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC
|
||||
""", (
|
||||
"__aktiv__",
|
||||
f"{tag_filter},%",
|
||||
f"%, {tag_filter},%",
|
||||
f"%, {tag_filter}",
|
||||
tag_filter,
|
||||
)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("""
|
||||
SELECT p.*, COUNT(ps.id) as song_count
|
||||
FROM playlists p
|
||||
LEFT JOIN playlist_songs ps ON ps.playlist_id = p.id
|
||||
WHERE p.name != ?
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC
|
||||
""", ("__aktiv__",)).fetchall()
|
||||
return rows
|
||||
|
||||
|
||||
def add_song_to_playlist(playlist_id: int, song_id: str, position: int | None = None) -> int:
|
||||
@@ -554,6 +624,29 @@ def clear_event_state():
|
||||
|
||||
# ── Dans-entitet funktioner ───────────────────────────────────────────────────
|
||||
|
||||
def get_dance(dance_id: int) -> sqlite3.Row | None:
|
||||
with get_db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM dances WHERE id=?", (dance_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def update_dance_info(dance_id: int, choreographer: str = "",
|
||||
video_url: str = "", stepsheet_url: str = "",
|
||||
notes: str = ""):
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
UPDATE dances SET
|
||||
choreographer = ?,
|
||||
video_url = ?,
|
||||
stepsheet_url = ?,
|
||||
notes = ?
|
||||
WHERE id = ?
|
||||
""", (choreographer.strip(), video_url.strip(),
|
||||
stepsheet_url.strip(), notes.strip(), dance_id))
|
||||
|
||||
|
||||
|
||||
def get_or_create_dance(name: str, level_id: int | None,
|
||||
conn=None) -> int:
|
||||
"""Find eller opret en dans (name + level_id kombination).
|
||||
@@ -605,11 +698,12 @@ def get_dance_suggestions(prefix: str, limit: int = 20) -> list[dict]:
|
||||
|
||||
|
||||
def get_dances_for_song(song_id: str) -> list[dict]:
|
||||
"""Hent hoveddanse for en sang med niveau-info."""
|
||||
"""Hent hoveddanse for en sang med niveau-info og workshop-flag."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT d.id as dance_id, d.name, d.level_id,
|
||||
dl.name as level_name, sd.dance_order, sd.id as song_dance_id
|
||||
dl.name as level_name, sd.dance_order,
|
||||
sd.id as song_dance_id, sd.is_workshop
|
||||
FROM song_dances sd
|
||||
JOIN dances d ON d.id = sd.dance_id
|
||||
LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
||||
|
||||
@@ -8,7 +8,6 @@ Start:
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Sørg for at rodmappen er i Python-stien
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
@@ -21,7 +20,13 @@ def main():
|
||||
app.setApplicationName("LineDance Player")
|
||||
app.setOrganizationName("LineDance")
|
||||
|
||||
apply_theme(app, dark=True)
|
||||
# Indlæs sprog fra indstillinger
|
||||
from ui.settings_dialog import load_settings
|
||||
from translations import load_language
|
||||
settings = load_settings()
|
||||
load_language(settings.get("language", "da"))
|
||||
|
||||
apply_theme(app, dark=settings.get("dark_theme", True))
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
@@ -63,7 +63,9 @@ class Player(QObject):
|
||||
self._demo_mode = False
|
||||
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
media = self._instance.media_new(path)
|
||||
# Konverter GVFS SMB-sti til SMB URL som VLC kan tilgå direkte
|
||||
vlc_path = self._resolve_path(path)
|
||||
media = self._instance.media_new(vlc_path)
|
||||
self._media_player.set_media(media)
|
||||
self._media_player.audio_set_volume(self._volume)
|
||||
|
||||
@@ -71,6 +73,33 @@ class Player(QObject):
|
||||
self.time_changed.emit(0, self._duration)
|
||||
self.state_changed.emit("stopped")
|
||||
|
||||
def _resolve_path(self, path: str) -> str:
|
||||
"""Konverter platform-specifikke netværksstier til URL'er VLC kan bruge."""
|
||||
import re, sys
|
||||
|
||||
# Linux GVFS SMB: /run/user/1000/gvfs/smb-share:server=X,share=Y/sti/fil.mp3
|
||||
m = re.match(r".*/gvfs/smb-share:server=([^,]+),share=([^/]+)(/.+)$", path)
|
||||
if m:
|
||||
server, share, rest = m.group(1), m.group(2), m.group(3)
|
||||
return f"smb://{server}/{share}{rest}"
|
||||
|
||||
# Windows UNC: \\server\share\sti\fil.mp3
|
||||
if path.startswith("\\\\"):
|
||||
# \\server\share\rest → smb://server/share/rest
|
||||
parts = path.replace("\\", "/").lstrip("/").split("/", 2)
|
||||
if len(parts) >= 2:
|
||||
server = parts[0]
|
||||
share = parts[1]
|
||||
rest = "/" + parts[2] if len(parts) > 2 else ""
|
||||
return f"smb://{server}/{share}{rest}"
|
||||
|
||||
# Lokale stier og drevbogstaver (C:\...) — VLC håndterer dem fint
|
||||
return path
|
||||
|
||||
self.position_changed.emit(0.0)
|
||||
self.time_changed.emit(0, self._duration)
|
||||
self.state_changed.emit("stopped")
|
||||
|
||||
# ── Transport ─────────────────────────────────────────────────────────────
|
||||
|
||||
def play(self):
|
||||
|
||||
24
linedance-app/setup.sh
Executable file
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 (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QWidget,
|
||||
QPushButton, QListWidget, QListWidgetItem, QMessageBox,
|
||||
QFrame, QSizePolicy,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QThread
|
||||
from PyQt6.QtGui import QColor
|
||||
|
||||
|
||||
class BpmScanWorker(QThread):
|
||||
progress = pyqtSignal(int, int) # done, total
|
||||
finished = pyqtSignal(int) # antal scannet
|
||||
|
||||
def __init__(self, library_id: int, scan_all: bool = False):
|
||||
super().__init__()
|
||||
self._library_id = library_id
|
||||
self._scan_all = scan_all # False = kun manglende, True = alle
|
||||
|
||||
def run(self):
|
||||
import sqlite3
|
||||
from local.local_db import DB_PATH
|
||||
from local.tag_reader import analyze_bpm
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
if self._scan_all:
|
||||
songs = conn.execute(
|
||||
"SELECT id, local_path FROM songs WHERE library_id=? AND file_missing=0",
|
||||
(self._library_id,)
|
||||
).fetchall()
|
||||
else:
|
||||
songs = conn.execute(
|
||||
"SELECT id, local_path FROM songs "
|
||||
"WHERE library_id=? AND file_missing=0 AND (bpm IS NULL OR bpm=0)",
|
||||
(self._library_id,)
|
||||
).fetchall()
|
||||
|
||||
total = len(songs)
|
||||
done = 0
|
||||
for song in songs:
|
||||
if self.isInterruptionRequested():
|
||||
break
|
||||
try:
|
||||
bpm = analyze_bpm(song["local_path"])
|
||||
if bpm:
|
||||
conn.execute(
|
||||
"UPDATE songs SET bpm=? WHERE id=?",
|
||||
(int(round(bpm)), song["id"])
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
done += 1
|
||||
self.progress.emit(done, total)
|
||||
|
||||
conn.close()
|
||||
self.finished.emit(done)
|
||||
|
||||
|
||||
class LibraryManagerDialog(QDialog):
|
||||
library_removed = pyqtSignal(int) # library_id
|
||||
library_removed = pyqtSignal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Administrer musikbiblioteker")
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(320)
|
||||
self.setMinimumWidth(580)
|
||||
self.setMinimumHeight(360)
|
||||
self._bpm_workers = {} # library_id → BpmScanWorker
|
||||
self._build_ui()
|
||||
self._load()
|
||||
|
||||
@@ -29,8 +83,10 @@ class LibraryManagerDialog(QDialog):
|
||||
lbl.setObjectName("track_meta")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
self._list = QListWidget()
|
||||
layout.addWidget(self._list)
|
||||
self._libs_layout = QVBoxLayout()
|
||||
self._libs_layout.setSpacing(6)
|
||||
layout.addLayout(self._libs_layout)
|
||||
layout.addStretch()
|
||||
|
||||
note = QLabel(
|
||||
"Når du fjerner et bibliotek, slettes det fra overvågningen.\n"
|
||||
@@ -44,16 +100,6 @@ class LibraryManagerDialog(QDialog):
|
||||
btn_add = QPushButton("+ Tilføj mappe")
|
||||
btn_add.clicked.connect(self._add_folder)
|
||||
btn_row.addWidget(btn_add)
|
||||
|
||||
btn_remove = QPushButton("✕ Fjern valgt")
|
||||
btn_remove.clicked.connect(self._remove_selected)
|
||||
btn_row.addWidget(btn_remove)
|
||||
|
||||
btn_scan = QPushButton("⟳ Scan alle")
|
||||
btn_scan.setToolTip("Scan alle mapper for nye og ændrede filer")
|
||||
btn_scan.clicked.connect(self._scan_all)
|
||||
btn_row.addWidget(btn_scan)
|
||||
|
||||
btn_row.addStretch()
|
||||
btn_close = QPushButton("Luk")
|
||||
btn_close.clicked.connect(self.accept)
|
||||
@@ -61,41 +107,141 @@ class LibraryManagerDialog(QDialog):
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
def _load(self):
|
||||
self._list.clear()
|
||||
# Ryd eksisterende widgets
|
||||
while self._libs_layout.count():
|
||||
item = self._libs_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
try:
|
||||
from local.local_db import get_libraries, get_db
|
||||
libs = get_libraries(active_only=True) # kun aktive
|
||||
libs = get_libraries(active_only=True)
|
||||
for lib in libs:
|
||||
self._libs_layout.addWidget(self._make_lib_row(lib))
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def _make_lib_row(self, lib: dict) -> QFrame:
|
||||
from pathlib import Path
|
||||
lib_id = lib["id"]
|
||||
path = lib["path"]
|
||||
exists = Path(path).exists()
|
||||
last_scan = lib["last_full_scan"] or "aldrig"
|
||||
|
||||
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:
|
||||
count = conn.execute(
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0",
|
||||
(lib["id"],)
|
||||
(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:
|
||||
print(f"Library manager load fejl: {e}")
|
||||
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
|
||||
|
||||
def _scan_all(self):
|
||||
lbl_path = QLabel(("⚠ " if not exists else "") + path)
|
||||
lbl_path.setObjectName("track_title" if exists else "result_count")
|
||||
vbox.addWidget(lbl_path)
|
||||
|
||||
lbl_info = QLabel(
|
||||
f" {total} sange · senest scannet: {last_scan} · "
|
||||
f"{missing_bpm} uden BPM"
|
||||
)
|
||||
lbl_info.setObjectName("result_count")
|
||||
vbox.addWidget(lbl_info)
|
||||
|
||||
# Statuslinje til BPM-fremgang
|
||||
lbl_status = QLabel("")
|
||||
lbl_status.setObjectName("result_count")
|
||||
lbl_status.hide()
|
||||
vbox.addWidget(lbl_status)
|
||||
|
||||
# Knap-række
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.setSpacing(6)
|
||||
|
||||
btn_scan = QPushButton("⟳ Fil-scan")
|
||||
btn_scan.setFixedHeight(24)
|
||||
btn_scan.setToolTip("Scan for nye og ændrede filer")
|
||||
btn_scan.clicked.connect(lambda _, lid=lib_id, p=path: self._scan_files(lid, p))
|
||||
btn_row.addWidget(btn_scan)
|
||||
|
||||
btn_bpm = QPushButton(f"♩ BPM manglende ({missing_bpm})")
|
||||
btn_bpm.setFixedHeight(24)
|
||||
btn_bpm.setToolTip("Analysér BPM på sange der mangler det")
|
||||
btn_bpm.setEnabled(missing_bpm > 0)
|
||||
btn_bpm.clicked.connect(
|
||||
lambda _, lid=lib_id, b=btn_bpm, s=lbl_status: self._start_bpm(lid, False, b, s)
|
||||
)
|
||||
btn_row.addWidget(btn_bpm)
|
||||
|
||||
btn_bpm_all = QPushButton("♩ BPM alle")
|
||||
btn_bpm_all.setFixedHeight(24)
|
||||
btn_bpm_all.setToolTip("Genanalysér BPM på alle sange (overskriver eksisterende)")
|
||||
btn_bpm_all.clicked.connect(
|
||||
lambda _, lid=lib_id, b=btn_bpm_all, s=lbl_status: self._start_bpm(lid, True, b, s)
|
||||
)
|
||||
btn_row.addWidget(btn_bpm_all)
|
||||
|
||||
btn_row.addStretch()
|
||||
|
||||
btn_remove = QPushButton("✕ Fjern")
|
||||
btn_remove.setFixedHeight(24)
|
||||
btn_remove.clicked.connect(lambda _, l=lib: self._remove_library(l))
|
||||
btn_row.addWidget(btn_remove)
|
||||
|
||||
vbox.addLayout(btn_row)
|
||||
return frame
|
||||
|
||||
def _scan_files(self, library_id: int, path: str):
|
||||
mw = self.parent()
|
||||
if hasattr(mw, "start_scan"):
|
||||
mw.start_scan()
|
||||
self._set_status("Scanning startet...")
|
||||
if hasattr(mw, "_watcher") and mw._watcher:
|
||||
mw._watcher._full_scan_library(library_id, path)
|
||||
from PyQt6.QtCore import QTimer
|
||||
QTimer.singleShot(1000, self._load)
|
||||
|
||||
def _set_status(self, text: str):
|
||||
pass # kan udvides med statuslinje i dialogen
|
||||
def _start_bpm(self, library_id: int, scan_all: bool,
|
||||
btn: QPushButton, lbl_status: QLabel):
|
||||
if library_id in self._bpm_workers:
|
||||
return # allerede i gang
|
||||
|
||||
worker = BpmScanWorker(library_id, scan_all=scan_all)
|
||||
|
||||
def on_progress(done, total):
|
||||
lbl_status.setText(f"♩ {done}/{total} analyseret...")
|
||||
lbl_status.show()
|
||||
btn.setEnabled(False)
|
||||
|
||||
def on_finished(count):
|
||||
lbl_status.setText(f"✓ {count} sange analyseret")
|
||||
btn.setEnabled(True)
|
||||
self._bpm_workers.pop(library_id, None)
|
||||
# Opdater UI og bibliotek
|
||||
from PyQt6.QtCore import QTimer
|
||||
QTimer.singleShot(500, self._load)
|
||||
mw = self.parent()
|
||||
if hasattr(mw, "_reload_library"):
|
||||
QTimer.singleShot(600, mw._reload_library)
|
||||
|
||||
worker.progress.connect(on_progress)
|
||||
worker.finished.connect(on_finished)
|
||||
self._bpm_workers[library_id] = worker
|
||||
worker.start()
|
||||
worker.setPriority(QThread.Priority.LowestPriority)
|
||||
|
||||
def _add_folder(self):
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
@@ -104,19 +250,14 @@ class LibraryManagerDialog(QDialog):
|
||||
mw = self.parent()
|
||||
if hasattr(mw, "add_library_path"):
|
||||
mw.add_library_path(folder)
|
||||
# Genindlæs listen efter kort pause så DB er opdateret
|
||||
from PyQt6.QtCore import QTimer
|
||||
QTimer.singleShot(600, self._load)
|
||||
QTimer.singleShot(800, self._load)
|
||||
|
||||
def _remove_selected(self):
|
||||
item = self._list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
lib = item.data(Qt.ItemDataRole.UserRole)
|
||||
def _remove_library(self, lib: dict):
|
||||
reply = QMessageBox.question(
|
||||
self, "Fjern bibliotek",
|
||||
f"Fjern overvågningen af:\n{lib['path']}\n\n"
|
||||
"Sange i biblioteket forbliver i databasen men markeres som manglende.",
|
||||
"Sange forbliver i databasen men markeres som manglende.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
|
||||
@@ -4,15 +4,47 @@ library_panel.py — Musikbibliotek med søgning og drag-and-drop til danseliste
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
||||
QLineEdit, QLabel, QHBoxLayout, QPushButton, QProgressBar,
|
||||
QAbstractItemView,
|
||||
QLineEdit, QLabel, QHBoxLayout, QPushButton,
|
||||
QAbstractItemView, QStyledItemDelegate,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray
|
||||
from PyQt6.QtGui import QColor, QDrag
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray, QRect
|
||||
from PyQt6.QtGui import QColor, QDrag, QPainter, QBrush, QPen, QFont
|
||||
|
||||
|
||||
class DanseButtonDelegate(QStyledItemDelegate):
|
||||
"""Tegner en orange 'Danse' label i højre side af hvert list-item."""
|
||||
|
||||
BTN_W = 54
|
||||
BTN_H = 22
|
||||
BTN_MARGIN = 8
|
||||
|
||||
def paint(self, painter: QPainter, option, index):
|
||||
super().paint(painter, option, index)
|
||||
rect = option.rect
|
||||
btn_rect = QRect(
|
||||
rect.right() - self.BTN_W - self.BTN_MARGIN,
|
||||
rect.top() + (rect.height() - self.BTN_H) // 2,
|
||||
self.BTN_W,
|
||||
self.BTN_H,
|
||||
)
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setBrush(QBrush(QColor("#e8a020")))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawRoundedRect(btn_rect, 4, 4)
|
||||
painter.setPen(QPen(QColor("#111111")))
|
||||
font = QFont()
|
||||
font.setPointSize(8)
|
||||
font.setBold(True)
|
||||
painter.setFont(font)
|
||||
painter.drawText(btn_rect, Qt.AlignmentFlag.AlignCenter, "Danse")
|
||||
painter.restore()
|
||||
|
||||
|
||||
class DraggableLibraryList(QListWidget):
|
||||
"""QListWidget der understøtter drag-start med sang-data som mime."""
|
||||
"""QListWidget med drag, dobbeltklik og klik på højre side for dans-tags."""
|
||||
|
||||
danse_clicked = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(dict)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -20,6 +52,28 @@ class DraggableLibraryList(QListWidget):
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
|
||||
self.setDefaultDropAction(Qt.DropAction.CopyAction)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
item = self.itemAt(event.pos())
|
||||
if item and event.pos().x() > self.viewport().width() - 75:
|
||||
song = item.data(Qt.ItemDataRole.UserRole)
|
||||
if song:
|
||||
self.danse_clicked.emit(song)
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
item = self.itemAt(event.pos())
|
||||
if item:
|
||||
# Dobbeltklik i højre 75px = Danse, ellers song_selected
|
||||
if event.pos().x() > self.viewport().width() - 75:
|
||||
song = item.data(Qt.ItemDataRole.UserRole)
|
||||
if song:
|
||||
self.danse_clicked.emit(song)
|
||||
return
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def startDrag(self, supported_actions):
|
||||
item = self.currentItem()
|
||||
if not item:
|
||||
@@ -71,34 +125,13 @@ class LibraryPanel(QWidget):
|
||||
header.addWidget(lbl)
|
||||
header.addStretch()
|
||||
|
||||
self._btn_bpm_scan = QPushButton("♩ BPM alle")
|
||||
self._btn_bpm_scan.setFixedHeight(24)
|
||||
self._btn_bpm_scan.setToolTip("Analysér BPM på alle sange uden BPM (kører i baggrunden)")
|
||||
self._btn_bpm_scan.clicked.connect(self._start_bulk_bpm_scan)
|
||||
header.addWidget(self._btn_bpm_scan)
|
||||
|
||||
btn_manage = QPushButton("⚙ Mapper")
|
||||
btn_manage.setFixedHeight(24)
|
||||
btn_manage.setFixedHeight(28)
|
||||
btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
|
||||
btn_manage.clicked.connect(self._manage_libraries)
|
||||
header.addWidget(btn_manage)
|
||||
layout.addLayout(header)
|
||||
|
||||
# Scan status
|
||||
self._scan_bar = QProgressBar()
|
||||
self._scan_bar.setObjectName("scan_bar")
|
||||
self._scan_bar.setTextVisible(True)
|
||||
self._scan_bar.setFormat("Scanner...")
|
||||
self._scan_bar.setFixedHeight(16)
|
||||
self._scan_bar.setRange(0, 0)
|
||||
self._scan_bar.hide()
|
||||
layout.addWidget(self._scan_bar)
|
||||
|
||||
self._scan_label = QLabel("")
|
||||
self._scan_label.setObjectName("result_count")
|
||||
self._scan_label.hide()
|
||||
layout.addWidget(self._scan_label)
|
||||
|
||||
# Søgefelt
|
||||
self._search = QLineEdit()
|
||||
self._search.setPlaceholderText("Søg i titel, artist, album, dans...")
|
||||
@@ -121,8 +154,10 @@ class LibraryPanel(QWidget):
|
||||
self._list = DraggableLibraryList()
|
||||
self._list.setObjectName("library_list")
|
||||
self._list.itemDoubleClicked.connect(self._on_double_click)
|
||||
self._list.danse_clicked.connect(self.edit_tags_requested)
|
||||
self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._list.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self._list.setItemDelegate(DanseButtonDelegate(self._list))
|
||||
layout.addWidget(self._list)
|
||||
|
||||
# ── Scanning ──────────────────────────────────────────────────────────────
|
||||
@@ -131,16 +166,10 @@ class LibraryPanel(QWidget):
|
||||
self.scan_requested.emit()
|
||||
|
||||
def set_scanning(self, scanning: bool, status_text: str = ""):
|
||||
if scanning:
|
||||
self._scan_bar.show()
|
||||
self._scan_label.setText(status_text or "Starter...")
|
||||
self._scan_label.show()
|
||||
else:
|
||||
self._scan_bar.hide()
|
||||
self._scan_label.hide()
|
||||
pass # Status vises i statuslinjen
|
||||
|
||||
def update_scan_status(self, text: str):
|
||||
self._scan_label.setText(text)
|
||||
pass # Status vises i statuslinjen
|
||||
|
||||
# ── Sange ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -185,41 +214,34 @@ class LibraryPanel(QWidget):
|
||||
dance_parts.append(f"{d} / {lvl}" if lvl else d)
|
||||
dance_str = " · " + " | ".join(dance_parts) if dance_parts else ""
|
||||
|
||||
line1 = ("⚠ " if missing else "") + song.get("title", "—")
|
||||
def _render(self):
|
||||
self._list.clear()
|
||||
q = self._search.text().strip().lower()
|
||||
for song in self._filtered:
|
||||
dances = song.get("dances", [])
|
||||
dance_levels = song.get("dance_levels", [])
|
||||
missing = song.get("file_missing", False)
|
||||
|
||||
dance_parts = []
|
||||
for i, d in enumerate(dances):
|
||||
lvl = dance_levels[i] if i < len(dance_levels) else ""
|
||||
dance_parts.append(f"{d} / {lvl}" if lvl else d)
|
||||
dance_str = " · " + " | ".join(dance_parts) if dance_parts else ""
|
||||
|
||||
prefix = "⚠ " if missing else ""
|
||||
bpm = song.get("bpm", 0)
|
||||
bpm_str = f"{bpm} BPM" if bpm else "? BPM"
|
||||
line1 = prefix + song.get("title", "—")
|
||||
line2 = f" {song.get('artist','—')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}"
|
||||
|
||||
row_widget = QWidget()
|
||||
row_widget.setStyleSheet("background: transparent;")
|
||||
row_layout = QHBoxLayout(row_widget)
|
||||
row_layout.setContentsMargins(2, 2, 2, 2)
|
||||
row_layout.setSpacing(8)
|
||||
|
||||
lbl = QLabel(f"{line1}\n{line2}")
|
||||
lbl.setWordWrap(False)
|
||||
row_layout.addWidget(lbl, stretch=1)
|
||||
|
||||
btn_danse = QPushButton("Danse")
|
||||
btn_danse.setFixedHeight(30)
|
||||
btn_danse.setFixedWidth(70)
|
||||
btn_danse.setToolTip("Rediger dans-tags")
|
||||
btn_danse.setStyleSheet(
|
||||
"QPushButton { background: #e8a020; color: #111; border-radius: 4px; "
|
||||
"font-weight: bold; font-size: 12px; border: none; }"
|
||||
"QPushButton:hover { background: #f0b030; }"
|
||||
)
|
||||
btn_danse.clicked.connect(lambda _, s=song: self.edit_tags_requested.emit(s))
|
||||
row_layout.addWidget(btn_danse)
|
||||
|
||||
item = QListWidgetItem()
|
||||
item = QListWidgetItem(f"{line1}\n{line2}")
|
||||
item.setData(Qt.ItemDataRole.UserRole, song)
|
||||
row_widget.adjustSize()
|
||||
hint = row_widget.sizeHint()
|
||||
hint.setHeight(max(hint.height(), 52))
|
||||
item.setSizeHint(hint)
|
||||
item.setSizeHint(__import__('PyQt6.QtCore', fromlist=['QSize']).QSize(0, 52))
|
||||
if missing:
|
||||
item.setForeground(QColor("#5a6070"))
|
||||
elif q and any(q in d.lower() for d in dances):
|
||||
item.setForeground(QColor("#e8a020"))
|
||||
self._list.addItem(item)
|
||||
self._list.setItemWidget(item, row_widget)
|
||||
|
||||
def _start_bulk_bpm_scan(self):
|
||||
"""Start BPM-analyse på alle sange uden BPM i baggrundstråd med lav prioritet."""
|
||||
@@ -301,6 +323,7 @@ class LibraryPanel(QWidget):
|
||||
act_play = menu.addAction("Afspil")
|
||||
menu.addSeparator()
|
||||
act_tags = menu.addAction("✎ Rediger dans-tags...")
|
||||
act_info = menu.addAction("ℹ Dans-info...")
|
||||
act_bpm = menu.addAction("♩ Analysér BPM")
|
||||
menu.addSeparator()
|
||||
send_menu = menu.addMenu("Send til")
|
||||
@@ -312,6 +335,10 @@ class LibraryPanel(QWidget):
|
||||
self.song_selected.emit(song)
|
||||
elif action == act_tags:
|
||||
self.edit_tags_requested.emit(song)
|
||||
elif action == act_info:
|
||||
from ui.dance_info_dialog import DanceInfoDialog
|
||||
dlg = DanceInfoDialog(song, parent=self.window())
|
||||
dlg.exec()
|
||||
elif action == act_bpm:
|
||||
self._analyze_bpm(song)
|
||||
elif action == act_mail:
|
||||
|
||||
@@ -91,6 +91,7 @@ class MainWindow(QMainWindow):
|
||||
self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
|
||||
|
||||
self._connect_player_signals()
|
||||
self._library_loaded.connect(self._apply_library)
|
||||
self._build_menu()
|
||||
self._build_ui()
|
||||
self._build_statusbar()
|
||||
@@ -367,66 +368,60 @@ class MainWindow(QMainWindow):
|
||||
# ── Lokal DB + scanning ───────────────────────────────────────────────────
|
||||
|
||||
def _init_local_db(self):
|
||||
# Debounce-timer til reload (skal oprettes i GUI-tråden)
|
||||
self._reload_timer = QTimer(self)
|
||||
self._reload_timer.setSingleShot(True)
|
||||
self._reload_timer.setInterval(2000)
|
||||
self._reload_timer.timeout.connect(self._reload_library)
|
||||
|
||||
# Kør init_db i baggrundstråd — blokerer ikke GUI
|
||||
import threading
|
||||
threading.Thread(target=self._init_db_background, daemon=True).start()
|
||||
|
||||
def _init_db_background(self):
|
||||
"""Kører i baggrundstråd — initialiserer DB og loader bibliotek."""
|
||||
try:
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from local.local_db import init_db
|
||||
from local.file_watcher import get_watcher
|
||||
|
||||
init_db()
|
||||
|
||||
# Brug et Qt signal til thread-safe reload fra watcher-tråden
|
||||
from PyQt6.QtCore import QMetaObject, Q_ARG
|
||||
def on_file_change(event_type, path, song_id):
|
||||
QTimer.singleShot(0, self._reload_library)
|
||||
|
||||
self._watcher = get_watcher(on_change=on_file_change)
|
||||
self._watcher.start()
|
||||
|
||||
# Indlæs hvad vi allerede kender fra SQLite
|
||||
self._reload_library()
|
||||
|
||||
# Gendan sidst aktive danseliste
|
||||
restored = self._playlist_panel.restore_active_playlist()
|
||||
|
||||
# Gendan event-fremgang hvis liste blev gendannet
|
||||
if restored:
|
||||
if self._playlist_panel.restore_event_state():
|
||||
# Indlæs den sang vi var nået til
|
||||
idx = self._playlist_panel._current_idx
|
||||
song = self._playlist_panel.get_song(idx)
|
||||
if song:
|
||||
self._current_idx = idx
|
||||
self._load_song(song)
|
||||
self._set_status(
|
||||
f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte",
|
||||
6000,
|
||||
)
|
||||
|
||||
# Kør automatisk scanning ved opstart
|
||||
self._set_status("Starter scanning af biblioteker...")
|
||||
QTimer.singleShot(100, self.start_scan)
|
||||
|
||||
# Trigger library load via signal
|
||||
self._library_loaded.emit([]) # tomt signal = "DB klar, load nu"
|
||||
except Exception as e:
|
||||
self._set_status(f"DB fejl: {e}")
|
||||
pass
|
||||
|
||||
def start_scan(self):
|
||||
"""Start fuld scanning af alle biblioteker i baggrundstråd."""
|
||||
if self._scan_worker and self._scan_worker.isRunning():
|
||||
return # Scanning kører allerede
|
||||
def _start_watcher(self):
|
||||
"""Start fil-watcher i baggrundstråd — blokerer aldrig GUI."""
|
||||
import threading
|
||||
|
||||
def _start():
|
||||
try:
|
||||
from local.file_watcher import get_watcher
|
||||
|
||||
def on_file_change(event_type, path, song_id):
|
||||
# QMetaObject.invokeMethod er den korrekte måde at kalde
|
||||
# en slot fra en ikke-GUI-tråd
|
||||
from PyQt6.QtCore import QMetaObject, Qt
|
||||
QMetaObject.invokeMethod(
|
||||
self._reload_timer, "start",
|
||||
Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
|
||||
watcher = get_watcher(on_change=on_file_change)
|
||||
watcher.start()
|
||||
self._watcher = watcher # sæt først når den er klar
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_start, daemon=True).start()
|
||||
|
||||
def start_scan(self):
|
||||
"""Start fuld scanning af alle biblioteker — watcher kører i egne baggrundstråde."""
|
||||
if not self._watcher:
|
||||
self._set_status("Ingen biblioteker at scanne — tilføj en mappe først")
|
||||
return
|
||||
|
||||
self._library_panel.set_scanning(True, "Forbereder scanning...")
|
||||
self._act_scan.setEnabled(False)
|
||||
|
||||
self._scan_worker = ScanWorker(self._watcher, parent=self)
|
||||
self._scan_worker.status_update.connect(self._on_scan_status)
|
||||
self._scan_worker.scan_done.connect(self._on_scan_done)
|
||||
self._scan_worker.start()
|
||||
self._set_status("Scanner biblioteker i baggrunden...")
|
||||
self._watcher._full_scan_all()
|
||||
# Genindlæs bibliotekslisten efter et øjeblik
|
||||
QTimer.singleShot(3000, self._reload_library)
|
||||
|
||||
def _on_scan_status(self, text: str):
|
||||
self._set_status(text)
|
||||
@@ -440,20 +435,41 @@ class MainWindow(QMainWindow):
|
||||
# Genindlæs biblioteket
|
||||
QTimer.singleShot(200, self._reload_library)
|
||||
|
||||
# Signal til at opdatere biblioteket fra baggrundstråd
|
||||
_library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list)
|
||||
|
||||
def _reload_library(self):
|
||||
"""Hent sange fra DB i baggrundstråd — thread-safe via signal."""
|
||||
import threading
|
||||
threading.Thread(target=self._fetch_library, daemon=True).start()
|
||||
|
||||
def _fetch_library(self):
|
||||
"""Kører i baggrundstråd — henter sange og sender til GUI via signal."""
|
||||
try:
|
||||
from local.local_db import search_songs, get_db
|
||||
songs_raw = search_songs("", limit=5000)
|
||||
songs = []
|
||||
for row in songs_raw:
|
||||
with get_db() as conn:
|
||||
dances_raw = conn.execute("""
|
||||
SELECT d.name, dl.name as level_name
|
||||
FROM song_dances sd
|
||||
JOIN dances d ON d.id = sd.dance_id
|
||||
import sqlite3
|
||||
from local.local_db import DB_PATH
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute("""
|
||||
SELECT s.id, s.title, s.artist, s.album, s.bpm,
|
||||
s.duration_sec, s.local_path, s.file_format,
|
||||
s.file_missing,
|
||||
GROUP_CONCAT(d.name, '||') AS dance_names,
|
||||
GROUP_CONCAT(COALESCE(dl.name,''), '||') AS dance_levels
|
||||
FROM songs s
|
||||
LEFT JOIN song_dances sd ON sd.song_id = s.id
|
||||
LEFT JOIN dances d ON d.id = sd.dance_id
|
||||
LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
||||
WHERE sd.song_id=? ORDER BY sd.dance_order
|
||||
""", (row["id"],)).fetchall()
|
||||
WHERE s.file_missing = 0
|
||||
GROUP BY s.id
|
||||
ORDER BY s.artist, s.title
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
|
||||
songs = []
|
||||
for row in rows:
|
||||
dances = row["dance_names"].split("||") if row["dance_names"] else []
|
||||
levels = row["dance_levels"].split("||") if row["dance_levels"] else []
|
||||
songs.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
@@ -464,14 +480,44 @@ class MainWindow(QMainWindow):
|
||||
"local_path": row["local_path"],
|
||||
"file_format": row["file_format"],
|
||||
"file_missing": bool(row["file_missing"]),
|
||||
"dances": [d["name"] for d in dances_raw],
|
||||
"dance_levels": [d["level_name"] or "" for d in dances_raw],
|
||||
"dances": dances,
|
||||
"dance_levels": levels,
|
||||
})
|
||||
self._library_loaded.emit(songs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_library(self, songs: list):
|
||||
if not songs:
|
||||
# Tomt signal = DB er klar, start library load og post-init
|
||||
self._reload_library()
|
||||
self._post_init()
|
||||
return
|
||||
self._library_panel.load_songs(songs)
|
||||
count = len(songs)
|
||||
self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000)
|
||||
except Exception as e:
|
||||
self._set_status(
|
||||
f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000
|
||||
)
|
||||
|
||||
def _post_init(self):
|
||||
"""Kør efter DB er initialiseret — gendan state og start watcher."""
|
||||
try:
|
||||
restored = self._playlist_panel.restore_active_playlist()
|
||||
if restored:
|
||||
if self._playlist_panel.restore_event_state():
|
||||
idx = self._playlist_panel._current_idx
|
||||
song = self._playlist_panel.get_song(idx)
|
||||
if song:
|
||||
self._current_idx = idx
|
||||
self._load_song(song)
|
||||
self._set_status(
|
||||
f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte",
|
||||
6000,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
QTimer.singleShot(5000, self._start_watcher)
|
||||
QTimer.singleShot(60000, self.start_scan)
|
||||
|
||||
def add_library_path(self, path: str):
|
||||
try:
|
||||
@@ -479,10 +525,9 @@ class MainWindow(QMainWindow):
|
||||
self._set_status("Watcher ikke klar endnu — prøv igen om et øjeblik", 3000)
|
||||
return
|
||||
self._watcher.add_library(path)
|
||||
self._set_status(f"Tilføjet: {path} — scanner...")
|
||||
# Genindlæs bibliotekslisten og start scan
|
||||
QTimer.singleShot(500, self._reload_library)
|
||||
QTimer.singleShot(1000, self.start_scan)
|
||||
self._set_status(f"Tilføjet: {path} — scanner i baggrunden...")
|
||||
# Genindlæs bibliotekslisten efter kort pause
|
||||
QTimer.singleShot(800, self._reload_library)
|
||||
except Exception as e:
|
||||
self._set_status(f"Fejl ved tilføjelse: {e}")
|
||||
|
||||
@@ -820,6 +865,10 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _on_library_song_selected(self, song: dict):
|
||||
self._load_song(song)
|
||||
# VLC er asynkron — vent kort på at media er klar
|
||||
QTimer.singleShot(150, self._play_after_load)
|
||||
|
||||
def _play_after_load(self):
|
||||
self._player.play()
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
|
||||
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 (
|
||||
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
||||
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
|
||||
QMessageBox, QInputDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray
|
||||
from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent
|
||||
@@ -76,6 +76,13 @@ class PlaylistPanel(QWidget):
|
||||
btn_save.clicked.connect(self._save_as)
|
||||
toolbar.addWidget(btn_save)
|
||||
|
||||
self._btn_save_current = QPushButton("💾 Gem")
|
||||
self._btn_save_current.setFixedHeight(26)
|
||||
self._btn_save_current.setToolTip("Gem ændringer til den indlæste liste")
|
||||
self._btn_save_current.clicked.connect(self._save_current)
|
||||
self._btn_save_current.setEnabled(False)
|
||||
toolbar.addWidget(self._btn_save_current)
|
||||
|
||||
btn_load = QPushButton("📂 Hent...")
|
||||
btn_load.setFixedHeight(26)
|
||||
btn_load.setToolTip("Hent en tidligere gemt danseliste")
|
||||
@@ -106,8 +113,24 @@ class PlaylistPanel(QWidget):
|
||||
self._lbl_progress.setObjectName("result_count")
|
||||
ctrl.addWidget(self._lbl_progress)
|
||||
|
||||
btn_info = QPushButton("ℹ")
|
||||
btn_info.setFixedSize(24, 28)
|
||||
btn_info.setToolTip("Danseliste-info: samlet tid, pause-tid m.m.")
|
||||
btn_info.clicked.connect(self._show_playlist_info)
|
||||
ctrl.addWidget(btn_info)
|
||||
|
||||
layout.addLayout(ctrl)
|
||||
|
||||
# Pause-tid per dans (skjult men kan vises via info)
|
||||
try:
|
||||
from ui.settings_dialog import load_settings
|
||||
s = load_settings()
|
||||
self._pause_seconds = s.get("between_seconds", 60)
|
||||
self._workshop_seconds = s.get("workshop_minutes", 10) * 60
|
||||
except Exception:
|
||||
self._pause_seconds = 60
|
||||
self._workshop_seconds = 600
|
||||
|
||||
# ── Liste ─────────────────────────────────────────────────────────────
|
||||
self._list = QListWidget()
|
||||
self._list.setObjectName("playlist_list")
|
||||
@@ -201,6 +224,11 @@ class PlaylistPanel(QWidget):
|
||||
|
||||
def _on_rows_moved(self, parent, start, end, dest, dest_row):
|
||||
"""Opdater _songs og _statuses når en sang flyttes via drag."""
|
||||
# Husk hvilken sang der er aktiv
|
||||
current_song_id = None
|
||||
if 0 <= self._current_idx < len(self._songs):
|
||||
current_song_id = self._songs[self._current_idx].get("id")
|
||||
|
||||
new_songs = []
|
||||
new_statuses = []
|
||||
for i in range(self._list.count()):
|
||||
@@ -210,17 +238,20 @@ class PlaylistPanel(QWidget):
|
||||
new_statuses.append(self._statuses[old_idx])
|
||||
self._songs = new_songs
|
||||
self._statuses = new_statuses
|
||||
|
||||
# Gendan current_idx til den sang der stadig spiller
|
||||
if current_song_id:
|
||||
for i, s in enumerate(self._songs):
|
||||
if s.get("id") == current_song_id:
|
||||
self._current_idx = i
|
||||
break
|
||||
else:
|
||||
self._current_idx = -1
|
||||
|
||||
self._song_ended = False
|
||||
self._refresh()
|
||||
self._trigger_autosave()
|
||||
|
||||
# Find første afspilbare sang og udsend signal så afspilleren opdateres
|
||||
ni = self.next_playable_idx()
|
||||
if ni is not None:
|
||||
self._current_idx = ni
|
||||
self._refresh()
|
||||
self.next_song_ready.emit(self._songs[ni])
|
||||
# Emit IKKE next_song_ready — afspilning fortsætter uforstyrret
|
||||
|
||||
# ── Event-state ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -292,42 +323,95 @@ class PlaylistPanel(QWidget):
|
||||
self._lbl_autosave.setText(f"⚠ gemfejl")
|
||||
pass
|
||||
|
||||
def _save_named_playlist_id(self, pl_id: int | None):
|
||||
"""Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart."""
|
||||
from PyQt6.QtCore import QSettings
|
||||
s = QSettings("LineDance", "Player")
|
||||
if pl_id:
|
||||
s.setValue("session/named_playlist_id", pl_id)
|
||||
else:
|
||||
s.remove("session/named_playlist_id")
|
||||
|
||||
def restore_active_playlist(self):
|
||||
"""Indlæs den sidst aktive liste ved opstart."""
|
||||
"""Gendan senest aktive navngivne liste med event-fremgang ved opstart."""
|
||||
try:
|
||||
from local.local_db import get_db
|
||||
with get_db() as conn:
|
||||
from PyQt6.QtCore import QSettings
|
||||
s = QSettings("LineDance", "Player")
|
||||
pl_id = s.value("session/named_playlist_id", None, type=int)
|
||||
if not pl_id:
|
||||
return False
|
||||
|
||||
import sqlite3
|
||||
from local.local_db import DB_PATH
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Verificer at listen stadig eksisterer
|
||||
pl = conn.execute(
|
||||
"SELECT id FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
|
||||
"SELECT id, name FROM playlists WHERE id=?", (pl_id,)
|
||||
).fetchone()
|
||||
if not pl:
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
# Hent sange med status, workshop og dans-override
|
||||
songs_raw = conn.execute("""
|
||||
SELECT s.*, ps.position FROM playlist_songs ps
|
||||
SELECT s.*, ps.position, ps.status,
|
||||
ps.is_workshop, ps.dance_override
|
||||
FROM playlist_songs ps
|
||||
JOIN songs s ON s.id = ps.song_id
|
||||
WHERE ps.playlist_id=? ORDER BY ps.position
|
||||
""", (pl["id"],)).fetchall()
|
||||
""", (pl_id,)).fetchall()
|
||||
|
||||
songs = []
|
||||
statuses = []
|
||||
for row in songs_raw:
|
||||
dances = conn.execute(
|
||||
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
|
||||
(row["id"],)
|
||||
).fetchall()
|
||||
dances = conn.execute("""
|
||||
SELECT d.name FROM song_dances sd
|
||||
JOIN dances d ON d.id = sd.dance_id
|
||||
WHERE sd.song_id=? ORDER BY sd.dance_order
|
||||
""", (row["id"],)).fetchall()
|
||||
dance_names = [d["name"] for d in dances]
|
||||
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"],
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"artist": row["artist"],
|
||||
"album": row["album"],
|
||||
"bpm": row["bpm"],
|
||||
"duration_sec": row["duration_sec"],
|
||||
"local_path": row["local_path"],
|
||||
"file_format": row["file_format"],
|
||||
"file_missing": bool(row["file_missing"]),
|
||||
"dances": [d["dance_name"] for d in dances],
|
||||
"dances": dance_names,
|
||||
"active_dance": active_dance,
|
||||
"is_workshop": bool(row["is_workshop"]),
|
||||
})
|
||||
statuses.append(row["status"] or "pending")
|
||||
conn.close()
|
||||
|
||||
if songs:
|
||||
self._songs = songs
|
||||
self._statuses = ["pending"] * len(songs)
|
||||
self._refresh()
|
||||
self._statuses = statuses
|
||||
self._named_playlist_id = pl_id
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._btn_save_current.setEnabled(True)
|
||||
self._btn_save_current.setToolTip(f"Gem ændringer til '{pl['name']}'")
|
||||
self._title_label.setText(f"DANSELISTE — {pl['name'].upper()}")
|
||||
self._lbl_autosave.setText("✓ gendannet")
|
||||
self._refresh()
|
||||
|
||||
# Find næste uafspillede og sæt den klar
|
||||
ni = self.next_playable_idx()
|
||||
if ni is not None:
|
||||
self._current_idx = ni
|
||||
self._refresh()
|
||||
self.next_song_ready.emit(self._songs[ni])
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
@@ -346,6 +430,10 @@ class PlaylistPanel(QWidget):
|
||||
self._statuses = []
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._named_playlist_id = None
|
||||
self._btn_save_current.setEnabled(False)
|
||||
self._btn_save_current.setToolTip("Gem ændringer til den indlæste liste")
|
||||
self._save_named_playlist_id(None)
|
||||
self._title_label.setText("DANSELISTE — NY")
|
||||
self._refresh()
|
||||
self._trigger_autosave()
|
||||
@@ -354,75 +442,88 @@ class PlaylistPanel(QWidget):
|
||||
if not self._songs:
|
||||
QMessageBox.information(self, "Gem", "Danselisten er tom.")
|
||||
return
|
||||
name, ok = QInputDialog.getText(
|
||||
self, "Gem danseliste", "Navn på danselisten:",
|
||||
from ui.playlist_browser import PlaylistBrowserDialog
|
||||
current_name = self._title_label.text().replace("DANSELISTE — ", "").replace("DANSELISTE", "").strip()
|
||||
dialog = PlaylistBrowserDialog(
|
||||
mode="save",
|
||||
current_songs=self._songs,
|
||||
current_name=current_name,
|
||||
parent=self.window()
|
||||
)
|
||||
if not ok or not name.strip():
|
||||
return
|
||||
name = name.strip()
|
||||
try:
|
||||
from local.local_db import create_playlist, add_song_to_playlist
|
||||
pl_id = create_playlist(name)
|
||||
for i, song in enumerate(self._songs, start=1):
|
||||
if song.get("id"):
|
||||
add_song_to_playlist(pl_id, song["id"], position=i)
|
||||
def on_saved(pl_id, name):
|
||||
self._named_playlist_id = pl_id
|
||||
self._title_label.setText(f"DANSELISTE — {name.upper()}")
|
||||
self._lbl_autosave.setText(f"✓ gemt som \"{name}\"")
|
||||
self._btn_save_current.setEnabled(True)
|
||||
self._btn_save_current.setToolTip(f"Gem ændringer til '{name}'")
|
||||
self._save_named_playlist_id(pl_id)
|
||||
dialog.playlist_selected.connect(on_saved)
|
||||
dialog.exec()
|
||||
|
||||
def _save_current(self):
|
||||
"""Gem ændringer tilbage til den aktuelt indlæste navngivne liste."""
|
||||
if not self._named_playlist_id:
|
||||
return
|
||||
if not self._songs:
|
||||
QMessageBox.information(self, "Gem", "Danselisten er tom.")
|
||||
return
|
||||
try:
|
||||
from local.local_db import get_db
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM playlist_songs WHERE playlist_id=?",
|
||||
(self._named_playlist_id,)
|
||||
)
|
||||
for i, song in enumerate(self._songs, start=1):
|
||||
if song.get("id"):
|
||||
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
|
||||
conn.execute(
|
||||
"INSERT INTO playlist_songs "
|
||||
"(playlist_id, song_id, position, status) VALUES (?,?,?,?)",
|
||||
(self._named_playlist_id, song["id"], i, status)
|
||||
)
|
||||
self._lbl_autosave.setText("✓ gemt")
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
|
||||
|
||||
def _load_dialog(self):
|
||||
"""Vis liste af gemte danselister og lad brugeren vælge."""
|
||||
try:
|
||||
from local.local_db import get_db
|
||||
with get_db() as conn:
|
||||
lists = conn.execute(
|
||||
"SELECT id, name, created_at FROM playlists "
|
||||
"WHERE name != ? ORDER BY created_at DESC",
|
||||
(ACTIVE_PLAYLIST_NAME,)
|
||||
).fetchall()
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke hente lister: {e}")
|
||||
return
|
||||
|
||||
if not lists:
|
||||
QMessageBox.information(self, "Hent liste", "Ingen gemte danselister fundet.")
|
||||
return
|
||||
|
||||
names = [f"{row['name']} ({row['created_at'][:10]})" for row in lists]
|
||||
choice, ok = QInputDialog.getItem(
|
||||
self, "Hent danseliste", "Vælg en liste:", names, editable=False
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
|
||||
idx = names.index(choice)
|
||||
pl_id = lists[idx]["id"]
|
||||
pl_name = lists[idx]["name"]
|
||||
from ui.playlist_browser import PlaylistBrowserDialog
|
||||
dialog = PlaylistBrowserDialog(mode="load", parent=self.window())
|
||||
dialog.playlist_selected.connect(self._load_playlist_by_id)
|
||||
dialog.exec()
|
||||
|
||||
def _load_playlist_by_id(self, pl_id: int, pl_name: str):
|
||||
try:
|
||||
from local.local_db import get_db
|
||||
with get_db() as conn:
|
||||
songs_raw = conn.execute("""
|
||||
SELECT s.*, ps.position, ps.status FROM playlist_songs ps
|
||||
SELECT s.*, ps.position, ps.status,
|
||||
ps.is_workshop, ps.dance_override
|
||||
FROM playlist_songs ps
|
||||
JOIN songs s ON s.id = ps.song_id
|
||||
WHERE ps.playlist_id=? ORDER BY ps.position
|
||||
""", (pl_id,)).fetchall()
|
||||
songs = []
|
||||
statuses = []
|
||||
for row in songs_raw:
|
||||
dances = conn.execute(
|
||||
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
|
||||
(row["id"],)
|
||||
).fetchall()
|
||||
dances = conn.execute("""
|
||||
SELECT d.name FROM song_dances sd
|
||||
JOIN dances d ON d.id = sd.dance_id
|
||||
WHERE sd.song_id=? ORDER BY sd.dance_order
|
||||
""", (row["id"],)).fetchall()
|
||||
dance_names = [d["name"] for d in dances]
|
||||
# dance_override bestemmer hvilken dans der vises
|
||||
override = row["dance_override"] or ""
|
||||
active_dance = override if override else (dance_names[0] if dance_names else "")
|
||||
songs.append({
|
||||
"id": row["id"], "title": row["title"],
|
||||
"artist": row["artist"], "album": row["album"],
|
||||
"bpm": row["bpm"], "duration_sec": row["duration_sec"],
|
||||
"local_path": row["local_path"], "file_format": row["file_format"],
|
||||
"file_missing": bool(row["file_missing"]),
|
||||
"dances": [d["dance_name"] for d in dances],
|
||||
"dances": dance_names,
|
||||
"active_dance": active_dance,
|
||||
"is_workshop": bool(row["is_workshop"]),
|
||||
})
|
||||
statuses.append(row["status"] or "pending")
|
||||
self._songs = songs
|
||||
@@ -432,6 +533,9 @@ class PlaylistPanel(QWidget):
|
||||
self._named_playlist_id = pl_id
|
||||
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}")
|
||||
self._lbl_autosave.setText("✓ gendannet")
|
||||
self._btn_save_current.setEnabled(True)
|
||||
self._btn_save_current.setToolTip(f"Gem ændringer til '{pl_name}'")
|
||||
self._save_named_playlist_id(pl_id)
|
||||
self._refresh()
|
||||
self._trigger_autosave()
|
||||
except Exception as e:
|
||||
@@ -439,6 +543,94 @@ class PlaylistPanel(QWidget):
|
||||
|
||||
# ── Start event ───────────────────────────────────────────────────────────
|
||||
|
||||
def _show_playlist_info(self):
|
||||
"""Åbn/luk det flydende danseliste-info vindue."""
|
||||
try:
|
||||
if hasattr(self, "_info_window") and self._info_window \
|
||||
and self._info_window.isVisible():
|
||||
self._info_window.close()
|
||||
self._info_window = None
|
||||
return
|
||||
except RuntimeError:
|
||||
self._info_window = None
|
||||
|
||||
# Opdater defaults fra indstillinger ved åbning
|
||||
try:
|
||||
from ui.settings_dialog import load_settings
|
||||
s = load_settings()
|
||||
self._pause_seconds = s.get("between_seconds", 60)
|
||||
self._workshop_seconds = s.get("workshop_minutes", 10) * 60
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from ui.playlist_info_dialog import PlaylistInfoWindow
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
main = self.window()
|
||||
self._info_window = PlaylistInfoWindow(self, parent=main)
|
||||
QApplication.instance().aboutToQuit.connect(self._info_window.close)
|
||||
if main:
|
||||
geo = main.geometry()
|
||||
self._info_window.move(geo.right() + 10, geo.top() + 100)
|
||||
self._info_window.show()
|
||||
|
||||
def _change_dance(self, idx: int, song: dict):
|
||||
"""Lad brugeren vælge/skrive hvilken dans der vises for dette nummer."""
|
||||
from ui.dance_picker_dialog import DancePickerDialog
|
||||
current = song.get("active_dance", "")
|
||||
if not current:
|
||||
dances = song.get("dances", [])
|
||||
current = dances[0] if dances else ""
|
||||
dlg = DancePickerDialog(
|
||||
current_dance=current,
|
||||
song_title=song.get("title", ""),
|
||||
parent=self.window()
|
||||
)
|
||||
if dlg.exec():
|
||||
chosen = dlg.get_dance()
|
||||
if chosen:
|
||||
song["active_dance"] = chosen
|
||||
self._refresh()
|
||||
self._sync_dance_to_db(idx, song)
|
||||
|
||||
def _sync_dance_to_db(self, idx: int, song: dict):
|
||||
"""Gem dance_override til playlist_songs."""
|
||||
if not self._named_playlist_id:
|
||||
return
|
||||
try:
|
||||
from local.local_db import get_db
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE playlist_songs SET dance_override=? "
|
||||
"WHERE playlist_id=? AND position=?",
|
||||
(song.get("active_dance", ""), self._named_playlist_id, idx + 1)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sync_ws_to_db(self, idx: int, song: dict):
|
||||
"""Gem is_workshop til playlist_songs — både navngiven og aktiv liste."""
|
||||
pl_ids = []
|
||||
if self._named_playlist_id:
|
||||
pl_ids.append(self._named_playlist_id)
|
||||
if self._active_playlist_id:
|
||||
pl_ids.append(self._active_playlist_id)
|
||||
if not pl_ids:
|
||||
return
|
||||
try:
|
||||
from local.local_db import get_db
|
||||
with get_db() as conn:
|
||||
for pl_id in pl_ids:
|
||||
conn.execute(
|
||||
"UPDATE playlist_songs SET is_workshop=? "
|
||||
"WHERE playlist_id=? AND position=?",
|
||||
(1 if song.get("is_workshop") else 0, pl_id, idx + 1)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_pause_changed(self, seconds: int):
|
||||
self._pause_seconds = seconds
|
||||
|
||||
def _start_event(self):
|
||||
if not self._songs:
|
||||
return
|
||||
@@ -469,6 +661,7 @@ class PlaylistPanel(QWidget):
|
||||
idx = item.data(Qt.ItemDataRole.UserRole)
|
||||
if idx is None:
|
||||
return
|
||||
song = self._songs[idx] if 0 <= idx < len(self._songs) else None
|
||||
menu = QMenu(self)
|
||||
act_play = menu.addAction("▶ Afspil denne")
|
||||
menu.addSeparator()
|
||||
@@ -476,6 +669,14 @@ class PlaylistPanel(QWidget):
|
||||
act_unplay = menu.addAction("↺ Sæt til ikke afspillet")
|
||||
act_played = menu.addAction("✓ Sæt til afspillet")
|
||||
menu.addSeparator()
|
||||
# Dans-valg
|
||||
act_dance = menu.addAction("💃 Vælg dans...")
|
||||
# Workshop toggle
|
||||
is_ws = song.get("is_workshop", False) if song else False
|
||||
act_ws = menu.addAction("🎓 Fjern workshop" if is_ws else "🎓 Markér som workshop")
|
||||
menu.addSeparator()
|
||||
act_dance_info = menu.addAction("ℹ Dans-info...")
|
||||
menu.addSeparator()
|
||||
act_remove = menu.addAction("✕ Fjern fra liste")
|
||||
action = menu.exec(self._list.mapToGlobal(pos))
|
||||
if action == act_play:
|
||||
@@ -492,6 +693,18 @@ class PlaylistPanel(QWidget):
|
||||
self._statuses[idx] = "played"
|
||||
self.status_changed.emit(idx, "played")
|
||||
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
|
||||
elif action == act_dance and song:
|
||||
self._change_dance(idx, song)
|
||||
elif action == act_ws and song:
|
||||
song["is_workshop"] = not song.get("is_workshop", False)
|
||||
self._sync_ws_to_db(idx, song)
|
||||
self._refresh()
|
||||
self.playlist_changed.emit()
|
||||
elif action == act_dance_info:
|
||||
if song:
|
||||
from ui.dance_info_dialog import DanceInfoDialog
|
||||
dlg = DanceInfoDialog(song, parent=self.window())
|
||||
dlg.exec()
|
||||
elif action == act_remove:
|
||||
self._songs.pop(idx)
|
||||
self._statuses.pop(idx)
|
||||
@@ -507,17 +720,22 @@ class PlaylistPanel(QWidget):
|
||||
self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet")
|
||||
for i, song in enumerate(self._songs):
|
||||
is_current = (i == self._current_idx and not self._song_ended)
|
||||
is_next = (self._song_ended and i == self._current_idx + 1) or \
|
||||
(self._current_idx == -1 and self._song_ended and i == 0)
|
||||
status = "playing" if is_current else "next" if is_next else self._statuses[i]
|
||||
status = "playing" if is_current else self._statuses[i]
|
||||
icon = self.STATUS_ICON.get(status, " ")
|
||||
dances = " / ".join(song.get("dances", [])) or "ingen dans tagget"
|
||||
text = f"{i+1:>2}. {song.get('title','—')}\n {song.get('artist','')} · {dances}"
|
||||
|
||||
# Vis active_dance (override eller første dans) eller alle danse
|
||||
active = song.get("active_dance", "")
|
||||
if not active:
|
||||
dances = song.get("dances", [])
|
||||
active = dances[0] if dances else "ingen dans tagget"
|
||||
ws_tag = " 🎓" if song.get("is_workshop") else ""
|
||||
|
||||
text = (f"{i+1:>2}. {song.get('title','—')}{ws_tag}\n"
|
||||
f" {song.get('artist','')} · {active}")
|
||||
item = QListWidgetItem(f"{icon} {text}")
|
||||
item.setData(Qt.ItemDataRole.UserRole, i)
|
||||
color = self.STATUS_COLOR.get(status, "#5a6070")
|
||||
if status in ("playing", "next"):
|
||||
item.setForeground(QColor(color))
|
||||
if status == "playing":
|
||||
item.setForeground(QColor(self.STATUS_COLOR["playing"]))
|
||||
f = item.font(); f.setBold(True); item.setFont(f)
|
||||
elif status == "played":
|
||||
item.setForeground(QColor("#2ecc71"))
|
||||
|
||||
@@ -20,6 +20,9 @@ SETTINGS_KEY_MAIL_PATH = "mail/custom_path"
|
||||
SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
|
||||
SETTINGS_KEY_USERNAME = "online/username"
|
||||
SETTINGS_KEY_PASSWORD = "online/password"
|
||||
SETTINGS_KEY_LANGUAGE = "appearance/language"
|
||||
SETTINGS_KEY_BETWEEN_SEC = "playback/between_seconds"
|
||||
SETTINGS_KEY_WORKSHOP_MIN = "playback/workshop_minutes"
|
||||
|
||||
|
||||
def load_settings() -> dict:
|
||||
@@ -34,6 +37,9 @@ def load_settings() -> dict:
|
||||
"auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool),
|
||||
"username": s.value(SETTINGS_KEY_USERNAME, ""),
|
||||
"password": s.value(SETTINGS_KEY_PASSWORD, ""),
|
||||
"language": s.value(SETTINGS_KEY_LANGUAGE, "da"),
|
||||
"between_seconds": s.value(SETTINGS_KEY_BETWEEN_SEC, 60, type=int),
|
||||
"workshop_minutes": s.value(SETTINGS_KEY_WORKSHOP_MIN, 10, type=int),
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +54,9 @@ def save_settings(values: dict):
|
||||
s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False))
|
||||
s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
|
||||
s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", ""))
|
||||
s.setValue(SETTINGS_KEY_LANGUAGE, values.get("language", "da"))
|
||||
s.setValue(SETTINGS_KEY_BETWEEN_SEC, values.get("between_seconds", 60))
|
||||
s.setValue(SETTINGS_KEY_WORKSHOP_MIN,values.get("workshop_minutes", 10))
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
@@ -70,6 +79,7 @@ class SettingsDialog(QDialog):
|
||||
tabs.addTab(self._build_playback_tab(), "▶ Afspilning")
|
||||
tabs.addTab(self._build_mail_tab(), "✉ Mail")
|
||||
tabs.addTab(self._build_online_tab(), "🌐 Online")
|
||||
tabs.addTab(self._build_language_tab(), "🌍 Sprog")
|
||||
layout.addWidget(tabs)
|
||||
|
||||
# Knapper
|
||||
@@ -142,6 +152,23 @@ class SettingsDialog(QDialog):
|
||||
note.setWordWrap(True)
|
||||
grp_layout.addRow(note)
|
||||
layout.addWidget(grp)
|
||||
|
||||
grp2 = QGroupBox("Danseliste-tider (ℹ info-vinduet)")
|
||||
grp2_layout = QFormLayout(grp2)
|
||||
|
||||
self._spin_between = QSpinBox()
|
||||
self._spin_between.setRange(0, 600)
|
||||
self._spin_between.setSuffix(" sekunder")
|
||||
self._spin_between.setFixedWidth(140)
|
||||
grp2_layout.addRow("Tid mellem musikstykker:", self._spin_between)
|
||||
|
||||
self._spin_workshop = QSpinBox()
|
||||
self._spin_workshop.setRange(0, 120)
|
||||
self._spin_workshop.setSuffix(" minutter")
|
||||
self._spin_workshop.setFixedWidth(140)
|
||||
grp2_layout.addRow("Tid per workshop:", self._spin_workshop)
|
||||
|
||||
layout.addWidget(grp2)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
@@ -230,6 +257,24 @@ class SettingsDialog(QDialog):
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _build_language_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(12)
|
||||
grp = QGroupBox("Sprog")
|
||||
grp_layout = QFormLayout(grp)
|
||||
self._lang_combo = QComboBox()
|
||||
self._lang_combo.addItem("Dansk", "da")
|
||||
self._lang_combo.addItem("English", "en")
|
||||
grp_layout.addRow("Programsprog:", self._lang_combo)
|
||||
note = QLabel("Sproget anvendes næste gang programmet startes.")
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
grp_layout.addRow(note)
|
||||
layout.addWidget(grp)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _on_auto_login_changed(self, state: int):
|
||||
enabled = state == Qt.CheckState.Checked.value
|
||||
self._user_input.setEnabled(enabled)
|
||||
@@ -242,6 +287,15 @@ class SettingsDialog(QDialog):
|
||||
self._chk_dark.setChecked(v.get("dark_theme", True))
|
||||
self._spin_demo.setValue(v.get("demo_seconds", 10))
|
||||
self._spin_fade.setValue(v.get("demo_fade_seconds", 5))
|
||||
self._spin_between.setValue(v.get("between_seconds", 60))
|
||||
self._spin_workshop.setValue(v.get("workshop_minutes", 10))
|
||||
|
||||
# Sprog
|
||||
lang = v.get("language", "da")
|
||||
for i in range(self._lang_combo.count()):
|
||||
if self._lang_combo.itemData(i) == lang:
|
||||
self._lang_combo.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
# Mail
|
||||
client = v.get("mail_client", "auto")
|
||||
@@ -267,11 +321,14 @@ class SettingsDialog(QDialog):
|
||||
"dark_theme": self._chk_dark.isChecked(),
|
||||
"demo_seconds": self._spin_demo.value(),
|
||||
"demo_fade_seconds": self._spin_fade.value(),
|
||||
"between_seconds": self._spin_between.value(),
|
||||
"workshop_minutes": self._spin_workshop.value(),
|
||||
"mail_client": self._mail_combo.currentData(),
|
||||
"mail_path": self._mail_path.text().strip(),
|
||||
"auto_login": self._chk_auto_login.isChecked(),
|
||||
"username": self._user_input.text().strip(),
|
||||
"password": self._pass_input.text(),
|
||||
"language": self._lang_combo.currentData(),
|
||||
}
|
||||
save_settings(values)
|
||||
self._values = values
|
||||
|
||||
@@ -6,87 +6,26 @@ Dans = navn + niveau kombination. Autoudfyld viser "Navn / Niveau".
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QComboBox, QWidget, QMessageBox, QGroupBox,
|
||||
QScrollArea, QFrame,
|
||||
QScrollArea, QFrame, QListWidget, QListWidgetItem,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, QStringListModel
|
||||
from PyQt6.QtWidgets import QCompleter
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
|
||||
|
||||
class DanceLineEdit(QLineEdit):
|
||||
"""Autoudfyld der viser 'Navn / Niveau' fra dances tabellen."""
|
||||
"""Simpelt tekstfelt til dans-navn i eksisterende rækker."""
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
dance_selected = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, placeholder="", parent=None):
|
||||
super().__init__(parent)
|
||||
self.setPlaceholderText(placeholder)
|
||||
self._model = QStringListModel()
|
||||
self._suggestions = [] # liste af {id, name, level_id, level_name}
|
||||
comp = QCompleter(self._model, self)
|
||||
comp.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
comp.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
|
||||
comp.setMaxVisibleItems(12)
|
||||
comp.activated.connect(self._on_activated)
|
||||
self.setCompleter(comp)
|
||||
self._selected_dance = None # {id, name, level_id, level_name}
|
||||
t = QTimer(self)
|
||||
t.setSingleShot(True)
|
||||
t.setInterval(150)
|
||||
t.timeout.connect(self._suggest)
|
||||
self.textChanged.connect(lambda _: (t.start(), self._clear_selection()))
|
||||
self._timer = t
|
||||
|
||||
def _clear_selection(self):
|
||||
self._selected_dance = None
|
||||
|
||||
def _suggest(self):
|
||||
prefix = self.text().strip()
|
||||
if "/" in prefix:
|
||||
prefix = prefix.split("/")[0].strip()
|
||||
if not prefix:
|
||||
return
|
||||
try:
|
||||
from local.local_db import get_dance_suggestions
|
||||
self._suggestions = get_dance_suggestions(prefix, limit=15)
|
||||
labels = []
|
||||
for s in self._suggestions:
|
||||
if s.get("level_name"):
|
||||
labels.append(f"{s['name']} / {s['level_name']}")
|
||||
else:
|
||||
labels.append(s["name"])
|
||||
self._model.setStringList(labels)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_activated(self, text: str):
|
||||
"""Bruger valgte et forslag — gem hele dance-objektet."""
|
||||
for s in self._suggestions:
|
||||
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
|
||||
if label == text:
|
||||
self._selected_dance = s
|
||||
break
|
||||
|
||||
def get_dance_info(self) -> dict:
|
||||
"""Returnerer {name, level_id} — fra valgt forslag eller fra fritekst."""
|
||||
if self._selected_dance:
|
||||
return {
|
||||
"name": self._selected_dance["name"],
|
||||
"level_id": self._selected_dance["level_id"],
|
||||
}
|
||||
# Fritekst — parse "Navn / Niveau" hvis bruger har skrevet det manuelt
|
||||
text = self.text().strip()
|
||||
if " / " in text:
|
||||
parts = text.split(" / ", 1)
|
||||
name = parts[0].strip()
|
||||
level_name = parts[1].strip()
|
||||
# Slå niveau op
|
||||
try:
|
||||
from local.local_db import get_dance_levels
|
||||
for lvl in get_dance_levels():
|
||||
if lvl["name"].lower() == level_name.lower():
|
||||
return {"name": name, "level_id": lvl["id"]}
|
||||
except Exception:
|
||||
pass
|
||||
return {"name": name, "level_id": None}
|
||||
return {"name": text, "level_id": None}
|
||||
return {"name": parts[0].strip()}
|
||||
return {"name": text}
|
||||
|
||||
|
||||
class TagEditorDialog(QDialog):
|
||||
@@ -167,8 +106,11 @@ class TagEditorDialog(QDialog):
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
def _build_dances_panel(self) -> QGroupBox:
|
||||
grp = QGroupBox("Danse")
|
||||
from translations import _
|
||||
grp = QGroupBox(_("tags.dances"))
|
||||
layout = QVBoxLayout(grp)
|
||||
|
||||
# Eksisterende danse
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
@@ -180,34 +122,77 @@ class TagEditorDialog(QDialog):
|
||||
layout.addWidget(scroll, stretch=1)
|
||||
self._dance_rows = []
|
||||
for d in self._dances:
|
||||
label = f"{d['name']} / {d['level_name']}" if d.get("level_name") else d["name"]
|
||||
self._add_dance_row(label)
|
||||
self._add_dance_row(d["name"], d["level_id"])
|
||||
|
||||
add_row = QHBoxLayout()
|
||||
self._new_dance = DanceLineEdit("Ny dans (f.eks. Cowboy Cha Cha / Begynder)...", self)
|
||||
# Søgefelt
|
||||
self._new_dance = QLineEdit()
|
||||
self._new_dance.setPlaceholderText(_("tags.new_dance"))
|
||||
self._new_dance.textChanged.connect(self._on_dance_search)
|
||||
self._new_dance.returnPressed.connect(self._on_add_dance)
|
||||
add_row.addWidget(self._new_dance)
|
||||
btn = QPushButton("+ Tilføj")
|
||||
btn.setFixedWidth(70)
|
||||
btn.clicked.connect(self._on_add_dance)
|
||||
add_row.addWidget(btn)
|
||||
layout.addLayout(add_row)
|
||||
layout.addWidget(self._new_dance)
|
||||
|
||||
# Forslags-liste
|
||||
self._dance_suggestions = QListWidget()
|
||||
self._dance_suggestions.setMaximumHeight(120)
|
||||
self._dance_suggestions.itemClicked.connect(
|
||||
lambda item: self._add_from_suggestion(item, "dance")
|
||||
)
|
||||
layout.addWidget(self._dance_suggestions)
|
||||
|
||||
# Timer til debounce
|
||||
self._dance_search_timer = QTimer(self)
|
||||
self._dance_search_timer.setSingleShot(True)
|
||||
self._dance_search_timer.setInterval(150)
|
||||
self._dance_search_timer.timeout.connect(
|
||||
lambda: self._load_dance_suggestions(
|
||||
self._new_dance.text().strip(), self._dance_suggestions
|
||||
)
|
||||
)
|
||||
self._load_dance_suggestions("", self._dance_suggestions)
|
||||
return grp
|
||||
|
||||
def _add_dance_row(self, text=""):
|
||||
def _add_dance_row(self, name="", level_id=None):
|
||||
from translations import _
|
||||
row_widget = QWidget()
|
||||
row_layout = QHBoxLayout(row_widget)
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
row_layout.setSpacing(4)
|
||||
|
||||
edit = DanceLineEdit("Dans...", self)
|
||||
edit.setText(text)
|
||||
edit.setText(name)
|
||||
row_layout.addWidget(edit, stretch=1)
|
||||
|
||||
# Niveau-dropdown
|
||||
level_cb = QComboBox()
|
||||
level_cb.addItem(_("tags.no_level"), None)
|
||||
for lvl in self._levels:
|
||||
from translations import translate_level
|
||||
level_cb.addItem(translate_level(lvl["name"]), lvl["id"])
|
||||
# Sæt til det rigtige niveau
|
||||
if level_id is not None:
|
||||
for i in range(level_cb.count()):
|
||||
if level_cb.itemData(i) == level_id:
|
||||
level_cb.setCurrentIndex(i)
|
||||
break
|
||||
level_cb.setFixedWidth(130)
|
||||
row_layout.addWidget(level_cb)
|
||||
|
||||
# Når autoudfyld vælger — opdater dropdown
|
||||
def on_dance_selected(dance_info, cb=level_cb):
|
||||
if dance_info.get("level_id") is not None:
|
||||
for i in range(cb.count()):
|
||||
if cb.itemData(i) == dance_info["level_id"]:
|
||||
cb.setCurrentIndex(i)
|
||||
break
|
||||
edit.dance_selected.connect(on_dance_selected)
|
||||
|
||||
btn_rm = QPushButton("✕")
|
||||
btn_rm.setFixedSize(24, 24)
|
||||
row_layout.addWidget(btn_rm)
|
||||
|
||||
idx = self._dance_layout.count() - 1
|
||||
self._dance_layout.insertWidget(idx, row_widget)
|
||||
entry = {"widget": row_widget, "edit": edit}
|
||||
entry = {"widget": row_widget, "edit": edit, "level": level_cb}
|
||||
self._dance_rows.append(entry)
|
||||
btn_rm.clicked.connect(lambda: self._remove_dance_row(entry))
|
||||
|
||||
@@ -215,13 +200,62 @@ class TagEditorDialog(QDialog):
|
||||
self._dance_rows.remove(entry)
|
||||
entry["widget"].deleteLater()
|
||||
|
||||
def _on_add_dance(self):
|
||||
if self._new_dance.text().strip():
|
||||
self._add_dance_row(self._new_dance.text().strip())
|
||||
def _on_dance_search(self):
|
||||
self._dance_search_timer.start()
|
||||
|
||||
def _on_alt_search(self):
|
||||
self._alt_search_timer.start()
|
||||
|
||||
def _load_dance_suggestions(self, prefix: str, list_widget):
|
||||
try:
|
||||
from local.local_db import get_dance_suggestions
|
||||
suggestions = get_dance_suggestions(prefix, limit=15)
|
||||
list_widget.clear()
|
||||
for s in suggestions:
|
||||
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
|
||||
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
|
||||
list_widget.addItem(item)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _add_from_suggestion(self, item, panel: str):
|
||||
"""Tilføj dans fra forslags-listen ved dobbeltklik."""
|
||||
name = item.data(Qt.ItemDataRole.UserRole + 1) or item.text().split(" / ")[0]
|
||||
level_id = item.data(Qt.ItemDataRole.UserRole)
|
||||
if panel == "dance":
|
||||
self._add_dance_row(name, level_id)
|
||||
self._new_dance.clear()
|
||||
self._load_dance_suggestions("", self._dance_suggestions)
|
||||
else:
|
||||
self._add_alt_row(name, level_id)
|
||||
self._new_alt.clear()
|
||||
self._load_dance_suggestions("", self._alt_suggestions)
|
||||
|
||||
def _on_add_dance(self):
|
||||
text = self._new_dance.text().strip()
|
||||
if text:
|
||||
name, level_id = self._parse_name_level(text)
|
||||
self._add_dance_row(name, level_id)
|
||||
self._new_dance.clear()
|
||||
self._load_dance_suggestions("", self._dance_suggestions)
|
||||
|
||||
def _parse_name_level(self, text: str) -> tuple:
|
||||
"""Parse 'Navn / Niveau' og returnér (name, level_id)."""
|
||||
if " / " in text:
|
||||
parts = text.split(" / ", 1)
|
||||
name = parts[0].strip()
|
||||
level_name = parts[1].strip()
|
||||
for lvl in self._levels:
|
||||
if lvl["name"].lower() == level_name.lower():
|
||||
return name, lvl["id"]
|
||||
return name, None
|
||||
return text, None
|
||||
|
||||
def _build_alts_panel(self) -> QGroupBox:
|
||||
grp = QGroupBox("Alternativ-danse")
|
||||
from translations import _
|
||||
grp = QGroupBox(_("tags.alts"))
|
||||
layout = QVBoxLayout(grp)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
@@ -234,42 +268,82 @@ class TagEditorDialog(QDialog):
|
||||
layout.addWidget(scroll, stretch=1)
|
||||
self._alt_rows = []
|
||||
for a in self._alts:
|
||||
label = f"{a['name']} / {a['level_name']}" if a.get("level_name") else a["name"]
|
||||
self._add_alt_row(label, a.get("note", ""))
|
||||
self._add_alt_row(a["name"], a["level_id"], a.get("note", ""))
|
||||
|
||||
add_row = QHBoxLayout()
|
||||
self._new_alt = DanceLineEdit("Alternativ dans...", self)
|
||||
self._new_alt = QLineEdit()
|
||||
self._new_alt.setPlaceholderText(_("tags.new_alt"))
|
||||
self._new_alt.textChanged.connect(self._on_alt_search)
|
||||
self._new_alt.returnPressed.connect(self._on_add_alt)
|
||||
add_row.addWidget(self._new_alt)
|
||||
btn = QPushButton("+ Tilføj")
|
||||
btn.setFixedWidth(70)
|
||||
btn.clicked.connect(self._on_add_alt)
|
||||
add_row.addWidget(btn)
|
||||
layout.addLayout(add_row)
|
||||
layout.addWidget(self._new_alt)
|
||||
|
||||
self._alt_suggestions = QListWidget()
|
||||
self._alt_suggestions.setMaximumHeight(120)
|
||||
self._alt_suggestions.itemClicked.connect(
|
||||
lambda item: self._add_from_suggestion(item, "alt")
|
||||
)
|
||||
layout.addWidget(self._alt_suggestions)
|
||||
|
||||
self._alt_search_timer = QTimer(self)
|
||||
self._alt_search_timer.setSingleShot(True)
|
||||
self._alt_search_timer.setInterval(150)
|
||||
self._alt_search_timer.timeout.connect(
|
||||
lambda: self._load_dance_suggestions(
|
||||
self._new_alt.text().strip(), self._alt_suggestions
|
||||
)
|
||||
)
|
||||
self._load_dance_suggestions("", self._alt_suggestions)
|
||||
return grp
|
||||
|
||||
def _add_alt_row(self, text="", note=""):
|
||||
def _add_alt_row(self, name="", level_id=None, note=""):
|
||||
from translations import _
|
||||
row_widget = QWidget()
|
||||
row_layout = QHBoxLayout(row_widget)
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
row_layout.setSpacing(4)
|
||||
|
||||
lbl = QLabel("→")
|
||||
lbl.setObjectName("track_meta")
|
||||
row_layout.addWidget(lbl)
|
||||
|
||||
edit = DanceLineEdit("Dans...", self)
|
||||
edit.setText(text)
|
||||
edit.setText(name)
|
||||
row_layout.addWidget(edit, stretch=1)
|
||||
|
||||
# Niveau-dropdown
|
||||
level_cb = QComboBox()
|
||||
level_cb.addItem(_("tags.no_level"), None)
|
||||
for lvl in self._levels:
|
||||
from translations import translate_level
|
||||
level_cb.addItem(translate_level(lvl["name"]), lvl["id"])
|
||||
if level_id is not None:
|
||||
for i in range(level_cb.count()):
|
||||
if level_cb.itemData(i) == level_id:
|
||||
level_cb.setCurrentIndex(i)
|
||||
break
|
||||
level_cb.setFixedWidth(130)
|
||||
row_layout.addWidget(level_cb)
|
||||
|
||||
def on_dance_selected(dance_info, cb=level_cb):
|
||||
if dance_info.get("level_id") is not None:
|
||||
for i in range(cb.count()):
|
||||
if cb.itemData(i) == dance_info["level_id"]:
|
||||
cb.setCurrentIndex(i)
|
||||
break
|
||||
edit.dance_selected.connect(on_dance_selected)
|
||||
|
||||
note_edit = QLineEdit()
|
||||
note_edit.setPlaceholderText("note...")
|
||||
note_edit.setPlaceholderText(_("tags.note"))
|
||||
note_edit.setText(note)
|
||||
note_edit.setFixedWidth(80)
|
||||
row_layout.addWidget(note_edit)
|
||||
|
||||
btn_rm = QPushButton("✕")
|
||||
btn_rm.setFixedSize(24, 24)
|
||||
row_layout.addWidget(btn_rm)
|
||||
|
||||
idx = self._alt_layout.count() - 1
|
||||
self._alt_layout.insertWidget(idx, row_widget)
|
||||
entry = {"widget": row_widget, "edit": edit, "note": note_edit}
|
||||
entry = {"widget": row_widget, "edit": edit, "level": level_cb, "note": note_edit}
|
||||
self._alt_rows.append(entry)
|
||||
btn_rm.clicked.connect(lambda: self._remove_alt_row(entry))
|
||||
|
||||
@@ -278,9 +352,12 @@ class TagEditorDialog(QDialog):
|
||||
entry["widget"].deleteLater()
|
||||
|
||||
def _on_add_alt(self):
|
||||
if self._new_alt.text().strip():
|
||||
self._add_alt_row(self._new_alt.text().strip())
|
||||
text = self._new_alt.text().strip()
|
||||
if text:
|
||||
name, level_id = self._parse_name_level(text)
|
||||
self._add_alt_row(name, level_id)
|
||||
self._new_alt.clear()
|
||||
self._load_dance_suggestions("", self._alt_suggestions)
|
||||
|
||||
def _save(self):
|
||||
song_id = self._song.get("id")
|
||||
@@ -290,18 +367,25 @@ class TagEditorDialog(QDialog):
|
||||
from local.local_db import new_conn, get_or_create_dance
|
||||
from local.tag_reader import write_dances, can_write_dances
|
||||
|
||||
# Saml data fra UI
|
||||
# Saml data fra UI — niveau kommer fra dropdown, ikke fra tekstfeltet
|
||||
dances = []
|
||||
for row in self._dance_rows:
|
||||
info = row["edit"].get_dance_info()
|
||||
if info["name"]:
|
||||
dances.append(info)
|
||||
name = row["edit"].text().strip()
|
||||
if name:
|
||||
dances.append({
|
||||
"name": name,
|
||||
"level_id": row["level"].currentData(),
|
||||
})
|
||||
|
||||
alts = []
|
||||
for row in self._alt_rows:
|
||||
info = row["edit"].get_dance_info()
|
||||
if info["name"]:
|
||||
alts.append({**info, "note": row["note"].text().strip()})
|
||||
name = row["edit"].text().strip()
|
||||
if name:
|
||||
alts.append({
|
||||
"name": name,
|
||||
"level_id": row["level"].currentData(),
|
||||
"note": row["note"].text().strip(),
|
||||
})
|
||||
|
||||
conn = new_conn()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user