""" playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik. """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView, QMessageBox, ) from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent ACTIVE_PLAYLIST_NAME = "__aktiv__" # fast navn til autogem-listen class PlaylistPanel(QWidget): song_selected = pyqtSignal(int) status_changed = pyqtSignal(int, str) song_dropped = pyqtSignal(dict) playlist_changed = pyqtSignal() event_started = pyqtSignal() next_song_ready = pyqtSignal(dict) # udsendes når næste sang ændres — main_window indlæser den # udsendes af Start event — main_window indlæser første sang # udsendes ved enhver ændring → trigger autogem STATUS_ICON = {"pending": " ", "playing": " ▶ ", "played": " ✓ ", "skipped": " — ", "next": " ▷ "} STATUS_COLOR = {"pending": "#5a6070", "playing": "#e8a020", "played": "#2ecc71", "skipped": "#e74c3c", "next": "#3b8fd4"} def __init__(self, parent=None): super().__init__(parent) self._songs: list[dict] = [] self._statuses: list[str] = [] self._current_idx = -1 self._song_ended = False self._active_playlist_id: int | None = None self._named_playlist_id: int | None = None # den indlæste/gemte navngivne liste self._build_ui() self.setAcceptDrops(True) # Autogem-timer — venter 800ms efter sidst ændring self._autosave_timer = QTimer(self) self._autosave_timer.setSingleShot(True) self._autosave_timer.setInterval(800) self._autosave_timer.timeout.connect(self._autosave) # Event-state gem — hurtig, kritisk for genopstart efter strømsvigt self._event_state_timer = QTimer(self) self._event_state_timer.setSingleShot(True) self._event_state_timer.setInterval(300) self._event_state_timer.timeout.connect(self._save_event_state) def _build_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # ── Header med titel ────────────────────────────────────────────────── header = QHBoxLayout() header.setContentsMargins(10, 6, 10, 6) self._title_label = QLabel("DANSELISTE") self._title_label.setObjectName("section_title") header.addWidget(self._title_label) layout.addLayout(header) # ── Ny / Gem / Hent knapper ─────────────────────────────────────────── toolbar = QHBoxLayout() toolbar.setContentsMargins(8, 2, 8, 4) toolbar.setSpacing(4) btn_new = QPushButton("✚ Ny") btn_new.setFixedHeight(26) btn_new.setToolTip("Opret en ny tom danseliste") btn_new.clicked.connect(self._new_playlist) toolbar.addWidget(btn_new) btn_save = QPushButton("💾 Gem som...") btn_save.setFixedHeight(26) btn_save.setToolTip("Gem aktuel liste med et navn") 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") btn_load.clicked.connect(self._load_dialog) toolbar.addWidget(btn_load) toolbar.addStretch() self._lbl_autosave = QLabel("") self._lbl_autosave.setObjectName("result_count") toolbar.addWidget(self._lbl_autosave) layout.addLayout(toolbar) # ── Event-kontrol ───────────────────────────────────────────────────── ctrl = QHBoxLayout() ctrl.setContentsMargins(8, 2, 8, 4) ctrl.setSpacing(6) self._btn_start = QPushButton("▶ START EVENT") self._btn_start.setFixedHeight(28) self._btn_start.setToolTip("Nulstil alle statusser og gør klar til event") self._btn_start.clicked.connect(self._start_event) ctrl.addWidget(self._btn_start) ctrl.addStretch() self._lbl_progress = QLabel("0 / 0") 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") self._list.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) self._list.setDefaultDropAction(Qt.DropAction.MoveAction) self._list.setAcceptDrops(True) self._list.itemDoubleClicked.connect(self._on_double_click) self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self._list.customContextMenuRequested.connect(self._show_context_menu) self._list.model().rowsMoved.connect(self._on_rows_moved) layout.addWidget(self._list) # ── Drag & drop ─────────────────────────────────────────────────────────── def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasFormat("application/x-linedance-song"): event.acceptProposedAction() else: event.ignore() def dropEvent(self, event: QDropEvent): mime = event.mimeData() if mime.hasFormat("application/x-linedance-song"): import json song = json.loads(mime.data("application/x-linedance-song").data().decode()) self._append_song(song) self.song_dropped.emit(song) event.acceptProposedAction() def _append_song(self, song: dict): self._songs.append(song) self._statuses.append("pending") self._refresh() self._trigger_autosave() # ── Data API ────────────────────────────────────────────────────────────── def load_songs(self, songs: list[dict], reset_statuses: bool = True, name: str = ""): self._songs = list(songs) if reset_statuses: self._statuses = ["pending"] * len(songs) self._current_idx = -1 self._song_ended = False if name: self._title_label.setText(f"DANSELISTE — {name.upper()}") self._refresh() self._trigger_autosave() def set_current(self, idx: int, song_ended: bool = False): self._current_idx = idx self._song_ended = song_ended if 0 <= idx < len(self._statuses) and not song_ended: self._statuses[idx] = "playing" self._refresh() self._scroll_to(idx) def mark_played(self, idx: int): if 0 <= idx < len(self._statuses): self._statuses[idx] = "played" self._refresh() self._trigger_autosave() self._trigger_event_state_save() def set_next_ready(self, idx: int): """Sæt næste sang klar — uden at overskrive skipped/played statusser.""" # Nulstil forrige current hvis den stadig er playing old = self._current_idx if old != idx and 0 <= old < len(self._statuses): if self._statuses[old] == "playing": self._statuses[old] = "pending" self._current_idx = idx self._song_ended = False # Ændr KUN status hvis den er pending — rør ikke skipped/played if 0 <= idx < len(self._statuses): if self._statuses[idx] not in ("skipped", "played"): self._statuses[idx] = "pending" self._refresh() self._scroll_to(idx) def get_song(self, idx: int) -> dict | None: return self._songs[idx] if 0 <= idx < len(self._songs) else None def get_songs(self) -> list[dict]: return list(self._songs) def get_statuses(self) -> list[str]: return list(self._statuses) def count(self) -> int: return len(self._songs) def set_playlist_name(self, name: str): self._title_label.setText(f"DANSELISTE — {name.upper()}") # ── Drag-flytning ───────────────────────────────────────────────────────── 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()): old_idx = self._list.item(i).data(Qt.ItemDataRole.UserRole) if old_idx is not None and 0 <= old_idx < len(self._songs): new_songs.append(self._songs[old_idx]) 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() # Emit IKKE next_song_ready — afspilning fortsætter uforstyrret # ── Event-state ─────────────────────────────────────────────────────────── def _save_event_state(self): """Gem current_idx og statuses — overlever strømsvigt.""" try: from local.local_db import save_event_state save_event_state(self._current_idx, self._statuses) except Exception as e: pass def _trigger_event_state_save(self): self._event_state_timer.start() def restore_event_state(self) -> bool: """Gendan gemt event-fremgang. Returnerer True hvis gendannet.""" try: from local.local_db import load_event_state result = load_event_state() if not result: return False idx, statuses = result if len(statuses) != len(self._songs): return False # listen er ændret siden sidst self._statuses = statuses self._current_idx = idx self._song_ended = False self._refresh() return True except Exception as e: pass return False def get_named_playlist_id(self) -> int | None: return self._named_playlist_id def next_playable_idx(self) -> int | None: """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", "playing"): if i == self._current_idx and not self._song_ended: continue return i return None # ── Autogem ─────────────────────────────────────────────────────────────── def _trigger_autosave(self): """Start/nulstil debounce-timer — gemmer 800ms efter sidst ændring.""" self._autosave_timer.start() self._lbl_autosave.setText("● ikke gemt") def _autosave(self): """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: conn.execute( "DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,) ) 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("⚠ gemfejl") 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): """Gendan senest aktive navngivne liste med event-fremgang ved opstart.""" try: from PyQt6.QtCore import QSettings s = QSettings("LineDance", "Player") pl_id = s.value("session/named_playlist_id", None, type=int) if not pl_id: return False import sqlite3 from local.local_db import DB_PATH conn = sqlite3.connect(str(DB_PATH)) conn.row_factory = sqlite3.Row # Verificer at listen stadig eksisterer pl = conn.execute( "SELECT id, name FROM playlists WHERE id=?", (pl_id,) ).fetchone() if not pl: conn.close() return False # Hent sange med status, workshop og dans-override songs_raw = conn.execute(""" SELECT s.*, ps.position, ps.status, ps.is_workshop, ps.dance_override FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id WHERE ps.playlist_id=? ORDER BY ps.position """, (pl_id,)).fetchall() songs = [] statuses = [] for row in songs_raw: dances = conn.execute(""" SELECT d.name FROM song_dances sd JOIN dances d ON d.id = sd.dance_id WHERE sd.song_id=? ORDER BY sd.dance_order """, (row["id"],)).fetchall() dance_names = [d["name"] for d in dances] override = row["dance_override"] or "" active_dance = override if override else (dance_names[0] if dance_names else "") 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"], "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"]), }) statuses.append(row["status"] or "pending") conn.close() if songs: self._songs = songs # Rens "playing" — må kun være én ad gangen self._statuses = [ "pending" if s == "playing" else s for s in 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") # Find næste uafspillede ni = self.next_playable_idx() if ni is not None: self._current_idx = ni self._refresh() return True except Exception: pass return False # ── Ny / Gem som / Hent ─────────────────────────────────────────────────── def _new_playlist(self): if self._songs: reply = QMessageBox.question( self, "Ny danseliste", "Ryd den aktuelle liste og start forfra?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return self._songs = [] 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() def _save_as(self): if not self._songs: QMessageBox.information(self, "Gem", "Danselisten er tom.") return 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() ) 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, 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}") def _load_dialog(self): 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 # 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, 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 = [] repaired = 0 for row in songs_raw: dances = conn.execute(""" SELECT d.name FROM song_dances sd JOIN dances d ON d.id = sd.dance_id WHERE sd.song_id=? ORDER BY sd.dance_order """, (row["id"],)).fetchall() dance_names = [d["name"] for d in dances] override = row["dance_override"] or "" active_dance = override if override else (dance_names[0] if dance_names else "") 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": local_path, "file_format": row["file_format"], "file_missing": file_missing, "dances": dance_names, "active_dance": active_dance, "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 # 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() except Exception as e: QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}") # ── 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 from PyQt6.QtCore import QSettings main = self.window() self._info_window = PlaylistInfoWindow(self, parent=main) QApplication.instance().aboutToQuit.connect(self._info_window.close) # Gendan gemt position eller placer ved siden af hovedvindue s = QSettings("LineDance", "Player") pos = s.value("window/info_pos") if pos: self._info_window.move(pos) elif main: geo = main.geometry() self._info_window.move(geo.right() + 10, geo.top()) # Sikr at vinduet er på skærmen screen = QApplication.primaryScreen().availableGeometry() w_geo = self._info_window.frameGeometry() x = max(0, min(w_geo.x(), screen.right() - w_geo.width())) y = max(0, min(w_geo.y(), screen.bottom() - w_geo.height())) self._info_window.move(x, y) 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 _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 def _start_event(self): if not self._songs: return reply = QMessageBox.question( self, "Start event", "Dette nulstiller alle statusser i danselisten.\nFortsæt?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: self._statuses = ["pending"] * len(self._songs) self._current_idx = -1 self._song_ended = False try: from local.local_db import clear_event_state clear_event_state() except Exception: 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 ───────────────────────────────────────────────────────────── def _show_context_menu(self, pos): item = self._list.itemAt(pos) if not item: return 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() act_skip = menu.addAction("— Spring over") 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: self.song_selected.emit(idx) elif action == act_skip: self._statuses[idx] = "skipped" self.status_changed.emit(idx, "skipped") self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() elif action == act_unplay: self._statuses[idx] = "pending" self.status_changed.emit(idx, "pending") self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() elif action == act_played: 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) if self._current_idx >= idx: self._current_idx = max(-1, self._current_idx - 1) self._refresh(); self._trigger_autosave() # ── Render ──────────────────────────────────────────────────────────────── def _refresh(self): 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) 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", "") 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) 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": item.setForeground(QColor("#e74c3c")) else: item.setForeground(QColor("#9aa0b0")) self._list.addItem(item) def _scroll_to(self, idx: int): if 0 <= idx < self._list.count(): self._list.scrollToItem( self._list.item(idx), QListWidget.ScrollHint.PositionAtCenter) def _on_double_click(self, item: QListWidgetItem): idx = item.data(Qt.ItemDataRole.UserRole) if idx is not None: self.song_selected.emit(idx)