Næster version

This commit is contained in:
2026-04-14 14:05:11 +02:00
parent 9257f198eb
commit 66804681da
11 changed files with 647 additions and 364 deletions

View File

@@ -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
@@ -15,21 +15,9 @@ router = APIRouter(prefix="/sharing", tags=["sharing"])
class ShareRequest(BaseModel):
email: EmailStr
permission: str = "view" # view | copy | edit
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 = []
@@ -244,151 +146,16 @@ def get_shared_playlist(
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,
"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)}

View File

@@ -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():
shared.append({
"id": p.id,
"name": p.name,
"owner_id": p.owner_id,
"visibility": p.visibility,
"songs": [
{
"song_id": ps.song_id,
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,
}
for ps in p.project_songs
]
"dance_override": ps.dance_override or "",
})
shared.append({
"server_id": p.id,
"name": p.name,
"owner": owner.username if owner else "?",
"songs": sorted(songs_out, key=lambda x: x["position"]),
})
# Egne playlister

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)
@@ -328,6 +391,15 @@ class LibraryPanel(QWidget):
menu = QMenu(self)
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", "")

View File

@@ -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)
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:

View File

@@ -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

View File

@@ -24,6 +24,10 @@ 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:
@@ -42,6 +46,10 @@ def load_settings() -> dict:
"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,6 +428,28 @@ 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):
@@ -375,6 +466,14 @@ class SettingsDialog(QDialog):
"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

View File

@@ -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}")

View File

@@ -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;