diff --git a/linedance-api/app/routers/sharing.py b/linedance-api/app/routers/sharing.py index 1f982a3f..9dd6b6fb 100644 --- a/linedance-api/app/routers/sharing.py +++ b/linedance-api/app/routers/sharing.py @@ -1,10 +1,10 @@ """ -sharing.py — Del playlister med andre brugere. +sharing.py — Forenklet deling af playlister. +Kun ejeren kan redigere. Delte brugere får read-only via sync. """ from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks from sqlalchemy.orm import Session from pydantic import BaseModel, EmailStr -from typing import Optional from app.core.database import get_db from app.core.security import get_current_user @@ -14,22 +14,10 @@ router = APIRouter(prefix="/sharing", tags=["sharing"]) class ShareRequest(BaseModel): - email: EmailStr - permission: str = "view" # view | copy | edit + email: EmailStr -class ShareOut(BaseModel): - id: str - project_id: str - invited_email: str - permission: str - accepted_at: Optional[str] = None - - class Config: - from_attributes = True - - -# ── Del en playliste ────────────────────────────────────────────────────────── +# ── Del med bruger ──────────────────────────────────────────────────────────── @router.post("/playlists/{project_id}/share", status_code=201) async def share_playlist( @@ -39,35 +27,27 @@ async def share_playlist( db: Session = Depends(get_db), me: User = Depends(get_current_user), ): + """Del en playliste med en bruger — de får listen ved næste sync.""" project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first() if not project: raise HTTPException(404, "Playliste ikke fundet eller du er ikke ejer") - if data.permission not in ("view", "copy", "edit"): - raise HTTPException(400, "Ugyldig rettighed — brug view, copy eller edit") - - # Find bruger via email target = db.query(User).filter_by(email=data.email).first() - # Tjek om deling allerede eksisterer existing = db.query(PlaylistShare).filter_by( - project_id=project_id, - invited_email=data.email, + project_id=project_id, invited_email=data.email ).first() if existing: - existing.permission = data.permission - db.commit() - return {"detail": "Rettigheder opdateret", "share_id": existing.id} + return {"detail": "Allerede delt med denne bruger"} share = PlaylistShare( project_id=project_id, shared_with_id=target.id if target else None, invited_email=data.email, - permission=data.permission, + permission="view", ) db.add(share) db.commit() - db.refresh(share) # Send invitation-mail try: @@ -78,32 +58,13 @@ async def share_playlist( email=data.email, owner_name=me.username, playlist_name=project.name, - permission=data.permission, - accept_url=f"{settings.BASE_URL}/sharing/accept/{share.id}", + permission="view", + accept_url=f"{settings.BASE_URL}/sharing/playlists/{project_id}", ) except Exception: pass - return {"detail": "Invitation sendt", "share_id": share.id} - - -@router.patch("/playlists/{project_id}/share/{share_id}") -def update_share( - project_id: str, - share_id: str, - data: ShareRequest, - db: Session = Depends(get_db), - me: User = Depends(get_current_user), -): - project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first() - if not project: - raise HTTPException(404, "Playliste ikke fundet") - share = db.query(PlaylistShare).filter_by(id=share_id, project_id=project_id).first() - if not share: - raise HTTPException(404, "Deling ikke fundet") - share.permission = data.permission - db.commit() - return {"detail": "Rettigheder opdateret"} + return {"detail": f"Delt med {data.email}"} @router.delete("/playlists/{project_id}/share/{share_id}", status_code=204) @@ -133,15 +94,7 @@ def list_shares( if not project: raise HTTPException(404, "Playliste ikke fundet") shares = db.query(PlaylistShare).filter_by(project_id=project_id).all() - return [ - { - "id": s.id, - "email": s.invited_email, - "permission": s.permission, - "accepted": s.accepted_at is not None, - } - for s in shares - ] + return [{"id": s.id, "email": s.invited_email} for s in shares] # ── Visibility ──────────────────────────────────────────────────────────────── @@ -154,64 +107,16 @@ def set_visibility( me: User = Depends(get_current_user), ): if visibility not in ("private", "shared", "public"): - raise HTTPException(400, "Ugyldig synlighed — brug private, shared eller public") + raise HTTPException(400, "Brug private, shared eller public") project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first() if not project: raise HTTPException(404, "Playliste ikke fundet") project.visibility = visibility db.commit() - return {"detail": f"Synlighed sat til {visibility}"} + return {"detail": f"Synlighed: {visibility}"} -# ── Hent delte lister ───────────────────────────────────────────────────────── - -@router.get("/playlists/shared-with-me") -def shared_with_me( - db: Session = Depends(get_db), - me: User = Depends(get_current_user), -): - """Hent alle playlister der er delt med mig.""" - # Via direkte deling - shares = db.query(PlaylistShare).filter_by( - shared_with_id=me.id - ).all() - project_ids = {s.project_id for s in shares} - - # Via email-invitation - email_shares = db.query(PlaylistShare).filter_by( - invited_email=me.email - ).all() - project_ids.update(s.project_id for s in email_shares) - - # Public playlister - public = db.query(Project).filter_by(visibility="public").all() - project_ids.update(p.id for p in public) - - result = [] - for pid in project_ids: - p = db.query(Project).filter_by(id=pid).first() - if not p or p.owner_id == me.id: - continue - # Find min rettighed - share = db.query(PlaylistShare).filter( - PlaylistShare.project_id == pid, - (PlaylistShare.shared_with_id == me.id) | - (PlaylistShare.invited_email == me.email) - ).first() - permission = share.permission if share else "view" - owner = db.query(User).filter_by(id=p.owner_id).first() - result.append({ - "project_id": p.id, - "name": p.name, - "owner": owner.username if owner else "?", - "visibility": p.visibility, - "permission": permission, - "song_count": len(p.project_songs), - }) - return result - - -# ── Hent en delt playliste ──────────────────────────────────────────────────── +# ── Hent playliste-indhold ──────────────────────────────────────────────────── @router.get("/playlists/{project_id}") def get_shared_playlist( @@ -219,12 +124,9 @@ def get_shared_playlist( db: Session = Depends(get_db), me: User = Depends(get_current_user), ): - """Hent indholdet af en delt playliste.""" p = db.query(Project).filter_by(id=project_id).first() if not p: raise HTTPException(404, "Playliste ikke fundet") - - # Tjek adgang if p.owner_id != me.id: if p.visibility != "public": share = db.query(PlaylistShare).filter( @@ -233,7 +135,7 @@ def get_shared_playlist( (PlaylistShare.invited_email == me.email) ).first() if not share: - raise HTTPException(403, "Du har ikke adgang til denne playliste") + raise HTTPException(403, "Ingen adgang") from app.models import Song songs = [] @@ -242,153 +144,18 @@ def get_shared_playlist( if not song: continue songs.append({ - "title": song.title, - "artist": song.artist, - "album": song.album, - "bpm": song.bpm, - "duration_sec": song.duration_sec, - "position": ps.position, - "status": ps.status, - "is_workshop": ps.is_workshop, - "dance_override": ps.dance_override, + "title": song.title, + "artist": song.artist, + "position": ps.position, + "status": ps.status, + "is_workshop": ps.is_workshop, + "dance_override": ps.dance_override or "", }) return { "id": p.id, "name": p.name, - "description": p.description, + "description": p.description or "", "visibility": p.visibility, "songs": sorted(songs, key=lambda x: x["position"]), } - - -# ── Opdater sange i en linket liste ────────────────────────────────────────── - -class LinkedSongData(BaseModel): - title: str - artist: str = "" - position: int = 1 - status: str = "pending" - is_workshop: bool = False - dance_override: str = "" - -class LinkedSongsUpdate(BaseModel): - songs: list[LinkedSongData] - - -@router.put("/playlists/{project_id}/songs") -def update_linked_songs( - project_id: str, - data: LinkedSongsUpdate, - db: Session = Depends(get_db), - me: User = Depends(get_current_user), -): - """Opdater sange i en linket playliste — kræver edit-rettighed.""" - from app.models import Song, ProjectSong - - p = db.query(Project).filter_by(id=project_id).first() - if not p: - raise HTTPException(404, "Playliste ikke fundet") - - # Tjek edit-rettighed - if p.owner_id != me.id: - share = db.query(PlaylistShare).filter( - PlaylistShare.project_id == project_id, - (PlaylistShare.shared_with_id == me.id) | - (PlaylistShare.invited_email == me.email) - ).first() - if not share or share.permission != "edit": - raise HTTPException(403, "Du har ikke redigerings-rettighed") - - # Slet eksisterende sange og geninsert - db.query(ProjectSong).filter_by(project_id=project_id).delete() - - for song_data in data.songs: - song = db.query(Song).filter_by( - title=song_data.title, artist=song_data.artist - ).first() - if not song: - continue - ps = ProjectSong( - project_id=project_id, - song_id=song.id, - position=song_data.position, - status=song_data.status, - is_workshop=song_data.is_workshop, - dance_override=song_data.dance_override, - ) - db.add(ps) - - db.commit() - return {"detail": "Liste opdateret", "songs": len(data.songs)} - - -# ── Opdater sange på en delt playliste ─────────────────────────────────────── - -class LinkedSongData(BaseModel): - title: str - artist: str - position: int - status: str = "pending" - is_workshop: bool = False - dance_override: str = "" - -class LinkedPlaylistUpdate(BaseModel): - songs: list[LinkedSongData] - - -@router.put("/playlists/{project_id}/songs") -def update_linked_playlist_songs( - project_id: str, - data: LinkedPlaylistUpdate, - db: Session = Depends(get_db), - me: User = Depends(get_current_user), -): - """Opdater sange på en delt playliste — kræver edit-rettighed.""" - from app.models import Song - - p = db.query(Project).filter_by(id=project_id).first() - if not p: - raise HTTPException(404, "Playliste ikke fundet") - - # Tjek rettighed - if p.owner_id != me.id: - from app.models import PlaylistShare - share = db.query(PlaylistShare).filter( - PlaylistShare.project_id == project_id, - (PlaylistShare.shared_with_id == me.id) | - (PlaylistShare.invited_email == me.email) - ).first() - if not share or share.permission != "edit": - raise HTTPException(403, "Du har ikke rettighed til at redigere denne liste") - - # Slet eksisterende sange og indsæt nye - from app.models import ProjectSong - db.query(ProjectSong).filter_by(project_id=project_id).delete() - - for song_data in data.songs: - # Match sang globalt på titel+artist - song = db.query(Song).filter_by( - title=song_data.title, artist=song_data.artist - ).first() - if not song: - song = Song( - owner_id=me.id, - title=song_data.title, - artist=song_data.artist, - ) - db.add(song) - db.flush() - - ps = ProjectSong( - project_id=project_id, - song_id=song.id, - position=song_data.position, - status=song_data.status, - is_workshop=song_data.is_workshop, - dance_override=song_data.dance_override, - ) - db.add(ps) - - db.commit() - return {"detail": "Playliste opdateret", "songs": len(data.songs)} diff --git a/linedance-api/app/routers/sync.py b/linedance-api/app/routers/sync.py index d30c03bb..08f3cc91 100644 --- a/linedance-api/app/routers/sync.py +++ b/linedance-api/app/routers/sync.py @@ -268,28 +268,37 @@ def pull( "dance_id": cd.dance_id, }) - # Delte playlister - shared_ids = [ - s.project_id for s in - db.query(PlaylistShare).filter_by(shared_with_id=me.id).all() - ] + # Delte playlister (read-only — kun ejeren kan redigere) + shared_ids = set() + for s in db.query(PlaylistShare).filter( + (PlaylistShare.shared_with_id == me.id) | + (PlaylistShare.invited_email == me.email) + ).all(): + shared_ids.add(s.project_id) + shared = [] for p in db.query(Project).filter(Project.id.in_(shared_ids)).all(): + if p.owner_id == me.id: + continue # Egne lister håndteres separat + owner = db.query(User).filter_by(id=p.owner_id).first() + songs_out = [] + for ps in p.project_songs: + song = db.query(Song).filter_by(id=ps.song_id).first() + if not song: + continue + songs_out.append({ + "title": song.title, + "artist": song.artist, + "position": ps.position, + "status": ps.status, + "is_workshop": ps.is_workshop, + "dance_override": ps.dance_override or "", + }) shared.append({ - "id": p.id, - "name": p.name, - "owner_id": p.owner_id, - "visibility": p.visibility, - "songs": [ - { - "song_id": ps.song_id, - "position": ps.position, - "status": ps.status, - "is_workshop": ps.is_workshop, - "dance_override": ps.dance_override, - } - for ps in p.project_songs - ] + "server_id": p.id, + "name": p.name, + "owner": owner.username if owner else "?", + "songs": sorted(songs_out, key=lambda x: x["position"]), }) # Egne playlister diff --git a/linedance-app/app_logger.py b/linedance-app/app_logger.py index 0938584c..bda88965 100644 --- a/linedance-app/app_logger.py +++ b/linedance-app/app_logger.py @@ -4,7 +4,6 @@ Paa Windows uden konsol skrives alt til ~/.linedance/app.log """ import logging -import sys from pathlib import Path LOG_PATH = Path.home() / ".linedance" / "app.log" @@ -13,12 +12,6 @@ LOG_PATH = Path.home() / ".linedance" / "app.log" def setup_logging(): LOG_PATH.parent.mkdir(parents=True, exist_ok=True) handlers = [logging.FileHandler(LOG_PATH, encoding="utf-8")] - if sys.stdout and hasattr(sys.stdout, 'write'): - try: - sys.stdout.write("") - handlers.append(logging.StreamHandler(sys.stdout)) - except Exception: - pass logging.basicConfig( level=logging.INFO, diff --git a/linedance-app/local/sync_manager.py b/linedance-app/local/sync_manager.py index 9c3d678d..06634204 100644 --- a/linedance-app/local/sync_manager.py +++ b/linedance-app/local/sync_manager.py @@ -312,5 +312,59 @@ class SyncManager: song_data.get("dance_override","") or "")) position += 1 + # Importer delte playlister (read-only — is_linked=1, server_permission='view') + for pl in data.get("shared", []): + server_id = pl.get("server_id") + name = pl.get("name", "") + owner = pl.get("owner", "?") + if not server_id or not name: + continue + + existing = conn.execute( + "SELECT id FROM playlists WHERE api_project_id=?", (server_id,) + ).fetchone() + + if existing: + # Opdater sange fra server (ejer kan have ændret listen) + pl_id = existing["id"] + conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,)) + else: + cur = conn.execute( + "INSERT INTO playlists (name, description, api_project_id, is_linked, server_permission) " + "VALUES (?,?,?,1,'view')", + (f"{name} ({owner})", "", server_id) + ) + pl_id = cur.lastrowid + + position = 1 + for song_data in pl.get("songs", []): + title = song_data.get("title", "") + artist = song_data.get("artist", "") + if not title: + continue + local = conn.execute( + "SELECT id FROM songs WHERE title=? AND artist=? LIMIT 1", + (title, artist) + ).fetchone() + if not local: + import uuid + new_id = str(uuid.uuid4()) + conn.execute( + "INSERT OR IGNORE INTO songs (id, title, artist, file_missing) VALUES (?,?,?,1)", + (new_id, title, artist) + ) + local_id = new_id + else: + local_id = local["id"] + conn.execute(""" + INSERT OR IGNORE INTO playlist_songs + (playlist_id, song_id, position, status, is_workshop, dance_override) + VALUES (?,?,?,?,?,?) + """, (pl_id, local_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() diff --git a/linedance-app/player/player.py b/linedance-app/player/player.py index 393185bd..db42bb24 100644 --- a/linedance-app/player/player.py +++ b/linedance-app/player/player.py @@ -216,9 +216,38 @@ class Player(QObject): self.levels_changed.emit(max(0.0, l), max(0.0, r)) + def set_audio_device(self, device_id: str): + """Sæt lydoutput-enhed. device_id fra get_audio_devices().""" + if VLC_AVAILABLE and self._media_player: + self._media_player.audio_output_device_set(None, device_id) + + @staticmethod + def get_audio_devices() -> list[dict]: + """Returner liste af tilgængelige lydenheder.""" + if not VLC_AVAILABLE: + return [] + try: + instance = vlc.Instance("--no-video", "--quiet") + mp = instance.media_player_new() + devices = [] + d = mp.audio_output_device_enum() + if d: + node = d + while node: + devices.append({ + "id": node.contents.device.decode("utf-8", errors="replace"), + "name": node.contents.description.decode("utf-8", errors="replace"), + }) + node = node.contents.next + vlc.libvlc_audio_output_device_list_release(d) + mp.release() + instance.release() + return devices + except Exception: + return [] + def _on_end_reached(self, event): """Kaldes fra VLC's event-tråd — må IKKE røre Qt-objekter direkte.""" - # QTimer.singleShot er thread-safe og sender alt til main thread from PyQt6.QtCore import QTimer as _QTimer _QTimer.singleShot(0, self._handle_end_in_main_thread) @@ -227,3 +256,77 @@ class Player(QObject): self._poll_timer.stop() self.song_ended.emit() self.state_changed.emit("stopped") + + +class PreviewPlayer(QObject): + """Simpel preview-afspiller til bibliotek — ingen signals, bare play/stop.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._volume = 78 + self._device_id = "" + if VLC_AVAILABLE: + self._instance = vlc.Instance("--no-video", "--quiet") + self._mp = self._instance.media_player_new() + else: + self._mp = None + + def play(self, path: str): + if not VLC_AVAILABLE or not self._mp: + return + from player.player import Player + vlc_path = Player._resolve_path(self, path) + media = self._instance.media_new(vlc_path) + self._mp.set_media(media) + self._mp.audio_set_volume(self._volume) + self._mp.play() + # Sæt lydenhed efter play — VLC nulstiller den ved ny media + if self._device_id: + self._mp.audio_output_device_set(None, self._device_id) + + def pause(self): + if VLC_AVAILABLE and self._mp: + self._mp.pause() + + def resume(self): + if VLC_AVAILABLE and self._mp: + self._mp.play() + + def stop(self): + if VLC_AVAILABLE and self._mp: + self._mp.stop() + + def seek(self, fraction: float): + if VLC_AVAILABLE and self._mp: + self._mp.set_position(fraction) + + def is_playing(self) -> bool: + if VLC_AVAILABLE and self._mp: + return bool(self._mp.is_playing()) + return False + + def get_position(self) -> float: + if VLC_AVAILABLE and self._mp: + return max(0.0, self._mp.get_position()) + return 0.0 + + def get_time(self) -> int: + if VLC_AVAILABLE and self._mp: + return max(0, self._mp.get_time() // 1000) + return 0 + + def get_duration(self) -> int: + if VLC_AVAILABLE and self._mp: + ms = self._mp.get_length() + return max(0, ms // 1000) + return 0 + + def set_volume(self, volume: int): + self._volume = volume + if VLC_AVAILABLE and self._mp: + self._mp.audio_set_volume(volume) + + def set_audio_device(self, device_id: str): + self._device_id = device_id + if VLC_AVAILABLE and self._mp: + self._mp.audio_output_device_set(None, device_id) diff --git a/linedance-app/ui/library_panel.py b/linedance-app/ui/library_panel.py index 0e44dbc1..e0cc42f2 100644 --- a/linedance-app/ui/library_panel.py +++ b/linedance-app/ui/library_panel.py @@ -5,6 +5,7 @@ library_panel.py — Musikbibliotek med søgning og drag-and-drop til danseliste from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLineEdit, QLabel, QHBoxLayout, QPushButton, + QFrame, QSlider, QCheckBox, QAbstractItemView, QStyledItemDelegate, ) from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray, QRect @@ -175,6 +176,73 @@ class LibraryPanel(QWidget): self._list.setItemDelegate(DanseButtonDelegate(self._list)) layout.addWidget(self._list) + # Preview-afspiller bar + self._preview_bar = self._build_preview_bar() + self._preview_bar.setVisible(False) + layout.addWidget(self._preview_bar) + + # Timer til preview progress opdatering + self._preview_timer = QTimer(self) + self._preview_timer.setInterval(200) + self._preview_timer.timeout.connect(self._update_preview_progress) + + def _build_preview_bar(self) -> QWidget: + bar = QFrame() + bar.setObjectName("track_display") + bar.setFixedHeight(62) + row = QHBoxLayout(bar) + row.setContentsMargins(10, 8, 10, 8) + row.setSpacing(10) + + # Play/pause knap — orange som hoved-afspiller + self._btn_preview_play = QPushButton("▶") + self._btn_preview_play.setFixedSize(36, 36) + self._btn_preview_play.setObjectName("btn_play_small") + self._btn_preview_play.setToolTip("Afspil / Pause") + self._btn_preview_play.clicked.connect(self._toggle_preview_playback) + row.addWidget(self._btn_preview_play) + + # Stop knap + self._btn_preview_stop = QPushButton("■") + self._btn_preview_stop.setFixedSize(30, 30) + self._btn_preview_stop.setObjectName("btn_stop_small") + self._btn_preview_stop.setToolTip("Stop preview") + self._btn_preview_stop.clicked.connect(self._stop_preview) + row.addWidget(self._btn_preview_stop) + + # Titel + progress i midten + info = QVBoxLayout() + info.setSpacing(4) + info.setContentsMargins(0, 0, 0, 0) + + self._lbl_preview_title = QLabel("—") + self._lbl_preview_title.setObjectName("track_meta") + info.addWidget(self._lbl_preview_title) + + self._preview_progress = QSlider(Qt.Orientation.Horizontal) + self._preview_progress.setRange(0, 1000) + self._preview_progress.sliderMoved.connect(self._seek_preview) + info.addWidget(self._preview_progress) + row.addLayout(info, stretch=1) + + # Tid + self._lbl_preview_time = QLabel("0:00") + self._lbl_preview_time.setObjectName("track_meta") + self._lbl_preview_time.setFixedWidth(70) + self._lbl_preview_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + row.addWidget(self._lbl_preview_time) + + # Volumen + self._preview_vol = QSlider(Qt.Orientation.Horizontal) + self._preview_vol.setRange(0, 100) + self._preview_vol.setValue(78) + self._preview_vol.setFixedWidth(70) + self._preview_vol.setToolTip("Volumen preview") + self._preview_vol.valueChanged.connect(self._on_preview_volume) + row.addWidget(self._preview_vol) + + return bar + # ── Scanning ────────────────────────────────────────────────────────────── def _on_scan_clicked(self): @@ -312,11 +380,6 @@ class LibraryPanel(QWidget): # ── Handlinger ──────────────────────────────────────────────────────────── - def _on_double_click(self, item: QListWidgetItem): - song = item.data(Qt.ItemDataRole.UserRole) - if song: - self.song_selected.emit(song) - def _show_context_menu(self, pos): from PyQt6.QtWidgets import QMenu item = self._list.itemAt(pos) @@ -326,8 +389,17 @@ class LibraryPanel(QWidget): if not song: return menu = QMenu(self) - act_add = menu.addAction("Tilføj til danseliste") - act_play = menu.addAction("Afspil") + act_add = menu.addAction("Tilføj til danseliste") + act_play = menu.addAction("Afspil") + # Preview kun hvis preview player er sat op + is_previewing = ( + hasattr(self, "_preview_player") and + self._preview_player and + self._preview_player.is_playing() + ) + act_preview = menu.addAction( + "⏹ Stop preview" if is_previewing else "▶ Preview (høretelefoner)" + ) menu.addSeparator() act_tags = menu.addAction("✎ Rediger dans-tags...") act_info = menu.addAction("ℹ Dans-info...") @@ -340,6 +412,8 @@ class LibraryPanel(QWidget): self.add_to_playlist.emit(song) elif action == act_play: self.song_selected.emit(song) + elif action == act_preview: + self._toggle_preview(song) elif action == act_tags: self.edit_tags_requested.emit(song) elif action == act_info: @@ -351,6 +425,78 @@ class LibraryPanel(QWidget): elif action == act_mail: self.send_mail_requested.emit(song) + def set_preview_player(self, preview_player): + """Sæt preview-afspilleren fra main_window.""" + self._preview_player = preview_player + + def _on_double_click(self, item: QListWidgetItem): + song = item.data(Qt.ItemDataRole.UserRole) + if song: + self._start_preview(song) + + def _start_preview(self, song: dict): + if not hasattr(self, "_preview_player") or not self._preview_player: + self.song_selected.emit(song) + return + path = song.get("local_path", "") + if not path: + return + self._preview_song = song + self._preview_player.play(path) + title = song.get("title", "—") + artist = song.get("artist", "") + self._lbl_preview_title.setText(f"{title} · {artist}") + self._btn_preview_play.setText("⏸") + self._preview_bar.setVisible(True) + self._preview_timer.start() + + def _toggle_preview_playback(self): + if not hasattr(self, "_preview_player") or not self._preview_player: + return + if self._preview_player.is_playing(): + self._preview_player.pause() + self._btn_preview_play.setText("▶") + else: + self._preview_player.resume() + self._btn_preview_play.setText("⏸") + + def _stop_preview(self): + if hasattr(self, "_preview_player") and self._preview_player: + self._preview_player.stop() + self._preview_timer.stop() + self._preview_bar.setVisible(False) + self._btn_preview_play.setText("▶") + + def _seek_preview(self, value: int): + if hasattr(self, "_preview_player") and self._preview_player: + self._preview_player.seek(value / 1000.0) + + def _on_preview_volume(self, value: int): + if hasattr(self, "_preview_player") and self._preview_player: + self._preview_player.set_volume(value) + + def _update_preview_progress(self): + if not hasattr(self, "_preview_player") or not self._preview_player: + return + if not self._preview_player.is_playing(): + self._btn_preview_play.setText("▶") + return + pos = self._preview_player.get_position() + cur = self._preview_player.get_time() + dur = self._preview_player.get_duration() + self._preview_progress.setValue(int(pos * 1000)) + def fmt(s): return f"{s//60}:{s%60:02d}" + self._lbl_preview_time.setText(f"{fmt(cur)} / {fmt(dur)}") + + def _toggle_preview(self, song: dict): + """Start/stop preview af en sang.""" + if not hasattr(self, "_preview_player") or not self._preview_player: + return + if self._preview_player.is_playing(): + self._stop_preview() + else: + self._start_preview(song) + def _analyze_bpm(self, song: dict): """Analysér BPM i baggrundstråd og opdater biblioteket.""" path = song.get("local_path", "") diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py index 7b78f946..9d6bdcd7 100644 --- a/linedance-app/ui/main_window.py +++ b/linedance-app/ui/main_window.py @@ -76,6 +76,9 @@ class MainWindow(QMainWindow): self._dark_theme = True self._player = Player(self) + # Preview-afspiller til bibliotek (høretelefoner) + from player.player import PreviewPlayer + self._preview_player = PreviewPlayer(self) self._current_idx = -1 self._song_ended = False self._demo_active = False @@ -349,6 +352,15 @@ class MainWindow(QMainWindow): self._sync_debounce.timeout.connect(self._auto_sync) self._library_panel = LibraryPanel() + self._library_panel.set_preview_player(self._preview_player) + + # Sæt audio devices fra indstillinger + main_device = self._settings.get("audio_device_main", "") + preview_device = self._settings.get("audio_device_preview", "") + if main_device: + self._player.set_audio_device(main_device) + if preview_device: + self._preview_player.set_audio_device(preview_device) self._library_panel.song_selected.connect(self._on_library_song_selected) self._library_panel.add_to_playlist.connect(self._add_song_to_playlist) self._library_panel.scan_requested.connect(self.start_scan) @@ -628,6 +640,13 @@ class MainWindow(QMainWindow): if dur > 0: self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0), min((self._demo_seconds + self._demo_fade_seconds) / dur, 1.0)) self._set_status("Indstillinger gemt", 2000) + # Anvend lydenheder med det samme + main_device = self._settings.get("audio_device_main", "") + preview_device = self._settings.get("audio_device_preview", "") + if main_device: + self._player.set_audio_device(main_device) + if preview_device: + self._preview_player.set_audio_device(preview_device) def _auto_login(self): """Forsøg automatisk login med gemte oplysninger.""" @@ -970,6 +989,10 @@ class MainWindow(QMainWindow): dur = song.get("duration_sec", 0) self._player.load(song.get("local_path", ""), dur) + # Stop preview hvis den kører + if hasattr(self, "_preview_player") and self._preview_player.is_playing(): + self._library_panel._stop_preview() + self._lbl_title.setText(song.get("title", "—")) bpm = song.get("bpm", 0) fmt_dur = f"{dur//60}:{dur%60:02d}" @@ -1006,6 +1029,7 @@ class MainWindow(QMainWindow): self._btn_demo.setChecked(False) self._btn_play.setText("▶") return + self._waiting_for_auto = False # annuller evt. auto-timer if self._player.is_playing(): self._player.pause() else: @@ -1014,6 +1038,13 @@ class MainWindow(QMainWindow): self._btn_play.setText("⏸") def _stop(self): + # Annuller evt. igangværende countdown + if getattr(self, "_waiting_for_auto", False): + self._waiting_for_auto = False + if hasattr(self, "_countdown_timer"): + self._countdown_timer.stop() + self._set_status("Auto-afspilning annulleret", 3000) + return self._player.stop() self._song_ended = False self._demo_active = False @@ -1104,7 +1135,27 @@ class MainWindow(QMainWindow): self._current_idx = ni self._playlist_panel.set_next_ready(ni) self._load_song(next_song) - self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte") + + mode = self._settings.get("after_song_mode", "manual") + delay = self._settings.get("after_song_delay", 2) + + if mode == "manual": + self._waiting_for_auto = False + self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte") + + elif mode in ("auto_demo", "auto_play"): + self._waiting_for_auto = True + self._countdown_secs = delay + self._countdown_mode = mode + self._countdown_title = next_song.get("title", "") + label = "Auto-demo" if mode == "auto_demo" else "Auto-play" + self._set_status(f"{label} om {delay}s — {self._countdown_title}") + + if not hasattr(self, "_countdown_timer"): + self._countdown_timer = QTimer(self) + self._countdown_timer.setInterval(1000) + self._countdown_timer.timeout.connect(self._countdown_tick) + self._countdown_timer.start() else: self._current_idx = -1 self._playlist_panel._current_idx = -1 @@ -1116,6 +1167,44 @@ class MainWindow(QMainWindow): self._lbl_dances.setText("") self._set_status("Danselisten er afsluttet") + def _countdown_tick(self): + """Tæller ned og starter auto-afspilning når den når 0.""" + if not getattr(self, "_waiting_for_auto", False): + self._countdown_timer.stop() + return + self._countdown_secs -= 1 + label = "Auto-demo" if self._countdown_mode == "auto_demo" else "Auto-play" + if self._countdown_secs > 0: + self._set_status(f"{label} om {self._countdown_secs}s — {self._countdown_title}") + else: + self._countdown_timer.stop() + if self._countdown_mode == "auto_demo": + self._auto_demo_next() + else: + self._auto_play_next() + + def _auto_demo_next(self): + """Afspil demo af den næste klargjorte sang automatisk.""" + if not getattr(self, "_waiting_for_auto", False): + return # Brugeren har allerede trykket ▶ manuelt + self._waiting_for_auto = False + self._playlist_panel.set_current(self._current_idx) + self._player.play_demo(self._demo_seconds, self._demo_fade_seconds) + self._demo_active = True + self._btn_demo.setChecked(True) + self._btn_play.setText("⏸") + self._song_ended = False + + def _auto_play_next(self): + """Start næste sang automatisk.""" + if not getattr(self, "_waiting_for_auto", False): + return # Brugeren har allerede trykket ▶ manuelt + self._waiting_for_auto = False + self._playlist_panel.set_current(self._current_idx) + self._player.play() + self._btn_play.setText("⏸") + self._song_ended = False + def _sync_event_status_to_playlist(self): """Gem event-fremgang (afspillet/sprunget over) til den navngivne liste.""" try: diff --git a/linedance-app/ui/playlist_panel.py b/linedance-app/ui/playlist_panel.py index 5ef6e31e..3150acb6 100644 --- a/linedance-app/ui/playlist_panel.py +++ b/linedance-app/ui/playlist_panel.py @@ -196,6 +196,11 @@ class PlaylistPanel(QWidget): 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 @@ -427,7 +432,11 @@ class PlaylistPanel(QWidget): if songs: self._songs = songs - self._statuses = statuses + # 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 @@ -440,7 +449,6 @@ class PlaylistPanel(QWidget): ni = self.next_playable_idx() if ni is not None: self._current_idx = ni - self._statuses[ni] = "playing" self._refresh() return True diff --git a/linedance-app/ui/settings_dialog.py b/linedance-app/ui/settings_dialog.py index 20148dc8..5530ca2a 100644 --- a/linedance-app/ui/settings_dialog.py +++ b/linedance-app/ui/settings_dialog.py @@ -24,24 +24,32 @@ SETTINGS_KEY_SERVER_URL = "online/server_url" SETTINGS_KEY_LANGUAGE = "appearance/language" SETTINGS_KEY_BETWEEN_SEC = "playback/between_seconds" SETTINGS_KEY_WORKSHOP_MIN = "playback/workshop_minutes" +SETTINGS_KEY_MAIN_DEVICE = "playback/audio_device_main" +SETTINGS_KEY_PREV_DEVICE = "playback/audio_device_preview" +SETTINGS_KEY_AFTER_SONG = "playback/after_song_mode" +SETTINGS_KEY_AFTER_DELAY = "playback/after_song_delay" def load_settings() -> dict: s = QSettings("LineDance", "Player") return { - "dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool), - "demo_seconds": s.value(SETTINGS_KEY_DEMO_SEC, 10, type=int), - "demo_fade_seconds": s.value(SETTINGS_KEY_DEMO_FADE, 5, type=int), - "volume": s.value(SETTINGS_KEY_VOLUME, 78, type=int), - "mail_client": s.value(SETTINGS_KEY_MAIL_CLIENT, "auto"), - "mail_path": s.value(SETTINGS_KEY_MAIL_PATH, ""), - "auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool), - "username": s.value(SETTINGS_KEY_USERNAME, ""), - "password": s.value(SETTINGS_KEY_PASSWORD, ""), - "server_url": s.value(SETTINGS_KEY_SERVER_URL, "http://localhost:8000"), - "language": s.value(SETTINGS_KEY_LANGUAGE, "da"), - "between_seconds": s.value(SETTINGS_KEY_BETWEEN_SEC, 60, type=int), - "workshop_minutes": s.value(SETTINGS_KEY_WORKSHOP_MIN, 10, type=int), + "dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool), + "demo_seconds": s.value(SETTINGS_KEY_DEMO_SEC, 10, type=int), + "demo_fade_seconds": s.value(SETTINGS_KEY_DEMO_FADE, 5, type=int), + "volume": s.value(SETTINGS_KEY_VOLUME, 78, type=int), + "mail_client": s.value(SETTINGS_KEY_MAIL_CLIENT, "auto"), + "mail_path": s.value(SETTINGS_KEY_MAIL_PATH, ""), + "auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool), + "username": s.value(SETTINGS_KEY_USERNAME, ""), + "password": s.value(SETTINGS_KEY_PASSWORD, ""), + "server_url": s.value(SETTINGS_KEY_SERVER_URL, "http://localhost:8000"), + "language": s.value(SETTINGS_KEY_LANGUAGE, "da"), + "between_seconds": s.value(SETTINGS_KEY_BETWEEN_SEC, 60, type=int), + "workshop_minutes": s.value(SETTINGS_KEY_WORKSHOP_MIN, 10, type=int), + "audio_device_main": s.value(SETTINGS_KEY_MAIN_DEVICE, ""), + "audio_device_preview":s.value(SETTINGS_KEY_PREV_DEVICE, ""), + "after_song_mode": s.value(SETTINGS_KEY_AFTER_SONG, "manual"), + "after_song_delay": s.value(SETTINGS_KEY_AFTER_DELAY, 2, type=int), } @@ -60,6 +68,10 @@ def save_settings(values: dict): s.setValue(SETTINGS_KEY_LANGUAGE, values.get("language", "da")) s.setValue(SETTINGS_KEY_BETWEEN_SEC, values.get("between_seconds", 60)) s.setValue(SETTINGS_KEY_WORKSHOP_MIN,values.get("workshop_minutes", 10)) + s.setValue(SETTINGS_KEY_MAIN_DEVICE, values.get("audio_device_main", "")) + s.setValue(SETTINGS_KEY_PREV_DEVICE, values.get("audio_device_preview", "")) + s.setValue(SETTINGS_KEY_AFTER_SONG, values.get("after_song_mode", "manual")) + s.setValue(SETTINGS_KEY_AFTER_DELAY, values.get("after_song_delay", 2)) class SettingsDialog(QDialog): @@ -191,6 +203,63 @@ class SettingsDialog(QDialog): grp2_layout.addRow("Tid per workshop:", self._spin_workshop) layout.addWidget(grp2) + + # Reaktion når sang slutter + from PyQt6.QtWidgets import QRadioButton, QButtonGroup + grp3 = QGroupBox("Når en sang slutter") + grp3_layout = QVBoxLayout(grp3) + grp3_layout.setSpacing(8) + + self._radio_manual = QRadioButton("Manuel — marker næste klar, vent på ▶") + self._radio_auto_demo = QRadioButton("Auto-demo — afspil demo af næste sang automatisk") + self._radio_auto_play = QRadioButton("Auto-play — start næste sang automatisk") + + self._after_song_group = QButtonGroup(self) + self._after_song_group.addButton(self._radio_manual, 0) + self._after_song_group.addButton(self._radio_auto_demo, 1) + self._after_song_group.addButton(self._radio_auto_play, 2) + + grp3_layout.addWidget(self._radio_manual) + grp3_layout.addWidget(self._radio_auto_demo) + grp3_layout.addWidget(self._radio_auto_play) + + delay_row = QHBoxLayout() + delay_row.addWidget(QLabel(" Pause før næste starter:")) + self._spin_after_delay = QSpinBox() + self._spin_after_delay.setRange(0, 30) + self._spin_after_delay.setSuffix(" sekunder") + self._spin_after_delay.setFixedWidth(160) + self._spin_after_delay.setToolTip( + "Bruges til auto-demo og auto-play.\n" + "Antal sekunder der ventes inden næste sang starter." + ) + delay_row.addWidget(self._spin_after_delay) + delay_row.addStretch() + grp3_layout.addLayout(delay_row) + + layout.addWidget(grp3) + grp3 = QGroupBox("Lydenheder") + grp3_layout = QFormLayout(grp3) + + from player.player import Player as _Player + devices = _Player.get_audio_devices() + device_items = [("Standard", "")] + [(d["name"], d["id"]) for d in devices] + + self._combo_main_device = QComboBox() + self._combo_preview_device = QComboBox() + for name, did in device_items: + self._combo_main_device.addItem(name, did) + self._combo_preview_device.addItem(name, did) + + grp3_layout.addRow("Hoved-afspiller (sal):", self._combo_main_device) + grp3_layout.addRow("Preview (høretelefoner):", self._combo_preview_device) + + note3 = QLabel("Preview-afspilleren bruges til at lytte til sange i biblioteket\nudenom at afbryde den sang der spiller i salen.") + note3.setObjectName("result_count") + note3.setWordWrap(True) + grp3_layout.addRow(note3) + layout.addWidget(grp3) + layout.addStretch() return tab @@ -359,22 +428,52 @@ class SettingsDialog(QDialog): self._user_input.setEnabled(auto) self._pass_input.setEnabled(auto) + # Lydenheder + main_dev = v.get("audio_device_main", "") + preview_dev = v.get("audio_device_preview", "") + for i in range(self._combo_main_device.count()): + if self._combo_main_device.itemData(i) == main_dev: + self._combo_main_device.setCurrentIndex(i) + break + for i in range(self._combo_preview_device.count()): + if self._combo_preview_device.itemData(i) == preview_dev: + self._combo_preview_device.setCurrentIndex(i) + break + + # Reaktion når sang slutter + mode = v.get("after_song_mode", "manual") + if mode == "auto_demo": + self._radio_auto_demo.setChecked(True) + elif mode == "auto_play": + self._radio_auto_play.setChecked(True) + else: + self._radio_manual.setChecked(True) + self._spin_after_delay.setValue(v.get("after_song_delay", 2)) + # ── Gem ─────────────────────────────────────────────────────────────────── def _save_and_close(self): values = { - "dark_theme": self._chk_dark.isChecked(), - "demo_seconds": self._spin_demo.value(), - "demo_fade_seconds": self._spin_fade.value(), - "between_seconds": self._spin_between.value(), - "workshop_minutes": self._spin_workshop.value(), - "mail_client": self._mail_combo.currentData(), - "mail_path": self._mail_path.text().strip(), - "auto_login": self._chk_auto_login.isChecked(), - "username": self._user_input.text().strip(), - "password": self._pass_input.text(), - "server_url": self._server_url.text().strip() or "http://localhost:8000", - "language": self._lang_combo.currentData(), + "dark_theme": self._chk_dark.isChecked(), + "demo_seconds": self._spin_demo.value(), + "demo_fade_seconds": self._spin_fade.value(), + "between_seconds": self._spin_between.value(), + "workshop_minutes": self._spin_workshop.value(), + "mail_client": self._mail_combo.currentData(), + "mail_path": self._mail_path.text().strip(), + "auto_login": self._chk_auto_login.isChecked(), + "username": self._user_input.text().strip(), + "password": self._pass_input.text(), + "server_url": self._server_url.text().strip() or "http://localhost:8000", + "language": self._lang_combo.currentData(), + "audio_device_main": self._combo_main_device.currentData() or "", + "audio_device_preview":self._combo_preview_device.currentData() or "", + "after_song_mode": ( + "auto_demo" if self._radio_auto_demo.isChecked() else + "auto_play" if self._radio_auto_play.isChecked() else + "manual" + ), + "after_song_delay": self._spin_after_delay.value(), } save_settings(values) self._values = values diff --git a/linedance-app/ui/share_dialog.py b/linedance-app/ui/share_dialog.py index 68e934fa..d9c6689e 100644 --- a/linedance-app/ui/share_dialog.py +++ b/linedance-app/ui/share_dialog.py @@ -1,5 +1,6 @@ """ -share_dialog.py — Del en playliste med andre brugere eller sæt den public. +share_dialog.py — Forenklet del-dialog. +Kun ejer kan dele. Delte brugere får listen ved næste sync. """ from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, @@ -19,7 +20,7 @@ class ShareDialog(QDialog): self._token = token self.setWindowTitle(f"Del — {playlist_name}") - self.setMinimumWidth(480) + self.setMinimumWidth(440) self._build_ui() self._load_shares() self._load_visibility() @@ -42,9 +43,9 @@ class ShareDialog(QDialog): vis_layout.setContentsMargins(10, 8, 10, 8) vis_layout.addWidget(QLabel("Synlighed:")) self._vis_combo = QComboBox() - self._vis_combo.addItem("🔒 Privat (kun mig)", "private") - self._vis_combo.addItem("👥 Delt (inviterede)", "shared") - self._vis_combo.addItem("🌐 Public (alle kan se)", "public") + self._vis_combo.addItem("Privat — kun dig", "private") + self._vis_combo.addItem("Delt — kun inviterede", "shared") + self._vis_combo.addItem("Public — hjemmesiden", "public") vis_layout.addWidget(self._vis_combo, stretch=1) btn_vis = QPushButton("Gem") btn_vis.setFixedHeight(28) @@ -52,38 +53,34 @@ class ShareDialog(QDialog): vis_layout.addWidget(btn_vis) layout.addWidget(vis_frame) - # Invitér bruger + # Del med bruger inv_frame = QFrame() inv_frame.setObjectName("track_display") inv_layout = QVBoxLayout(inv_frame) inv_layout.setContentsMargins(10, 8, 10, 8) inv_layout.setSpacing(6) - inv_layout.addWidget(QLabel("Invitér via e-mail:")) - + inv_layout.addWidget(QLabel("Del med (e-mail):")) row = QHBoxLayout() self._email_input = QLineEdit() self._email_input.setPlaceholderText("bruger@eksempel.dk") row.addWidget(self._email_input) - self._perm_combo = QComboBox() - self._perm_combo.addItem("Se", "view") - self._perm_combo.addItem("Kopiere", "copy") - self._perm_combo.addItem("Redigere","edit") - self._perm_combo.setFixedWidth(90) - row.addWidget(self._perm_combo) - btn_inv = QPushButton("Invitér") + btn_inv = QPushButton("Del") btn_inv.setFixedHeight(28) btn_inv.clicked.connect(self._invite) row.addWidget(btn_inv) inv_layout.addLayout(row) + note = QLabel("Brugeren får listen ved næste synkronisering.\nKun du kan redigere listen.") + note.setObjectName("result_count") + note.setWordWrap(True) + inv_layout.addWidget(note) layout.addWidget(inv_frame) - # Liste over delinger + # Liste lbl = QLabel("Delt med:") lbl.setObjectName("track_meta") layout.addWidget(lbl) - self._shares_list = QListWidget() - self._shares_list.setMaximumHeight(150) + self._shares_list.setMaximumHeight(120) layout.addWidget(self._shares_list) btn_remove = QPushButton("✕ Fjern valgt deling") @@ -116,6 +113,19 @@ class ShareDialog(QDialog): except Exception: pass + def _set_visibility(self): + vis = self._vis_combo.currentData() + try: + import urllib.request, json + req = urllib.request.Request( + f"{self._server_url}/sharing/playlists/{self._playlist_id}/visibility?visibility={vis}", + data=b"", headers=self._headers(), method="PATCH" + ) + urllib.request.urlopen(req, timeout=8) + self._status.setText(f"✓ Synlighed gemt") + except Exception as e: + self._status.setText(f"⚠ {e}") + def _load_shares(self): try: import urllib.request, json @@ -127,52 +137,30 @@ class ShareDialog(QDialog): shares = json.loads(resp.read()) self._shares_list.clear() for s in shares: - perm = {"view": "Se", "copy": "Kopiere", "edit": "Redigere"}.get( - s["permission"], s["permission"] - ) - accepted = "✓" if s["accepted"] else "⏳" - item = QListWidgetItem(f"{accepted} {s['email']} — {perm}") + item = QListWidgetItem(s["email"]) item.setData(Qt.ItemDataRole.UserRole, s["id"]) self._shares_list.addItem(item) except Exception as e: self._status.setText(f"Kunne ikke hente delinger: {e}") - def _set_visibility(self): - vis = self._vis_combo.currentData() - try: - import urllib.request, json - req = urllib.request.Request( - f"{self._server_url}/sharing/playlists/{self._playlist_id}/visibility?visibility={vis}", - data=b"", - headers=self._headers(), - method="PATCH" - ) - with urllib.request.urlopen(req, timeout=8) as resp: - json.loads(resp.read()) - self._status.setText(f"✓ Synlighed sat til {self._vis_combo.currentText()}") - except Exception as e: - self._status.setText(f"⚠ Fejl: {e}") - def _invite(self): email = self._email_input.text().strip() - perm = self._perm_combo.currentData() if not email or "@" not in email: self._status.setText("⚠ Ugyldig e-mailadresse") return try: import urllib.request, json - data = json.dumps({"email": email, "permission": perm}).encode() + data = json.dumps({"email": email}).encode() req = urllib.request.Request( f"{self._server_url}/sharing/playlists/{self._playlist_id}/share", data=data, headers=self._headers(), method="POST" ) - with urllib.request.urlopen(req, timeout=8) as resp: - json.loads(resp.read()) + urllib.request.urlopen(req, timeout=8) self._email_input.clear() - self._status.setText(f"✓ Invitation sendt til {email}") + self._status.setText(f"✓ Delt med {email}") self._load_shares() except Exception as e: - self._status.setText(f"⚠ Fejl: {e}") + self._status.setText(f"⚠ {e}") def _remove_share(self): item = self._shares_list.currentItem() @@ -189,4 +177,4 @@ class ShareDialog(QDialog): self._status.setText("✓ Deling fjernet") self._load_shares() except Exception as e: - self._status.setText(f"⚠ Fejl: {e}") + self._status.setText(f"⚠ {e}") diff --git a/linedance-app/ui/themes.py b/linedance-app/ui/themes.py index 16a5d94b..ae3923e0 100644 --- a/linedance-app/ui/themes.py +++ b/linedance-app/ui/themes.py @@ -44,6 +44,25 @@ QPushButton#btn_play { QPushButton#btn_play:hover { background-color: #c47a10; } +QPushButton#btn_play_small { + background-color: #e8a020; + color: #111214; + border-color: #c47a10; + font-size: 14px; + font-weight: bold; + padding: 0px 0px; +} +QPushButton#btn_play_small:hover { + background-color: #c47a10; +} +QPushButton#btn_stop_small { + color: #e74c3c; + font-size: 12px; + padding: 0px 0px; +} +QPushButton#btn_stop_small:hover { + border-color: #e74c3c; +} QPushButton#btn_stop { color: #e74c3c; } @@ -301,6 +320,14 @@ QPushButton#btn_play { color: #fff; border-color: #a05808; } +QPushButton#btn_play_small { + background-color: #c07010; + color: #fff; + border-color: #a05808; +} +QPushButton#btn_play_small:hover { + background-color: #a05808; +} QListWidget { background-color: #d8dae0; color: #1a1c22;