""" playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik. """ import sys as _sys from pathlib import Path as _Path def _is_local_path(path: str) -> bool: """Returnerer True hvis stien er på et lokalt/USB-drev, False hvis netværk.""" try: if _sys.platform == "win32": import ctypes drive = path[:3] # GetDriveType: 2=Removable, 3=Fixed, 4=Remote(netværk), 5=CDROM, 6=RAMdisk dtype = ctypes.windll.kernel32.GetDriveTypeW(drive) return dtype not in (4,) # 4 = netværksdrev else: # Linux/Mac — tjek /proc/mounts NETWORK_FS = { "nfs", "nfs4", "cifs", "smb", "smb2", "smb3", "fuse.sshfs", "fuse.gvfsd-fuse", "fuse.s3fs", "davfs", "ncpfs", "afs", "glusterfs", "fuse.glusterfs", } try: with open("/proc/mounts") as f: mounts = [] for line in f: parts = line.split() if len(parts) >= 3: mounts.append((parts[1], parts[2])) # Find længste matchende mount-punkt mounts.sort(key=lambda x: len(x[0]), reverse=True) for mount_point, fs_type in mounts: if path.startswith(mount_point): return fs_type not in NETWORK_FS except Exception: pass return True # Antag lokal except Exception: return True # Antag lokal ved fejl from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView, QMessageBox, QStyledItemDelegate, ) from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray, QRect from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent, QPainter ACTIVE_PLAYLIST_NAME = "__aktiv__" class AvailabilityDelegate(QStyledItemDelegate): """Tegner en farvet dot til højre for hvert playlist-item.""" def paint(self, painter, option, index): super().paint(painter, option, index) color = index.data(Qt.ItemDataRole.UserRole + 1) if not color: return painter.save() painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setBrush(QColor(color)) painter.setPen(Qt.PenStyle.NoPen) r = option.rect cx = r.right() - 10 cy = r.center().y() painter.drawEllipse(cx - 5, cy - 5, 10, 10) painter.restore() 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 sync_requested = pyqtSignal() # bed main_window om at køre sync (efter sletning) 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: str | None = None self._named_playlist_id: str | 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) self._btn_offline_check = QPushButton("⬤ Offline tjek") self._btn_offline_check.setFixedHeight(26) self._btn_offline_check.setToolTip("Kontrollér om alle sange er tilgængelige på denne maskine") self._btn_offline_check.clicked.connect(self._check_offline) toolbar.addWidget(self._btn_offline_check) 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.setItemDelegate(AvailabilityDelegate(self._list)) 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._check_availability(self._songs) 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) -> str | 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" import uuid as _uuid conn.execute( "INSERT INTO playlist_songs " "(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) " "VALUES (?,?,?,?,?,?,?,?)", (str(_uuid.uuid4()), self._named_playlist_id, song["id"], song.get("file_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: str | 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=str) 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 # JOIN songs — sangen er altid i songs tabellen (oprettet ved pull med file_missing=1) # file_missing betyder bare at filen ikke er på denne maskine songs_raw = conn.execute(""" SELECT s.id, s.title, s.artist, s.album, s.bpm, s.duration_sec, ps.file_id, f.local_path, f.file_format, COALESCE(f.file_missing, 1) as file_missing, ps.position, ps.status, ps.is_workshop, ps.dance_override FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id LEFT JOIN files f ON f.id = ps.file_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"] or "" file_missing = bool(row["file_missing"]) # Forsøg at finde en anden fil lokalt hvis den specifikke mangler if file_missing or not local_path: match = conn.execute( "SELECT f.local_path FROM files f " "WHERE f.song_id=? AND f.file_missing=0 LIMIT 1", (row["id"],) ).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"] or "", "bpm": row["bpm"] or 0, "duration_sec": row["duration_sec"] or 0, "file_id": row["file_id"] if "file_id" in row.keys() else None, "local_path": local_path, "file_format": row["file_format"] or "", "file_missing": file_missing, "dances": dance_names, "active_dance": active_dance, "alt_dance": row["alt_dance_override"] if "alt_dance_override" in row.keys() else "", "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") self._check_availability(self._songs) # 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.sync_requested.connect(self._request_sync) 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" import uuid as _uuid conn.execute( "INSERT INTO playlist_songs " "(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) " "VALUES (?,?,?,?,?,?,?,?)", (str(_uuid.uuid4()), self._named_playlist_id, song["id"], song.get("file_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 _request_sync(self): """Bobl sync-anmodning op til main_window.""" self.sync_requested.emit() 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.sync_requested.connect(self._request_sync) dialog.exec() def _load_playlist_by_id(self, pl_id: str, 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: # JOIN songs — sangen er altid i songs tabellen (oprettet ved pull med file_missing=1) songs_raw = conn.execute(""" SELECT s.id, s.title, s.artist, s.album, s.bpm, s.duration_sec, ps.file_id, f.local_path, f.file_format, COALESCE(f.file_missing, 1) as file_missing, ps.position, ps.status, ps.is_workshop, ps.dance_override FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id LEFT JOIN files f ON f.id = ps.file_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"] or "" file_missing = bool(row["file_missing"]) # Forsøg at finde en anden fil lokalt hvis den specifikke mangler if file_missing or not local_path: match = conn.execute( "SELECT f.local_path FROM files f " "WHERE f.song_id=? AND f.file_missing=0 LIMIT 1", (row["id"],) ).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"] or "", "bpm": row["bpm"] or 0, "duration_sec": row["duration_sec"] or 0, "file_id": row["file_id"] if "file_id" in row.keys() else None, "local_path": local_path, "file_format": row["file_format"] or "", "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 dances = song.get("dances", []) current = song.get("active_dance", "") if not current: current = dances[0] if dances else "" current_choreo = song.get("active_choreo", "") # Afgør om valget er permanent eller midlertidigt # Permanent: ingen dans tagget, eller valgt dans er ikke i de taggede # Midlertidig: sangen har flere danse og brugeren vælger en af dem dlg = DancePickerDialog( current_dance=current, song_title=song.get("title", ""), existing_dances=dances, parent=self.window() ) if dlg.exec(): chosen = dlg.get_dance() # Dans-valg i playlisten er altid midlertidigt — kun dance_override song["active_dance"] = chosen # tom streng = ingen dans self._refresh() self._sync_dance_to_db(idx, song) def _change_alt_dance(self, idx: int, song: dict): """Lad brugeren vælge alternativ dans til denne sang i playlisten.""" from ui.alt_dance_picker_dialog import AltDancePickerDialog dlg = AltDancePickerDialog(song, parent=self.window()) if dlg.exec(): if dlg.was_cleared(): chosen = "" else: chosen = dlg.get_dance() rating = dlg.get_rating() song["alt_dance"] = chosen self._refresh() # Gem alt_dance_override på playlist_songs self._sync_alt_dance_to_db(idx, song, chosen) # Gem rating hvis givet if chosen and rating is not None: self._save_alt_dance_rating(song, chosen, rating) def _sync_alt_dance_to_db(self, idx: int, song: dict, alt_dance: str): """Gem alt_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 alt_dance_override=? " "WHERE playlist_id=? AND position=?", (alt_dance, self._named_playlist_id, idx + 1) ) except Exception as e: import logging logging.getLogger(__name__).warning(f"alt_dance_to_db fejl: {e}") def _save_alt_dance_rating(self, song: dict, dance_name: str, rating: int): """Gem brugerens rating på en alternativ-dans.""" import uuid song_id = song.get("id", "") try: from local.local_db import get_db with get_db() as conn: # Find dance_id dance_row = conn.execute( "SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1", (dance_name,) ).fetchone() if not dance_row: return dance_id = dance_row["id"] # Opdater eller indsæt rating existing = conn.execute( "SELECT id FROM song_alt_dances WHERE song_id=? AND dance_id=?", (song_id, dance_id) ).fetchone() if existing: conn.execute( "UPDATE song_alt_dances SET user_rating=? WHERE song_id=? AND dance_id=?", (rating, song_id, dance_id) ) else: conn.execute( "INSERT INTO song_alt_dances (id, song_id, dance_id, user_rating) VALUES (?,?,?,?)", (str(uuid.uuid4()), song_id, dance_id, rating) ) except Exception as e: import logging logging.getLogger(__name__).warning(f"save_alt_dance_rating fejl: {e}") def _sync_dance_to_db(self, idx: int, song: dict): """Gem dance_override til playlist_songs (midlertidigt valg).""" import logging _log = logging.getLogger(__name__) if not self._named_playlist_id: _log.warning("_sync_dance_to_db: ingen named_playlist_id") return try: from local.local_db import get_db dance_val = song.get("active_dance") or "" with get_db() as conn: rows_affected = conn.execute( "UPDATE playlist_songs SET dance_override=? " "WHERE playlist_id=? AND position=?", (dance_val, self._named_playlist_id, idx + 1) ).rowcount _log.info(f"dance_override='{dance_val}' gemt på position {idx+1}, {rows_affected} rækker") except Exception as e: import logging logging.getLogger(__name__).warning(f"_sync_dance_to_db fejl: {e}") def _save_dance_permanently(self, idx: int, song: dict, dance_name: str, choreo: str = ""): """ Gem dans permanent på sangen: 1. song_dances tabellen 2. ID3-tag i filen (hvis tilgængelig) 3. Opdater sang-dict så listen vises korrekt """ import uuid song_id = song.get("id", "") local_path = song.get("local_path", "") try: from local.local_db import get_db with get_db() as conn: # Find eller opret dans dance_row = conn.execute( "SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1", (dance_name,) ).fetchone() if dance_row: dance_id = dance_row["id"] if choreo: conn.execute( "UPDATE dances SET choreographer=? WHERE id=? AND choreographer=''", (choreo, dance_id) ) else: cur = conn.execute( "INSERT INTO dances (name, choreographer) VALUES (?,?)", (dance_name, choreo or "") ) dance_id = cur.lastrowid # Tilføj til song_dances existing = conn.execute( "SELECT id FROM song_dances WHERE song_id=? AND dance_id=?", (song_id, dance_id) ).fetchone() if not existing: # Find næste dance_order max_order = conn.execute( "SELECT MAX(dance_order) FROM song_dances WHERE song_id=?", (song_id,) ).fetchone()[0] or 0 conn.execute( "INSERT INTO song_dances (id, song_id, dance_id, dance_order) VALUES (?,?,?,?)", (str(uuid.uuid4()), song_id, dance_id, max_order + 1) ) # Opdater sang-dict dances = song.get("dances", []) if dance_name not in dances: dances.append(dance_name) song["dances"] = dances song["active_dance"] = dance_name # Gem i ID3-tag hvis filen er tilgængelig if local_path: try: from local.tag_reader import write_dance_to_file write_dance_to_file(local_path, dances) except Exception: pass # Opdater også dance_override på listen self._sync_dance_to_db(idx, song) import logging logging.getLogger(__name__).info( f"Dans gemt permanent: '{dance_name}' → '{song.get('title','?')}'" ) except Exception as e: import logging logging.getLogger(__name__).warning(f"Kunne ikke gemme dans permanent: {e}") 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: str, server_id: str): """Hent seneste version af en linket liste fra serveren — i baggrundstråd.""" import threading import uuid def _do_pull(): try: from ui.settings_dialog import load_settings from local.local_db import DB_PATH import sqlite3, urllib.request, json 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 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()) conn = sqlite3.connect(str(DB_PATH), timeout=10) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,)) position = 1 for song_data in pl_data.get("songs", []): title = song_data.get("title", "") artist = song_data.get("artist", "") if not title: continue # Find sang via titel+artist local = conn.execute( "SELECT s.id FROM songs s " "JOIN files f ON f.song_id = s.id AND f.file_missing=0 " "WHERE s.title=? AND s.artist=? LIMIT 1", (title, artist) ).fetchone() if not local: # Sang mangler lokalt — opret som missing local = conn.execute( "SELECT id FROM songs WHERE title=? AND artist=? LIMIT 1", (title, artist) ).fetchone() if not local: new_id = str(uuid.uuid4()) conn.execute( "INSERT INTO songs (id, title, artist) VALUES (?,?,?)", (new_id, title, artist) ) song_id = new_id else: song_id = local["id"] # Find fil file_row = conn.execute( "SELECT id FROM files WHERE song_id=? AND file_missing=0 LIMIT 1", (song_id,) ).fetchone() file_id = file_row["id"] if file_row else None conn.execute( "INSERT INTO playlist_songs " "(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) " "VALUES (?,?,?,?,?,?,?,?)", (str(uuid.uuid4()), pl_id, song_id, file_id, position, song_data.get("status", "pending"), 1 if song_data.get("is_workshop") else 0, song_data.get("dance_override") or "") ) position += 1 conn.commit() conn.close() except Exception: pass # Offline — brug lokalt cachet version threading.Thread(target=_do_pull, daemon=True).start() def _push_linked_playlist(self, pl_id: str, 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() # ── Tilgængelighed ──────────────────────────────────────────────────────── def _check_availability(self, songs: list[dict]) -> list[dict]: """Tjek om sange er tilgængelige lokalt — forsøger auto-match ved gule.""" from pathlib import Path from local.local_db import get_db try: with get_db() as conn: for song in songs: path = song.get("local_path", "") if path and Path(path).exists(): # Grøn = lokal, Gul = netværk men tilgængeligt song["availability"] = "green" if _is_local_path(path) else "yellow" continue # Forsøg auto-match via titel+artist title = song.get("title", "") artist = song.get("artist", "") try: match = conn.execute(""" SELECT id, local_path FROM songs WHERE title=? AND artist=? AND file_missing=0 AND local_path IS NOT NULL AND local_path != '' LIMIT 1 """, (title, artist)).fetchone() if match and Path(match["local_path"]).exists(): song["local_path"] = match["local_path"] song["id"] = match["id"] song["file_missing"] = False song["availability"] = "green" continue except Exception: pass song["availability"] = "yellow" if not path else "red" except Exception: # DB fejl — sæt alle til yellow for song in songs: song.setdefault("availability", "yellow") return songs def check_offline_availability(self): """Gennemgå alle sange og forsøg auto-match for gule/røde.""" from pathlib import Path from local.local_db import get_db with get_db() as conn: for song in self._songs: path = song.get("local_path", "") # Grøn = lokal, Gul = netværk men tilgængeligt if path and Path(path).exists(): song["availability"] = "green" if _is_local_path(path) else "yellow" song["file_missing"] = False # Opdater files tabellen conn.execute( "UPDATE files SET file_missing=0 WHERE local_path=?", (path,) ) continue # Forsøg auto-match via titel+artist i files tabellen title = song.get("title", "") artist = song.get("artist", "") match = conn.execute(""" SELECT f.id as file_id, f.local_path, s.id as song_id FROM files f JOIN songs s ON s.id = f.song_id WHERE s.title=? AND s.artist=? AND f.file_missing=0 AND f.local_path IS NOT NULL AND f.local_path != '' LIMIT 1 """, (title, artist)).fetchone() if match and Path(match["local_path"]).exists(): song["local_path"] = match["local_path"] song["file_id"] = match["file_id"] song["availability"] = "green" if _is_local_path(match["local_path"]) else "yellow" song["file_missing"] = False # Opdater playlist_songs til at pege på den fundne fil if self._named_playlist_id: conn.execute( "UPDATE playlist_songs SET file_id=? " "WHERE playlist_id=? AND song_id=?", (match["file_id"], self._named_playlist_id, song["id"]) ) else: song["availability"] = "red" self._refresh() green_count = sum(1 for s in self._songs if s.get("availability") == "green") return green_count, len(self._songs) # ── 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 avail = song.get("availability", "yellow") if song else "yellow" all_green = all(s.get("availability", "yellow") == "green" for s in self._songs) 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_dance = menu.addAction("💃 Vælg dans...") act_alt_dance = menu.addAction("💃 Vælg alternativ dans...") 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() # Offline tilgængelighed act_check = menu.addAction("🔍 Kontrollér offline afvikling") act_check.setEnabled(not all_green) act_manual = menu.addAction("📂 Manuel match af fil...") act_manual.setEnabled(avail != "green") 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_alt_dance and song: self._change_alt_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_check: self._check_offline() elif action == act_manual and song: self._manual_match(idx, song) 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() def _check_offline(self): """Kør offline tjek og vis resultat.""" green, total = self.check_offline_availability() red = sum(1 for s in self._songs if s.get("availability") == "red") if red == 0: self._set_status_msg(f"✓ Alle {total} sange er tilgængelige lokalt", 4000) else: self._set_status_msg( f"⚠ {green}/{total} tilgængelige — {red} ikke fundet (rød markering)", 6000 ) def _set_status_msg(self, msg: str, ms: int = 3000): """Vis besked i playlist-titlen kortvarigt.""" old = self._title_label.text() self._title_label.setText(msg) QTimer.singleShot(ms, lambda: self._title_label.setText(old)) def _manual_match(self, idx: int, song: dict): """Søg i biblioteket og lad brugeren vælge den rigtige sang.""" from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QLabel ) from local.local_db import get_db from pathlib import Path dlg = QDialog(self.window()) dlg.setWindowTitle(f"Manuel match: {song.get('title','?')}") dlg.setMinimumSize(500, 400) layout = QVBoxLayout(dlg) layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(8) lbl = QLabel(f"Søg i biblioteket efter en sang til: {song.get('title','')} – {song.get('artist','')}") lbl.setWordWrap(True) layout.addWidget(lbl) search = QLineEdit() search.setPlaceholderText("Søg på titel eller artist...") search.setText(song.get("title", "")) layout.addWidget(search) lst = QListWidget() layout.addWidget(lst) def _load_results(text): lst.clear() try: with get_db() as conn: rows = conn.execute(""" SELECT id, title, artist, local_path, file_format FROM songs WHERE file_missing=0 AND (title LIKE ? OR artist LIKE ?) ORDER BY title LIMIT 100 """, (f"%{text}%", f"%{text}%")).fetchall() for row in rows: label = f"{row['title']} · {row['artist']} [{row['file_format']}]" item = QListWidgetItem(label) item.setData(Qt.ItemDataRole.UserRole, dict(row)) lst.addItem(item) except Exception: pass search.textChanged.connect(_load_results) _load_results(search.text()) btn_row = QHBoxLayout() btn_ok = QPushButton("✓ Brug valgt sang") btn_ok.setEnabled(False) btn_cancel = QPushButton("Annuller") btn_row.addWidget(btn_ok) btn_row.addWidget(btn_cancel) layout.addLayout(btn_row) lst.itemSelectionChanged.connect(lambda: btn_ok.setEnabled(bool(lst.currentItem()))) btn_cancel.clicked.connect(dlg.reject) def _accept(): item = lst.currentItem() if not item: return chosen = item.data(Qt.ItemDataRole.UserRole) path = chosen.get("local_path", "") if not path or not Path(path).exists(): return song["local_path"] = path song["availability"] = "green" song["file_missing"] = False # Opdater DB try: with get_db() as conn: conn.execute( "UPDATE songs SET local_path=?, file_missing=0 WHERE id=?", (path, song.get("id", "")) ) except Exception: pass self._refresh() dlg.accept() btn_ok.clicked.connect(_accept) lst.itemDoubleClicked.connect(lambda _: _accept()) dlg.exec() # ── 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, " ") # Dans er primær tekst, sang er sekundær active = song.get("active_dance", "") if not active: dances = song.get("dances", []) active = dances[0] if dances else "— ingen dans —" ws_tag = " 🎓" if song.get("is_workshop") else "" # Tilgængeligheds-dot til højre — kun hvis tjekket (ikke yellow) avail = song.get("availability", "yellow") avail_color = {"green": "#27ae60", "red": "#e74c3c"}.get(avail, None) avail_tip = {"green": "Tilgængelig lokalt", "red": "Ikke fundet lokalt"}.get(avail, "") text = (f"{i+1:>2}. {active}{ws_tag}\n" f" {song.get('title','—')} · {song.get('artist','')}") item = QListWidgetItem(f"{icon} {text}") item.setData(Qt.ItemDataRole.UserRole, i) item.setData(Qt.ItemDataRole.UserRole + 1, avail_color) if avail_tip: item.setToolTip(avail_tip) 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)