""" 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, QInputDialog, ) 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) 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) layout.addLayout(ctrl) # ── 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.""" 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.""" 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 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]) # ── 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 'skipped' eller 'played'.""" for i in range(len(self._songs)): if self._statuses[i] not in ("skipped", "played"): 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 den faste 'Aktiv liste' i SQLite.""" 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) self._lbl_autosave.setText("✓ gemt") self.playlist_changed.emit() except Exception as e: self._lbl_autosave.setText(f"⚠ gemfejl") pass def restore_active_playlist(self): """Indlæs den sidst aktive liste ved opstart.""" try: from local.local_db import get_db with get_db() as conn: pl = conn.execute( "SELECT id FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,) ).fetchone() if not pl: return False songs_raw = conn.execute(""" SELECT s.*, ps.position FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id WHERE ps.playlist_id=? ORDER BY ps.position """, (pl["id"],)).fetchall() songs = [] for row in songs_raw: dances = conn.execute( "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order", (row["id"],) ).fetchall() songs.append({ "id": row["id"], "title": row["title"], "artist": row["artist"], "album": row["album"], "bpm": row["bpm"], "duration_sec": row["duration_sec"], "local_path": row["local_path"], "file_format": row["file_format"], "file_missing": bool(row["file_missing"]), "dances": [d["dance_name"] for d in dances], }) if songs: self._songs = songs self._statuses = ["pending"] * len(songs) self._refresh() self._lbl_autosave.setText("✓ gendannet") return True except Exception as e: 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._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 name, ok = QInputDialog.getText( self, "Gem danseliste", "Navn på danselisten:", ) 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) self._named_playlist_id = pl_id self._title_label.setText(f"DANSELISTE — {name.upper()}") self._lbl_autosave.setText(f"✓ gemt som \"{name}\"") 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"] 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 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() 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], }) 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._refresh() self._trigger_autosave() except Exception as e: QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}") # ── Start event ─────────────────────────────────────────────────────────── 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 = True try: from local.local_db import clear_event_state clear_event_state() except Exception: pass self._refresh() self._scroll_to(0) 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 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() 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_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") 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] icon = self.STATUS_ICON.get(status, " ") dances = " / ".join(song.get("dances", [])) or "ingen dans tagget" text = f"{i+1:>2}. {song.get('title','—')}\n {song.get('artist','')} · {dances}" item = QListWidgetItem(f"{icon} {text}") item.setData(Qt.ItemDataRole.UserRole, i) color = self.STATUS_COLOR.get(status, "#5a6070") if status in ("playing", "next"): item.setForeground(QColor(color)) 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)