Rettelsaer

This commit is contained in:
2026-04-13 07:23:37 +02:00
parent 45dcedaed4
commit bbd5690d72
22 changed files with 2026 additions and 538 deletions

View File

@@ -95,6 +95,9 @@ class MainWindow(QMainWindow):
self._connect_player_signals()
self._library_loaded.connect(self._apply_library)
self._db_ready.connect(self._on_db_ready)
self._login_success_signal.connect(self._on_login_success)
self._login_fail_signal.connect(self._on_login_fail)
self._status_signal.connect(self._set_status)
self._build_menu()
self._build_ui()
self._build_statusbar()
@@ -130,15 +133,15 @@ class MainWindow(QMainWindow):
# ── Filer ─────────────────────────────────────────────────────────────
file_menu = menubar.addMenu("Filer")
self._act_go_online = QAction("Gå online...", self)
self._act_go_online = QAction("Gå online", self)
self._act_go_online.setShortcut("Ctrl+L")
self._act_go_online.triggered.connect(self._go_online)
file_menu.addAction(self._act_go_online)
self._act_go_offline = QAction("Gå offline", self)
self._act_go_offline.triggered.connect(self._go_offline)
self._act_go_offline.setEnabled(False)
file_menu.addAction(self._act_go_offline)
self._act_sync = QAction("↕ Synkroniser nu", self)
self._act_sync.setShortcut("Ctrl+Shift+S")
self._act_sync.triggered.connect(self._manual_sync)
file_menu.addAction(self._act_sync)
file_menu.addSeparator()
@@ -287,28 +290,26 @@ class MainWindow(QMainWindow):
b.setCheckable(True)
return b
self._btn_prev = btn("|◀◀", size=52)
self._btn_play = btn("", "btn_play", size=72)
self._btn_stop = btn("", "btn_stop", size=52)
self._btn_next = btn("▶▶|", size=52)
self._btn_stop = btn("", "btn_stop", size=72)
self._btn_demo = btn(f"\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True)
self._btn_prev.clicked.connect(self._prev_song)
self._btn_play.clicked.connect(self._toggle_play)
self._btn_stop.clicked.connect(self._stop)
self._btn_next.clicked.connect(self._next_song)
self._btn_demo.clicked.connect(self._toggle_demo)
layout.addWidget(self._btn_prev)
layout.addWidget(self._btn_play)
layout.addWidget(self._btn_stop)
layout.addWidget(self._btn_next)
layout.addSpacing(24)
sep1 = QFrame()
sep1.setFrameShape(QFrame.Shape.VLine)
sep1.setFixedWidth(1)
layout.addWidget(sep1)
layout.addSpacing(24)
layout.addWidget(self._btn_demo)
layout.addStretch()
@@ -319,7 +320,9 @@ class MainWindow(QMainWindow):
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
self._vol_slider.setRange(0, 100)
self._vol_slider.setValue(self._settings.get("volume", 78))
self._vol_slider.setFixedWidth(100)
self._vol_slider.setFixedWidth(160)
self._vol_slider.setFixedHeight(36)
self._vol_slider.setObjectName("vol_slider")
self._vol_slider.valueChanged.connect(self._on_volume)
layout.addWidget(self._vol_slider)
@@ -336,7 +339,14 @@ class MainWindow(QMainWindow):
self._playlist_panel.song_selected.connect(self._load_song_by_idx)
self._playlist_panel.song_dropped.connect(self._on_song_dropped)
self._playlist_panel.event_started.connect(self._on_event_started)
self._playlist_panel.next_song_ready.connect(self._load_song)
self._playlist_panel.next_song_ready.connect(self._on_next_song_ready)
self._playlist_panel.playlist_changed.connect(self._on_playlist_changed)
# Debounce-timer til auto-sync — starter sync 5 sek efter sidst ændring
self._sync_debounce = QTimer(self)
self._sync_debounce.setSingleShot(True)
self._sync_debounce.setInterval(5000)
self._sync_debounce.timeout.connect(self._auto_sync)
self._library_panel = LibraryPanel()
self._library_panel.song_selected.connect(self._on_library_song_selected)
@@ -433,9 +443,12 @@ class MainWindow(QMainWindow):
QTimer.singleShot(200, self._reload_library)
# Signal til at opdatere biblioteket fra baggrundstråd
_library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list)
_db_ready = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
_file_changed_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
_library_loaded = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(list)
_db_ready = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
_file_changed_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal()
_login_success_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str)
_login_fail_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str)
_status_signal = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(str, int)
def _reload_library(self):
"""Hent sange fra DB i baggrundstråd — thread-safe via signal."""
@@ -508,20 +521,36 @@ class MainWindow(QMainWindow):
try:
restored = self._playlist_panel.restore_active_playlist()
if restored:
# Hent den sang der er klar (current_idx sat af restore)
idx = self._playlist_panel._current_idx
song = self._playlist_panel.get_song(idx)
if self._playlist_panel.restore_event_state():
idx = self._playlist_panel._current_idx
# Event var i gang — genoptag
idx = self._playlist_panel._current_idx
song = self._playlist_panel.get_song(idx)
if song:
self._current_idx = idx
self._song_ended = False
self._load_song(song)
self._playlist_panel.set_current(idx)
self._set_status(
f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte",
f"Event genoptaget ved: {song.get('title','')} — tryk ▶",
6000,
)
elif song:
# Normal opstart — load første sang klar
self._current_idx = idx
self._song_ended = False
self._load_song(song)
self._playlist_panel.set_current(idx)
self._set_status(
f"Klar: {song.get('title','')} — tryk ▶ for at starte",
4000,
)
except Exception:
pass
# Scan 30 sek efter opstart — fanger ændringer siden sidst
QTimer.singleShot(30000, self.start_background_scan)
def start_background_scan(self):
@@ -602,40 +631,104 @@ class MainWindow(QMainWindow):
def _auto_login(self):
"""Forsøg automatisk login med gemte oplysninger."""
username = self._settings.get("username", "")
password = self._settings.get("password", "")
username = self._settings.get("username", "")
password = self._settings.get("password", "")
server_url = self._settings.get("server_url", "http://localhost:8000").rstrip("/")
if not username or not password:
return
try:
import urllib.request, urllib.parse, json
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
req = urllib.request.Request(
f"{API_URL}/auth/login", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=8) as resp:
body = json.loads(resp.read())
self._api_token = body.get("access_token")
self._api_url = API_URL
self._api_username = username
self._set_online_state(True)
self._set_status(f"Automatisk logget ind som {username}", 4000)
# Synkroniser dans-niveauer og navne
QTimer.singleShot(500, self._sync_dance_data)
except Exception:
self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000)
def _run():
try:
import urllib.request, urllib.parse, json
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
req = urllib.request.Request(
f"{server_url}/auth/login", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=8) as resp:
body = json.loads(resp.read())
self._api_token = body.get("access_token")
self._api_url = server_url
self._api_username = username
# Kald GUI-opdatering via signal — thread-safe
self._login_success_signal.emit(username)
except Exception as e:
self._login_fail_signal.emit(str(e))
import threading
threading.Thread(target=_run, daemon=True).start()
def _on_playlist_changed(self):
"""Danseliste ændret — start debounce-timer til auto-sync."""
if hasattr(self, "_sync_debounce"):
self._sync_debounce.start()
def _auto_sync(self):
"""Kør sync hvis vi er online — kaldes af debounce-timer."""
if not self._api_token:
return
if not hasattr(self, "_sync_manager") or not self._sync_manager:
return
self._sync_manager.push(
on_done=lambda r: self._status_signal.emit(
f"↑ Synkroniseret — {r.get('songs_synced', 0)} sange", 3000
),
on_error=lambda e: self._status_signal.emit(
f"⚠ Sync fejl: {e}", 8000
),
)
def _on_next_song_ready(self, song: dict):
"""Næste sang er klar — load den i afspilleren og markér orange."""
idx = self._playlist_panel._current_idx
self._current_idx = idx
self._song_ended = False
self._playlist_panel._song_ended = False
self._load_song(song)
self._playlist_panel.set_current(idx)
def _on_login_success(self, username: str):
"""Kaldes i GUI-tråden når login lykkes."""
self._set_online_state(True)
self._set_status(f"Logget ind som {username}", 4000)
def _on_login_fail(self, error: str):
"""Kaldes i GUI-tråden når login fejler."""
self._set_status(f"Login fejlede: {error}", 5000)
def _go_online(self):
dialog = LoginDialog(self)
if dialog.exec():
url, username, token = dialog.get_credentials()
self._api_url = url
self._api_token = token
self._api_username = username
self._set_online_state(True)
self._set_status(f"Online som {username}", 5000)
QTimer.singleShot(500, self._sync_dance_data)
"""Log ind/ud med gemte credentials."""
if self._api_token:
self._go_offline()
return
username = self._settings.get("username", "")
password = self._settings.get("password", "")
server_url = self._settings.get("server_url", "http://localhost:8000").rstrip("/")
if not username or not password:
self._set_status("Udfyld brugernavn og kodeord i Indstillinger → Online", 5000)
return
def _run():
try:
import urllib.request, urllib.parse, json
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
req = urllib.request.Request(
f"{server_url}/auth/login", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=8) as resp:
body = json.loads(resp.read())
self._api_token = body.get("access_token")
self._api_url = server_url
self._api_username = username
self._login_success_signal.emit(username)
except Exception as e:
self._login_fail_signal.emit(str(e))
import threading
threading.Thread(target=_run, daemon=True).start()
def _sync_dance_data(self):
"""Synkroniser dans-niveauer og navne fra API."""
@@ -669,15 +762,56 @@ class MainWindow(QMainWindow):
self._set_status("Offline — arbejder lokalt", 3000)
def _set_online_state(self, online: bool):
self._act_go_online.setEnabled(not online)
self._act_go_offline.setEnabled(online)
if online:
name = self._api_username or "?"
self._conn_label.setText(f"● ONLINE ({name})")
self._conn_label.setStyleSheet("color: #2ecc71;")
self._act_go_online.setText("● Gå offline")
self._init_sync()
else:
self._conn_label.setText("● OFFLINE")
self._conn_label.setStyleSheet("color: #5a6070;")
self._act_go_online.setText("● Gå online")
self._sync_manager = None
def _init_sync(self):
"""Opret SyncManager og kør initial push+pull."""
try:
from local.local_db import DB_PATH
from local.sync_manager import SyncManager
server_url = self._settings.get("server_url", "http://localhost:8000")
self._sync_manager = SyncManager(
db_path=str(DB_PATH),
server_url=server_url,
token=self._api_token,
)
self._sync_manager.push_and_pull(
on_done=lambda r: self._status_signal.emit(
f"✓ Synkroniseret — {r['push']['songs_synced']} sange", 5000
),
on_error=lambda e: self._status_signal.emit(
f"⚠ Sync fejl: {e}", 5000
),
)
except Exception as e:
self._set_status(f"⚠ Sync fejl: {e}", 5000)
def _manual_sync(self):
if not self._api_token:
self._set_status("Log ind for at synkronisere", 3000)
return
if not hasattr(self, "_sync_manager") or not self._sync_manager:
self._init_sync()
return
self._set_status("Synkroniserer...", 2000)
self._sync_manager.push_and_pull(
on_done=lambda r: self._status_signal.emit(
f"✓ Synkroniseret — {r['push']['songs_synced']} sange", 4000
),
on_error=lambda e: self._status_signal.emit(
f"⚠ Sync fejl: {e}", 5000
),
)
def _new_playlist(self):
self._stop()
@@ -851,6 +985,12 @@ class MainWindow(QMainWindow):
song = self._playlist_panel.get_song(idx)
if not song:
return
# Nulstil gammel markering
old_idx = self._playlist_panel._current_idx
if old_idx is not None and old_idx != idx:
if 0 <= old_idx < len(self._playlist_panel._statuses):
if self._playlist_panel._statuses[old_idx] == "playing":
self._playlist_panel._statuses[old_idx] = "pending"
self._current_idx = idx
self._load_song(song)
self._playlist_panel.set_current(idx)
@@ -944,13 +1084,16 @@ class MainWindow(QMainWindow):
self._btn_play.setText("")
self._vu.reset()
# Synkroniser current_idx til playlist_panel
self._playlist_panel._current_idx = self._current_idx
# Markér den afspillede sang
self._playlist_panel.mark_played(self._current_idx)
# Synkroniser event-status til den gemte navngivne liste
self._sync_event_status_to_playlist()
# Find første ikke-afspillede og ikke-skippede sang fra TOPPEN
# Find ste uafspillede
ni = self._playlist_panel.next_playable_idx()
next_song = self._playlist_panel.get_song(ni) if ni is not None else None
if next_song:
@@ -959,7 +1102,6 @@ class MainWindow(QMainWindow):
self._load_song(next_song)
self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte")
else:
# Danseliste afsluttet — nulstil liste-markering og synkroniser
self._current_idx = -1
self._playlist_panel._current_idx = -1
self._playlist_panel._song_ended = False

View File

@@ -130,6 +130,12 @@ class PlaylistBrowserDialog(QDialog):
btn_tags = QPushButton("🏷 Rediger tags")
btn_tags.clicked.connect(self._edit_tags)
btn_row.addWidget(btn_tags)
btn_share = QPushButton("↗ Del...")
btn_share.clicked.connect(self._share_selected)
btn_row.addWidget(btn_share)
btn_shared = QPushButton("🌐 Hent delte")
btn_shared.clicked.connect(self._fetch_shared)
btn_row.addWidget(btn_shared)
btn_row.addStretch()
btn_cancel = QPushButton("Annuller")
@@ -344,3 +350,167 @@ class PlaylistBrowserDialog(QDialog):
self._load_data()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke slette: {e}")
def _share_selected(self):
"""Åbn del-dialog for den valgte playliste."""
item = self._list.currentItem()
if not item:
QMessageBox.information(self, "Del", "Vælg en playliste først.")
return
pl = item.data(Qt.ItemDataRole.UserRole)
if not isinstance(pl, dict):
return
# Hent server-info fra settings
try:
from ui.settings_dialog import load_settings
s = load_settings()
server_url = s.get("server_url", "")
token = self._get_token()
if not token:
QMessageBox.warning(self, "Ikke logget ind",
"Du skal være logget ind for at dele.")
return
# Find server-ID for playlisten
server_id = pl.get("api_project_id")
if not server_id:
QMessageBox.warning(self, "Ikke synkroniseret",
"Synkroniser playlisten til serveren først\n"
"(Filer → Synkroniser nu).")
return
from ui.share_dialog import ShareDialog
dlg = ShareDialog(server_id, pl["name"], server_url, token,
parent=self)
dlg.exec()
except Exception as e:
QMessageBox.warning(self, "Fejl", str(e))
def _get_token(self) -> str | None:
"""Hent JWT token fra main_window."""
mw = self.parent()
while mw and not hasattr(mw, "_api_token"):
mw = mw.parent()
return getattr(mw, "_api_token", None) if mw else None
def _fetch_shared(self):
"""Hent playlister der er delt med mig fra serveren."""
try:
from ui.settings_dialog import load_settings
s = load_settings()
server_url = s.get("server_url", "").rstrip("/")
token = self._get_token()
if not token:
QMessageBox.warning(self, "Ikke logget ind",
"Du skal være logget ind for at hente delte lister.")
return
import urllib.request, json
req = urllib.request.Request(
f"{server_url}/sharing/playlists/shared-with-me",
headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
shared = json.loads(resp.read())
if not shared:
QMessageBox.information(self, "Ingen delte lister",
"Ingen playlister er delt med dig.")
return
# Vis valgdialog
from PyQt6.QtWidgets import QInputDialog
options = [
f"{p['name']} (af {p['owner']}, {p['song_count']} sange, {p['permission']})"
for p in shared
]
choice, ok = QInputDialog.getItem(
self, "Hent delt playliste",
"Vælg en playliste at hente:",
options, 0, False
)
if not ok:
return
idx = options.index(choice)
chosen = shared[idx]
# Hent indholdet
req2 = urllib.request.Request(
f"{server_url}/sharing/playlists/{chosen['project_id']}",
headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req2, timeout=10) as resp:
pl_data = json.loads(resp.read())
self._import_shared_playlist(pl_data, server_url, token,
permission=chosen.get("permission", "view"))
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke hente: {e}")
def _import_shared_playlist(self, pl_data: dict, server_url: str, token: str,
permission: str = "view"):
"""Importer en delt playliste som en linket liste."""
import sqlite3
from local.local_db import DB_PATH, get_db, add_song_to_playlist
name = pl_data["name"]
server_id = pl_data["id"]
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
# Tjek om listen allerede er linket
existing = conn.execute(
"SELECT id FROM playlists WHERE api_project_id=?", (server_id,)
).fetchone()
conn.close()
if existing:
# Opdater eksisterende
pl_id = existing["id"]
with get_db() as c:
c.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
else:
# Opret ny linket playliste
with get_db() as c:
c.execute(
"INSERT INTO playlists (name, api_project_id, is_linked, server_permission) "
"VALUES (?, ?, 1, ?)",
(name, server_id, permission)
)
pl_id = c.execute("SELECT last_insert_rowid()").fetchone()[0]
# Indsæt sange med sang-matching
matched = 0
with get_db() as c:
for song_data in pl_data.get("songs", []):
local = c.execute(
"SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0",
(song_data["title"], song_data["artist"])
).fetchone()
if local:
c.execute(
"INSERT INTO playlist_songs "
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(pl_id, local["id"], song_data["position"],
song_data.get("status", "pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override") or "")
)
matched += 1
self._load_data()
self.playlist_selected.emit(pl_id, name)
perm_text = {"view": "se", "copy": "kopiere", "edit": "redigere"}.get(
permission, permission
)
QMessageBox.information(
self, "Linket",
f"'{name}' er nu linket til server-listen.\n"
f"Du har rettighed til at {perm_text} listen.\n\n"
f"{matched} af {len(pl_data.get('songs', []))} sange fundet lokalt."
)

View File

@@ -3,7 +3,7 @@ playlist_info_dialog.py — Flydende danseliste-info vindue med dynamisk opdater
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox,
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QFrame, QGridLayout,
)
from PyQt6.QtCore import Qt, pyqtSignal
@@ -22,7 +22,6 @@ def fmt_time(seconds: int) -> str:
class PlaylistInfoWindow(QWidget):
pause_changed = pyqtSignal(int)
def __init__(self, playlist_panel, parent=None):
super().__init__(parent,
@@ -83,33 +82,6 @@ class PlaylistInfoWindow(QWidget):
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")
@@ -131,26 +103,14 @@ class PlaylistInfoWindow(QWidget):
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
done = played + skipped # samlet "overstået"
remaining = total - done
ws_total = sum(1 for s in songs if s.get("is_workshop"))
ws_remain = sum(1 for s, st in zip(songs, statuses)
@@ -189,10 +149,10 @@ class PlaylistInfoWindow(QWidget):
self._lbl_eta.setText("✓ Danselisten er afsluttet!")
self._lbl_finish.setText("")
elif total > 0:
pct = int(played / total * 100) if total > 0 else 0
pct = int(done / 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)}"
if done > 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')}")

View File

@@ -289,9 +289,11 @@ class PlaylistPanel(QWidget):
return self._named_playlist_id
def next_playable_idx(self) -> int | None:
"""Find første sang fra toppen der ikke er 'skipped' eller 'played'."""
"""Find første sang fra toppen der ikke er afspillet, sprunget over eller i gang."""
for i in range(len(self._songs)):
if self._statuses[i] not in ("skipped", "played"):
if self._statuses[i] not in ("skipped", "played", "playing"):
if i == self._current_idx and not self._song_ended:
continue
return i
return None
@@ -303,25 +305,42 @@ class PlaylistPanel(QWidget):
self._lbl_autosave.setText("● ikke gemt")
def _autosave(self):
"""Gem til den faste 'Aktiv liste' i SQLite."""
"""Gem til '__aktiv__' OG til den navngivne liste hvis der er én."""
try:
from local.local_db import get_db, create_playlist, add_song_to_playlist
with get_db() as conn:
# Slet den gamle aktive liste
conn.execute(
"DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
)
# Opret ny
pl_id = create_playlist(ACTIVE_PLAYLIST_NAME)
self._active_playlist_id = pl_id
for i, song in enumerate(self._songs, start=1):
if song.get("id"):
add_song_to_playlist(pl_id, song["id"], position=i)
# Gem også til den navngivne liste
if self._named_playlist_id:
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, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(self._named_playlist_id, song["id"], i, status,
1 if song.get("is_workshop") else 0,
song.get("active_dance") or "")
)
self._lbl_autosave.setText("✓ gemt")
self.playlist_changed.emit()
except Exception as e:
self._lbl_autosave.setText(f"⚠ gemfejl")
pass
self._lbl_autosave.setText("⚠ gemfejl")
def _save_named_playlist_id(self, pl_id: int | None):
"""Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart."""
@@ -374,6 +393,21 @@ class PlaylistPanel(QWidget):
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 "")
local_path = row["local_path"]
file_missing = bool(row["file_missing"])
# Forsøg at finde sangen lokalt hvis den mangler
if file_missing or not local_path:
match = conn.execute("""
SELECT local_path FROM songs
WHERE title=? AND artist=? AND file_missing=0
LIMIT 1
""", (row["title"], row["artist"])).fetchone()
if match:
local_path = match["local_path"]
file_missing = False
songs.append({
"id": row["id"],
"title": row["title"],
@@ -381,9 +415,9 @@ class PlaylistPanel(QWidget):
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"local_path": row["local_path"],
"local_path": local_path,
"file_format": row["file_format"],
"file_missing": bool(row["file_missing"]),
"file_missing": file_missing,
"dances": dance_names,
"active_dance": active_dance,
"is_workshop": bool(row["is_workshop"]),
@@ -401,15 +435,14 @@ class PlaylistPanel(QWidget):
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
# Find næste uafspillede
ni = self.next_playable_idx()
if ni is not None:
self._current_idx = ni
self._refresh()
self.next_song_ready.emit(self._songs[ni])
self._statuses[ni] = "playing"
self._refresh()
return True
except Exception:
pass
@@ -479,10 +512,28 @@ class PlaylistPanel(QWidget):
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)
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(self._named_playlist_id, song["id"], i, status,
1 if song.get("is_workshop") else 0,
song.get("active_dance") or "")
)
self._lbl_autosave.setText("✓ gemt")
# Push til server hvis linket med edit-rettighed
if getattr(self, "_can_edit_server", False):
from local.local_db import get_db as _gdb
with _gdb() as c:
meta = c.execute(
"SELECT api_project_id FROM playlists WHERE id=?",
(self._named_playlist_id,)
).fetchone()
if meta and meta["api_project_id"]:
self._push_linked_playlist(
self._named_playlist_id, meta["api_project_id"]
)
self._lbl_autosave.setText("✓ gemt og synkroniseret")
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
@@ -495,6 +546,22 @@ class PlaylistPanel(QWidget):
def _load_playlist_by_id(self, pl_id: int, pl_name: str):
try:
from local.local_db import get_db
# Tjek om listen er linket til serveren — pull først
with get_db() as conn:
pl_meta = conn.execute(
"SELECT api_project_id, is_linked, server_permission "
"FROM playlists WHERE id=?", (pl_id,)
).fetchone()
if pl_meta and pl_meta["is_linked"] and pl_meta["api_project_id"]:
self._pull_linked_playlist(pl_id, pl_meta["api_project_id"])
# Opdater gem-knap baseret på rettighed
perm = pl_meta["server_permission"] or "view"
self._named_playlist_id = pl_id
self._can_edit_server = (perm == "edit")
else:
self._can_edit_server = False
with get_db() as conn:
songs_raw = conn.execute("""
SELECT s.*, ps.position, ps.status,
@@ -505,6 +572,7 @@ class PlaylistPanel(QWidget):
""", (pl_id,)).fetchall()
songs = []
statuses = []
repaired = 0
for row in songs_raw:
dances = conn.execute("""
SELECT d.name FROM song_dances sd
@@ -512,29 +580,64 @@ class PlaylistPanel(QWidget):
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 "")
local_path = row["local_path"]
file_missing = bool(row["file_missing"])
# Forsøg at finde sangen lokalt hvis den mangler
if file_missing or not local_path:
match = conn.execute("""
SELECT local_path FROM songs
WHERE title=? AND artist=? AND file_missing=0
LIMIT 1
""", (row["title"], row["artist"])).fetchone()
if match:
local_path = match["local_path"]
file_missing = False
repaired += 1
songs.append({
"id": row["id"], "title": row["title"],
"artist": row["artist"], "album": row["album"],
"bpm": row["bpm"], "duration_sec": row["duration_sec"],
"local_path": row["local_path"], "file_format": row["file_format"],
"file_missing": bool(row["file_missing"]),
"dances": dance_names,
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"local_path": local_path,
"file_format": row["file_format"],
"file_missing": file_missing,
"dances": dance_names,
"active_dance": active_dance,
"is_workshop": bool(row["is_workshop"]),
"is_workshop": bool(row["is_workshop"]),
})
statuses.append(row["status"] or "pending")
self._songs = songs
self._statuses = statuses
self._current_idx = -1
self._song_ended = False
self._named_playlist_id = pl_id
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}")
self._lbl_autosave.setText("✓ gendannet")
self._btn_save_current.setEnabled(True)
self._btn_save_current.setToolTip(f"Gem ændringer til '{pl_name}'")
# Vis link-indikator i titlen
is_linked = pl_meta and pl_meta["is_linked"]
perm = pl_meta["server_permission"] if is_linked else "edit"
link_icon = " 🔗" if is_linked else ""
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}{link_icon}")
status_txt = f"✓ indlæst — {repaired} sange fundet lokalt" if repaired else "✓ indlæst"
if is_linked:
status_txt += f" ({perm})"
self._lbl_autosave.setText(status_txt)
# Gem-knap: deaktiver hvis view-only linket liste
can_save = not is_linked or perm == "edit"
self._btn_save_current.setEnabled(can_save)
self._btn_save_current.setToolTip(
f"Gem ændringer til '{pl_name}'" if can_save
else "Du har kun læse-adgang til denne delte liste"
)
self._save_named_playlist_id(pl_id)
self._refresh()
self._trigger_autosave()
@@ -628,6 +731,98 @@ class PlaylistPanel(QWidget):
except Exception:
pass
def _pull_linked_playlist(self, pl_id: int, server_id: str):
"""Hent seneste version af en linket liste fra serveren."""
try:
from ui.settings_dialog import load_settings
from local.local_db import get_db, DB_PATH
s = load_settings()
server_url = s.get("server_url", "").rstrip("/")
# Hent token fra main_window
mw = self.window()
token = getattr(mw, "_api_token", None)
if not token or not server_url:
return
import urllib.request, json
req = urllib.request.Request(
f"{server_url}/sharing/playlists/{server_id}",
headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req, timeout=8) as resp:
pl_data = json.loads(resp.read())
# Opdater lokal liste med server-data
import sqlite3
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
for song_data in pl_data.get("songs", []):
local = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0",
(song_data["title"], song_data["artist"])
).fetchone()
if local:
conn.execute(
"INSERT INTO playlist_songs "
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(pl_id, local["id"], song_data["position"],
song_data.get("status", "pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override") or "")
)
conn.commit()
conn.close()
except Exception as e:
pass # Offline — brug lokalt cachet version
def _push_linked_playlist(self, pl_id: int, server_id: str):
"""Push ændringer til server for en linket liste."""
try:
from ui.settings_dialog import load_settings
from local.local_db import DB_PATH
s = load_settings()
server_url = s.get("server_url", "").rstrip("/")
mw = self.window()
token = getattr(mw, "_api_token", None)
if not token or not server_url:
return
import sqlite3, json, urllib.request
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
songs = []
for ps in conn.execute(
"SELECT s.title, s.artist, 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.append({
"title": ps["title"],
"artist": ps["artist"],
"position": ps["position"],
"status": ps["status"] or "pending",
"is_workshop": bool(ps["is_workshop"]),
"dance_override": ps["dance_override"] or "",
})
conn.close()
data = json.dumps({"songs": songs}).encode()
req = urllib.request.Request(
f"{server_url}/sharing/playlists/{server_id}/songs",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
method="PUT"
)
urllib.request.urlopen(req, timeout=8)
except Exception as e:
pass
def _on_pause_changed(self, seconds: int):
self._pause_seconds = seconds
@@ -642,7 +837,7 @@ class PlaylistPanel(QWidget):
if reply == QMessageBox.StandardButton.Yes:
self._statuses = ["pending"] * len(self._songs)
self._current_idx = -1
self._song_ended = True
self._song_ended = False
try:
from local.local_db import clear_event_state
clear_event_state()
@@ -650,6 +845,12 @@ class PlaylistPanel(QWidget):
pass
self._refresh()
self._scroll_to(0)
# Sæt første sang 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])
self.event_started.emit()
# ── Højreklik ─────────────────────────────────────────────────────────────
@@ -718,10 +919,26 @@ class PlaylistPanel(QWidget):
self._list.clear()
played = sum(1 for s in self._statuses if s == "played")
self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet")
# Find næste uafspillede til blå markering — aldrig samme som current
next_idx = None
if self._current_idx >= 0 and not self._song_ended:
# Sang spiller — vis næste som blå
next_idx = self.next_playable_idx()
elif self._current_idx == -1 or self._song_ended:
# Ingen sang spiller — vis første som blå
next_idx = self.next_playable_idx()
for i, song in enumerate(self._songs):
is_current = (i == self._current_idx and not self._song_ended)
status = "playing" if is_current else self._statuses[i]
icon = self.STATUS_ICON.get(status, " ")
is_next = (i == next_idx and not is_current)
if is_current:
status = "playing"
elif is_next:
status = "next"
else:
status = self._statuses[i]
icon = self.STATUS_ICON.get(status, " ")
# Vis active_dance (override eller første dans) eller alle danse
active = song.get("active_dance", "")
@@ -737,6 +954,9 @@ class PlaylistPanel(QWidget):
if status == "playing":
item.setForeground(QColor(self.STATUS_COLOR["playing"]))
f = item.font(); f.setBold(True); item.setFont(f)
elif status == "next":
item.setForeground(QColor(self.STATUS_COLOR["next"]))
f = item.font(); f.setBold(True); item.setFont(f)
elif status == "played":
item.setForeground(QColor("#2ecc71"))
elif status == "skipped":

View File

@@ -78,11 +78,30 @@ class SettingsDialog(QDialog):
layout.setSpacing(12)
tabs = QTabWidget()
tabs.addTab(self._build_appearance_tab(), "🎨 Udseende")
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")
tabs.setStyleSheet("""
QTabBar::tab {
padding: 6px 14px;
font-size: 13px;
color: #9aa0b0;
background: #1e2128;
border: none;
min-width: 80px;
}
QTabBar::tab:selected {
color: #e0e4f0;
background: #2a2d36;
border-bottom: 2px solid #e8a020;
}
QTabBar::tab:hover {
color: #e0e4f0;
background: #252830;
}
""")
tabs.addTab(self._build_appearance_tab(), "Udseende")
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

View File

@@ -0,0 +1,192 @@
"""
share_dialog.py — Del en playliste med andre brugere eller sæt den public.
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QComboBox, QFrame, QListWidget, QListWidgetItem,
QMessageBox,
)
from PyQt6.QtCore import Qt
class ShareDialog(QDialog):
def __init__(self, playlist_id: str, playlist_name: str,
server_url: str, token: str, parent=None):
super().__init__(parent)
self._playlist_id = playlist_id
self._playlist_name = playlist_name
self._server_url = server_url.rstrip("/")
self._token = token
self.setWindowTitle(f"Del — {playlist_name}")
self.setMinimumWidth(480)
self._build_ui()
self._load_shares()
self._load_visibility()
def _headers(self):
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
}
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(10)
# Synlighed
vis_frame = QFrame()
vis_frame.setObjectName("track_display")
vis_layout = QHBoxLayout(vis_frame)
vis_layout.setContentsMargins(10, 8, 10, 8)
vis_layout.addWidget(QLabel("Synlighed:"))
self._vis_combo = QComboBox()
self._vis_combo.addItem("🔒 Privat (kun mig)", "private")
self._vis_combo.addItem("👥 Delt (inviterede)", "shared")
self._vis_combo.addItem("🌐 Public (alle kan se)", "public")
vis_layout.addWidget(self._vis_combo, stretch=1)
btn_vis = QPushButton("Gem")
btn_vis.setFixedHeight(28)
btn_vis.clicked.connect(self._set_visibility)
vis_layout.addWidget(btn_vis)
layout.addWidget(vis_frame)
# Invitér bruger
inv_frame = QFrame()
inv_frame.setObjectName("track_display")
inv_layout = QVBoxLayout(inv_frame)
inv_layout.setContentsMargins(10, 8, 10, 8)
inv_layout.setSpacing(6)
inv_layout.addWidget(QLabel("Invitér via e-mail:"))
row = QHBoxLayout()
self._email_input = QLineEdit()
self._email_input.setPlaceholderText("bruger@eksempel.dk")
row.addWidget(self._email_input)
self._perm_combo = QComboBox()
self._perm_combo.addItem("Se", "view")
self._perm_combo.addItem("Kopiere", "copy")
self._perm_combo.addItem("Redigere","edit")
self._perm_combo.setFixedWidth(90)
row.addWidget(self._perm_combo)
btn_inv = QPushButton("Invitér")
btn_inv.setFixedHeight(28)
btn_inv.clicked.connect(self._invite)
row.addWidget(btn_inv)
inv_layout.addLayout(row)
layout.addWidget(inv_frame)
# Liste over delinger
lbl = QLabel("Delt med:")
lbl.setObjectName("track_meta")
layout.addWidget(lbl)
self._shares_list = QListWidget()
self._shares_list.setMaximumHeight(150)
layout.addWidget(self._shares_list)
btn_remove = QPushButton("✕ Fjern valgt deling")
btn_remove.clicked.connect(self._remove_share)
layout.addWidget(btn_remove)
self._status = QLabel("")
self._status.setObjectName("result_count")
self._status.setWordWrap(True)
layout.addWidget(self._status)
btn_close = QPushButton("Luk")
btn_close.clicked.connect(self.accept)
layout.addWidget(btn_close)
def _load_visibility(self):
try:
import urllib.request, json
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}",
headers=self._headers()
)
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read())
vis = data.get("visibility", "private")
for i in range(self._vis_combo.count()):
if self._vis_combo.itemData(i) == vis:
self._vis_combo.setCurrentIndex(i)
break
except Exception:
pass
def _load_shares(self):
try:
import urllib.request, json
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/shares",
headers=self._headers()
)
with urllib.request.urlopen(req, timeout=8) as resp:
shares = json.loads(resp.read())
self._shares_list.clear()
for s in shares:
perm = {"view": "Se", "copy": "Kopiere", "edit": "Redigere"}.get(
s["permission"], s["permission"]
)
accepted = "" if s["accepted"] else ""
item = QListWidgetItem(f"{accepted} {s['email']}{perm}")
item.setData(Qt.ItemDataRole.UserRole, s["id"])
self._shares_list.addItem(item)
except Exception as e:
self._status.setText(f"Kunne ikke hente delinger: {e}")
def _set_visibility(self):
vis = self._vis_combo.currentData()
try:
import urllib.request, json
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/visibility?visibility={vis}",
data=b"",
headers=self._headers(),
method="PATCH"
)
with urllib.request.urlopen(req, timeout=8) as resp:
json.loads(resp.read())
self._status.setText(f"✓ Synlighed sat til {self._vis_combo.currentText()}")
except Exception as e:
self._status.setText(f"⚠ Fejl: {e}")
def _invite(self):
email = self._email_input.text().strip()
perm = self._perm_combo.currentData()
if not email or "@" not in email:
self._status.setText("⚠ Ugyldig e-mailadresse")
return
try:
import urllib.request, json
data = json.dumps({"email": email, "permission": perm}).encode()
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/share",
data=data, headers=self._headers(), method="POST"
)
with urllib.request.urlopen(req, timeout=8) as resp:
json.loads(resp.read())
self._email_input.clear()
self._status.setText(f"✓ Invitation sendt til {email}")
self._load_shares()
except Exception as e:
self._status.setText(f"⚠ Fejl: {e}")
def _remove_share(self):
item = self._shares_list.currentItem()
if not item:
return
share_id = item.data(Qt.ItemDataRole.UserRole)
try:
import urllib.request
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/share/{share_id}",
headers=self._headers(), method="DELETE"
)
urllib.request.urlopen(req, timeout=8)
self._status.setText("✓ Deling fjernet")
self._load_shares()
except Exception as e:
self._status.setText(f"⚠ Fejl: {e}")

View File

@@ -79,6 +79,25 @@ QSlider::handle:horizontal {
border-radius: 6px;
}
/* Volume slider — stor og tydelig */
QSlider#vol_slider::groove:horizontal {
height: 6px;
background: #2c3038;
border-radius: 3px;
}
QSlider#vol_slider::sub-page:horizontal {
background: #e8a020;
border-radius: 3px;
}
QSlider#vol_slider::handle:horizontal {
background: #e8a020;
border: 3px solid #f0c060;
width: 22px;
height: 22px;
margin: -9px 0;
border-radius: 12px;
}
/* Lister */
QListWidget {
background-color: #1a1c1f;