Files
LinedanceAfspiller/linedance-app/ui/playlist_panel.py
2026-04-19 23:45:59 +02:00

1261 lines
54 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.
"""
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: int | None = None
self._named_playlist_id: int | None = None # den indlæste/gemte navngivne liste
self._build_ui()
self.setAcceptDrops(True)
# Autogem-timer — venter 800ms efter sidst ændring
self._autosave_timer = QTimer(self)
self._autosave_timer.setSingleShot(True)
self._autosave_timer.setInterval(800)
self._autosave_timer.timeout.connect(self._autosave)
# Event-state gem — hurtig, kritisk for genopstart efter strømsvigt
self._event_state_timer = QTimer(self)
self._event_state_timer.setSingleShot(True)
self._event_state_timer.setInterval(300)
self._event_state_timer.timeout.connect(self._save_event_state)
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── Header med titel ──────────────────────────────────────────────────
header = QHBoxLayout()
header.setContentsMargins(10, 6, 10, 6)
self._title_label = QLabel("DANSELISTE")
self._title_label.setObjectName("section_title")
header.addWidget(self._title_label)
layout.addLayout(header)
# ── Ny / Gem / Hent knapper ───────────────────────────────────────────
toolbar = QHBoxLayout()
toolbar.setContentsMargins(8, 2, 8, 4)
toolbar.setSpacing(4)
btn_new = QPushButton("✚ Ny")
btn_new.setFixedHeight(26)
btn_new.setToolTip("Opret en ny tom danseliste")
btn_new.clicked.connect(self._new_playlist)
toolbar.addWidget(btn_new)
btn_save = QPushButton("💾 Gem som...")
btn_save.setFixedHeight(26)
btn_save.setToolTip("Gem aktuel liste med et navn")
btn_save.clicked.connect(self._save_as)
toolbar.addWidget(btn_save)
self._btn_save_current = QPushButton("💾 Gem")
self._btn_save_current.setFixedHeight(26)
self._btn_save_current.setToolTip("Gem ændringer til den indlæste liste")
self._btn_save_current.clicked.connect(self._save_current)
self._btn_save_current.setEnabled(False)
toolbar.addWidget(self._btn_save_current)
btn_load = QPushButton("📂 Hent...")
btn_load.setFixedHeight(26)
btn_load.setToolTip("Hent en tidligere gemt danseliste")
btn_load.clicked.connect(self._load_dialog)
toolbar.addWidget(btn_load)
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) -> int | None:
return self._named_playlist_id
def next_playable_idx(self) -> int | None:
"""Find første sang fra toppen der ikke er afspillet, sprunget over eller i gang."""
for i in range(len(self._songs)):
if self._statuses[i] not in ("skipped", "played", "playing"):
if i == self._current_idx and not self._song_ended:
continue
return i
return None
# ── Autogem ───────────────────────────────────────────────────────────────
def _trigger_autosave(self):
"""Start/nulstil debounce-timer — gemmer 800ms efter sidst ændring."""
self._autosave_timer.start()
self._lbl_autosave.setText("● ikke gemt")
def _autosave(self):
"""Gem til '__aktiv__' OG til den navngivne liste hvis der er én."""
try:
from local.local_db import get_db, create_playlist, add_song_to_playlist
with get_db() as conn:
conn.execute(
"DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
)
pl_id = create_playlist(ACTIVE_PLAYLIST_NAME)
self._active_playlist_id = pl_id
for i, song in enumerate(self._songs, start=1):
if song.get("id"):
add_song_to_playlist(pl_id, song["id"], position=i)
# Gem også til den navngivne liste
if self._named_playlist_id:
with get_db() as conn:
conn.execute(
"DELETE FROM playlist_songs WHERE playlist_id=?",
(self._named_playlist_id,)
)
for i, song in enumerate(self._songs, start=1):
if song.get("id"):
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
conn.execute(
"INSERT INTO playlist_songs "
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(self._named_playlist_id, song["id"], i, status,
1 if song.get("is_workshop") else 0,
song.get("active_dance") or "")
)
self._lbl_autosave.setText("✓ gemt")
self.playlist_changed.emit()
except Exception as e:
self._lbl_autosave.setText("⚠ gemfejl")
def _save_named_playlist_id(self, pl_id: int | None):
"""Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart."""
from PyQt6.QtCore import QSettings
s = QSettings("LineDance", "Player")
if pl_id:
s.setValue("session/named_playlist_id", pl_id)
else:
s.remove("session/named_playlist_id")
def restore_active_playlist(self):
"""Gendan senest aktive navngivne liste med event-fremgang ved opstart."""
try:
from PyQt6.QtCore import QSettings
s = QSettings("LineDance", "Player")
pl_id = s.value("session/named_playlist_id", None, type=int)
if not pl_id:
return False
import sqlite3
from local.local_db import DB_PATH
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
# Verificer at listen stadig eksisterer
pl = conn.execute(
"SELECT id, name FROM playlists WHERE id=?", (pl_id,)
).fetchone()
if not pl:
conn.close()
return False
# Hent sange med status, workshop og dans-override
# 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, s.file_format,
s.local_path, s.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
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 filen lokalt hvis den mangler på denne maskine
if file_missing or not local_path:
match = conn.execute("""
SELECT local_path FROM songs
WHERE title=? AND artist=? AND file_missing=0
LIMIT 1
""", (row["title"], row["artist"])).fetchone()
if match:
local_path = match["local_path"]
file_missing = False
songs.append({
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"] or "",
"bpm": row["bpm"] or 0,
"duration_sec": row["duration_sec"] or 0,
"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")
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"
conn.execute(
"INSERT INTO playlist_songs "
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(self._named_playlist_id, song["id"], i, status,
1 if song.get("is_workshop") else 0,
song.get("active_dance") or "")
)
self._lbl_autosave.setText("✓ gemt")
# Push til server hvis linket med edit-rettighed
if getattr(self, "_can_edit_server", False):
from local.local_db import get_db as _gdb
with _gdb() as c:
meta = c.execute(
"SELECT api_project_id FROM playlists WHERE id=?",
(self._named_playlist_id,)
).fetchone()
if meta and meta["api_project_id"]:
self._push_linked_playlist(
self._named_playlist_id, meta["api_project_id"]
)
self._lbl_autosave.setText("✓ gemt og synkroniseret")
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
def _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: int, pl_name: str):
try:
from local.local_db import get_db
# Tjek om listen er linket til serveren — pull først
with get_db() as conn:
pl_meta = conn.execute(
"SELECT api_project_id, is_linked, server_permission "
"FROM playlists WHERE id=?", (pl_id,)
).fetchone()
if pl_meta and pl_meta["is_linked"] and pl_meta["api_project_id"]:
self._pull_linked_playlist(pl_id, pl_meta["api_project_id"])
# Opdater gem-knap baseret på rettighed
perm = pl_meta["server_permission"] or "view"
self._named_playlist_id = pl_id
self._can_edit_server = (perm == "edit")
else:
self._can_edit_server = False
with get_db() as conn:
# 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, s.file_format,
s.local_path, s.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
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 = sad.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 filen lokalt hvis den mangler på denne maskine
if file_missing or not local_path:
match = conn.execute("""
SELECT local_path FROM songs
WHERE title=? AND artist=? AND file_missing=0
LIMIT 1
""", (row["title"], row["artist"])).fetchone()
if match:
local_path = match["local_path"]
file_missing = False
repaired += 1
songs.append({
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"] or "",
"bpm": row["bpm"] or 0,
"duration_sec": row["duration_sec"] or 0,
"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
current = song.get("active_dance", "")
if not current:
dances = song.get("dances", [])
current = dances[0] if dances else ""
current_choreo = song.get("active_choreo", "")
dlg = DancePickerDialog(
current_dance=current,
current_choreo=current_choreo,
song_title=song.get("title", ""),
parent=self.window()
)
if dlg.exec():
chosen = dlg.get_dance()
choreo = dlg.get_choreo()
if chosen:
song["active_dance"] = chosen
song["active_choreo"] = choreo
self._refresh()
self._sync_dance_to_db(idx, song)
def _sync_dance_to_db(self, idx: int, song: dict):
"""Gem dance_override til playlist_songs."""
if not self._named_playlist_id:
return
try:
from local.local_db import get_db
with get_db() as conn:
conn.execute(
"UPDATE playlist_songs SET dance_override=? "
"WHERE playlist_id=? AND position=?",
(song.get("active_dance", ""), self._named_playlist_id, idx + 1)
)
except Exception:
pass
def _sync_ws_to_db(self, idx: int, song: dict):
"""Gem is_workshop til playlist_songs — både navngiven og aktiv liste."""
pl_ids = []
if self._named_playlist_id:
pl_ids.append(self._named_playlist_id)
if self._active_playlist_id:
pl_ids.append(self._active_playlist_id)
if not pl_ids:
return
try:
from local.local_db import get_db
with get_db() as conn:
for pl_id in pl_ids:
conn.execute(
"UPDATE playlist_songs SET is_workshop=? "
"WHERE playlist_id=? AND position=?",
(1 if song.get("is_workshop") else 0, pl_id, idx + 1)
)
except Exception:
pass
def _pull_linked_playlist(self, pl_id: int, server_id: str):
"""Hent seneste version af en linket liste fra serveren."""
try:
from ui.settings_dialog import load_settings
from local.local_db import get_db, DB_PATH
s = load_settings()
server_url = s.get("server_url", "").rstrip("/")
# Hent token fra main_window
mw = self.window()
token = getattr(mw, "_api_token", None)
if not token or not server_url:
return
import urllib.request, json
req = urllib.request.Request(
f"{server_url}/sharing/playlists/{server_id}",
headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req, timeout=8) as resp:
pl_data = json.loads(resp.read())
# Opdater lokal liste med server-data
import sqlite3
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
for song_data in pl_data.get("songs", []):
local = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0",
(song_data["title"], song_data["artist"])
).fetchone()
if local:
conn.execute(
"INSERT INTO playlist_songs "
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(pl_id, local["id"], song_data["position"],
song_data.get("status", "pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override") or "")
)
conn.commit()
conn.close()
except Exception as e:
pass # Offline — brug lokalt cachet version
def _push_linked_playlist(self, pl_id: int, server_id: str):
"""Push ændringer til server for en linket liste."""
try:
from ui.settings_dialog import load_settings
from local.local_db import DB_PATH
s = load_settings()
server_url = s.get("server_url", "").rstrip("/")
mw = self.window()
token = getattr(mw, "_api_token", None)
if not token or not server_url:
return
import sqlite3, json, urllib.request
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
songs = []
for ps in conn.execute(
"SELECT s.title, s.artist, ps.position, ps.status, "
"ps.is_workshop, ps.dance_override "
"FROM playlist_songs ps JOIN songs s ON s.id=ps.song_id "
"WHERE ps.playlist_id=? ORDER BY ps.position", (pl_id,)
).fetchall():
songs.append({
"title": ps["title"],
"artist": ps["artist"],
"position": ps["position"],
"status": ps["status"] or "pending",
"is_workshop": bool(ps["is_workshop"]),
"dance_override": ps["dance_override"] or "",
})
conn.close()
data = json.dumps({"songs": songs}).encode()
req = urllib.request.Request(
f"{server_url}/sharing/playlists/{server_id}/songs",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
method="PUT"
)
urllib.request.urlopen(req, timeout=8)
except Exception as e:
pass
def _on_pause_changed(self, seconds: int):
self._pause_seconds = seconds
def _start_event(self):
if not self._songs:
return
reply = QMessageBox.question(
self, "Start event",
"Dette nulstiller alle statusser i danselisten.\nFortsæt?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
self._statuses = ["pending"] * len(self._songs)
self._current_idx = -1
self._song_ended = False
try:
from local.local_db import clear_event_state
clear_event_state()
except Exception:
pass
self._refresh()
self._scroll_to(0)
# Sæt første sang klar
ni = self.next_playable_idx()
if ni is not None:
self._current_idx = ni
self._refresh()
self.next_song_ready.emit(self._songs[ni])
self.event_started.emit()
# ── 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():
song["availability"] = "green"
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 — eksisterer og tilgængeligt
if path and Path(path).exists():
song["availability"] = "green"
song["file_missing"] = False
# Opdater songs tabellen
conn.execute(
"UPDATE songs SET file_missing=0, local_path=? WHERE id=?",
(path, song["id"])
)
continue
# Forsøg auto-match via titel+artist
title = song.get("title", "")
artist = song.get("artist", "")
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["availability"] = "green"
song["file_missing"] = False
# Opdater playlist_songs til at pege på den fundne sang
if self._named_playlist_id:
conn.execute("""
UPDATE playlist_songs SET song_id=?
WHERE playlist_id=? AND song_id=(
SELECT id FROM songs
WHERE title=? AND artist=?
LIMIT 1
)
""", (match["id"], self._named_playlist_id, title, artist))
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...")
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_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, " ")
# Vis active_dance (override eller første dans) eller alle danse
active = song.get("active_dance", "")
if not active:
dances = song.get("dances", [])
active = dances[0] if dances else "ingen dans tagget"
ws_tag = " 🎓" if song.get("is_workshop") else ""
# 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}. {song.get('title','')}{ws_tag}\n"
f" {song.get('artist','')} · {active}")
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)