Files
LinedanceAfspiller/linedance-app/ui/playlist_panel.py
2026-04-25 21:28:31 +02:00

1509 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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: <b>{song.get('title','')} {song.get('artist','')}</b>")
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)