524 lines
23 KiB
Python
524 lines
23 KiB
Python
"""
|
|
playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik.
|
|
"""
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
|
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
|
|
QMessageBox, QInputDialog,
|
|
)
|
|
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray
|
|
from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent
|
|
|
|
|
|
ACTIVE_PLAYLIST_NAME = "__aktiv__" # fast navn til autogem-listen
|
|
|
|
|
|
class PlaylistPanel(QWidget):
|
|
song_selected = pyqtSignal(int)
|
|
status_changed = pyqtSignal(int, str)
|
|
song_dropped = pyqtSignal(dict)
|
|
playlist_changed = pyqtSignal()
|
|
event_started = pyqtSignal()
|
|
next_song_ready = pyqtSignal(dict) # udsendes når næste sang ændres — main_window indlæser den # udsendes af Start event — main_window indlæser første sang # udsendes ved enhver ændring → trigger autogem
|
|
|
|
STATUS_ICON = {"pending": " ", "playing": " ▶ ", "played": " ✓ ", "skipped": " — ", "next": " ▷ "}
|
|
STATUS_COLOR = {"pending": "#5a6070", "playing": "#e8a020", "played": "#2ecc71", "skipped": "#e74c3c", "next": "#3b8fd4"}
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._songs: list[dict] = []
|
|
self._statuses: list[str] = []
|
|
self._current_idx = -1
|
|
self._song_ended = False
|
|
self._active_playlist_id: int | None = None
|
|
self._build_ui()
|
|
self.setAcceptDrops(True)
|
|
# Autogem-timer — venter 800ms efter sidst ændring
|
|
self._autosave_timer = QTimer(self)
|
|
self._autosave_timer.setSingleShot(True)
|
|
self._autosave_timer.setInterval(800)
|
|
self._autosave_timer.timeout.connect(self._autosave)
|
|
# Event-state gem — hurtig, kritisk for genopstart efter strømsvigt
|
|
self._event_state_timer = QTimer(self)
|
|
self._event_state_timer.setSingleShot(True)
|
|
self._event_state_timer.setInterval(300)
|
|
self._event_state_timer.timeout.connect(self._save_event_state)
|
|
|
|
def _build_ui(self):
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
|
|
# ── Header med titel ──────────────────────────────────────────────────
|
|
header = QHBoxLayout()
|
|
header.setContentsMargins(10, 6, 10, 6)
|
|
self._title_label = QLabel("DANSELISTE")
|
|
self._title_label.setObjectName("section_title")
|
|
header.addWidget(self._title_label)
|
|
layout.addLayout(header)
|
|
|
|
# ── Ny / Gem / Hent knapper ───────────────────────────────────────────
|
|
toolbar = QHBoxLayout()
|
|
toolbar.setContentsMargins(8, 2, 8, 4)
|
|
toolbar.setSpacing(4)
|
|
|
|
btn_new = QPushButton("✚ Ny")
|
|
btn_new.setFixedHeight(26)
|
|
btn_new.setToolTip("Opret en ny tom danseliste")
|
|
btn_new.clicked.connect(self._new_playlist)
|
|
toolbar.addWidget(btn_new)
|
|
|
|
btn_save = QPushButton("💾 Gem som...")
|
|
btn_save.setFixedHeight(26)
|
|
btn_save.setToolTip("Gem aktuel liste med et navn")
|
|
btn_save.clicked.connect(self._save_as)
|
|
toolbar.addWidget(btn_save)
|
|
|
|
btn_load = QPushButton("📂 Hent...")
|
|
btn_load.setFixedHeight(26)
|
|
btn_load.setToolTip("Hent en tidligere gemt danseliste")
|
|
btn_load.clicked.connect(self._load_dialog)
|
|
toolbar.addWidget(btn_load)
|
|
|
|
toolbar.addStretch()
|
|
|
|
self._lbl_autosave = QLabel("")
|
|
self._lbl_autosave.setObjectName("result_count")
|
|
toolbar.addWidget(self._lbl_autosave)
|
|
|
|
layout.addLayout(toolbar)
|
|
|
|
# ── Event-kontrol ─────────────────────────────────────────────────────
|
|
ctrl = QHBoxLayout()
|
|
ctrl.setContentsMargins(8, 2, 8, 4)
|
|
ctrl.setSpacing(6)
|
|
|
|
self._btn_start = QPushButton("▶ START EVENT")
|
|
self._btn_start.setFixedHeight(28)
|
|
self._btn_start.setToolTip("Nulstil alle statusser og gør klar til event")
|
|
self._btn_start.clicked.connect(self._start_event)
|
|
ctrl.addWidget(self._btn_start)
|
|
ctrl.addStretch()
|
|
|
|
self._lbl_progress = QLabel("0 / 0")
|
|
self._lbl_progress.setObjectName("result_count")
|
|
ctrl.addWidget(self._lbl_progress)
|
|
|
|
layout.addLayout(ctrl)
|
|
|
|
# ── Liste ─────────────────────────────────────────────────────────────
|
|
self._list = QListWidget()
|
|
self._list.setObjectName("playlist_list")
|
|
self._list.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
|
|
self._list.setDefaultDropAction(Qt.DropAction.MoveAction)
|
|
self._list.setAcceptDrops(True)
|
|
self._list.itemDoubleClicked.connect(self._on_double_click)
|
|
self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self._list.customContextMenuRequested.connect(self._show_context_menu)
|
|
self._list.model().rowsMoved.connect(self._on_rows_moved)
|
|
layout.addWidget(self._list)
|
|
|
|
# ── Drag & drop ───────────────────────────────────────────────────────────
|
|
|
|
def dragEnterEvent(self, event: QDragEnterEvent):
|
|
if event.mimeData().hasFormat("application/x-linedance-song"):
|
|
event.acceptProposedAction()
|
|
else:
|
|
event.ignore()
|
|
|
|
def dropEvent(self, event: QDropEvent):
|
|
mime = event.mimeData()
|
|
if mime.hasFormat("application/x-linedance-song"):
|
|
import json
|
|
song = json.loads(mime.data("application/x-linedance-song").data().decode())
|
|
self._append_song(song)
|
|
self.song_dropped.emit(song)
|
|
event.acceptProposedAction()
|
|
|
|
def _append_song(self, song: dict):
|
|
self._songs.append(song)
|
|
self._statuses.append("pending")
|
|
self._refresh()
|
|
self._trigger_autosave()
|
|
|
|
# ── Data API ──────────────────────────────────────────────────────────────
|
|
|
|
def load_songs(self, songs: list[dict], reset_statuses: bool = True, name: str = ""):
|
|
self._songs = list(songs)
|
|
if reset_statuses:
|
|
self._statuses = ["pending"] * len(songs)
|
|
self._current_idx = -1
|
|
self._song_ended = False
|
|
if name:
|
|
self._title_label.setText(f"DANSELISTE — {name.upper()}")
|
|
self._refresh()
|
|
self._trigger_autosave()
|
|
|
|
def set_current(self, idx: int, song_ended: bool = False):
|
|
self._current_idx = idx
|
|
self._song_ended = song_ended
|
|
if 0 <= idx < len(self._statuses) and not song_ended:
|
|
self._statuses[idx] = "playing"
|
|
self._refresh()
|
|
self._scroll_to(idx)
|
|
|
|
def mark_played(self, idx: int):
|
|
if 0 <= idx < len(self._statuses):
|
|
self._statuses[idx] = "played"
|
|
self._refresh()
|
|
self._trigger_autosave()
|
|
self._trigger_event_state_save()
|
|
|
|
def set_next_ready(self, idx: int):
|
|
"""Sæt næste sang klar — uden at overskrive skipped/played statusser."""
|
|
self._current_idx = idx
|
|
self._song_ended = False
|
|
# Ændr KUN status hvis den er pending — rør ikke skipped/played
|
|
if 0 <= idx < len(self._statuses):
|
|
if self._statuses[idx] not in ("skipped", "played"):
|
|
self._statuses[idx] = "pending"
|
|
self._refresh()
|
|
self._scroll_to(idx)
|
|
|
|
def get_song(self, idx: int) -> dict | None:
|
|
return self._songs[idx] if 0 <= idx < len(self._songs) else None
|
|
|
|
def get_songs(self) -> list[dict]:
|
|
return list(self._songs)
|
|
|
|
def get_statuses(self) -> list[str]:
|
|
return list(self._statuses)
|
|
|
|
def count(self) -> int:
|
|
return len(self._songs)
|
|
|
|
def set_playlist_name(self, name: str):
|
|
self._title_label.setText(f"DANSELISTE — {name.upper()}")
|
|
|
|
# ── Drag-flytning ─────────────────────────────────────────────────────────
|
|
|
|
def _on_rows_moved(self, parent, start, end, dest, dest_row):
|
|
"""Opdater _songs og _statuses når en sang flyttes via drag."""
|
|
new_songs = []
|
|
new_statuses = []
|
|
for i in range(self._list.count()):
|
|
old_idx = self._list.item(i).data(Qt.ItemDataRole.UserRole)
|
|
if old_idx is not None and 0 <= old_idx < len(self._songs):
|
|
new_songs.append(self._songs[old_idx])
|
|
new_statuses.append(self._statuses[old_idx])
|
|
self._songs = new_songs
|
|
self._statuses = new_statuses
|
|
self._current_idx = -1
|
|
self._song_ended = False
|
|
self._refresh()
|
|
self._trigger_autosave()
|
|
|
|
# Find første afspilbare sang og udsend signal så afspilleren opdateres
|
|
ni = self.next_playable_idx()
|
|
if ni is not None:
|
|
self._current_idx = ni
|
|
self._refresh()
|
|
self.next_song_ready.emit(self._songs[ni])
|
|
|
|
# ── Event-state ───────────────────────────────────────────────────────────
|
|
|
|
def _save_event_state(self):
|
|
"""Gem current_idx og statuses — overlever strømsvigt."""
|
|
try:
|
|
from local.local_db import save_event_state
|
|
save_event_state(self._current_idx, self._statuses)
|
|
except Exception as e:
|
|
print(f"Event-state gem fejl: {e}")
|
|
|
|
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:
|
|
print(f"Event-state gendan fejl: {e}")
|
|
return False
|
|
|
|
def next_playable_idx(self) -> int | None:
|
|
"""Find første sang fra toppen der ikke er 'skipped' eller 'played'."""
|
|
for i in range(len(self._songs)):
|
|
if self._statuses[i] not in ("skipped", "played"):
|
|
return i
|
|
return None
|
|
|
|
# ── Autogem ───────────────────────────────────────────────────────────────
|
|
|
|
def _trigger_autosave(self):
|
|
"""Start/nulstil debounce-timer — gemmer 800ms efter sidst ændring."""
|
|
self._autosave_timer.start()
|
|
self._lbl_autosave.setText("● ikke gemt")
|
|
|
|
def _autosave(self):
|
|
"""Gem til den faste 'Aktiv liste' i SQLite."""
|
|
try:
|
|
from local.local_db import get_db, create_playlist, add_song_to_playlist
|
|
with get_db() as conn:
|
|
# Slet den gamle aktive liste
|
|
conn.execute(
|
|
"DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
|
|
)
|
|
# Opret ny
|
|
pl_id = create_playlist(ACTIVE_PLAYLIST_NAME)
|
|
self._active_playlist_id = pl_id
|
|
for i, song in enumerate(self._songs, start=1):
|
|
if song.get("id"):
|
|
add_song_to_playlist(pl_id, song["id"], position=i)
|
|
self._lbl_autosave.setText("✓ gemt")
|
|
self.playlist_changed.emit()
|
|
except Exception as e:
|
|
self._lbl_autosave.setText(f"⚠ gemfejl")
|
|
print(f"Autogem fejl: {e}")
|
|
|
|
def restore_active_playlist(self):
|
|
"""Indlæs den sidst aktive liste ved opstart."""
|
|
try:
|
|
from local.local_db import get_db
|
|
with get_db() as conn:
|
|
pl = conn.execute(
|
|
"SELECT id FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
|
|
).fetchone()
|
|
if not pl:
|
|
return False
|
|
songs_raw = conn.execute("""
|
|
SELECT s.*, ps.position FROM playlist_songs ps
|
|
JOIN songs s ON s.id = ps.song_id
|
|
WHERE ps.playlist_id=? ORDER BY ps.position
|
|
""", (pl["id"],)).fetchall()
|
|
songs = []
|
|
for row in songs_raw:
|
|
dances = conn.execute(
|
|
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
|
|
(row["id"],)
|
|
).fetchall()
|
|
songs.append({
|
|
"id": row["id"], "title": row["title"],
|
|
"artist": row["artist"], "album": row["album"],
|
|
"bpm": row["bpm"], "duration_sec": row["duration_sec"],
|
|
"local_path": row["local_path"], "file_format": row["file_format"],
|
|
"file_missing": bool(row["file_missing"]),
|
|
"dances": [d["dance_name"] for d in dances],
|
|
})
|
|
if songs:
|
|
self._songs = songs
|
|
self._statuses = ["pending"] * len(songs)
|
|
self._refresh()
|
|
self._lbl_autosave.setText("✓ gendannet")
|
|
return True
|
|
except Exception as e:
|
|
print(f"Gendan aktiv liste fejl: {e}")
|
|
return False
|
|
|
|
# ── Ny / Gem som / Hent ───────────────────────────────────────────────────
|
|
|
|
def _new_playlist(self):
|
|
if self._songs:
|
|
reply = QMessageBox.question(
|
|
self, "Ny danseliste",
|
|
"Ryd den aktuelle liste og start forfra?",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
)
|
|
if reply != QMessageBox.StandardButton.Yes:
|
|
return
|
|
self._songs = []
|
|
self._statuses = []
|
|
self._current_idx = -1
|
|
self._song_ended = False
|
|
self._title_label.setText("DANSELISTE — NY")
|
|
self._refresh()
|
|
self._trigger_autosave()
|
|
|
|
def _save_as(self):
|
|
if not self._songs:
|
|
QMessageBox.information(self, "Gem", "Danselisten er tom.")
|
|
return
|
|
name, ok = QInputDialog.getText(
|
|
self, "Gem danseliste", "Navn på danselisten:",
|
|
)
|
|
if not ok or not name.strip():
|
|
return
|
|
name = name.strip()
|
|
try:
|
|
from local.local_db import create_playlist, add_song_to_playlist
|
|
pl_id = create_playlist(name)
|
|
for i, song in enumerate(self._songs, start=1):
|
|
if song.get("id"):
|
|
add_song_to_playlist(pl_id, song["id"], position=i)
|
|
self._title_label.setText(f"DANSELISTE — {name.upper()}")
|
|
self._lbl_autosave.setText(f"✓ gemt som \"{name}\"")
|
|
except Exception as e:
|
|
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
|
|
|
|
def _load_dialog(self):
|
|
"""Vis liste af gemte danselister og lad brugeren vælge."""
|
|
try:
|
|
from local.local_db import get_db
|
|
with get_db() as conn:
|
|
lists = conn.execute(
|
|
"SELECT id, name, created_at FROM playlists "
|
|
"WHERE name != ? ORDER BY created_at DESC",
|
|
(ACTIVE_PLAYLIST_NAME,)
|
|
).fetchall()
|
|
except Exception as e:
|
|
QMessageBox.warning(self, "Fejl", f"Kunne ikke hente lister: {e}")
|
|
return
|
|
|
|
if not lists:
|
|
QMessageBox.information(self, "Hent liste", "Ingen gemte danselister fundet.")
|
|
return
|
|
|
|
names = [f"{row['name']} ({row['created_at'][:10]})" for row in lists]
|
|
choice, ok = QInputDialog.getItem(
|
|
self, "Hent danseliste", "Vælg en liste:", names, editable=False
|
|
)
|
|
if not ok:
|
|
return
|
|
|
|
idx = names.index(choice)
|
|
pl_id = lists[idx]["id"]
|
|
pl_name = lists[idx]["name"]
|
|
|
|
try:
|
|
from local.local_db import get_db
|
|
with get_db() as conn:
|
|
songs_raw = conn.execute("""
|
|
SELECT s.*, ps.position FROM playlist_songs ps
|
|
JOIN songs s ON s.id = ps.song_id
|
|
WHERE ps.playlist_id=? ORDER BY ps.position
|
|
""", (pl_id,)).fetchall()
|
|
songs = []
|
|
for row in songs_raw:
|
|
dances = conn.execute(
|
|
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
|
|
(row["id"],)
|
|
).fetchall()
|
|
songs.append({
|
|
"id": row["id"], "title": row["title"],
|
|
"artist": row["artist"], "album": row["album"],
|
|
"bpm": row["bpm"], "duration_sec": row["duration_sec"],
|
|
"local_path": row["local_path"], "file_format": row["file_format"],
|
|
"file_missing": bool(row["file_missing"]),
|
|
"dances": [d["dance_name"] for d in dances],
|
|
})
|
|
self.load_songs(songs, name=pl_name)
|
|
except Exception as e:
|
|
QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}")
|
|
|
|
# ── Start event ───────────────────────────────────────────────────────────
|
|
|
|
def _start_event(self):
|
|
if not self._songs:
|
|
return
|
|
reply = QMessageBox.question(
|
|
self, "Start event",
|
|
"Dette nulstiller alle statusser i danselisten.\nFortsæt?",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
)
|
|
if reply == QMessageBox.StandardButton.Yes:
|
|
self._statuses = ["pending"] * len(self._songs)
|
|
self._current_idx = -1
|
|
self._song_ended = True
|
|
try:
|
|
from local.local_db import clear_event_state
|
|
clear_event_state()
|
|
except Exception:
|
|
pass
|
|
self._refresh()
|
|
self._scroll_to(0)
|
|
self.event_started.emit()
|
|
|
|
# ── Højreklik ─────────────────────────────────────────────────────────────
|
|
|
|
def _show_context_menu(self, pos):
|
|
item = self._list.itemAt(pos)
|
|
if not item:
|
|
return
|
|
idx = item.data(Qt.ItemDataRole.UserRole)
|
|
if idx is None:
|
|
return
|
|
menu = QMenu(self)
|
|
act_play = menu.addAction("▶ Afspil denne")
|
|
menu.addSeparator()
|
|
act_skip = menu.addAction("— Spring over")
|
|
act_unplay = menu.addAction("↺ Sæt til ikke afspillet")
|
|
act_played = menu.addAction("✓ Sæt til afspillet")
|
|
menu.addSeparator()
|
|
act_remove = menu.addAction("✕ Fjern fra liste")
|
|
action = menu.exec(self._list.mapToGlobal(pos))
|
|
if action == act_play:
|
|
self.song_selected.emit(idx)
|
|
elif action == act_skip:
|
|
self._statuses[idx] = "skipped"
|
|
self.status_changed.emit(idx, "skipped")
|
|
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
|
|
elif action == act_unplay:
|
|
self._statuses[idx] = "pending"
|
|
self.status_changed.emit(idx, "pending")
|
|
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
|
|
elif action == act_played:
|
|
self._statuses[idx] = "played"
|
|
self.status_changed.emit(idx, "played")
|
|
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
|
|
elif action == act_remove:
|
|
self._songs.pop(idx)
|
|
self._statuses.pop(idx)
|
|
if self._current_idx >= idx:
|
|
self._current_idx = max(-1, self._current_idx - 1)
|
|
self._refresh(); self._trigger_autosave()
|
|
|
|
# ── Render ────────────────────────────────────────────────────────────────
|
|
|
|
def _refresh(self):
|
|
self._list.clear()
|
|
played = sum(1 for s in self._statuses if s == "played")
|
|
self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet")
|
|
for i, song in enumerate(self._songs):
|
|
is_current = (i == self._current_idx and not self._song_ended)
|
|
is_next = (self._song_ended and i == self._current_idx + 1) or \
|
|
(self._current_idx == -1 and self._song_ended and i == 0)
|
|
status = "playing" if is_current else "next" if is_next else self._statuses[i]
|
|
icon = self.STATUS_ICON.get(status, " ")
|
|
dances = " / ".join(song.get("dances", [])) or "ingen dans tagget"
|
|
text = f"{i+1:>2}. {song.get('title','—')}\n {song.get('artist','')} · {dances}"
|
|
item = QListWidgetItem(f"{icon} {text}")
|
|
item.setData(Qt.ItemDataRole.UserRole, i)
|
|
color = self.STATUS_COLOR.get(status, "#5a6070")
|
|
if status in ("playing", "next"):
|
|
item.setForeground(QColor(color))
|
|
f = item.font(); f.setBold(True); item.setFont(f)
|
|
elif status == "played":
|
|
item.setForeground(QColor("#2ecc71"))
|
|
elif status == "skipped":
|
|
item.setForeground(QColor("#e74c3c"))
|
|
else:
|
|
item.setForeground(QColor("#9aa0b0"))
|
|
self._list.addItem(item)
|
|
|
|
def _scroll_to(self, idx: int):
|
|
if 0 <= idx < self._list.count():
|
|
self._list.scrollToItem(
|
|
self._list.item(idx), QListWidget.ScrollHint.PositionAtCenter)
|
|
|
|
def _on_double_click(self, item: QListWidgetItem):
|
|
idx = item.data(Qt.ItemDataRole.UserRole)
|
|
if idx is not None:
|
|
self.song_selected.emit(idx)
|