Tomt
This commit is contained in:
3
.env
3
.env
@@ -1,3 +0,0 @@
|
||||
DATABASE_URL=mysql+pymysql://linedance:20gorm66@mysql.ckvist.lan:3306/linedance
|
||||
SECRET_KEY=e0a15d5a35d1091261cbdf0fd6310492ebd23d66a6d4a8c4253ab33e2594c67a
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
@@ -1,3 +0,0 @@
|
||||
DATABASE_URL=mysql+pymysql://bruger:kodeord@localhost:3306/linedance
|
||||
SECRET_KEY=skift-denne-til-en-lang-tilfaeldig-streng
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
@@ -1,161 +0,0 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
#
|
||||
# LineDancePlayer.spec
|
||||
#
|
||||
# Byg med: pyinstaller LineDancePlayer.spec
|
||||
# Output: dist\LineDancePlayer.exe
|
||||
#
|
||||
# Kræver: VLC installeret på byggemaskinen
|
||||
# (typisk C:\Program Files\VideoLAN\VLC)
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── Find VLC-installation ─────────────────────────────────────────────────────
|
||||
|
||||
def find_vlc_path() -> Path | None:
|
||||
"""Find VLC på Windows — tjekker de mest almindelige installationsstier."""
|
||||
candidates = [
|
||||
Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) / "VideoLAN" / "VLC",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)")) / "VideoLAN" / "VLC",
|
||||
Path("C:/Program Files/VideoLAN/VLC"),
|
||||
Path("C:/Program Files (x86)/VideoLAN/VLC"),
|
||||
]
|
||||
# Tjek også PYTHONPATH og registry via python-vlc
|
||||
try:
|
||||
import vlc
|
||||
vlc_path = Path(vlc.plugin_path).parent if vlc.plugin_path else None
|
||||
if vlc_path and vlc_path.exists():
|
||||
candidates.insert(0, vlc_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for path in candidates:
|
||||
if path.exists() and (path / "libvlc.dll").exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
VLC_PATH = find_vlc_path()
|
||||
if VLC_PATH is None:
|
||||
print("=" * 60)
|
||||
print("ADVARSEL: VLC ikke fundet!")
|
||||
print("Installer VLC fra https://www.videolan.org/vlc/")
|
||||
print("og kør pyinstaller igen.")
|
||||
print("=" * 60)
|
||||
VLC_PATH = Path("C:/Program Files/VideoLAN/VLC") # fallback
|
||||
|
||||
print(f"VLC fundet: {VLC_PATH}")
|
||||
|
||||
# ── Saml VLC binære filer ─────────────────────────────────────────────────────
|
||||
|
||||
vlc_binaries = []
|
||||
vlc_datas = []
|
||||
|
||||
if VLC_PATH.exists():
|
||||
# Hoved-DLL filer
|
||||
for dll in ["libvlc.dll", "libvlccore.dll", "libvlc.lib"]:
|
||||
dll_path = VLC_PATH / dll
|
||||
if dll_path.exists():
|
||||
vlc_binaries.append((str(dll_path), "."))
|
||||
|
||||
# Plugins-mappe — indeholder codecs, demuxers osv.
|
||||
plugins_dir = VLC_PATH / "plugins"
|
||||
if plugins_dir.exists():
|
||||
vlc_datas.append((str(plugins_dir), "plugins"))
|
||||
|
||||
# Locale-filer
|
||||
locale_dir = VLC_PATH / "locale"
|
||||
if locale_dir.exists():
|
||||
vlc_datas.append((str(locale_dir), "locale"))
|
||||
|
||||
# ── PyInstaller konfiguration ─────────────────────────────────────────────────
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(
|
||||
["main.py"],
|
||||
pathex=["."],
|
||||
binaries=vlc_binaries,
|
||||
datas=[
|
||||
("ui", "ui"),
|
||||
("local", "local"),
|
||||
("player", "player"),
|
||||
] + vlc_datas,
|
||||
hiddenimports=[
|
||||
# PyQt6
|
||||
"PyQt6.sip",
|
||||
"PyQt6.QtCore",
|
||||
"PyQt6.QtGui",
|
||||
"PyQt6.QtWidgets",
|
||||
# Lyd og tags
|
||||
"vlc",
|
||||
"mutagen",
|
||||
"mutagen.mp3",
|
||||
"mutagen.id3",
|
||||
"mutagen.flac",
|
||||
"mutagen.mp4",
|
||||
"mutagen.oggvorbis",
|
||||
"mutagen.oggopus",
|
||||
# Fil-overvågning
|
||||
"watchdog",
|
||||
"watchdog.observers",
|
||||
"watchdog.observers.polling",
|
||||
"watchdog.events",
|
||||
# Database
|
||||
"sqlite3",
|
||||
# Standard
|
||||
"json",
|
||||
"pathlib",
|
||||
"threading",
|
||||
"urllib.request",
|
||||
"urllib.parse",
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
# Ting vi ikke bruger — reducerer filstørrelse
|
||||
"tkinter",
|
||||
"matplotlib",
|
||||
"numpy",
|
||||
"pandas",
|
||||
"scipy",
|
||||
"PIL",
|
||||
"cv2",
|
||||
"pytest",
|
||||
],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name="LineDancePlayer",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True, # komprimer med UPX hvis tilgængeligt
|
||||
upx_exclude=[
|
||||
"libvlc.dll", # VLC må ikke komprimeres — den loader plugins dynamisk
|
||||
"libvlccore.dll",
|
||||
],
|
||||
runtime_tmpdir=None,
|
||||
console=False, # ingen konsol-vindue
|
||||
disable_windowed_traceback=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
# Ikon — kommenter ud hvis du ikke har en .ico fil endnu
|
||||
# icon="assets/icon.ico",
|
||||
)
|
||||
57
README.md
57
README.md
@@ -1,57 +0,0 @@
|
||||
# LineDance Player — Desktop App
|
||||
|
||||
PyQt6-baseret afspiller til linedance-events.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
VLC skal også være installeret på systemet:
|
||||
- **Linux**: `sudo apt install vlc`
|
||||
- **Windows**: Download fra https://www.videolan.org/vlc/
|
||||
- **Mac**: `brew install vlc`
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Mappestruktur
|
||||
|
||||
```
|
||||
linedance-app/
|
||||
├── main.py # Entry point
|
||||
├── requirements.txt
|
||||
├── local/ # Lokal SQLite + fil-scanning
|
||||
│ ├── local_db.py # Database operationer
|
||||
│ ├── tag_reader.py # Læs/skriv MP3-tags
|
||||
│ └── file_watcher.py # Overvåg mapper med watchdog
|
||||
├── player/
|
||||
│ └── player.py # VLC afspiller wrapper
|
||||
└── ui/
|
||||
├── main_window.py # Hoved-vindue
|
||||
├── playlist_panel.py # Danseliste
|
||||
├── library_panel.py # Musikbibliotek med søgning
|
||||
├── next_up_bar.py # "Næste sang klar" banner
|
||||
├── vu_meter.py # VU-meter widget
|
||||
└── themes.py # Lyst / mørkt tema
|
||||
```
|
||||
|
||||
## Brug
|
||||
|
||||
1. Klik **+ MAPPE** i biblioteks-panelet og peg på din musikmappe
|
||||
2. Appen scanner automatisk alle undermapper og høster tags
|
||||
3. Dobbeltklik på en sang for at afspille, eller højreklik → Tilføj til danseliste
|
||||
4. Brug **▶ 10 SEK** knappen til at høre introen inden dansen starter
|
||||
5. Sangen stopper automatisk når den er færdig — tryk **▶ AFSPIL NÆSTE** for at fortsætte
|
||||
|
||||
## Lokal database
|
||||
|
||||
Gemmes i `~/.linedance/local.db` — bevares mellem sessioner.
|
||||
68
build.bat
68
build.bat
@@ -1,68 +0,0 @@
|
||||
@echo off
|
||||
echo ================================================
|
||||
echo LineDance Player - Byg EXE
|
||||
echo ================================================
|
||||
echo.
|
||||
|
||||
REM Tjek at vi er i det rigtige bibliotek
|
||||
if not exist main.py (
|
||||
echo FEJL: Kør build.bat fra LinedanceAfspiller\linedance-app mappen
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Aktiver venv
|
||||
if not exist venv\Scripts\activate.bat (
|
||||
echo Opretter virtuelt miljø...
|
||||
python -m venv venv
|
||||
)
|
||||
call venv\Scripts\activate.bat
|
||||
|
||||
REM Installer/opdater pakker
|
||||
echo Installerer pakker...
|
||||
pip install -r requirements.txt --quiet
|
||||
pip install pyinstaller --quiet
|
||||
|
||||
REM Tjek VLC
|
||||
if not exist "C:\Program Files\VideoLAN\VLC\libvlc.dll" (
|
||||
if not exist "C:\Program Files (x86)\VideoLAN\VLC\libvlc.dll" (
|
||||
echo.
|
||||
echo ADVARSEL: VLC ser ikke ud til at vaere installeret!
|
||||
echo Download VLC fra: https://www.videolan.org/vlc/
|
||||
echo Vaelg 64-bit versionen.
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
REM Ryd gamle build-filer
|
||||
echo Rydder gamle build-filer...
|
||||
if exist build rmdir /s /q build
|
||||
if exist dist rmdir /s /q dist
|
||||
|
||||
REM Byg EXE
|
||||
echo.
|
||||
echo Bygger LineDancePlayer.exe ...
|
||||
echo (Dette tager typisk 1-3 minutter)
|
||||
echo.
|
||||
pyinstaller LineDancePlayer.spec
|
||||
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo.
|
||||
echo FEJL under build! Se fejlbesked ovenfor.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ================================================
|
||||
echo BUILD FAERDIG!
|
||||
echo Filen ligger i: dist\LineDancePlayer.exe
|
||||
echo ================================================
|
||||
echo.
|
||||
|
||||
REM Vis filstoerrelse
|
||||
for %%A in (dist\LineDancePlayer.exe) do echo Filstoerrelse: %%~zA bytes
|
||||
|
||||
pause
|
||||
@@ -1,47 +0,0 @@
|
||||
# Byg LineDance Player til Windows .exe
|
||||
|
||||
## Krav
|
||||
|
||||
1. **Python 3.11+** installeret
|
||||
2. **VLC** installeret (skal også være på den maskine der kører .exe)
|
||||
3. Alle Python-pakker installeret (`pip install -r requirements.txt`)
|
||||
|
||||
## Bygge på Windows
|
||||
|
||||
```cmd
|
||||
cd linedance-app
|
||||
build.bat
|
||||
```
|
||||
|
||||
Det færdige program ligger i `dist\LineDancePlayer\LineDancePlayer.exe`
|
||||
|
||||
## Bygge på Linux (til Linux)
|
||||
|
||||
```bash
|
||||
cd linedance-app
|
||||
./build_linux.sh
|
||||
```
|
||||
|
||||
## Distribuere til andre
|
||||
|
||||
Kopiér hele `dist\LineDancePlayer\` mappen — IKKE kun .exe filen!
|
||||
Mappen indeholder alle nødvendige DLL-filer og biblioteker.
|
||||
|
||||
Modtageren skal stadig have **VLC installeret**:
|
||||
- Windows: https://www.videolan.org/vlc/
|
||||
- Linux: `sudo apt install vlc`
|
||||
|
||||
## Hvis VLC ikke kan findes
|
||||
|
||||
PyInstaller kan ikke automatisk inkludere VLC da det er et system-program.
|
||||
Alternativt kan du kopiere `libvlc.dll` og `libvlccore.dll` fra
|
||||
`C:\Program Files\VideoLAN\VLC\` ind i `dist\LineDancePlayer\`-mappen.
|
||||
|
||||
## Fejlsøgning
|
||||
|
||||
Hvis .exe crasher uden fejlbesked, byg med `console=True` i spec-filen
|
||||
og kør fra kommandoprompten for at se fejlbeskeder.
|
||||
|
||||
## Størrelse
|
||||
|
||||
Den færdige mappe er typisk 80-150 MB med PyQt6.
|
||||
@@ -1,161 +0,0 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
#
|
||||
# LineDancePlayer.spec
|
||||
#
|
||||
# Byg med: pyinstaller LineDancePlayer.spec
|
||||
# Output: dist\LineDancePlayer.exe
|
||||
#
|
||||
# Kræver: VLC installeret på byggemaskinen
|
||||
# (typisk C:\Program Files\VideoLAN\VLC)
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── Find VLC-installation ─────────────────────────────────────────────────────
|
||||
|
||||
def find_vlc_path() -> Path | None:
|
||||
"""Find VLC på Windows — tjekker de mest almindelige installationsstier."""
|
||||
candidates = [
|
||||
Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) / "VideoLAN" / "VLC",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)")) / "VideoLAN" / "VLC",
|
||||
Path("C:/Program Files/VideoLAN/VLC"),
|
||||
Path("C:/Program Files (x86)/VideoLAN/VLC"),
|
||||
]
|
||||
# Tjek også PYTHONPATH og registry via python-vlc
|
||||
try:
|
||||
import vlc
|
||||
vlc_path = Path(vlc.plugin_path).parent if vlc.plugin_path else None
|
||||
if vlc_path and vlc_path.exists():
|
||||
candidates.insert(0, vlc_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for path in candidates:
|
||||
if path.exists() and (path / "libvlc.dll").exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
VLC_PATH = find_vlc_path()
|
||||
if VLC_PATH is None:
|
||||
print("=" * 60)
|
||||
print("ADVARSEL: VLC ikke fundet!")
|
||||
print("Installer VLC fra https://www.videolan.org/vlc/")
|
||||
print("og kør pyinstaller igen.")
|
||||
print("=" * 60)
|
||||
VLC_PATH = Path("C:/Program Files/VideoLAN/VLC") # fallback
|
||||
|
||||
print(f"VLC fundet: {VLC_PATH}")
|
||||
|
||||
# ── Saml VLC binære filer ─────────────────────────────────────────────────────
|
||||
|
||||
vlc_binaries = []
|
||||
vlc_datas = []
|
||||
|
||||
if VLC_PATH.exists():
|
||||
# Hoved-DLL filer
|
||||
for dll in ["libvlc.dll", "libvlccore.dll", "libvlc.lib"]:
|
||||
dll_path = VLC_PATH / dll
|
||||
if dll_path.exists():
|
||||
vlc_binaries.append((str(dll_path), "."))
|
||||
|
||||
# Plugins-mappe — indeholder codecs, demuxers osv.
|
||||
plugins_dir = VLC_PATH / "plugins"
|
||||
if plugins_dir.exists():
|
||||
vlc_datas.append((str(plugins_dir), "plugins"))
|
||||
|
||||
# Locale-filer
|
||||
locale_dir = VLC_PATH / "locale"
|
||||
if locale_dir.exists():
|
||||
vlc_datas.append((str(locale_dir), "locale"))
|
||||
|
||||
# ── PyInstaller konfiguration ─────────────────────────────────────────────────
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(
|
||||
["main.py"],
|
||||
pathex=["."],
|
||||
binaries=vlc_binaries,
|
||||
datas=[
|
||||
("ui", "ui"),
|
||||
("local", "local"),
|
||||
("player", "player"),
|
||||
] + vlc_datas,
|
||||
hiddenimports=[
|
||||
# PyQt6
|
||||
"PyQt6.sip",
|
||||
"PyQt6.QtCore",
|
||||
"PyQt6.QtGui",
|
||||
"PyQt6.QtWidgets",
|
||||
# Lyd og tags
|
||||
"vlc",
|
||||
"mutagen",
|
||||
"mutagen.mp3",
|
||||
"mutagen.id3",
|
||||
"mutagen.flac",
|
||||
"mutagen.mp4",
|
||||
"mutagen.oggvorbis",
|
||||
"mutagen.oggopus",
|
||||
# Fil-overvågning
|
||||
"watchdog",
|
||||
"watchdog.observers",
|
||||
"watchdog.observers.polling",
|
||||
"watchdog.events",
|
||||
# Database
|
||||
"sqlite3",
|
||||
# Standard
|
||||
"json",
|
||||
"pathlib",
|
||||
"threading",
|
||||
"urllib.request",
|
||||
"urllib.parse",
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
# Ting vi ikke bruger — reducerer filstørrelse
|
||||
"tkinter",
|
||||
"matplotlib",
|
||||
"numpy",
|
||||
"pandas",
|
||||
"scipy",
|
||||
"PIL",
|
||||
"cv2",
|
||||
"pytest",
|
||||
],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name="LineDancePlayer",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True, # komprimer med UPX hvis tilgængeligt
|
||||
upx_exclude=[
|
||||
"libvlc.dll", # VLC må ikke komprimeres — den loader plugins dynamisk
|
||||
"libvlccore.dll",
|
||||
],
|
||||
runtime_tmpdir=None,
|
||||
console=False, # ingen konsol-vindue
|
||||
disable_windowed_traceback=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
# Ikon — kommenter ud hvis du ikke har en .ico fil endnu
|
||||
# icon="assets/icon.ico",
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
# LineDance Player — Desktop App
|
||||
|
||||
PyQt6-baseret afspiller til linedance-events.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
VLC skal også være installeret på systemet:
|
||||
- **Linux**: `sudo apt install vlc`
|
||||
- **Windows**: Download fra https://www.videolan.org/vlc/
|
||||
- **Mac**: `brew install vlc`
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Mappestruktur
|
||||
|
||||
```
|
||||
linedance-app/
|
||||
├── main.py # Entry point
|
||||
├── requirements.txt
|
||||
├── local/ # Lokal SQLite + fil-scanning
|
||||
│ ├── local_db.py # Database operationer
|
||||
│ ├── tag_reader.py # Læs/skriv MP3-tags
|
||||
│ └── file_watcher.py # Overvåg mapper med watchdog
|
||||
├── player/
|
||||
│ └── player.py # VLC afspiller wrapper
|
||||
└── ui/
|
||||
├── main_window.py # Hoved-vindue
|
||||
├── playlist_panel.py # Danseliste
|
||||
├── library_panel.py # Musikbibliotek med søgning
|
||||
├── next_up_bar.py # "Næste sang klar" banner
|
||||
├── vu_meter.py # VU-meter widget
|
||||
└── themes.py # Lyst / mørkt tema
|
||||
```
|
||||
|
||||
## Brug
|
||||
|
||||
1. Klik **+ MAPPE** i biblioteks-panelet og peg på din musikmappe
|
||||
2. Appen scanner automatisk alle undermapper og høster tags
|
||||
3. Dobbeltklik på en sang for at afspille, eller højreklik → Tilføj til danseliste
|
||||
4. Brug **▶ 10 SEK** knappen til at høre introen inden dansen starter
|
||||
5. Sangen stopper automatisk når den er færdig — tryk **▶ AFSPIL NÆSTE** for at fortsætte
|
||||
|
||||
## Lokal database
|
||||
|
||||
Gemmes i `~/.linedance/local.db` — bevares mellem sessioner.
|
||||
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
app_logger.py — Central logging til fil i stedet for konsol.
|
||||
P<EFBFBD> Windows uden konsol skrives alt til ~/.linedance/app.log
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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")]
|
||||
# Kun tilføj konsol-handler hvis vi kører med konsol (development)
|
||||
if sys.stdout and hasattr(sys.stdout, 'write'):
|
||||
try:
|
||||
sys.stdout.write("") # test om konsol virker
|
||||
handlers.append(logging.StreamHandler(sys.stdout))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
handlers=handlers,
|
||||
force=True,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger("linedance")
|
||||
@@ -1,35 +0,0 @@
|
||||
@echo off
|
||||
echo === LineDance Player - Windows Build ===
|
||||
echo.
|
||||
|
||||
if exist "venv\Scripts\activate.bat" (
|
||||
call venv\Scripts\activate.bat
|
||||
) else (
|
||||
echo ADVARSEL: venv ikke fundet
|
||||
)
|
||||
|
||||
pip install pyinstaller >nul 2>&1
|
||||
|
||||
if exist "dist\LineDancePlayer" rmdir /s /q "dist\LineDancePlayer"
|
||||
if exist "build\LineDancePlayer" rmdir /s /q "build\LineDancePlayer"
|
||||
|
||||
echo Bygger... (1-3 minutter)
|
||||
echo.
|
||||
|
||||
pyinstaller build_windows.spec --clean --noconfirm
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo FEJL: Se fejlbesked ovenfor
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo === FAERDIG ===
|
||||
echo Program: dist\LineDancePlayer\LineDancePlayer.exe
|
||||
echo.
|
||||
echo HUSK: Kopieer hele dist\LineDancePlayer\ mappen - ikke kun .exe!
|
||||
echo HUSK: VLC skal vaere installeret paa maskinen.
|
||||
echo.
|
||||
pause
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "=== LineDance Player - Linux Build ==="
|
||||
echo
|
||||
|
||||
# Aktiver venv
|
||||
source venv/bin/activate 2>/dev/null || echo "ADVARSEL: venv ikke aktiveret"
|
||||
|
||||
# Installer PyInstaller
|
||||
pip show pyinstaller > /dev/null 2>&1 || pip install pyinstaller
|
||||
|
||||
# Ryd tidligere build
|
||||
rm -rf dist/LineDancePlayer build/LineDancePlayer
|
||||
|
||||
echo "Bygger LineDance Player..."
|
||||
echo "Dette tager 1-3 minutter..."
|
||||
echo
|
||||
|
||||
pyinstaller build_windows.spec --clean
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo
|
||||
echo "=== BUILD FÆRDIG ==="
|
||||
echo "Programmet ligger i: dist/LineDancePlayer/LineDancePlayer"
|
||||
echo
|
||||
echo "HUSK: VLC skal stadig være installeret på maskinen!"
|
||||
echo " sudo apt install vlc"
|
||||
else
|
||||
echo "FEJL: Build mislykkedes!"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,84 +0,0 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_all, collect_submodules
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# Saml ALT fra PyQt6 inkl. plugins og DLL-filer
|
||||
pyqt6_datas, pyqt6_binaries, pyqt6_hiddenimports = collect_all('PyQt6')
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=['.'],
|
||||
binaries=pyqt6_binaries,
|
||||
datas=pyqt6_datas,
|
||||
hiddenimports=pyqt6_hiddenimports + [
|
||||
'PyQt6.sip',
|
||||
'PyQt6.QtCore',
|
||||
'PyQt6.QtGui',
|
||||
'PyQt6.QtWidgets',
|
||||
# UI moduler
|
||||
'ui.main_window',
|
||||
'ui.playlist_panel',
|
||||
'ui.library_panel',
|
||||
'ui.library_manager',
|
||||
'ui.themes',
|
||||
'ui.vu_meter',
|
||||
'ui.scan_worker',
|
||||
'ui.tag_editor',
|
||||
'ui.login_dialog',
|
||||
'ui.settings_dialog',
|
||||
'ui.playlist_manager',
|
||||
'ui.next_up_bar',
|
||||
# Player + local
|
||||
'player.player',
|
||||
'local.local_db',
|
||||
'local.tag_reader',
|
||||
'local.file_watcher',
|
||||
# Biblioteker
|
||||
'mutagen', 'mutagen.mp3', 'mutagen.id3', 'mutagen.flac',
|
||||
'mutagen.mp4', 'mutagen.oggvorbis', 'mutagen.ogg',
|
||||
'mutagen.wave', 'mutagen.aiff', 'mutagen.asf',
|
||||
'watchdog', 'watchdog.observers', 'watchdog.events',
|
||||
'watchdog.observers.winapi',
|
||||
'vlc', 'sqlite3',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=['tkinter', 'matplotlib', 'pandas', 'scipy', 'IPython'],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='LineDancePlayer',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False, # UPX kan give problemer med PyQt6 DLL-filer
|
||||
console=False, # Ingen konsol-vindue
|
||||
disable_windowed_traceback=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=None,
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name='LineDancePlayer',
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
main.py — Linedance afspiller.
|
||||
|
||||
Start:
|
||||
python main.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Sørg for at rodmappen er i Python-stien
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from ui.main_window import MainWindow
|
||||
from ui.themes import apply_theme
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("LineDance Player")
|
||||
app.setOrganizationName("LineDance")
|
||||
|
||||
apply_theme(app, dark=True)
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,200 +0,0 @@
|
||||
"""
|
||||
player.py — VLC-baseret afspiller med PyQt6 signals.
|
||||
|
||||
Sender signals til GUI:
|
||||
position_changed(float) — 0.0–1.0 progress
|
||||
time_changed(int, int) — (current_sec, total_sec)
|
||||
levels_changed(float, float) — VU-meter L/R 0.0–1.0
|
||||
song_ended() — sang færdig
|
||||
state_changed(str) — 'playing'|'paused'|'stopped'
|
||||
"""
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal, QTimer
|
||||
import random
|
||||
import math
|
||||
|
||||
try:
|
||||
import vlc
|
||||
VLC_AVAILABLE = True
|
||||
except ImportError:
|
||||
VLC_AVAILABLE = False
|
||||
print("Advarsel: python-vlc ikke installeret — afspilning deaktiveret")
|
||||
|
||||
|
||||
class Player(QObject):
|
||||
position_changed = pyqtSignal(float)
|
||||
time_changed = pyqtSignal(int, int)
|
||||
levels_changed = pyqtSignal(float, float)
|
||||
song_ended = pyqtSignal()
|
||||
state_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._path: str | None = None
|
||||
self._duration: int = 0
|
||||
self._demo_mode = False
|
||||
self._demo_stop_sec = 10
|
||||
self._demo_fade_sec = 5
|
||||
self._demo_fading = False
|
||||
self._volume = 78
|
||||
|
||||
if VLC_AVAILABLE:
|
||||
self._instance = vlc.Instance("--no-video", "--quiet")
|
||||
self._media_player = self._instance.media_player_new()
|
||||
self._events = self._media_player.event_manager()
|
||||
self._events.event_attach(
|
||||
vlc.EventType.MediaPlayerEndReached,
|
||||
self._on_end_reached,
|
||||
)
|
||||
else:
|
||||
self._media_player = None
|
||||
|
||||
# Timer til polling af position + VU-simulation
|
||||
self._poll_timer = QTimer(self)
|
||||
self._poll_timer.setInterval(80)
|
||||
self._poll_timer.timeout.connect(self._poll)
|
||||
|
||||
# ── Indlæsning ────────────────────────────────────────────────────────────
|
||||
|
||||
def load(self, path: str, duration_sec: int = 0):
|
||||
"""Indlæs en lydfil uden at starte afspilning."""
|
||||
self._path = path
|
||||
self._duration = duration_sec
|
||||
self._demo_mode = False
|
||||
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
media = self._instance.media_new(path)
|
||||
self._media_player.set_media(media)
|
||||
self._media_player.audio_set_volume(self._volume)
|
||||
|
||||
self.position_changed.emit(0.0)
|
||||
self.time_changed.emit(0, self._duration)
|
||||
self.state_changed.emit("stopped")
|
||||
|
||||
# ── Transport ─────────────────────────────────────────────────────────────
|
||||
|
||||
def play(self):
|
||||
self._demo_mode = False
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.play()
|
||||
self._poll_timer.start()
|
||||
self.state_changed.emit("playing")
|
||||
|
||||
def play_demo(self, stop_at_sec: int = 10, fade_sec: int = 5):
|
||||
"""
|
||||
Afspil fra start, fade ud over fade_sec sekunder og stop.
|
||||
Total afspilningstid = stop_at_sec + fade_sec.
|
||||
fade_sec=0 giver ingen fade.
|
||||
"""
|
||||
self._demo_mode = True
|
||||
self._demo_stop_sec = stop_at_sec + fade_sec # total tid inkl. fade
|
||||
self._demo_fade_sec = fade_sec
|
||||
self._demo_fading = False
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.set_time(0)
|
||||
self._media_player.audio_set_volume(self._volume)
|
||||
self._media_player.play()
|
||||
self._poll_timer.start()
|
||||
self.state_changed.emit("playing")
|
||||
|
||||
def pause(self):
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.pause()
|
||||
self.state_changed.emit("paused")
|
||||
|
||||
def stop(self):
|
||||
self._demo_mode = False
|
||||
self._demo_fading = False
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.audio_set_volume(self._volume)
|
||||
self._media_player.stop()
|
||||
self._poll_timer.stop()
|
||||
self.position_changed.emit(0.0)
|
||||
self.time_changed.emit(0, self._duration)
|
||||
self.state_changed.emit("stopped")
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
return self._media_player.is_playing()
|
||||
return False
|
||||
|
||||
def set_volume(self, volume: int):
|
||||
"""0–100"""
|
||||
self._volume = volume
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.audio_set_volume(volume)
|
||||
|
||||
def set_position(self, fraction: float):
|
||||
"""Søg til position 0.0–1.0"""
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.set_position(fraction)
|
||||
|
||||
# ── Intern polling ────────────────────────────────────────────────────────
|
||||
|
||||
def _poll(self):
|
||||
"""Køres ~12 gange per sekund — opdaterer position og VU-meter."""
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
pos = self._media_player.get_position()
|
||||
ms = self._media_player.get_time()
|
||||
cur = max(0, ms // 1000)
|
||||
else:
|
||||
# Simuleret tilstand (til UI-test uden VLC)
|
||||
pos = getattr(self, "_sim_pos", 0.0)
|
||||
self._sim_pos = min(1.0, pos + 0.001)
|
||||
cur = int(self._sim_pos * self._duration)
|
||||
pos = self._sim_pos
|
||||
if self._sim_pos >= 1.0:
|
||||
self._on_end_reached(None)
|
||||
return
|
||||
|
||||
self.position_changed.emit(pos)
|
||||
self.time_changed.emit(cur, self._duration)
|
||||
|
||||
# Demo fade-out og stop
|
||||
if self._demo_mode and cur >= self._demo_stop_sec:
|
||||
# Færdig — gendan volumen og stop
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.audio_set_volume(self._volume)
|
||||
self.stop()
|
||||
self._demo_mode = False
|
||||
self._demo_fading = False
|
||||
self.position_changed.emit(0.0)
|
||||
self.time_changed.emit(0, self._duration)
|
||||
self.state_changed.emit("demo_ended")
|
||||
return
|
||||
|
||||
# Demo fade-out — de sidste _demo_fade_sec sekunder (0 = ingen fade)
|
||||
if self._demo_mode and VLC_AVAILABLE and self._media_player and self._demo_fade_sec > 0:
|
||||
secs_left = self._demo_stop_sec - cur
|
||||
if secs_left <= self._demo_fade_sec and secs_left > 0:
|
||||
fade_fraction = secs_left / self._demo_fade_sec # 1.0 → 0.0
|
||||
log_fraction = math.log10(1 + fade_fraction * 9) / math.log10(10)
|
||||
faded_vol = int(self._volume * log_fraction)
|
||||
self._media_player.audio_set_volume(max(0, faded_vol))
|
||||
self._demo_fading = True
|
||||
elif not self._demo_fading:
|
||||
self._media_player.audio_set_volume(self._volume)
|
||||
|
||||
# VU-meter: brug VLC's audio-amplitude hvis tilgængelig, ellers simulér
|
||||
if VLC_AVAILABLE and self._media_player and self._media_player.is_playing():
|
||||
# VLC eksponerer ikke amplitude direkte — vi bruger en blød simulation
|
||||
# der er baseret på position så det ser organisk ud
|
||||
base = 0.55 + 0.3 * abs(pos - 0.5)
|
||||
l = min(1.0, base + random.gauss(0, 0.12))
|
||||
r = min(1.0, base + random.gauss(0, 0.12))
|
||||
else:
|
||||
l = r = 0.0
|
||||
|
||||
self.levels_changed.emit(max(0.0, l), max(0.0, r))
|
||||
|
||||
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)
|
||||
|
||||
def _handle_end_in_main_thread(self):
|
||||
"""Kaldes i main thread — her er det sikkert at røre Qt."""
|
||||
self._poll_timer.stop()
|
||||
self.song_ended.emit()
|
||||
self.state_changed.emit("stopped")
|
||||
@@ -1,7 +0,0 @@
|
||||
PyQt6>=6.6.0
|
||||
python-vlc>=3.0.18
|
||||
mutagen>=1.47.0
|
||||
watchdog>=4.0.0
|
||||
|
||||
# BPM-analyse
|
||||
librosa>=0.10.0
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,135 +0,0 @@
|
||||
"""
|
||||
library_manager.py — Dialog til at se og fjerne musikbiblioteker.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QListWidget, QListWidgetItem, QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
|
||||
|
||||
class LibraryManagerDialog(QDialog):
|
||||
library_removed = pyqtSignal(int) # library_id
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Administrer musikbiblioteker")
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(320)
|
||||
self._build_ui()
|
||||
self._load()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
lbl = QLabel("Aktive musikbiblioteker:")
|
||||
lbl.setObjectName("track_meta")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
self._list = QListWidget()
|
||||
layout.addWidget(self._list)
|
||||
|
||||
note = QLabel(
|
||||
"Når du fjerner et bibliotek, slettes det fra overvågningen.\n"
|
||||
"Sangene forbliver i databasen men markeres som manglende (⚠)."
|
||||
)
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
layout.addWidget(note)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_add = QPushButton("+ Tilføj mappe")
|
||||
btn_add.clicked.connect(self._add_folder)
|
||||
btn_row.addWidget(btn_add)
|
||||
|
||||
btn_remove = QPushButton("✕ Fjern valgt")
|
||||
btn_remove.clicked.connect(self._remove_selected)
|
||||
btn_row.addWidget(btn_remove)
|
||||
|
||||
btn_scan = QPushButton("⟳ Scan alle")
|
||||
btn_scan.setToolTip("Scan alle mapper for nye og ændrede filer")
|
||||
btn_scan.clicked.connect(self._scan_all)
|
||||
btn_row.addWidget(btn_scan)
|
||||
|
||||
btn_row.addStretch()
|
||||
btn_close = QPushButton("Luk")
|
||||
btn_close.clicked.connect(self.accept)
|
||||
btn_row.addWidget(btn_close)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
def _load(self):
|
||||
self._list.clear()
|
||||
try:
|
||||
from local.local_db import get_libraries, get_db
|
||||
libs = get_libraries(active_only=True) # kun aktive
|
||||
for lib in libs:
|
||||
from pathlib import Path
|
||||
path = lib["path"]
|
||||
exists = Path(path).exists()
|
||||
last_scan = lib["last_full_scan"] or "aldrig"
|
||||
if isinstance(last_scan, str) and len(last_scan) > 10:
|
||||
last_scan = last_scan[:10]
|
||||
with get_db() as conn:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0",
|
||||
(lib["id"],)
|
||||
).fetchone()[0]
|
||||
exist_icon = "" if exists else " ⚠ mappe ikke fundet"
|
||||
label = f"{path}{exist_icon}\n {count} sange · senest scannet: {last_scan}"
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.ItemDataRole.UserRole, dict(lib))
|
||||
if not exists:
|
||||
from PyQt6.QtGui import QColor
|
||||
item.setForeground(QColor("#5a6070"))
|
||||
self._list.addItem(item)
|
||||
except Exception as e:
|
||||
print(f"Library manager load fejl: {e}")
|
||||
|
||||
def _scan_all(self):
|
||||
mw = self.parent()
|
||||
if hasattr(mw, "start_scan"):
|
||||
mw.start_scan()
|
||||
self._set_status("Scanning startet...")
|
||||
|
||||
def _set_status(self, text: str):
|
||||
pass # kan udvides med statuslinje i dialogen
|
||||
|
||||
def _add_folder(self):
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||||
if folder:
|
||||
mw = self.parent()
|
||||
if hasattr(mw, "add_library_path"):
|
||||
mw.add_library_path(folder)
|
||||
# Genindlæs listen efter kort pause så DB er opdateret
|
||||
from PyQt6.QtCore import QTimer
|
||||
QTimer.singleShot(600, self._load)
|
||||
|
||||
def _remove_selected(self):
|
||||
item = self._list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
lib = item.data(Qt.ItemDataRole.UserRole)
|
||||
reply = QMessageBox.question(
|
||||
self, "Fjern bibliotek",
|
||||
f"Fjern overvågningen af:\n{lib['path']}\n\n"
|
||||
"Sange i biblioteket forbliver i databasen men markeres som manglende.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
try:
|
||||
mw = self.parent()
|
||||
if hasattr(mw, "_watcher") and mw._watcher:
|
||||
mw._watcher.remove_library(lib["id"])
|
||||
else:
|
||||
from local.local_db import remove_library
|
||||
remove_library(lib["id"])
|
||||
self.library_removed.emit(lib["id"])
|
||||
if hasattr(mw, "_reload_library"):
|
||||
mw._reload_library()
|
||||
self._load()
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}")
|
||||
@@ -1,364 +0,0 @@
|
||||
"""
|
||||
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, QProgressBar,
|
||||
QAbstractItemView,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray
|
||||
from PyQt6.QtGui import QColor, QDrag
|
||||
|
||||
|
||||
class DraggableLibraryList(QListWidget):
|
||||
"""QListWidget der understøtter drag-start med sang-data som mime."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setDragEnabled(True)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
|
||||
self.setDefaultDropAction(Qt.DropAction.CopyAction)
|
||||
|
||||
def startDrag(self, supported_actions):
|
||||
item = self.currentItem()
|
||||
if not item:
|
||||
return
|
||||
song = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not song:
|
||||
return
|
||||
|
||||
import json
|
||||
data = json.dumps(song).encode("utf-8")
|
||||
|
||||
mime = QMimeData()
|
||||
mime.setData("application/x-linedance-song", QByteArray(data))
|
||||
mime.setText(song.get("title", ""))
|
||||
|
||||
drag = QDrag(self)
|
||||
drag.setMimeData(mime)
|
||||
drag.exec(Qt.DropAction.CopyAction)
|
||||
|
||||
|
||||
class LibraryPanel(QWidget):
|
||||
song_selected = pyqtSignal(dict)
|
||||
add_to_playlist = pyqtSignal(dict)
|
||||
scan_requested = pyqtSignal()
|
||||
edit_tags_requested = pyqtSignal(dict)
|
||||
send_mail_requested = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._all_songs: list[dict] = []
|
||||
self._filtered: list[dict] = []
|
||||
self._bpm_scan_running = False
|
||||
self._search_timer = QTimer(self)
|
||||
self._search_timer.setSingleShot(True)
|
||||
self._search_timer.setInterval(150)
|
||||
self._search_timer.timeout.connect(self._do_search)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Header
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(10, 6, 10, 6)
|
||||
lbl = QLabel("BIBLIOTEK")
|
||||
lbl.setObjectName("section_title")
|
||||
header.addWidget(lbl)
|
||||
header.addStretch()
|
||||
|
||||
self._btn_bpm_scan = QPushButton("♩ BPM alle")
|
||||
self._btn_bpm_scan.setFixedHeight(24)
|
||||
self._btn_bpm_scan.setToolTip("Analysér BPM på alle sange uden BPM (kører i baggrunden)")
|
||||
self._btn_bpm_scan.clicked.connect(self._start_bulk_bpm_scan)
|
||||
header.addWidget(self._btn_bpm_scan)
|
||||
|
||||
btn_manage = QPushButton("⚙ Mapper")
|
||||
btn_manage.setFixedHeight(24)
|
||||
btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
|
||||
btn_manage.clicked.connect(self._manage_libraries)
|
||||
header.addWidget(btn_manage)
|
||||
layout.addLayout(header)
|
||||
|
||||
# Scan status
|
||||
self._scan_bar = QProgressBar()
|
||||
self._scan_bar.setObjectName("scan_bar")
|
||||
self._scan_bar.setTextVisible(True)
|
||||
self._scan_bar.setFormat("Scanner...")
|
||||
self._scan_bar.setFixedHeight(16)
|
||||
self._scan_bar.setRange(0, 0)
|
||||
self._scan_bar.hide()
|
||||
layout.addWidget(self._scan_bar)
|
||||
|
||||
self._scan_label = QLabel("")
|
||||
self._scan_label.setObjectName("result_count")
|
||||
self._scan_label.hide()
|
||||
layout.addWidget(self._scan_label)
|
||||
|
||||
# Søgefelt
|
||||
self._search = QLineEdit()
|
||||
self._search.setPlaceholderText("Søg i titel, artist, album, dans...")
|
||||
self._search.textChanged.connect(self._on_search_changed)
|
||||
layout.addWidget(self._search)
|
||||
|
||||
# Resultat-tæller + drag-hint
|
||||
hint_row = QHBoxLayout()
|
||||
hint_row.setContentsMargins(8, 2, 8, 2)
|
||||
self._count_label = QLabel("0 sange")
|
||||
self._count_label.setObjectName("result_count")
|
||||
hint_row.addWidget(self._count_label)
|
||||
hint_row.addStretch()
|
||||
drag_hint = QLabel("træk til danseliste →")
|
||||
drag_hint.setObjectName("result_count")
|
||||
hint_row.addWidget(drag_hint)
|
||||
layout.addLayout(hint_row)
|
||||
|
||||
# Liste — draggable
|
||||
self._list = DraggableLibraryList()
|
||||
self._list.setObjectName("library_list")
|
||||
self._list.itemDoubleClicked.connect(self._on_double_click)
|
||||
self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._list.customContextMenuRequested.connect(self._show_context_menu)
|
||||
layout.addWidget(self._list)
|
||||
|
||||
# ── Scanning ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_scan_clicked(self):
|
||||
self.scan_requested.emit()
|
||||
|
||||
def set_scanning(self, scanning: bool, status_text: str = ""):
|
||||
if scanning:
|
||||
self._scan_bar.show()
|
||||
self._scan_label.setText(status_text or "Starter...")
|
||||
self._scan_label.show()
|
||||
else:
|
||||
self._scan_bar.hide()
|
||||
self._scan_label.hide()
|
||||
|
||||
def update_scan_status(self, text: str):
|
||||
self._scan_label.setText(text)
|
||||
|
||||
# ── Sange ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_songs(self, songs: list[dict]):
|
||||
self._all_songs = songs
|
||||
self._do_search()
|
||||
|
||||
# ── Søgning ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_search_changed(self):
|
||||
self._search_timer.start()
|
||||
|
||||
def _do_search(self):
|
||||
q = self._search.text().strip().lower()
|
||||
self._filtered = [s for s in self._all_songs if self._matches(s, q)] if q else list(self._all_songs)
|
||||
total = len(self._all_songs)
|
||||
found = len(self._filtered)
|
||||
q_text = self._search.text().strip()
|
||||
self._count_label.setText(
|
||||
f"{found} resultat{'er' if found != 1 else ''} for \"{q_text}\"" if q_text
|
||||
else f"{total} sang{'e' if total != 1 else ''}"
|
||||
)
|
||||
self._render()
|
||||
|
||||
def _matches(self, song: dict, q: str) -> bool:
|
||||
return any(q in f.lower() for f in [
|
||||
song.get("title", ""), song.get("artist", ""),
|
||||
song.get("album", ""), song.get("file_format", ""),
|
||||
] + song.get("dances", []))
|
||||
|
||||
def _render(self):
|
||||
self._list.clear()
|
||||
q = self._search.text().strip().lower()
|
||||
for song in self._filtered:
|
||||
dances = song.get("dances", [])
|
||||
dance_levels = song.get("dance_levels", [])
|
||||
missing = song.get("file_missing", False)
|
||||
|
||||
dance_parts = []
|
||||
for i, d in enumerate(dances):
|
||||
lvl = dance_levels[i] if i < len(dance_levels) else ""
|
||||
dance_parts.append(f"{d} / {lvl}" if lvl else d)
|
||||
dance_str = " · " + " | ".join(dance_parts) if dance_parts else ""
|
||||
|
||||
line1 = ("⚠ " if missing else "") + song.get("title", "—")
|
||||
bpm = song.get("bpm", 0)
|
||||
bpm_str = f"{bpm} BPM" if bpm else "? BPM"
|
||||
line2 = f" {song.get('artist','—')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}"
|
||||
|
||||
row_widget = QWidget()
|
||||
row_widget.setStyleSheet("background: transparent;")
|
||||
row_layout = QHBoxLayout(row_widget)
|
||||
row_layout.setContentsMargins(2, 2, 2, 2)
|
||||
row_layout.setSpacing(8)
|
||||
|
||||
lbl = QLabel(f"{line1}\n{line2}")
|
||||
lbl.setWordWrap(False)
|
||||
row_layout.addWidget(lbl, stretch=1)
|
||||
|
||||
btn_danse = QPushButton("Danse")
|
||||
btn_danse.setFixedHeight(30)
|
||||
btn_danse.setFixedWidth(70)
|
||||
btn_danse.setToolTip("Rediger dans-tags")
|
||||
btn_danse.setStyleSheet(
|
||||
"QPushButton { background: #e8a020; color: #111; border-radius: 4px; "
|
||||
"font-weight: bold; font-size: 12px; border: none; }"
|
||||
"QPushButton:hover { background: #f0b030; }"
|
||||
)
|
||||
btn_danse.clicked.connect(lambda _, s=song: self.edit_tags_requested.emit(s))
|
||||
row_layout.addWidget(btn_danse)
|
||||
|
||||
item = QListWidgetItem()
|
||||
item.setData(Qt.ItemDataRole.UserRole, song)
|
||||
row_widget.adjustSize()
|
||||
hint = row_widget.sizeHint()
|
||||
hint.setHeight(max(hint.height(), 52))
|
||||
item.setSizeHint(hint)
|
||||
self._list.addItem(item)
|
||||
self._list.setItemWidget(item, row_widget)
|
||||
|
||||
def _start_bulk_bpm_scan(self):
|
||||
"""Start BPM-analyse på alle sange uden BPM i baggrundstråd med lav prioritet."""
|
||||
if self._bpm_scan_running:
|
||||
return
|
||||
songs_without_bpm = [s for s in self._all_songs
|
||||
if not s.get("bpm") and not s.get("file_missing")]
|
||||
if not songs_without_bpm:
|
||||
self._btn_bpm_scan.setText("♩ Alle har BPM")
|
||||
return
|
||||
|
||||
self._bpm_scan_running = True
|
||||
self._btn_bpm_scan.setText(f"♩ Scanner 0/{len(songs_without_bpm)}...")
|
||||
self._btn_bpm_scan.setEnabled(False)
|
||||
|
||||
from PyQt6.QtCore import QThread, pyqtSignal as _sig
|
||||
|
||||
class BulkBpmWorker(QThread):
|
||||
progress = _sig(int, int, str) # done, total, title
|
||||
finished = _sig()
|
||||
|
||||
def __init__(self, songs):
|
||||
super().__init__()
|
||||
self._songs = songs
|
||||
|
||||
def run(self):
|
||||
from local.tag_reader import analyze_and_save_bpm
|
||||
total = len(self._songs)
|
||||
for i, song in enumerate(self._songs, start=1):
|
||||
if self.isInterruptionRequested():
|
||||
break
|
||||
try:
|
||||
bpm = analyze_and_save_bpm(song["local_path"], song["id"])
|
||||
if bpm:
|
||||
song["bpm"] = int(round(bpm))
|
||||
except Exception:
|
||||
pass
|
||||
self.progress.emit(i, total, song.get("title", ""))
|
||||
self.finished.emit()
|
||||
|
||||
self._bulk_bpm_worker = BulkBpmWorker(songs_without_bpm)
|
||||
|
||||
def on_progress(done, total, title):
|
||||
self._btn_bpm_scan.setText(f"♩ {done}/{total}...")
|
||||
# Opdater sangen i listen
|
||||
for s in self._all_songs:
|
||||
if s.get("title") == title and s.get("bpm"):
|
||||
break
|
||||
self._do_search()
|
||||
|
||||
def on_finished():
|
||||
self._bpm_scan_running = False
|
||||
self._btn_bpm_scan.setEnabled(True)
|
||||
self._btn_bpm_scan.setText("♩ BPM alle")
|
||||
self._do_search()
|
||||
|
||||
self._bulk_bpm_worker.progress.connect(on_progress)
|
||||
self._bulk_bpm_worker.finished.connect(on_finished)
|
||||
self._bulk_bpm_worker.start()
|
||||
self._bulk_bpm_worker.setPriority(QThread.Priority.LowestPriority)
|
||||
|
||||
# ── 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)
|
||||
if not item:
|
||||
return
|
||||
song = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not song:
|
||||
return
|
||||
menu = QMenu(self)
|
||||
act_add = menu.addAction("Tilføj til danseliste")
|
||||
act_play = menu.addAction("Afspil")
|
||||
menu.addSeparator()
|
||||
act_tags = menu.addAction("✎ Rediger dans-tags...")
|
||||
act_bpm = menu.addAction("♩ Analysér BPM")
|
||||
menu.addSeparator()
|
||||
send_menu = menu.addMenu("Send til")
|
||||
act_mail = send_menu.addAction("✉ Send som mail")
|
||||
action = menu.exec(self._list.mapToGlobal(pos))
|
||||
if action == act_add:
|
||||
self.add_to_playlist.emit(song)
|
||||
elif action == act_play:
|
||||
self.song_selected.emit(song)
|
||||
elif action == act_tags:
|
||||
self.edit_tags_requested.emit(song)
|
||||
elif action == act_bpm:
|
||||
self._analyze_bpm(song)
|
||||
elif action == act_mail:
|
||||
self.send_mail_requested.emit(song)
|
||||
|
||||
def _analyze_bpm(self, song: dict):
|
||||
"""Analysér BPM i baggrundstråd og opdater biblioteket."""
|
||||
path = song.get("local_path", "")
|
||||
song_id = song.get("id", "")
|
||||
if not path or not song_id:
|
||||
return
|
||||
from PyQt6.QtCore import QThread, pyqtSignal as _sig
|
||||
|
||||
class BpmWorker(QThread):
|
||||
done = _sig(float)
|
||||
def __init__(self, p, sid):
|
||||
super().__init__()
|
||||
self._p, self._sid = p, sid
|
||||
def run(self):
|
||||
from local.tag_reader import analyze_and_save_bpm
|
||||
bpm = analyze_and_save_bpm(self._p, self._sid)
|
||||
if bpm:
|
||||
self.done.emit(bpm)
|
||||
|
||||
self._bpm_worker = BpmWorker(path, song_id)
|
||||
|
||||
def on_bpm_done(bpm):
|
||||
# Opdater sangen i _all_songs listen direkte
|
||||
for s in self._all_songs:
|
||||
if s.get("id") == song_id:
|
||||
s["bpm"] = int(round(bpm))
|
||||
break
|
||||
self._do_search()
|
||||
|
||||
self._bpm_worker.done.connect(on_bpm_done)
|
||||
self._bpm_worker.start()
|
||||
|
||||
def _manage_libraries(self):
|
||||
from ui.library_manager import LibraryManagerDialog
|
||||
dialog = LibraryManagerDialog(parent=self.window())
|
||||
dialog.library_removed.connect(lambda _: self.scan_requested.emit())
|
||||
dialog.exec()
|
||||
|
||||
def _add_folder(self):
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||||
if folder:
|
||||
mw = self.window()
|
||||
if hasattr(mw, "add_library_path"):
|
||||
mw.add_library_path(folder)
|
||||
@@ -1,139 +0,0 @@
|
||||
"""
|
||||
login_dialog.py — Login-dialog til at gå online.
|
||||
Server-URL er hardcodet i config.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QLineEdit, QPushButton, QFrame, QCheckBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QSettings
|
||||
|
||||
# ── Hardcodet server-URL ──────────────────────────────────────────────────────
|
||||
API_URL = "http://din-server:8000"
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class LoginDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Gå online")
|
||||
self.setFixedWidth(340)
|
||||
self.setModal(True)
|
||||
|
||||
self._token: str | None = None
|
||||
self._username: str | None = None
|
||||
self._api_url = API_URL
|
||||
|
||||
self._build_ui()
|
||||
self._load_saved_settings()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
title = QLabel("Log ind på LineDance")
|
||||
title.setObjectName("track_title")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
sub = QLabel("Synkroniser projekter og alternativ-danse med andre brugere")
|
||||
sub.setObjectName("track_meta")
|
||||
sub.setWordWrap(True)
|
||||
sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(sub)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
layout.addWidget(line)
|
||||
|
||||
layout.addWidget(QLabel("Brugernavn:"))
|
||||
self._user_input = QLineEdit()
|
||||
self._user_input.setPlaceholderText("dit-brugernavn")
|
||||
layout.addWidget(self._user_input)
|
||||
|
||||
layout.addWidget(QLabel("Kodeord:"))
|
||||
self._pass_input = QLineEdit()
|
||||
self._pass_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self._pass_input.setPlaceholderText("••••••••")
|
||||
self._pass_input.returnPressed.connect(self._on_login)
|
||||
layout.addWidget(self._pass_input)
|
||||
|
||||
self._remember = QCheckBox("Husk brugernavn")
|
||||
self._remember.setChecked(True)
|
||||
layout.addWidget(self._remember)
|
||||
|
||||
self._status_label = QLabel("")
|
||||
self._status_label.setObjectName("track_meta")
|
||||
self._status_label.setWordWrap(True)
|
||||
layout.addWidget(self._status_label)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_cancel = QPushButton("Annuller")
|
||||
btn_cancel.clicked.connect(self.reject)
|
||||
btn_row.addWidget(btn_cancel)
|
||||
|
||||
self._btn_login = QPushButton("Log ind")
|
||||
self._btn_login.setObjectName("btn_play")
|
||||
self._btn_login.setDefault(True)
|
||||
self._btn_login.clicked.connect(self._on_login)
|
||||
btn_row.addWidget(self._btn_login)
|
||||
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
def _load_saved_settings(self):
|
||||
settings = QSettings("LineDance", "Player")
|
||||
self._user_input.setText(settings.value("username", ""))
|
||||
|
||||
def _save_settings(self):
|
||||
if self._remember.isChecked():
|
||||
settings = QSettings("LineDance", "Player")
|
||||
settings.setValue("username", self._user_input.text().strip())
|
||||
|
||||
def _on_login(self):
|
||||
username = self._user_input.text().strip()
|
||||
password = self._pass_input.text()
|
||||
|
||||
if not username or not password:
|
||||
self._set_status("Udfyld brugernavn og kodeord", error=True)
|
||||
return
|
||||
|
||||
self._btn_login.setEnabled(False)
|
||||
self._set_status("Forbinder...")
|
||||
|
||||
try:
|
||||
import urllib.request, urllib.parse, json
|
||||
|
||||
data = urllib.parse.urlencode({
|
||||
"username": username,
|
||||
"password": password,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/auth/login",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
body = json.loads(resp.read())
|
||||
self._token = body.get("access_token")
|
||||
self._username = username
|
||||
|
||||
self._save_settings()
|
||||
self._set_status("Logget ind!", error=False)
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
self._set_status(f"Fejl: {e}", error=True)
|
||||
self._btn_login.setEnabled(True)
|
||||
|
||||
def _set_status(self, text: str, error: bool = False):
|
||||
self._status_label.setText(text)
|
||||
color = "#e74c3c" if error else "#2ecc71"
|
||||
self._status_label.setStyleSheet(f"color: {color};")
|
||||
|
||||
def get_credentials(self) -> tuple[str, str, str]:
|
||||
"""Returnerer (api_url, username, token) efter succesfuldt login."""
|
||||
return self._api_url, self._username, self._token
|
||||
@@ -1,943 +0,0 @@
|
||||
"""
|
||||
main_window.py — Linedance afspiller hovedvindue.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QSlider, QLabel, QFrame, QSplitter,
|
||||
QSizePolicy, QMenuBar, QMenu, QStatusBar, QFileDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QAction
|
||||
|
||||
from ui.vu_meter import VUMeter
|
||||
from ui.playlist_panel import PlaylistPanel
|
||||
from ui.library_panel import LibraryPanel
|
||||
from ui.themes import apply_theme
|
||||
from ui.scan_worker import ScanWorker
|
||||
from ui.login_dialog import LoginDialog, API_URL
|
||||
from ui.playlist_manager import PlaylistManagerDialog
|
||||
from ui.settings_dialog import SettingsDialog, load_settings
|
||||
from player.player import Player
|
||||
|
||||
|
||||
class ProgressBar(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._fraction = 0.0
|
||||
self._demo_fraction = 0.0 # hvor musikken stopper (blå)
|
||||
self._demo_fade_fraction = 0.0 # hvor fade slutter (grå)
|
||||
self.setFixedHeight(10)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
def set_fraction(self, f: float):
|
||||
self._fraction = max(0.0, min(1.0, f))
|
||||
self.update()
|
||||
|
||||
def set_demo_marker(self, demo_f: float, fade_f: float = 0.0):
|
||||
self._demo_fraction = max(0.0, min(1.0, demo_f))
|
||||
self._demo_fade_fraction = max(0.0, min(1.0, fade_f))
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
from PyQt6.QtGui import QPainter, QColor
|
||||
p = QPainter(self)
|
||||
w, h = self.width(), self.height()
|
||||
p.fillRect(0, 0, w, h, QColor("#2c3038"))
|
||||
fill_w = int(w * self._fraction)
|
||||
if fill_w > 0:
|
||||
p.fillRect(0, 0, fill_w, h, QColor("#e8a020"))
|
||||
# Fade-slut markør (grå) — vises bag demo-markøren
|
||||
if self._demo_fade_fraction > 0:
|
||||
fx = int(w * self._demo_fade_fraction)
|
||||
p.fillRect(fx - 1, 0, 2, h, QColor("#6a7080"))
|
||||
# Demo-stop markør (blå)
|
||||
if self._demo_fraction > 0:
|
||||
mx = int(w * self._demo_fraction)
|
||||
p.fillRect(mx - 1, 0, 2, h, QColor("#3b8fd4"))
|
||||
p.end()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
fraction = event.position().x() / self.width()
|
||||
mw = self.window()
|
||||
if hasattr(mw, "_on_seek"):
|
||||
mw._on_seek(fraction)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("LineDance Player")
|
||||
self.setMinimumSize(1000, 680)
|
||||
self.resize(1600, 820)
|
||||
|
||||
self._dark_theme = True
|
||||
self._player = Player(self)
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._demo_active = False
|
||||
self._watcher = None
|
||||
self._scan_worker = None
|
||||
self._api_url: str | None = None
|
||||
self._api_token: str | None = None
|
||||
self._api_username: str | None = None
|
||||
|
||||
# Indlæs indstillinger
|
||||
self._settings = load_settings()
|
||||
self._dark_theme = self._settings.get("dark_theme", True)
|
||||
self._demo_seconds = self._settings.get("demo_seconds", 10)
|
||||
self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
|
||||
|
||||
self._connect_player_signals()
|
||||
self._build_menu()
|
||||
self._build_ui()
|
||||
self._build_statusbar()
|
||||
apply_theme(self._app_ref(), dark=self._dark_theme)
|
||||
self._theme_btn.setText("☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA")
|
||||
|
||||
# Gendan gemt vinduestørrelse og splitter-position
|
||||
self._restore_window_state()
|
||||
|
||||
# Start DB og scanning ved opstart
|
||||
QTimer.singleShot(200, self._init_local_db)
|
||||
|
||||
# Auto-login hvis aktiveret i indstillinger
|
||||
if self._settings.get("auto_login") and self._settings.get("password"):
|
||||
QTimer.singleShot(800, self._auto_login)
|
||||
|
||||
def _app_ref(self):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
return QApplication.instance()
|
||||
|
||||
def _connect_player_signals(self):
|
||||
self._player.position_changed.connect(self._on_position)
|
||||
self._player.time_changed.connect(self._on_time)
|
||||
self._player.levels_changed.connect(self._on_levels)
|
||||
self._player.song_ended.connect(self._on_song_ended)
|
||||
self._player.state_changed.connect(self._on_state_changed)
|
||||
|
||||
# ── Menu ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_menu(self):
|
||||
menubar = self.menuBar()
|
||||
|
||||
# ── Filer ─────────────────────────────────────────────────────────────
|
||||
file_menu = menubar.addMenu("Filer")
|
||||
|
||||
self._act_go_online = QAction("Gå online...", self)
|
||||
self._act_go_online.setShortcut("Ctrl+L")
|
||||
self._act_go_online.triggered.connect(self._go_online)
|
||||
file_menu.addAction(self._act_go_online)
|
||||
|
||||
self._act_go_offline = QAction("Gå offline", self)
|
||||
self._act_go_offline.triggered.connect(self._go_offline)
|
||||
self._act_go_offline.setEnabled(False)
|
||||
file_menu.addAction(self._act_go_offline)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
act_settings = QAction("Indstillinger...", self)
|
||||
act_settings.setShortcut("Ctrl+,")
|
||||
act_settings.triggered.connect(self._open_settings)
|
||||
file_menu.addAction(act_settings)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
act_quit = QAction("Afslut", self)
|
||||
act_quit.setShortcut("Ctrl+Q")
|
||||
act_quit.triggered.connect(self.close)
|
||||
file_menu.addAction(act_quit)
|
||||
|
||||
# ── Ingen Danseliste- eller Visning-menu ──────────────────────────────
|
||||
# Ny/Gem/Hent ligger direkte i danseliste-panelet
|
||||
# Tema-skift ligger i topbar-knappen
|
||||
# Mapper og scan ligger i ⚙ Mapper dialogen
|
||||
|
||||
# Gem reference til scan-action (bruges stadig internt)
|
||||
self._act_scan = QAction("Scan", self)
|
||||
self._act_scan.triggered.connect(self.start_scan)
|
||||
|
||||
# ── Statuslinje ───────────────────────────────────────────────────────────
|
||||
|
||||
def _build_statusbar(self):
|
||||
self._statusbar = QStatusBar()
|
||||
self.setStatusBar(self._statusbar)
|
||||
self._statusbar.showMessage("Klar")
|
||||
|
||||
def _set_status(self, text: str, timeout_ms: int = 0):
|
||||
"""Vis besked i statuslinjen. timeout_ms=0 = permanent."""
|
||||
self._statusbar.showMessage(text, timeout_ms)
|
||||
|
||||
# ── UI byggeri ────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QWidget()
|
||||
root.setObjectName("root")
|
||||
self.setCentralWidget(root)
|
||||
main_layout = QVBoxLayout(root)
|
||||
main_layout.setContentsMargins(10, 6, 10, 10)
|
||||
main_layout.setSpacing(4)
|
||||
|
||||
main_layout.addWidget(self._build_topbar())
|
||||
main_layout.addWidget(self._build_now_playing())
|
||||
main_layout.addWidget(self._build_progress())
|
||||
main_layout.addWidget(self._build_transport())
|
||||
main_layout.addWidget(self._build_panels(), stretch=1)
|
||||
|
||||
def _build_topbar(self) -> QFrame:
|
||||
bar = QFrame()
|
||||
bar.setObjectName("topbar")
|
||||
layout = QHBoxLayout(bar)
|
||||
layout.setContentsMargins(12, 6, 12, 6)
|
||||
|
||||
logo = QLabel("LINE<span style='color:#9aa0b0;font-weight:400'>DANCE</span> PLAYER")
|
||||
logo.setObjectName("logo")
|
||||
logo.setTextFormat(Qt.TextFormat.RichText)
|
||||
layout.addWidget(logo)
|
||||
layout.addStretch()
|
||||
|
||||
self._conn_label = QLabel("● OFFLINE")
|
||||
self._conn_label.setObjectName("conn_label")
|
||||
layout.addWidget(self._conn_label)
|
||||
|
||||
self._theme_btn = QPushButton("☀ LYS TEMA")
|
||||
self._theme_btn.setFixedHeight(26)
|
||||
self._theme_btn.clicked.connect(self._toggle_theme)
|
||||
layout.addWidget(self._theme_btn)
|
||||
|
||||
return bar
|
||||
|
||||
def _build_now_playing(self) -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setObjectName("now_playing_frame")
|
||||
layout = QHBoxLayout(frame)
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
track_frame = QFrame()
|
||||
track_frame.setObjectName("track_display")
|
||||
track_layout = QVBoxLayout(track_frame)
|
||||
track_layout.setContentsMargins(10, 8, 10, 8)
|
||||
track_layout.setSpacing(3)
|
||||
|
||||
self._lbl_title = QLabel("—")
|
||||
self._lbl_title.setObjectName("track_title")
|
||||
track_layout.addWidget(self._lbl_title)
|
||||
|
||||
self._lbl_meta = QLabel("—")
|
||||
self._lbl_meta.setObjectName("track_meta")
|
||||
track_layout.addWidget(self._lbl_meta)
|
||||
|
||||
self._lbl_dances = QLabel("")
|
||||
self._lbl_dances.setObjectName("track_meta")
|
||||
self._lbl_dances.setWordWrap(True)
|
||||
track_layout.addWidget(self._lbl_dances)
|
||||
|
||||
layout.addWidget(track_frame, stretch=1)
|
||||
|
||||
self._vu = VUMeter()
|
||||
layout.addWidget(self._vu)
|
||||
|
||||
return frame
|
||||
|
||||
def _build_progress(self) -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setObjectName("progress_frame")
|
||||
layout = QHBoxLayout(frame)
|
||||
layout.setContentsMargins(12, 6, 12, 6)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self._lbl_cur = QLabel("0:00")
|
||||
self._lbl_cur.setObjectName("track_meta")
|
||||
self._lbl_cur.setFixedWidth(36)
|
||||
layout.addWidget(self._lbl_cur)
|
||||
|
||||
self._progress = ProgressBar(self)
|
||||
self._progress.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
layout.addWidget(self._progress, stretch=1)
|
||||
|
||||
self._lbl_tot = QLabel("0:00")
|
||||
self._lbl_tot.setObjectName("track_meta")
|
||||
self._lbl_tot.setFixedWidth(36)
|
||||
self._lbl_tot.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
layout.addWidget(self._lbl_tot)
|
||||
|
||||
return frame
|
||||
|
||||
def _build_transport(self) -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setObjectName("transport_frame")
|
||||
layout = QHBoxLayout(frame)
|
||||
layout.setContentsMargins(14, 10, 14, 10)
|
||||
layout.setSpacing(8)
|
||||
|
||||
def btn(text, name=None, size=52, checkable=False):
|
||||
b = QPushButton(text)
|
||||
if name:
|
||||
b.setObjectName(name)
|
||||
b.setFixedSize(size, size)
|
||||
if checkable:
|
||||
b.setCheckable(True)
|
||||
return b
|
||||
|
||||
self._btn_prev = btn("⏮", size=52)
|
||||
self._btn_play = btn("▶", "btn_play", size=72)
|
||||
self._btn_stop = btn("⏹", "btn_stop", size=52)
|
||||
self._btn_next = btn("⏭", size=52)
|
||||
self._btn_demo = btn(f"▶\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True)
|
||||
|
||||
self._btn_prev.clicked.connect(self._prev_song)
|
||||
self._btn_play.clicked.connect(self._toggle_play)
|
||||
self._btn_stop.clicked.connect(self._stop)
|
||||
self._btn_next.clicked.connect(self._next_song)
|
||||
self._btn_demo.clicked.connect(self._toggle_demo)
|
||||
|
||||
layout.addWidget(self._btn_prev)
|
||||
layout.addWidget(self._btn_play)
|
||||
layout.addWidget(self._btn_stop)
|
||||
layout.addWidget(self._btn_next)
|
||||
|
||||
sep1 = QFrame()
|
||||
sep1.setFrameShape(QFrame.Shape.VLine)
|
||||
sep1.setFixedWidth(1)
|
||||
layout.addWidget(sep1)
|
||||
|
||||
layout.addWidget(self._btn_demo)
|
||||
layout.addStretch()
|
||||
|
||||
lbl_vol = QLabel("VOL")
|
||||
lbl_vol.setObjectName("vol_label")
|
||||
layout.addWidget(lbl_vol)
|
||||
|
||||
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._vol_slider.setRange(0, 100)
|
||||
self._vol_slider.setValue(self._settings.get("volume", 78))
|
||||
self._vol_slider.setFixedWidth(100)
|
||||
self._vol_slider.valueChanged.connect(self._on_volume)
|
||||
layout.addWidget(self._vol_slider)
|
||||
|
||||
self._lbl_vol = QLabel(str(self._settings.get("volume", 78)))
|
||||
self._lbl_vol.setObjectName("vol_val")
|
||||
layout.addWidget(self._lbl_vol)
|
||||
|
||||
return frame
|
||||
|
||||
def _build_panels(self) -> QSplitter:
|
||||
self._splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
self._playlist_panel = PlaylistPanel()
|
||||
self._playlist_panel.song_selected.connect(self._load_song_by_idx)
|
||||
self._playlist_panel.song_dropped.connect(self._on_song_dropped)
|
||||
self._playlist_panel.event_started.connect(self._on_event_started)
|
||||
self._playlist_panel.next_song_ready.connect(self._load_song)
|
||||
|
||||
self._library_panel = LibraryPanel()
|
||||
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)
|
||||
self._library_panel.edit_tags_requested.connect(self._open_tag_editor)
|
||||
self._library_panel.send_mail_requested.connect(self._send_mail)
|
||||
|
||||
self._splitter.addWidget(self._playlist_panel)
|
||||
self._splitter.addWidget(self._library_panel)
|
||||
self._splitter.setSizes([700, 900])
|
||||
|
||||
return self._splitter
|
||||
|
||||
def _restore_window_state(self):
|
||||
from PyQt6.QtCore import QSettings, QByteArray
|
||||
settings = QSettings("LineDance", "Player")
|
||||
geom = settings.value("window/geometry")
|
||||
if geom:
|
||||
self.restoreGeometry(geom)
|
||||
splitter_state = settings.value("window/splitter")
|
||||
if splitter_state and hasattr(self, "_splitter"):
|
||||
self._splitter.restoreState(splitter_state)
|
||||
|
||||
def _save_window_state(self):
|
||||
from PyQt6.QtCore import QSettings
|
||||
settings = QSettings("LineDance", "Player")
|
||||
settings.setValue("window/geometry", self.saveGeometry())
|
||||
if hasattr(self, "_splitter"):
|
||||
settings.setValue("window/splitter", self._splitter.saveState())
|
||||
|
||||
# ── Lokal DB + scanning ───────────────────────────────────────────────────
|
||||
|
||||
def _init_local_db(self):
|
||||
try:
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from local.local_db import init_db
|
||||
from local.file_watcher import get_watcher
|
||||
|
||||
init_db()
|
||||
|
||||
# Brug et Qt signal til thread-safe reload fra watcher-tråden
|
||||
from PyQt6.QtCore import QMetaObject, Q_ARG
|
||||
def on_file_change(event_type, path, song_id):
|
||||
QTimer.singleShot(0, self._reload_library)
|
||||
|
||||
self._watcher = get_watcher(on_change=on_file_change)
|
||||
self._watcher.start()
|
||||
|
||||
# Indlæs hvad vi allerede kender fra SQLite
|
||||
self._reload_library()
|
||||
|
||||
# Gendan sidst aktive danseliste
|
||||
restored = self._playlist_panel.restore_active_playlist()
|
||||
|
||||
# Gendan event-fremgang hvis liste blev gendannet
|
||||
if restored:
|
||||
if self._playlist_panel.restore_event_state():
|
||||
# Indlæs den sang vi var nået til
|
||||
idx = self._playlist_panel._current_idx
|
||||
song = self._playlist_panel.get_song(idx)
|
||||
if song:
|
||||
self._current_idx = idx
|
||||
self._load_song(song)
|
||||
self._set_status(
|
||||
f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte",
|
||||
6000,
|
||||
)
|
||||
|
||||
# Kør automatisk scanning ved opstart
|
||||
self._set_status("Starter scanning af biblioteker...")
|
||||
QTimer.singleShot(100, self.start_scan)
|
||||
|
||||
except Exception as e:
|
||||
self._set_status(f"DB fejl: {e}")
|
||||
pass
|
||||
|
||||
def start_scan(self):
|
||||
"""Start fuld scanning af alle biblioteker i baggrundstråd."""
|
||||
if self._scan_worker and self._scan_worker.isRunning():
|
||||
return # Scanning kører allerede
|
||||
|
||||
if not self._watcher:
|
||||
self._set_status("Ingen biblioteker at scanne — tilføj en mappe først")
|
||||
return
|
||||
|
||||
self._library_panel.set_scanning(True, "Forbereder scanning...")
|
||||
self._act_scan.setEnabled(False)
|
||||
|
||||
self._scan_worker = ScanWorker(self._watcher, parent=self)
|
||||
self._scan_worker.status_update.connect(self._on_scan_status)
|
||||
self._scan_worker.scan_done.connect(self._on_scan_done)
|
||||
self._scan_worker.start()
|
||||
|
||||
def _on_scan_status(self, text: str):
|
||||
self._set_status(text)
|
||||
self._library_panel.update_scan_status(text)
|
||||
|
||||
def _on_scan_done(self, count: int):
|
||||
self._library_panel.set_scanning(False)
|
||||
self._act_scan.setEnabled(True)
|
||||
msg = f"Scanning færdig — {count} filer gennemgået"
|
||||
self._set_status(msg, timeout_ms=5000)
|
||||
# Genindlæs biblioteket
|
||||
QTimer.singleShot(200, self._reload_library)
|
||||
|
||||
def _reload_library(self):
|
||||
try:
|
||||
from local.local_db import search_songs, get_db
|
||||
songs_raw = search_songs("", limit=5000)
|
||||
songs = []
|
||||
for row in songs_raw:
|
||||
with get_db() as conn:
|
||||
dances_raw = conn.execute(
|
||||
"SELECT sd.dance_name, dl.name as level_name "
|
||||
"FROM song_dances sd "
|
||||
"LEFT JOIN dance_levels dl ON dl.id = sd.level_id "
|
||||
"WHERE sd.song_id=? ORDER BY sd.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_raw],
|
||||
"dance_levels": [d["level_name"] or "" for d in dances_raw],
|
||||
})
|
||||
self._library_panel.load_songs(songs)
|
||||
count = len(songs)
|
||||
self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def add_library_path(self, path: str):
|
||||
try:
|
||||
if not self._watcher:
|
||||
self._set_status("Watcher ikke klar endnu — prøv igen om et øjeblik", 3000)
|
||||
return
|
||||
self._watcher.add_library(path)
|
||||
self._set_status(f"Tilføjet: {path} — scanner...")
|
||||
# Genindlæs bibliotekslisten og start scan
|
||||
QTimer.singleShot(500, self._reload_library)
|
||||
QTimer.singleShot(1000, self.start_scan)
|
||||
except Exception as e:
|
||||
self._set_status(f"Fejl ved tilføjelse: {e}")
|
||||
|
||||
def _open_settings(self):
|
||||
dialog = SettingsDialog(parent=self)
|
||||
if dialog.exec():
|
||||
self._settings = dialog.get_values()
|
||||
self._demo_seconds = self._settings.get("demo_seconds", 10)
|
||||
self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
|
||||
# Opdater tema hvis ændret
|
||||
new_dark = self._settings.get("dark_theme", True)
|
||||
if new_dark != self._dark_theme:
|
||||
self._dark_theme = new_dark
|
||||
apply_theme(self._app_ref(), dark=self._dark_theme)
|
||||
self._theme_btn.setText(
|
||||
"☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA"
|
||||
)
|
||||
self._vu.set_dark(self._dark_theme)
|
||||
# Opdater demo-knap tekst
|
||||
self._btn_demo.setText(f"▶\n{self._demo_seconds} SEK")
|
||||
# Opdater demo-markør hvis en sang er indlæst
|
||||
if hasattr(self, "_current_song") and self._current_song:
|
||||
dur = self._current_song.get("duration_sec", 0)
|
||||
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)
|
||||
|
||||
def _auto_login(self):
|
||||
"""Forsøg automatisk login med gemte oplysninger."""
|
||||
username = self._settings.get("username", "")
|
||||
password = self._settings.get("password", "")
|
||||
if not username or not password:
|
||||
return
|
||||
try:
|
||||
import urllib.request, urllib.parse, json
|
||||
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/auth/login", data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
body = json.loads(resp.read())
|
||||
self._api_token = body.get("access_token")
|
||||
self._api_url = API_URL
|
||||
self._api_username = username
|
||||
self._set_online_state(True)
|
||||
self._set_status(f"Automatisk logget ind som {username}", 4000)
|
||||
# Synkroniser dans-niveauer og navne
|
||||
QTimer.singleShot(500, self._sync_dance_data)
|
||||
except Exception:
|
||||
self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000)
|
||||
|
||||
def _go_online(self):
|
||||
dialog = LoginDialog(self)
|
||||
if dialog.exec():
|
||||
url, username, token = dialog.get_credentials()
|
||||
self._api_url = url
|
||||
self._api_token = token
|
||||
self._api_username = username
|
||||
self._set_online_state(True)
|
||||
self._set_status(f"Online som {username}", 5000)
|
||||
QTimer.singleShot(500, self._sync_dance_data)
|
||||
|
||||
def _sync_dance_data(self):
|
||||
"""Synkroniser dans-niveauer og navne fra API."""
|
||||
if not self._api_token:
|
||||
return
|
||||
try:
|
||||
import urllib.request, json
|
||||
headers = {"Authorization": f"Bearer {self._api_token}"}
|
||||
|
||||
# Hent niveauer
|
||||
req = urllib.request.Request(f"{API_URL}/dances/levels", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
levels = json.loads(resp.read())
|
||||
from local.local_db import sync_dance_levels_from_api
|
||||
sync_dance_levels_from_api(levels)
|
||||
|
||||
# Hent populære dans-navne
|
||||
req = urllib.request.Request(f"{API_URL}/dances/names?limit=500", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
names = json.loads(resp.read())
|
||||
from local.local_db import sync_dance_names_from_api
|
||||
sync_dance_names_from_api(names)
|
||||
|
||||
self._set_status(f"Synkroniseret {len(levels)} niveauer og {len(names)} dans-navne", 4000)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def _go_offline(self):
|
||||
self._api_url = self._api_token = self._api_username = None
|
||||
self._set_online_state(False)
|
||||
self._set_status("Offline — arbejder lokalt", 3000)
|
||||
|
||||
def _set_online_state(self, online: bool):
|
||||
self._act_go_online.setEnabled(not online)
|
||||
self._act_go_offline.setEnabled(online)
|
||||
if online:
|
||||
name = self._api_username or "?"
|
||||
self._conn_label.setText(f"● ONLINE ({name})")
|
||||
self._conn_label.setStyleSheet("color: #2ecc71;")
|
||||
else:
|
||||
self._conn_label.setText("● OFFLINE")
|
||||
self._conn_label.setStyleSheet("color: #5a6070;")
|
||||
|
||||
def _new_playlist(self):
|
||||
self._stop()
|
||||
self._playlist_panel.load_songs([])
|
||||
self._playlist_panel.set_playlist_name("Ny liste")
|
||||
self._set_status("Ny danseliste oprettet", 2000)
|
||||
|
||||
def _open_playlist_manager(self):
|
||||
dialog = PlaylistManagerDialog(
|
||||
current_songs=self._playlist_panel.get_songs(),
|
||||
parent=self,
|
||||
)
|
||||
dialog.playlist_loaded.connect(self._on_playlist_loaded)
|
||||
dialog.exec()
|
||||
|
||||
def _on_playlist_loaded(self, name: str, songs: list[dict]):
|
||||
self._stop()
|
||||
self._playlist_panel.load_songs(songs)
|
||||
self._playlist_panel.set_playlist_name(name)
|
||||
self._set_status(f"Indlæst: {name} ({len(songs)} sange)", 3000)
|
||||
|
||||
def _open_tag_editor(self, song: dict):
|
||||
from ui.tag_editor import TagEditorDialog
|
||||
dialog = TagEditorDialog(song, parent=self)
|
||||
if dialog.exec():
|
||||
# Genindlæs biblioteket så ændringer vises
|
||||
QTimer.singleShot(200, self._reload_library)
|
||||
|
||||
def _send_mail(self, song: dict):
|
||||
import subprocess, sys, shutil, urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
path = song.get("local_path", "")
|
||||
title = song.get("title", "")
|
||||
artist = song.get("artist", "")
|
||||
|
||||
if not path or not Path(path).exists():
|
||||
self._set_status("Filen blev ikke fundet — kan ikke sende mail", 4000)
|
||||
return
|
||||
|
||||
# ── Auto-detekter mailklient ───────────────────────────────────────────
|
||||
|
||||
def try_thunderbird() -> bool:
|
||||
"""Thunderbird: thunderbird -compose attachment='file:///sti'"""
|
||||
candidates = []
|
||||
if sys.platform == "win32":
|
||||
import winreg
|
||||
for base in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
||||
try:
|
||||
key = winreg.OpenKey(base,
|
||||
r"SOFTWARE\Mozilla\Mozilla Thunderbird")
|
||||
inst, _ = winreg.QueryValueEx(key, "Install Directory")
|
||||
candidates.append(str(Path(inst) / "thunderbird.exe"))
|
||||
except Exception:
|
||||
pass
|
||||
candidates += [
|
||||
r"C:\Program Files\Mozilla Thunderbird\thunderbird.exe",
|
||||
r"C:\Program Files (x86)\Mozilla Thunderbird\thunderbird.exe",
|
||||
]
|
||||
elif sys.platform == "darwin":
|
||||
candidates = [
|
||||
"/Applications/Thunderbird.app/Contents/MacOS/thunderbird",
|
||||
]
|
||||
else:
|
||||
candidates = [shutil.which("thunderbird") or "",
|
||||
"/usr/bin/thunderbird",
|
||||
"/usr/local/bin/thunderbird",
|
||||
"/snap/bin/thunderbird"]
|
||||
|
||||
tb = next((c for c in candidates if c and Path(c).exists()), None)
|
||||
if not tb:
|
||||
return False
|
||||
|
||||
file_uri = Path(path).as_uri()
|
||||
subject = f"Linedance sang: {title} — {artist}"
|
||||
compose = (
|
||||
f"subject='{subject}',"
|
||||
f"attachment='{file_uri}'"
|
||||
)
|
||||
subprocess.Popen([tb, "-compose", compose])
|
||||
return True
|
||||
|
||||
def try_outlook() -> bool:
|
||||
"""Outlook: outlook.exe /a 'filsti' (kun Windows)"""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
candidates = [
|
||||
shutil.which("outlook") or "",
|
||||
r"C:\Program Files\Microsoft Office\root\Office16\OUTLOOK.EXE",
|
||||
r"C:\Program Files (x86)\Microsoft Office\root\Office16\OUTLOOK.EXE",
|
||||
r"C:\Program Files\Microsoft Office\Office16\OUTLOOK.EXE",
|
||||
]
|
||||
ol = next((c for c in candidates if c and Path(c).exists()), None)
|
||||
if not ol:
|
||||
return False
|
||||
subprocess.Popen([ol, "/a", path])
|
||||
return True
|
||||
|
||||
def fallback_mailto():
|
||||
"""Ingen vedhæftning — åbn standard-mailprogram via mailto:"""
|
||||
subject = urllib.parse.quote(f"Linedance sang: {title} — {artist}")
|
||||
body = urllib.parse.quote(
|
||||
f"Sang: {title}\nArtist: {artist}\nFil: {path}\n\n"
|
||||
f"(Vedhæft filen manuelt fra ovenstående sti)"
|
||||
)
|
||||
mailto = f"mailto:?subject={subject}&body={body}"
|
||||
if sys.platform == "win32":
|
||||
import os; os.startfile(mailto)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", mailto])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", mailto])
|
||||
|
||||
# ── Prøv i rækkefølge ─────────────────────────────────────────────────
|
||||
if try_thunderbird():
|
||||
self._set_status(f"Thunderbird åbnet med {Path(path).name} vedh.", 4000)
|
||||
elif try_outlook():
|
||||
self._set_status(f"Outlook åbnet med {Path(path).name} vedh.", 4000)
|
||||
else:
|
||||
fallback_mailto()
|
||||
self._set_status(
|
||||
f"Ingen kendt mailklient fundet — åbnet mailto: (uden vedhæftning)", 5000
|
||||
)
|
||||
|
||||
def _on_event_started(self):
|
||||
"""Start event — indlæs første sang i afspilleren klar til afspilning."""
|
||||
first = self._playlist_panel.get_song(0)
|
||||
if not first:
|
||||
return
|
||||
self._stop()
|
||||
self._current_idx = 0
|
||||
self._song_ended = False
|
||||
self._load_song(first)
|
||||
self._set_status("Event klar — tryk ▶ for at starte", 5000)
|
||||
|
||||
def _on_song_dropped(self, song: dict):
|
||||
self._set_status(f"Tilføjet: {song.get('title','')}", 2000)
|
||||
|
||||
def _menu_add_folder(self):
|
||||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||||
if folder:
|
||||
self.add_library_path(folder)
|
||||
|
||||
# ── Afspilning ────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_song(self, song: dict):
|
||||
self._current_song = song
|
||||
self._song_ended = False
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
|
||||
dur = song.get("duration_sec", 0)
|
||||
self._player.load(song.get("local_path", ""), dur)
|
||||
|
||||
self._lbl_title.setText(song.get("title", "—"))
|
||||
bpm = song.get("bpm", 0)
|
||||
fmt_dur = f"{dur//60}:{dur%60:02d}"
|
||||
self._lbl_meta.setText(f"{song.get('artist','')} · {bpm} BPM · {fmt_dur}")
|
||||
|
||||
dances = song.get("dances", [])
|
||||
self._lbl_dances.setText(
|
||||
" · ".join(f"[{d}]" for d in dances) if dances else "ingen danse tagget"
|
||||
)
|
||||
|
||||
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(f"Indlæst: {song.get('title','—')}", 3000)
|
||||
|
||||
def _load_song_by_idx(self, idx: int):
|
||||
song = self._playlist_panel.get_song(idx)
|
||||
if not song:
|
||||
return
|
||||
self._current_idx = idx
|
||||
self._load_song(song)
|
||||
self._playlist_panel.set_current(idx)
|
||||
|
||||
def _toggle_play(self):
|
||||
if self._demo_active:
|
||||
self._player.stop()
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
return
|
||||
if self._player.is_playing():
|
||||
self._player.pause()
|
||||
else:
|
||||
self._song_ended = False
|
||||
self._player.play()
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
def _stop(self):
|
||||
self._player.stop()
|
||||
self._song_ended = False
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
self._vu.reset()
|
||||
|
||||
def _toggle_demo(self):
|
||||
if self._demo_active:
|
||||
self._player.stop()
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
else:
|
||||
self._demo_active = True
|
||||
self._btn_demo.setChecked(True)
|
||||
self._player.play_demo(
|
||||
stop_at_sec=self._demo_seconds,
|
||||
fade_sec=self._demo_fade_seconds,
|
||||
)
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
def _prev_song(self):
|
||||
if self._current_idx > 0:
|
||||
self._stop()
|
||||
self._load_song_by_idx(self._current_idx - 1)
|
||||
|
||||
def _next_song(self):
|
||||
if self._current_idx < self._playlist_panel.count() - 1:
|
||||
self._stop()
|
||||
self._playlist_panel.mark_played(self._current_idx)
|
||||
self._load_song_by_idx(self._current_idx + 1)
|
||||
|
||||
def _play_next(self):
|
||||
self._song_ended = False
|
||||
self._player.play()
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
def _on_library_song_selected(self, song: dict):
|
||||
self._load_song(song)
|
||||
self._player.play()
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
def _add_song_to_playlist(self, song: dict):
|
||||
songs = [self._playlist_panel.get_song(i)
|
||||
for i in range(self._playlist_panel.count())]
|
||||
songs = [s for s in songs if s]
|
||||
songs.append(song)
|
||||
self._playlist_panel.load_songs(songs)
|
||||
self._set_status(f"Tilføjet til danseliste: {song.get('title','')}", 2000)
|
||||
|
||||
# ── Player signals ────────────────────────────────────────────────────────
|
||||
|
||||
def _on_position(self, fraction: float):
|
||||
self._progress.set_fraction(fraction)
|
||||
|
||||
def _on_time(self, cur: int, tot: int):
|
||||
self._lbl_cur.setText(f"{cur//60}:{cur%60:02d}")
|
||||
self._lbl_tot.setText(f"{tot//60}:{tot%60:02d}")
|
||||
|
||||
def _on_levels(self, left: float, right: float):
|
||||
self._vu.set_levels(left, right)
|
||||
|
||||
def _on_song_ended(self):
|
||||
self._song_ended = True
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
self._vu.reset()
|
||||
|
||||
# Markér den afspillede sang
|
||||
self._playlist_panel.mark_played(self._current_idx)
|
||||
|
||||
# Synkroniser event-status til den gemte navngivne liste
|
||||
self._sync_event_status_to_playlist()
|
||||
|
||||
# Find første ikke-afspillede og ikke-skippede sang fra TOPPEN
|
||||
ni = self._playlist_panel.next_playable_idx()
|
||||
next_song = self._playlist_panel.get_song(ni) if ni is not None else None
|
||||
if next_song:
|
||||
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")
|
||||
else:
|
||||
# Danseliste afsluttet — nulstil liste-markering og synkroniser
|
||||
self._current_idx = -1
|
||||
self._playlist_panel._current_idx = -1
|
||||
self._playlist_panel._song_ended = False
|
||||
self._playlist_panel._refresh()
|
||||
self._sync_event_status_to_playlist()
|
||||
self._lbl_title.setText("— Danseliste afsluttet —")
|
||||
self._lbl_meta.setText("")
|
||||
self._lbl_dances.setText("")
|
||||
self._set_status("Danselisten er afsluttet")
|
||||
|
||||
def _sync_event_status_to_playlist(self):
|
||||
"""Gem event-fremgang (afspillet/sprunget over) til den navngivne liste."""
|
||||
try:
|
||||
pl_id = self._playlist_panel.get_named_playlist_id()
|
||||
if not pl_id:
|
||||
return
|
||||
statuses = self._playlist_panel.get_statuses()
|
||||
from local.local_db import get_db
|
||||
with get_db() as conn:
|
||||
for position, status in enumerate(statuses, start=1):
|
||||
conn.execute(
|
||||
"UPDATE playlist_songs SET status=? "
|
||||
"WHERE playlist_id=? AND position=?",
|
||||
(status, pl_id, position)
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def _on_state_changed(self, state: str):
|
||||
if state == "playing":
|
||||
self._btn_play.setText("⏸")
|
||||
elif state in ("paused", "stopped"):
|
||||
self._btn_play.setText("▶")
|
||||
if state == "stopped" and not self._song_ended:
|
||||
self._vu.reset()
|
||||
elif state == "demo_ended":
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
self._vu.reset()
|
||||
|
||||
def _on_seek(self, fraction: float):
|
||||
self._player.set_position(fraction)
|
||||
|
||||
def _on_volume(self, value: int):
|
||||
self._lbl_vol.setText(str(value))
|
||||
self._player.set_volume(value)
|
||||
from ui.settings_dialog import save_settings
|
||||
self._settings["volume"] = value
|
||||
save_settings(self._settings)
|
||||
|
||||
# ── Tema ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _toggle_theme(self):
|
||||
self._dark_theme = not self._dark_theme
|
||||
apply_theme(self._app_ref(), dark=self._dark_theme)
|
||||
self._theme_btn.setText(
|
||||
"● MØRKT TEMA" if not self._dark_theme else "☀ LYS TEMA"
|
||||
)
|
||||
self._vu.set_dark(self._dark_theme)
|
||||
|
||||
# ── Luk ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._save_window_state()
|
||||
self._player.stop()
|
||||
if self._scan_worker and self._scan_worker.isRunning():
|
||||
self._scan_worker.quit()
|
||||
self._scan_worker.wait(2000)
|
||||
try:
|
||||
if self._watcher:
|
||||
self._watcher.stop()
|
||||
except Exception:
|
||||
pass
|
||||
event.accept()
|
||||
@@ -1,59 +0,0 @@
|
||||
"""
|
||||
next_up_bar.py — Banner der vises når en sang er færdig.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QFrame, QHBoxLayout, QVBoxLayout, QLabel, QPushButton,
|
||||
)
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
|
||||
|
||||
class NextUpBar(QFrame):
|
||||
play_next_clicked = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("next_up_frame")
|
||||
self.hide()
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(16, 10, 16, 10)
|
||||
|
||||
# Tekst
|
||||
text_layout = QVBoxLayout()
|
||||
text_layout.setSpacing(2)
|
||||
|
||||
self._label = QLabel("NÆSTE SANG KLAR")
|
||||
self._label.setObjectName("next_up_label")
|
||||
text_layout.addWidget(self._label)
|
||||
|
||||
self._title = QLabel("—")
|
||||
self._title.setObjectName("next_up_title")
|
||||
text_layout.addWidget(self._title)
|
||||
|
||||
self._sub = QLabel("—")
|
||||
self._sub.setObjectName("next_up_sub")
|
||||
text_layout.addWidget(self._sub)
|
||||
|
||||
layout.addLayout(text_layout)
|
||||
layout.addStretch()
|
||||
|
||||
# Knap
|
||||
self._btn = QPushButton("▶ AFSPIL NÆSTE")
|
||||
self._btn.setObjectName("btn_play_next")
|
||||
self._btn.setFixedHeight(44)
|
||||
self._btn.setMinimumWidth(160)
|
||||
self._btn.clicked.connect(self.play_next_clicked.emit)
|
||||
layout.addWidget(self._btn)
|
||||
|
||||
def show_next(self, title: str, artist: str, dances: list[str]):
|
||||
dance_str = "Dans: " + ", ".join(dances) if dances else ""
|
||||
sub = f"{artist}{' · ' + dance_str if dance_str else ''}"
|
||||
self._title.setText(title)
|
||||
self._sub.setText(sub)
|
||||
self.show()
|
||||
|
||||
def hide_bar(self):
|
||||
self.hide()
|
||||
@@ -1,324 +0,0 @@
|
||||
"""
|
||||
playlist_manager.py — Dialog til danseliste-administration.
|
||||
Ny liste, gem, load og importer M3U/M3U8/tekst.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QListWidget, QListWidgetItem, QFileDialog,
|
||||
QMessageBox, QTabWidget, QWidget, QTextEdit,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
|
||||
|
||||
class PlaylistManagerDialog(QDialog):
|
||||
"""
|
||||
Fanebaseret dialog med tre faner:
|
||||
1. Gem aktuel liste
|
||||
2. Indlæs gemt liste
|
||||
3. Importer fra fil (M3U / M3U8 / tekst)
|
||||
"""
|
||||
playlist_loaded = pyqtSignal(str, list) # (navn, liste af dict)
|
||||
|
||||
def __init__(self, current_songs: list[dict], parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Danseliste-administration")
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(460)
|
||||
self._current_songs = current_songs
|
||||
self._build_ui()
|
||||
self._load_saved_playlists()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self._build_save_tab(), "💾 Gem liste")
|
||||
tabs.addTab(self._build_load_tab(), "📂 Indlæs liste")
|
||||
tabs.addTab(self._build_import_tab(), "📥 Importer")
|
||||
layout.addWidget(tabs)
|
||||
|
||||
btn_close = QPushButton("Luk")
|
||||
btn_close.clicked.connect(self.accept)
|
||||
row = QHBoxLayout()
|
||||
row.addStretch()
|
||||
row.addWidget(btn_close)
|
||||
layout.addLayout(row)
|
||||
|
||||
# ── Fane 1: Gem ───────────────────────────────────────────────────────────
|
||||
|
||||
def _build_save_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(10)
|
||||
|
||||
layout.addWidget(QLabel(f"Aktuel liste har {len(self._current_songs)} sange."))
|
||||
|
||||
layout.addWidget(QLabel("Navn på danselisten:"))
|
||||
self._save_name = QLineEdit()
|
||||
self._save_name.setPlaceholderText("f.eks. Sommer Event 2025")
|
||||
layout.addWidget(self._save_name)
|
||||
|
||||
btn_save = QPushButton("💾 Gem")
|
||||
btn_save.clicked.connect(self._save_playlist)
|
||||
layout.addWidget(btn_save)
|
||||
|
||||
self._save_status = QLabel("")
|
||||
self._save_status.setObjectName("result_count")
|
||||
layout.addWidget(self._save_status)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _save_playlist(self):
|
||||
name = self._save_name.text().strip()
|
||||
if not name:
|
||||
self._save_status.setText("Angiv et navn")
|
||||
return
|
||||
if not self._current_songs:
|
||||
self._save_status.setText("Danselisten er tom")
|
||||
return
|
||||
try:
|
||||
from local.local_db import create_playlist, add_song_to_playlist, get_db
|
||||
pl_id = create_playlist(name)
|
||||
for i, song in enumerate(self._current_songs, start=1):
|
||||
add_song_to_playlist(pl_id, song["id"], position=i)
|
||||
self._save_status.setText(f"✓ Gemt som \"{name}\"")
|
||||
self._load_saved_playlists()
|
||||
except Exception as e:
|
||||
self._save_status.setText(f"Fejl: {e}")
|
||||
|
||||
# ── Fane 2: Indlæs ────────────────────────────────────────────────────────
|
||||
|
||||
def _build_load_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
layout.addWidget(QLabel("Gemte danselister:"))
|
||||
self._pl_list = QListWidget()
|
||||
self._pl_list.itemDoubleClicked.connect(self._load_selected)
|
||||
layout.addWidget(self._pl_list)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_load = QPushButton("📂 Indlæs valgte")
|
||||
btn_load.clicked.connect(self._load_selected_btn)
|
||||
btn_delete = QPushButton("🗑 Slet valgte")
|
||||
btn_delete.clicked.connect(self._delete_selected)
|
||||
btn_row.addWidget(btn_load)
|
||||
btn_row.addWidget(btn_delete)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self._load_status = QLabel("")
|
||||
self._load_status.setObjectName("result_count")
|
||||
layout.addWidget(self._load_status)
|
||||
return tab
|
||||
|
||||
def _load_saved_playlists(self):
|
||||
if not hasattr(self, "_pl_list"):
|
||||
return
|
||||
self._pl_list.clear()
|
||||
try:
|
||||
from local.local_db import get_playlists
|
||||
for pl in get_playlists():
|
||||
item = QListWidgetItem(pl["name"])
|
||||
item.setData(Qt.ItemDataRole.UserRole, dict(pl))
|
||||
self._pl_list.addItem(item)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_selected_btn(self):
|
||||
item = self._pl_list.currentItem()
|
||||
if item:
|
||||
self._load_selected(item)
|
||||
|
||||
def _load_selected(self, item: QListWidgetItem):
|
||||
pl = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not pl:
|
||||
return
|
||||
try:
|
||||
from local.local_db import get_playlist_with_songs, get_db
|
||||
data = get_playlist_with_songs(pl["id"])
|
||||
songs = []
|
||||
for row in data.get("songs", []):
|
||||
with get_db() as conn:
|
||||
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.get("title", ""),
|
||||
"artist": row.get("artist", ""),
|
||||
"album": row.get("album", ""),
|
||||
"bpm": row.get("bpm", 0),
|
||||
"duration_sec": row.get("duration_sec", 0),
|
||||
"local_path": row.get("local_path", ""),
|
||||
"file_format": row.get("file_format", ""),
|
||||
"file_missing": bool(row.get("file_missing", False)),
|
||||
"dances": [d["dance_name"] for d in dances],
|
||||
})
|
||||
self.playlist_loaded.emit(pl["name"], songs)
|
||||
self._load_status.setText(f"✓ Indlæst: {pl['name']} ({len(songs)} sange)")
|
||||
except Exception as e:
|
||||
self._load_status.setText(f"Fejl: {e}")
|
||||
|
||||
def _delete_selected(self):
|
||||
item = self._pl_list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
pl = item.data(Qt.ItemDataRole.UserRole)
|
||||
reply = QMessageBox.question(
|
||||
self, "Slet liste",
|
||||
f"Slet danselisten \"{pl['name']}\"?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
try:
|
||||
from local.local_db import get_db
|
||||
with get_db() as conn:
|
||||
conn.execute("DELETE FROM playlists WHERE id=?", (pl["id"],))
|
||||
self._load_saved_playlists()
|
||||
except Exception as e:
|
||||
self._load_status.setText(f"Fejl: {e}")
|
||||
|
||||
# ── Fane 3: Importer ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_import_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(8)
|
||||
|
||||
lbl = QLabel(
|
||||
"Importer fra M3U, M3U8 eller en tekstfil med én filsti per linje.\n"
|
||||
"Sange der ikke er i biblioteket forsøges tilføjet automatisk."
|
||||
)
|
||||
lbl.setWordWrap(True)
|
||||
lbl.setObjectName("result_count")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
btn_browse = QPushButton("📂 Vælg fil...")
|
||||
btn_browse.clicked.connect(self._browse_import)
|
||||
layout.addWidget(btn_browse)
|
||||
|
||||
layout.addWidget(QLabel("Eller indsæt filstier direkte (én per linje):"))
|
||||
self._import_text = QTextEdit()
|
||||
self._import_text.setPlaceholderText(
|
||||
"/sti/til/sang1.mp3\n/sti/til/sang2.flac\n..."
|
||||
)
|
||||
self._import_text.setMaximumHeight(120)
|
||||
layout.addWidget(self._import_text)
|
||||
|
||||
layout.addWidget(QLabel("Navn på den importerede liste:"))
|
||||
self._import_name = QLineEdit()
|
||||
self._import_name.setPlaceholderText("Importeret liste")
|
||||
layout.addWidget(self._import_name)
|
||||
|
||||
btn_import = QPushButton("📥 Importer")
|
||||
btn_import.clicked.connect(self._do_import)
|
||||
layout.addWidget(btn_import)
|
||||
|
||||
self._import_status = QLabel("")
|
||||
self._import_status.setObjectName("result_count")
|
||||
self._import_status.setWordWrap(True)
|
||||
layout.addWidget(self._import_status)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _browse_import(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Vælg afspilningsliste",
|
||||
filter="Afspilningslister (*.m3u *.m3u8 *.txt);;Alle filer (*)"
|
||||
)
|
||||
if path:
|
||||
self._import_name.setText(Path(path).stem)
|
||||
paths = self._parse_playlist_file(path)
|
||||
self._import_text.setPlainText("\n".join(paths))
|
||||
|
||||
def _parse_playlist_file(self, path: str) -> list[str]:
|
||||
"""Parser M3U, M3U8 og tekst — returnerer liste af filstier."""
|
||||
paths = []
|
||||
base_dir = str(Path(path).parent)
|
||||
try:
|
||||
enc = "utf-8-sig" if path.lower().endswith(".m3u8") else "latin-1"
|
||||
with open(path, encoding=enc, errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# Gør relativ sti absolut
|
||||
if not os.path.isabs(line):
|
||||
line = os.path.join(base_dir, line)
|
||||
paths.append(line)
|
||||
except Exception as e:
|
||||
self._import_status.setText(f"Læsefejl: {e}")
|
||||
return paths
|
||||
|
||||
def _do_import(self):
|
||||
raw = self._import_text.toPlainText().strip()
|
||||
if not raw:
|
||||
self._import_status.setText("Ingen filstier angivet")
|
||||
return
|
||||
|
||||
name = self._import_name.text().strip() or "Importeret liste"
|
||||
paths = [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
|
||||
found = []
|
||||
missing = []
|
||||
|
||||
try:
|
||||
from local.local_db import get_song_by_path, upsert_song, get_db
|
||||
from local.tag_reader import read_tags, is_supported
|
||||
|
||||
for p in paths:
|
||||
row = get_song_by_path(p)
|
||||
if row:
|
||||
# Hent danse
|
||||
with get_db() as conn:
|
||||
dances = conn.execute(
|
||||
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
|
||||
(row["id"],)
|
||||
).fetchall()
|
||||
found.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],
|
||||
})
|
||||
elif os.path.exists(p) and is_supported(p):
|
||||
# Filen er ikke scannet endnu — høst tags og tilføj
|
||||
tags = read_tags(p)
|
||||
song_id = upsert_song(tags)
|
||||
found.append({
|
||||
"id": song_id,
|
||||
"title": tags.get("title", Path(p).stem),
|
||||
"artist": tags.get("artist", ""),
|
||||
"album": tags.get("album", ""),
|
||||
"bpm": tags.get("bpm", 0),
|
||||
"duration_sec": tags.get("duration_sec", 0),
|
||||
"local_path": p,
|
||||
"file_format": tags.get("file_format", ""),
|
||||
"file_missing": False,
|
||||
"dances": tags.get("dances", []),
|
||||
})
|
||||
else:
|
||||
missing.append(p)
|
||||
|
||||
if found:
|
||||
self.playlist_loaded.emit(name, found)
|
||||
status = f"✓ Importeret {len(found)} sange som \"{name}\""
|
||||
if missing:
|
||||
status += f"\n⚠ {len(missing)} filer ikke fundet"
|
||||
self._import_status.setText(status)
|
||||
else:
|
||||
self._import_status.setText("Ingen filer fundet — tjek stierne")
|
||||
|
||||
except Exception as e:
|
||||
self._import_status.setText(f"Importfejl: {e}")
|
||||
@@ -1,538 +0,0 @@
|
||||
"""
|
||||
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._named_playlist_id: int | None = None # den indlæste/gemte navngivne liste
|
||||
self._build_ui()
|
||||
self.setAcceptDrops(True)
|
||||
# Autogem-timer — venter 800ms efter sidst ændring
|
||||
self._autosave_timer = QTimer(self)
|
||||
self._autosave_timer.setSingleShot(True)
|
||||
self._autosave_timer.setInterval(800)
|
||||
self._autosave_timer.timeout.connect(self._autosave)
|
||||
# Event-state gem — hurtig, kritisk for genopstart efter strømsvigt
|
||||
self._event_state_timer = QTimer(self)
|
||||
self._event_state_timer.setSingleShot(True)
|
||||
self._event_state_timer.setInterval(300)
|
||||
self._event_state_timer.timeout.connect(self._save_event_state)
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# ── Header med titel ──────────────────────────────────────────────────
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(10, 6, 10, 6)
|
||||
self._title_label = QLabel("DANSELISTE")
|
||||
self._title_label.setObjectName("section_title")
|
||||
header.addWidget(self._title_label)
|
||||
layout.addLayout(header)
|
||||
|
||||
# ── Ny / Gem / Hent knapper ───────────────────────────────────────────
|
||||
toolbar = QHBoxLayout()
|
||||
toolbar.setContentsMargins(8, 2, 8, 4)
|
||||
toolbar.setSpacing(4)
|
||||
|
||||
btn_new = QPushButton("✚ Ny")
|
||||
btn_new.setFixedHeight(26)
|
||||
btn_new.setToolTip("Opret en ny tom danseliste")
|
||||
btn_new.clicked.connect(self._new_playlist)
|
||||
toolbar.addWidget(btn_new)
|
||||
|
||||
btn_save = QPushButton("💾 Gem som...")
|
||||
btn_save.setFixedHeight(26)
|
||||
btn_save.setToolTip("Gem aktuel liste med et navn")
|
||||
btn_save.clicked.connect(self._save_as)
|
||||
toolbar.addWidget(btn_save)
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
def _trigger_event_state_save(self):
|
||||
self._event_state_timer.start()
|
||||
|
||||
def restore_event_state(self) -> bool:
|
||||
"""Gendan gemt event-fremgang. Returnerer True hvis gendannet."""
|
||||
try:
|
||||
from local.local_db import load_event_state
|
||||
result = load_event_state()
|
||||
if not result:
|
||||
return False
|
||||
idx, statuses = result
|
||||
if len(statuses) != len(self._songs):
|
||||
return False # listen er ændret siden sidst
|
||||
self._statuses = statuses
|
||||
self._current_idx = idx
|
||||
self._song_ended = False
|
||||
self._refresh()
|
||||
return True
|
||||
except Exception as e:
|
||||
pass
|
||||
return False
|
||||
|
||||
def get_named_playlist_id(self) -> int | None:
|
||||
return self._named_playlist_id
|
||||
|
||||
def next_playable_idx(self) -> int | None:
|
||||
"""Find første sang fra toppen der ikke er '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")
|
||||
pass
|
||||
|
||||
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:
|
||||
pass
|
||||
return False
|
||||
|
||||
# ── Ny / Gem som / Hent ───────────────────────────────────────────────────
|
||||
|
||||
def _new_playlist(self):
|
||||
if self._songs:
|
||||
reply = QMessageBox.question(
|
||||
self, "Ny danseliste",
|
||||
"Ryd den aktuelle liste og start forfra?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self._songs = []
|
||||
self._statuses = []
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._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._named_playlist_id = pl_id
|
||||
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, ps.status FROM playlist_songs ps
|
||||
JOIN songs s ON s.id = ps.song_id
|
||||
WHERE ps.playlist_id=? ORDER BY ps.position
|
||||
""", (pl_id,)).fetchall()
|
||||
songs = []
|
||||
statuses = []
|
||||
for row in songs_raw:
|
||||
dances = conn.execute(
|
||||
"SELECT 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],
|
||||
})
|
||||
statuses.append(row["status"] or "pending")
|
||||
self._songs = songs
|
||||
self._statuses = statuses
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._named_playlist_id = pl_id
|
||||
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}")
|
||||
self._lbl_autosave.setText("✓ gendannet")
|
||||
self._refresh()
|
||||
self._trigger_autosave()
|
||||
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)
|
||||
@@ -1,64 +0,0 @@
|
||||
"""
|
||||
scan_worker.py — Kører fuld biblioteks-scanning i en baggrundstråd
|
||||
så GUI ikke fryser.
|
||||
"""
|
||||
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
|
||||
class ScanWorker(QThread):
|
||||
"""
|
||||
Kører _full_scan_all() i en baggrundstråd.
|
||||
Sender status-opdateringer undervejs.
|
||||
"""
|
||||
status_update = pyqtSignal(str) # løbende statusbeskeder
|
||||
scan_done = pyqtSignal(int) # antal behandlede filer
|
||||
|
||||
def __init__(self, watcher, parent=None):
|
||||
super().__init__(parent)
|
||||
self._watcher = watcher
|
||||
self._total = 0
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from local.local_db import get_libraries
|
||||
from local.tag_reader import is_supported
|
||||
import os
|
||||
libraries = get_libraries(active_only=True)
|
||||
|
||||
if not libraries:
|
||||
self.status_update.emit("Ingen biblioteker konfigureret")
|
||||
self.scan_done.emit(0)
|
||||
return
|
||||
|
||||
total_processed = 0
|
||||
for lib in libraries:
|
||||
from pathlib import Path
|
||||
path = Path(lib["path"])
|
||||
name = path.name
|
||||
|
||||
if not path.exists():
|
||||
self.status_update.emit(f"⚠ Mappe ikke fundet: {path}")
|
||||
continue
|
||||
|
||||
self.status_update.emit(f"Scanner: {name}...")
|
||||
|
||||
# Tæl filer med os.walk — håndterer permission-fejl sikkert
|
||||
count = 0
|
||||
for dirpath, _, filenames in os.walk(str(path), followlinks=False):
|
||||
for f in filenames:
|
||||
if is_supported(f):
|
||||
count += 1
|
||||
|
||||
self.status_update.emit(f"Scanner: {name} ({count} filer)...")
|
||||
|
||||
# Kør scanning
|
||||
self._watcher._full_scan_library(lib["id"], str(path))
|
||||
total_processed += count
|
||||
|
||||
self.status_update.emit(f"Scan færdig — {total_processed} filer gennemgået")
|
||||
self.scan_done.emit(total_processed)
|
||||
|
||||
except Exception as e:
|
||||
self.status_update.emit(f"Scan fejl: {e}")
|
||||
self.scan_done.emit(0)
|
||||
@@ -1,281 +0,0 @@
|
||||
"""
|
||||
settings_dialog.py — Indstillinger for LineDance Player.
|
||||
Gemmes via QSettings og læses ved opstart.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QComboBox, QSpinBox, QCheckBox, QFrame,
|
||||
QTabWidget, QWidget, QFileDialog, QGroupBox, QFormLayout,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QSettings
|
||||
|
||||
|
||||
SETTINGS_KEY_THEME = "appearance/dark_theme"
|
||||
SETTINGS_KEY_DEMO_SEC = "playback/demo_seconds"
|
||||
SETTINGS_KEY_DEMO_FADE = "playback/demo_fade_seconds"
|
||||
SETTINGS_KEY_VOLUME = "playback/volume"
|
||||
SETTINGS_KEY_MAIL_CLIENT = "mail/client"
|
||||
SETTINGS_KEY_MAIL_PATH = "mail/custom_path"
|
||||
SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
|
||||
SETTINGS_KEY_USERNAME = "online/username"
|
||||
SETTINGS_KEY_PASSWORD = "online/password"
|
||||
|
||||
|
||||
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, ""),
|
||||
}
|
||||
|
||||
|
||||
def save_settings(values: dict):
|
||||
s = QSettings("LineDance", "Player")
|
||||
s.setValue(SETTINGS_KEY_THEME, values.get("dark_theme", True))
|
||||
s.setValue(SETTINGS_KEY_DEMO_SEC, values.get("demo_seconds", 10))
|
||||
s.setValue(SETTINGS_KEY_DEMO_FADE, values.get("demo_fade_seconds", 5))
|
||||
s.setValue(SETTINGS_KEY_VOLUME, values.get("volume", 78))
|
||||
s.setValue(SETTINGS_KEY_MAIL_CLIENT, values.get("mail_client", "auto"))
|
||||
s.setValue(SETTINGS_KEY_MAIL_PATH, values.get("mail_path", ""))
|
||||
s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False))
|
||||
s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
|
||||
s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", ""))
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Indstillinger")
|
||||
self.setMinimumWidth(480)
|
||||
self.setModal(True)
|
||||
self._values = load_settings()
|
||||
self._build_ui()
|
||||
self._populate()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(12)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self._build_appearance_tab(), "🎨 Udseende")
|
||||
tabs.addTab(self._build_playback_tab(), "▶ Afspilning")
|
||||
tabs.addTab(self._build_mail_tab(), "✉ Mail")
|
||||
tabs.addTab(self._build_online_tab(), "🌐 Online")
|
||||
layout.addWidget(tabs)
|
||||
|
||||
# Knapper
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
btn_cancel = QPushButton("Annuller")
|
||||
btn_cancel.clicked.connect(self.reject)
|
||||
btn_row.addWidget(btn_cancel)
|
||||
btn_save = QPushButton("💾 Gem indstillinger")
|
||||
btn_save.setObjectName("btn_play")
|
||||
btn_save.setDefault(True)
|
||||
btn_save.clicked.connect(self._save_and_close)
|
||||
btn_row.addWidget(btn_save)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
# ── Fane: Udseende ────────────────────────────────────────────────────────
|
||||
|
||||
def _build_appearance_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(12)
|
||||
|
||||
grp = QGroupBox("Standard tema")
|
||||
grp_layout = QVBoxLayout(grp)
|
||||
|
||||
self._chk_dark = QCheckBox("Start med mørkt tema")
|
||||
grp_layout.addWidget(self._chk_dark)
|
||||
|
||||
note = QLabel("Du kan altid skifte tema mens programmet kører via topbar-knappen.")
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
grp_layout.addWidget(note)
|
||||
layout.addWidget(grp)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
# ── Fane: Afspilning ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_playback_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(12)
|
||||
|
||||
grp = QGroupBox("Forspil (▶ N SEK knappen)")
|
||||
grp_layout = QFormLayout(grp)
|
||||
|
||||
self._spin_demo = QSpinBox()
|
||||
self._spin_demo.setRange(3, 60)
|
||||
self._spin_demo.setSuffix(" sekunder")
|
||||
self._spin_demo.setFixedWidth(140)
|
||||
grp_layout.addRow("Forspil-længde:", self._spin_demo)
|
||||
|
||||
self._spin_fade = QSpinBox()
|
||||
self._spin_fade.setRange(0, 15)
|
||||
self._spin_fade.setSuffix(" sekunder (0 = ingen fade)")
|
||||
self._spin_fade.setFixedWidth(220)
|
||||
self._spin_fade.setToolTip(
|
||||
"Fade-out tilføjes til forspillets længde.\n"
|
||||
"F.eks. 10 sek forspil + 5 sek fade = 15 sek total.\n"
|
||||
"Sæt til 0 for ingen fade."
|
||||
)
|
||||
grp_layout.addRow("Fade-ud:", self._spin_fade)
|
||||
|
||||
note = QLabel(
|
||||
"Forspillet afspiller begyndelsen af sangen så arrangøren kan bekræfte\n"
|
||||
"at det er den rigtige sang og dans inden eventet starter.\n"
|
||||
"Fade-ud tilføjes oven i forspillets længde og fades logaritmisk."
|
||||
)
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
grp_layout.addRow(note)
|
||||
layout.addWidget(grp)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
# ── Fane: Mail ────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_mail_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(12)
|
||||
|
||||
grp = QGroupBox("Mailklient")
|
||||
grp_layout = QFormLayout(grp)
|
||||
|
||||
self._mail_combo = QComboBox()
|
||||
self._mail_combo.addItem("Auto-detekter (Thunderbird → Outlook → mailto:)", "auto")
|
||||
self._mail_combo.addItem("Thunderbird", "thunderbird")
|
||||
self._mail_combo.addItem("Outlook (Windows)", "outlook")
|
||||
self._mail_combo.addItem("Brugerdefineret sti", "custom")
|
||||
self._mail_combo.addItem("Kun mailto: (ingen vedhæftning)", "mailto")
|
||||
self._mail_combo.currentIndexChanged.connect(self._on_mail_combo_changed)
|
||||
grp_layout.addRow("Klient:", self._mail_combo)
|
||||
|
||||
path_row = QHBoxLayout()
|
||||
self._mail_path = QLineEdit()
|
||||
self._mail_path.setPlaceholderText("/usr/bin/thunderbird eller C:\\...\\thunderbird.exe")
|
||||
path_row.addWidget(self._mail_path)
|
||||
btn_browse = QPushButton("...")
|
||||
btn_browse.setFixedWidth(32)
|
||||
btn_browse.clicked.connect(self._browse_mail_path)
|
||||
path_row.addWidget(btn_browse)
|
||||
self._mail_path_row_widget = QWidget()
|
||||
self._mail_path_row_widget.setLayout(path_row)
|
||||
grp_layout.addRow("Sti:", self._mail_path_row_widget)
|
||||
|
||||
note = QLabel(
|
||||
"Med Thunderbird og Outlook åbnes et nyt compose-vindue med filen vedhæftet.\n"
|
||||
"mailto: åbner standard-mailprogrammet men uden automatisk vedhæftning."
|
||||
)
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
grp_layout.addRow(note)
|
||||
layout.addWidget(grp)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _on_mail_combo_changed(self, idx: int):
|
||||
is_custom = self._mail_combo.currentData() == "custom"
|
||||
self._mail_path_row_widget.setVisible(is_custom)
|
||||
|
||||
def _browse_mail_path(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Vælg mailklient")
|
||||
if path:
|
||||
self._mail_path.setText(path)
|
||||
|
||||
# ── Fane: Online ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build_online_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(12)
|
||||
|
||||
grp = QGroupBox("Automatisk login ved opstart")
|
||||
grp_layout = QFormLayout(grp)
|
||||
|
||||
self._chk_auto_login = QCheckBox("Log automatisk ind når programmet starter")
|
||||
self._chk_auto_login.stateChanged.connect(self._on_auto_login_changed)
|
||||
grp_layout.addRow(self._chk_auto_login)
|
||||
|
||||
self._user_input = QLineEdit()
|
||||
self._user_input.setPlaceholderText("dit-brugernavn")
|
||||
grp_layout.addRow("Brugernavn:", self._user_input)
|
||||
|
||||
self._pass_input = QLineEdit()
|
||||
self._pass_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self._pass_input.setPlaceholderText("••••••••")
|
||||
grp_layout.addRow("Kodeord:", self._pass_input)
|
||||
|
||||
note = QLabel(
|
||||
"⚠ Kodeordet gemmes lokalt på denne computer.\n"
|
||||
"Brug kun dette på en personlig maskine."
|
||||
)
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
grp_layout.addRow(note)
|
||||
layout.addWidget(grp)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _on_auto_login_changed(self, state: int):
|
||||
enabled = state == Qt.CheckState.Checked.value
|
||||
self._user_input.setEnabled(enabled)
|
||||
self._pass_input.setEnabled(enabled)
|
||||
|
||||
# ── Populer fra gemte værdier ─────────────────────────────────────────────
|
||||
|
||||
def _populate(self):
|
||||
v = self._values
|
||||
self._chk_dark.setChecked(v.get("dark_theme", True))
|
||||
self._spin_demo.setValue(v.get("demo_seconds", 10))
|
||||
self._spin_fade.setValue(v.get("demo_fade_seconds", 5))
|
||||
|
||||
# Mail
|
||||
client = v.get("mail_client", "auto")
|
||||
for i in range(self._mail_combo.count()):
|
||||
if self._mail_combo.itemData(i) == client:
|
||||
self._mail_combo.setCurrentIndex(i)
|
||||
break
|
||||
self._mail_path.setText(v.get("mail_path", ""))
|
||||
self._on_mail_combo_changed(self._mail_combo.currentIndex())
|
||||
|
||||
# Online
|
||||
auto = v.get("auto_login", False)
|
||||
self._chk_auto_login.setChecked(auto)
|
||||
self._user_input.setText(v.get("username", ""))
|
||||
self._pass_input.setText(v.get("password", ""))
|
||||
self._user_input.setEnabled(auto)
|
||||
self._pass_input.setEnabled(auto)
|
||||
|
||||
# ── 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(),
|
||||
"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(),
|
||||
}
|
||||
save_settings(values)
|
||||
self._values = values
|
||||
self.accept()
|
||||
|
||||
def get_values(self) -> dict:
|
||||
return self._values
|
||||
@@ -1,427 +0,0 @@
|
||||
"""
|
||||
tag_editor.py — Simpel og robust dans-tag editor.
|
||||
|
||||
Danse gemmes til MP3-filen via mutagen.
|
||||
Niveau og alternativ-danse gemmes til SQLite.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QComboBox, QWidget, QMessageBox, QGroupBox,
|
||||
QScrollArea, QFrame, QGridLayout,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, QStringListModel
|
||||
from PyQt6.QtWidgets import QCompleter
|
||||
|
||||
|
||||
# ── Autoudfyld søgefelt ───────────────────────────────────────────────────────
|
||||
|
||||
class AutoLineEdit(QLineEdit):
|
||||
def __init__(self, placeholder="", parent=None):
|
||||
super().__init__(parent)
|
||||
self.setPlaceholderText(placeholder)
|
||||
self._model = QStringListModel()
|
||||
comp = QCompleter(self._model, self)
|
||||
comp.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
comp.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
|
||||
comp.setMaxVisibleItems(10)
|
||||
self.setCompleter(comp)
|
||||
t = QTimer(self)
|
||||
t.setSingleShot(True)
|
||||
t.setInterval(200)
|
||||
t.timeout.connect(self._suggest)
|
||||
self.textChanged.connect(lambda _: t.start())
|
||||
self._timer = t
|
||||
|
||||
def _suggest(self):
|
||||
prefix = self.text().strip()
|
||||
if not prefix:
|
||||
return
|
||||
try:
|
||||
from local.local_db import get_dance_name_suggestions
|
||||
self._model.setStringList(get_dance_name_suggestions(prefix))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Niveau dropdown ───────────────────────────────────────────────────────────
|
||||
|
||||
def make_level_combo(levels: list, current_id=None) -> QComboBox:
|
||||
cb = QComboBox()
|
||||
cb.addItem("— intet niveau —", None)
|
||||
for lvl in levels:
|
||||
cb.addItem(lvl["name"], lvl["id"])
|
||||
if current_id is not None:
|
||||
for i in range(cb.count()):
|
||||
if cb.itemData(i) == current_id:
|
||||
cb.setCurrentIndex(i)
|
||||
break
|
||||
cb.setFixedWidth(130)
|
||||
return cb
|
||||
|
||||
|
||||
# ── Hoved-dialog ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TagEditorDialog(QDialog):
|
||||
def __init__(self, song: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self._song = song
|
||||
self._levels = []
|
||||
self._dances = [] # list of {name, level_id, db_id}
|
||||
self._alts = [] # list of {name, level_id, note}
|
||||
|
||||
self.setWindowTitle(f"Rediger tags — {song.get('title', '')}")
|
||||
self.setMinimumSize(720, 500)
|
||||
self.resize(820, 580)
|
||||
|
||||
self._load_levels()
|
||||
self._load_existing()
|
||||
self._build_ui()
|
||||
|
||||
# ── Indlæsning ────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_levels(self):
|
||||
try:
|
||||
from local.local_db import get_dance_levels
|
||||
self._levels = [dict(r) for r in get_dance_levels()]
|
||||
except Exception as e:
|
||||
pass # log fejl
|
||||
self._levels = []
|
||||
|
||||
def _load_existing(self):
|
||||
"""Indlæs eksisterende danse og alternativer fra DB."""
|
||||
try:
|
||||
from local.local_db import new_conn
|
||||
conn = new_conn()
|
||||
song_id = self._song.get("id")
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, dance_name, level_id FROM song_dances "
|
||||
"WHERE song_id=? ORDER BY dance_order",
|
||||
(song_id,)
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
|
||||
for row in rows:
|
||||
alts = conn.execute(
|
||||
"SELECT alt_dance_name, level_id, note FROM dance_alternatives "
|
||||
"WHERE song_dance_id=? AND source='local'",
|
||||
(row["id"],)
|
||||
).fetchall()
|
||||
self._dances.append({
|
||||
"name": row["dance_name"],
|
||||
"level_id": row["level_id"],
|
||||
"db_id": row["id"],
|
||||
})
|
||||
for alt in alts:
|
||||
self._alts.append({
|
||||
"name": alt["alt_dance_name"],
|
||||
"level_id": alt["level_id"],
|
||||
"note": alt["note"] or "",
|
||||
})
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
pass # log fejl
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Sang-info
|
||||
info = QFrame()
|
||||
info.setObjectName("track_display")
|
||||
il = QHBoxLayout(info)
|
||||
il.setContentsMargins(10, 8, 10, 8)
|
||||
lbl_t = QLabel(self._song.get("title", "—"))
|
||||
lbl_t.setObjectName("track_title")
|
||||
il.addWidget(lbl_t, stretch=1)
|
||||
fmt = self._song.get("file_format", "").lower()
|
||||
can_write = fmt in ("mp3", "flac", "ogg", "opus", "m4a")
|
||||
lbl_w = QLabel("✓ Danse skrives til filen" if can_write
|
||||
else "⚠ Dette format understøtter ikke fil-skrivning")
|
||||
lbl_w.setObjectName("result_count")
|
||||
il.addWidget(lbl_w)
|
||||
layout.addWidget(info)
|
||||
|
||||
# To kolonner
|
||||
cols = QHBoxLayout()
|
||||
cols.setSpacing(12)
|
||||
cols.addWidget(self._build_dances_panel())
|
||||
cols.addWidget(self._build_alts_panel())
|
||||
layout.addLayout(cols, stretch=1)
|
||||
|
||||
# Knapper
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
btn_cancel = QPushButton("Annuller")
|
||||
btn_cancel.clicked.connect(self.reject)
|
||||
btn_row.addWidget(btn_cancel)
|
||||
btn_save = QPushButton("💾 Gem tags")
|
||||
btn_save.setObjectName("btn_play")
|
||||
btn_save.clicked.connect(self._save)
|
||||
btn_row.addWidget(btn_save)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
def _build_dances_panel(self) -> QGroupBox:
|
||||
grp = QGroupBox("Danse")
|
||||
layout = QVBoxLayout(grp)
|
||||
|
||||
# Scroll-område til eksisterende danse
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
container = QWidget()
|
||||
self._dance_layout = QVBoxLayout(container)
|
||||
self._dance_layout.setSpacing(4)
|
||||
self._dance_layout.addStretch()
|
||||
scroll.setWidget(container)
|
||||
layout.addWidget(scroll, stretch=1)
|
||||
|
||||
# Udfyld med eksisterende
|
||||
self._dance_rows = []
|
||||
for d in self._dances:
|
||||
self._add_dance_row(d["name"], d["level_id"])
|
||||
|
||||
# Tilføj-linje
|
||||
add_row = QHBoxLayout()
|
||||
self._new_dance = AutoLineEdit("Ny dans...", self)
|
||||
self._new_dance.returnPressed.connect(self._on_add_dance)
|
||||
add_row.addWidget(self._new_dance)
|
||||
btn = QPushButton("+ Tilføj")
|
||||
btn.setFixedWidth(70)
|
||||
btn.clicked.connect(self._on_add_dance)
|
||||
add_row.addWidget(btn)
|
||||
layout.addLayout(add_row)
|
||||
|
||||
return grp
|
||||
|
||||
def _add_dance_row(self, name="", level_id=None):
|
||||
row_widget = QWidget()
|
||||
row_layout = QHBoxLayout(row_widget)
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
row_layout.setSpacing(4)
|
||||
|
||||
name_edit = AutoLineEdit("Dans...", self)
|
||||
name_edit.setText(name)
|
||||
row_layout.addWidget(name_edit, stretch=1)
|
||||
|
||||
level_cb = make_level_combo(self._levels, level_id)
|
||||
row_layout.addWidget(level_cb)
|
||||
|
||||
btn_rm = QPushButton("✕")
|
||||
btn_rm.setFixedSize(24, 24)
|
||||
row_layout.addWidget(btn_rm)
|
||||
|
||||
# Indsæt FØR stretch
|
||||
idx = self._dance_layout.count() - 1
|
||||
self._dance_layout.insertWidget(idx, row_widget)
|
||||
|
||||
entry = {"widget": row_widget, "name": name_edit, "level": level_cb}
|
||||
self._dance_rows.append(entry)
|
||||
btn_rm.clicked.connect(lambda: self._remove_dance_row(entry))
|
||||
|
||||
def _remove_dance_row(self, entry):
|
||||
self._dance_rows.remove(entry)
|
||||
entry["widget"].deleteLater()
|
||||
|
||||
def _on_add_dance(self):
|
||||
name = self._new_dance.text().strip()
|
||||
if name:
|
||||
self._add_dance_row(name)
|
||||
self._new_dance.clear()
|
||||
|
||||
def _build_alts_panel(self) -> QGroupBox:
|
||||
grp = QGroupBox("Alternativ-danse")
|
||||
layout = QVBoxLayout(grp)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
container = QWidget()
|
||||
self._alt_layout = QVBoxLayout(container)
|
||||
self._alt_layout.setSpacing(4)
|
||||
self._alt_layout.addStretch()
|
||||
scroll.setWidget(container)
|
||||
layout.addWidget(scroll, stretch=1)
|
||||
|
||||
self._alt_rows = []
|
||||
for a in self._alts:
|
||||
self._add_alt_row(a["name"], a["level_id"], a["note"])
|
||||
|
||||
add_row = QHBoxLayout()
|
||||
self._new_alt = AutoLineEdit("Nyt alternativ...", self)
|
||||
self._new_alt.returnPressed.connect(self._on_add_alt)
|
||||
add_row.addWidget(self._new_alt)
|
||||
btn = QPushButton("+ Tilføj")
|
||||
btn.setFixedWidth(70)
|
||||
btn.clicked.connect(self._on_add_alt)
|
||||
add_row.addWidget(btn)
|
||||
layout.addLayout(add_row)
|
||||
|
||||
return grp
|
||||
|
||||
def _add_alt_row(self, name="", level_id=None, note=""):
|
||||
row_widget = QWidget()
|
||||
row_layout = QHBoxLayout(row_widget)
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
row_layout.setSpacing(4)
|
||||
|
||||
lbl = QLabel("→")
|
||||
lbl.setObjectName("track_meta")
|
||||
row_layout.addWidget(lbl)
|
||||
|
||||
name_edit = AutoLineEdit("Dans...", self)
|
||||
name_edit.setText(name)
|
||||
row_layout.addWidget(name_edit, stretch=1)
|
||||
|
||||
level_cb = make_level_combo(self._levels, level_id)
|
||||
row_layout.addWidget(level_cb)
|
||||
|
||||
note_edit = QLineEdit()
|
||||
note_edit.setPlaceholderText("note...")
|
||||
note_edit.setText(note)
|
||||
note_edit.setFixedWidth(80)
|
||||
row_layout.addWidget(note_edit)
|
||||
|
||||
btn_rm = QPushButton("✕")
|
||||
btn_rm.setFixedSize(24, 24)
|
||||
row_layout.addWidget(btn_rm)
|
||||
|
||||
idx = self._alt_layout.count() - 1
|
||||
self._alt_layout.insertWidget(idx, row_widget)
|
||||
|
||||
entry = {"widget": row_widget, "name": name_edit,
|
||||
"level": level_cb, "note": note_edit}
|
||||
self._alt_rows.append(entry)
|
||||
btn_rm.clicked.connect(lambda: self._remove_alt_row(entry))
|
||||
|
||||
def _remove_alt_row(self, entry):
|
||||
self._alt_rows.remove(entry)
|
||||
entry["widget"].deleteLater()
|
||||
|
||||
def _on_add_alt(self):
|
||||
name = self._new_alt.text().strip()
|
||||
if name:
|
||||
self._add_alt_row(name)
|
||||
self._new_alt.clear()
|
||||
|
||||
# ── Gem ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _save(self):
|
||||
import uuid
|
||||
song_id = self._song.get("id")
|
||||
local_path = self._song.get("local_path", "")
|
||||
|
||||
# Saml data fra UI
|
||||
dances = []
|
||||
for row in self._dance_rows:
|
||||
name = row["name"].text().strip()
|
||||
if name:
|
||||
dances.append((name, row["level"].currentData()))
|
||||
|
||||
alts = []
|
||||
for row in self._alt_rows:
|
||||
name = row["name"].text().strip()
|
||||
if name:
|
||||
alts.append((name, row["level"].currentData(),
|
||||
row["note"].text().strip()))
|
||||
|
||||
try:
|
||||
from local.local_db import new_conn
|
||||
from local.tag_reader import write_dances, can_write_dances
|
||||
import uuid
|
||||
|
||||
|
||||
conn = new_conn()
|
||||
|
||||
# Slet gammelt
|
||||
old = conn.execute(
|
||||
"SELECT id FROM song_dances WHERE song_id=?", (song_id,)
|
||||
).fetchall()
|
||||
for o in old:
|
||||
conn.execute(
|
||||
"DELETE FROM dance_alternatives WHERE song_dance_id=?",
|
||||
(o["id"],)
|
||||
)
|
||||
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
|
||||
|
||||
# Indsæt danse
|
||||
dance_ids = []
|
||||
for i, (name, level_id) in enumerate(dances, 1):
|
||||
conn.execute(
|
||||
"INSERT INTO song_dances "
|
||||
"(song_id, dance_name, dance_order, level_id) VALUES (?,?,?,?)",
|
||||
(song_id, name, i, level_id)
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM song_dances "
|
||||
"WHERE song_id=? AND dance_order=?", (song_id, i)
|
||||
).fetchone()
|
||||
dance_ids.append(row["id"])
|
||||
|
||||
# Opdater dance_names
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM dance_names WHERE name=? COLLATE NOCASE",
|
||||
(name,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE dance_names SET use_count=use_count+1 WHERE id=?",
|
||||
(existing["id"],)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO dance_names (name, source, use_count) "
|
||||
"VALUES (?,?,1)", (name, "local")
|
||||
)
|
||||
|
||||
# Indsæt alternativer på første dans
|
||||
if dance_ids and alts:
|
||||
fid = dance_ids[0]
|
||||
for alt_name, alt_level, alt_note in alts:
|
||||
conn.execute(
|
||||
"INSERT INTO dance_alternatives "
|
||||
"(id, song_dance_id, alt_dance_name, level_id, note, source) "
|
||||
"VALUES (?,?,?,?,?,'local')",
|
||||
(str(uuid.uuid4()), fid, alt_name, alt_level, alt_note)
|
||||
)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM dance_names WHERE name=? COLLATE NOCASE",
|
||||
(alt_name,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE dance_names SET use_count=use_count+1 WHERE id=?",
|
||||
(existing["id"],)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO dance_names (name, source, use_count) "
|
||||
"VALUES (?,?,1)", (alt_name, "local")
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
"SELECT COUNT(*) FROM song_dances WHERE song_id=?", (song_id,)
|
||||
conn.close()
|
||||
|
||||
# Skriv danse til filen
|
||||
if local_path:
|
||||
from local.tag_reader import write_dances, can_write_dances
|
||||
if can_write_dances(local_path):
|
||||
dance_names = [n for n, _ in dances]
|
||||
if not write_dances(local_path, dance_names):
|
||||
QMessageBox.warning(
|
||||
self, "Advarsel",
|
||||
"Gemt i database, men kunne ikke skrive til filen."
|
||||
)
|
||||
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
|
||||
@@ -1,334 +0,0 @@
|
||||
"""
|
||||
themes.py — Lyst og mørkt tema til PyQt6.
|
||||
"""
|
||||
|
||||
DARK = """
|
||||
QWidget {
|
||||
background-color: #1a1c1f;
|
||||
color: #e8eaf0;
|
||||
font-family: 'Barlow', 'Segoe UI', sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
QMainWindow, #root {
|
||||
background-color: #111214;
|
||||
}
|
||||
|
||||
/* Knapper */
|
||||
QPushButton {
|
||||
background-color: #30343c;
|
||||
color: #9aa0b0;
|
||||
border: 1px solid #4a5060;
|
||||
border-radius: 4px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #454a56;
|
||||
color: #e8eaf0;
|
||||
border-color: #e8a020;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #22252a;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #e8a020;
|
||||
color: #111214;
|
||||
border-color: #c47a10;
|
||||
}
|
||||
QPushButton#btn_play {
|
||||
background-color: #e8a020;
|
||||
color: #111214;
|
||||
border-color: #c47a10;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton#btn_play:hover {
|
||||
background-color: #c47a10;
|
||||
}
|
||||
QPushButton#btn_stop {
|
||||
color: #e74c3c;
|
||||
}
|
||||
QPushButton#btn_stop:hover {
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
QPushButton#btn_demo {
|
||||
color: #3b8fd4;
|
||||
border-color: #3b8fd4;
|
||||
font-size: 11px;
|
||||
}
|
||||
QPushButton#btn_demo:hover, QPushButton#btn_demo:checked {
|
||||
background-color: #3b8fd4;
|
||||
color: #111214;
|
||||
border-color: #3b8fd4;
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
QSlider::groove:horizontal {
|
||||
height: 4px;
|
||||
background: #2c3038;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::sub-page:horizontal {
|
||||
background: #e8a020;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::handle:horizontal {
|
||||
background: #e8a020;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -4px 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Lister */
|
||||
QListWidget {
|
||||
background-color: #1a1c1f;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
QListWidget::item {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #22252a;
|
||||
}
|
||||
QListWidget::item:selected {
|
||||
background-color: #2c3038;
|
||||
color: #e8eaf0;
|
||||
border-left: 2px solid #e8a020;
|
||||
}
|
||||
QListWidget::item:hover {
|
||||
background-color: #22252a;
|
||||
}
|
||||
|
||||
/* Søgefelt */
|
||||
QLineEdit {
|
||||
background-color: #111214;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 3px;
|
||||
padding: 5px 8px;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border-color: #e8a020;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
QLabel#track_title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #e8eaf0;
|
||||
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
|
||||
}
|
||||
QLabel#track_meta {
|
||||
font-size: 11px;
|
||||
color: #9aa0b0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
QLabel#section_title {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #5a6070;
|
||||
letter-spacing: 2px;
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 6px 10px;
|
||||
background-color: #22252a;
|
||||
border-bottom: 1px solid #3a3e46;
|
||||
}
|
||||
QLabel#next_up_label {
|
||||
color: #e8a020;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
QLabel#next_up_title {
|
||||
font-size: 17px;
|
||||
font-weight: bold;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
QLabel#next_up_sub {
|
||||
font-size: 11px;
|
||||
color: #9aa0b0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
QLabel#vol_label {
|
||||
font-size: 10px;
|
||||
color: #5a6070;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
QLabel#vol_val {
|
||||
font-size: 11px;
|
||||
color: #9aa0b0;
|
||||
font-family: 'Courier New', monospace;
|
||||
min-width: 28px;
|
||||
}
|
||||
QLabel#result_count {
|
||||
font-size: 10px;
|
||||
color: #5a6070;
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
/* Frames / paneler */
|
||||
QFrame#panel {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QFrame#now_playing_frame {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
QFrame#track_display {
|
||||
background-color: #111214;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 3px;
|
||||
padding: 4px;
|
||||
}
|
||||
QFrame#transport_frame {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
QFrame#next_up_frame {
|
||||
background-color: #22252a;
|
||||
border: 1px solid #e8a020;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
QFrame#progress_frame {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
QScrollBar:vertical {
|
||||
background: #1a1c1f;
|
||||
width: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #4a5060;
|
||||
border-radius: 3px;
|
||||
min-height: 20px;
|
||||
}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||
|
||||
/* Højreklik-menu */
|
||||
QMenu {
|
||||
background-color: #22252a;
|
||||
color: #e8eaf0;
|
||||
border: 1px solid #4a5060;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
QMenu::item {
|
||||
padding: 8px 24px;
|
||||
border-radius: 0;
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: #e8a020;
|
||||
color: #111214;
|
||||
}
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background: #3a3e46;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* Topbar */
|
||||
QFrame#topbar {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QLabel#logo {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 3px;
|
||||
color: #e8a020;
|
||||
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
|
||||
}
|
||||
QLabel#conn_label {
|
||||
font-size: 11px;
|
||||
color: #5a6070;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
"""
|
||||
|
||||
LIGHT = DARK + """
|
||||
QWidget {
|
||||
background-color: #d8dae0;
|
||||
color: #1a1c22;
|
||||
}
|
||||
QMainWindow, #root {
|
||||
background-color: #c8cad0;
|
||||
}
|
||||
QPushButton {
|
||||
background-color: #b0b4bc;
|
||||
color: #1a1c22;
|
||||
border-color: #8890a0;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #c8ccd4;
|
||||
color: #1a1c22;
|
||||
border-color: #c07010;
|
||||
}
|
||||
QPushButton#btn_play {
|
||||
background-color: #c07010;
|
||||
color: #fff;
|
||||
border-color: #a05808;
|
||||
}
|
||||
QListWidget {
|
||||
background-color: #d8dae0;
|
||||
color: #1a1c22;
|
||||
}
|
||||
QListWidget::item {
|
||||
color: #1a1c22;
|
||||
}
|
||||
QListWidget::item:selected {
|
||||
background-color: #c07010;
|
||||
color: #ffffff;
|
||||
border-left: 2px solid #a05808;
|
||||
}
|
||||
QListWidget::item:hover {
|
||||
background-color: #c8ccd4;
|
||||
color: #1a1c22;
|
||||
}
|
||||
QLineEdit {
|
||||
background-color: #c8cad0;
|
||||
border-color: #aab0bc;
|
||||
color: #1a1c22;
|
||||
}
|
||||
QLineEdit:focus { border-color: #c07010; }
|
||||
QFrame#panel, QFrame#now_playing_frame,
|
||||
QFrame#transport_frame, QFrame#progress_frame {
|
||||
background-color: #d8dae0;
|
||||
border-color: #aab0bc;
|
||||
}
|
||||
QFrame#track_display { background-color: #c8cad0; border-color: #aab0bc; }
|
||||
QFrame#topbar { background-color: #d8dae0; border-color: #aab0bc; }
|
||||
QLabel#section_title { background-color: #e4e6ec; color: #1a1c22; border-color: #aab0bc; }
|
||||
QLabel#track_title { color: #1a1c22; }
|
||||
QLabel#track_meta { color: #4a5060; }
|
||||
QLabel#result_count { color: #5a6070; }
|
||||
QSlider::groove:horizontal { background: #b0b4bc; }
|
||||
QScrollBar:vertical { background: #d8dae0; }
|
||||
QScrollBar::handle:vertical { background: #8890a0; }
|
||||
QMenu {
|
||||
background-color: #e4e6ec;
|
||||
color: #1a1c22;
|
||||
border: 1px solid #aab0bc;
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: #c07010;
|
||||
color: #ffffff;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def apply_theme(app, dark: bool = True):
|
||||
app.setStyleSheet(DARK if dark else LIGHT)
|
||||
@@ -1,96 +0,0 @@
|
||||
"""
|
||||
vu_meter.py — VU-meter widget der tegner L og R kanaler.
|
||||
Opdateres via set_levels(left, right) med værdier 0.0–1.0.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QPainter, QColor
|
||||
import random
|
||||
|
||||
|
||||
NUM_BARS = 14
|
||||
BAR_W = 14
|
||||
BAR_H = 4
|
||||
BAR_GAP = 2
|
||||
CHAN_GAP = 6
|
||||
PADDING = 4
|
||||
|
||||
COLOR_OFF = QColor("#1a2218")
|
||||
COLOR_GREEN = QColor("#28a050")
|
||||
COLOR_YELLOW = QColor("#c8a020")
|
||||
COLOR_RED = QColor("#c83020")
|
||||
|
||||
# Grænser for farver (bar-indeks fra bunden)
|
||||
YELLOW_FROM = NUM_BARS - 4
|
||||
RED_FROM = NUM_BARS - 2
|
||||
|
||||
|
||||
class VUMeter(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._left = 0.0
|
||||
self._right = 0.0
|
||||
self._peak_l = 0.0
|
||||
self._peak_r = 0.0
|
||||
self._dark = True
|
||||
|
||||
total_h = NUM_BARS * (BAR_H + BAR_GAP) + PADDING * 2 + 16 # +16 til label
|
||||
total_w = (BAR_W + CHAN_GAP) * 2 + PADDING * 2
|
||||
self.setFixedSize(total_w, total_h)
|
||||
|
||||
def set_dark(self, dark: bool):
|
||||
self._dark = dark
|
||||
self.update()
|
||||
|
||||
def set_levels(self, left: float, right: float):
|
||||
"""Sæt niveauer 0.0–1.0. Kaldes fra afspiller-tråden via signal."""
|
||||
self._left = max(0.0, min(1.0, left))
|
||||
self._right = max(0.0, min(1.0, right))
|
||||
self._peak_l = max(self._peak_l * 0.92, self._left)
|
||||
self._peak_r = max(self._peak_r * 0.92, self._right)
|
||||
self.update()
|
||||
|
||||
def reset(self):
|
||||
self._left = self._right = self._peak_l = self._peak_r = 0.0
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
off_color = QColor("#d0d8cc") if not self._dark else COLOR_OFF
|
||||
|
||||
for ch_idx, level in enumerate([self._left, self._right]):
|
||||
x = PADDING + ch_idx * (BAR_W + CHAN_GAP)
|
||||
active_bars = int(level * NUM_BARS)
|
||||
|
||||
for bar_idx in range(NUM_BARS):
|
||||
y = PADDING + (NUM_BARS - 1 - bar_idx) * (BAR_H + BAR_GAP)
|
||||
|
||||
if bar_idx < active_bars:
|
||||
if bar_idx >= RED_FROM:
|
||||
color = COLOR_RED
|
||||
elif bar_idx >= YELLOW_FROM:
|
||||
color = COLOR_YELLOW
|
||||
else:
|
||||
color = COLOR_GREEN
|
||||
else:
|
||||
color = off_color
|
||||
|
||||
painter.fillRect(x, y, BAR_W, BAR_H,
|
||||
QColor(color.red(), color.green(), color.blue(), 220))
|
||||
|
||||
# Kanal-labels
|
||||
label_y = PADDING + NUM_BARS * (BAR_H + BAR_GAP) + 4
|
||||
painter.setPen(QColor("#5a6070"))
|
||||
font = painter.font()
|
||||
font.setPointSize(8)
|
||||
font.setFamily("Courier New")
|
||||
painter.setFont(font)
|
||||
|
||||
for ch_idx, label in enumerate(["L", "R"]):
|
||||
x = PADDING + ch_idx * (BAR_W + CHAN_GAP) + BAR_W // 2
|
||||
painter.drawText(x - 4, label_y + 10, label)
|
||||
|
||||
painter.end()
|
||||
@@ -1,29 +0,0 @@
|
||||
"""
|
||||
local/ — Lokalt data-lag til Linedance-afspilleren.
|
||||
|
||||
Moduler:
|
||||
local_db.py — SQLite database (sange, afspilningslister, biblioteker)
|
||||
tag_reader.py — Læser/skriver metadata fra lydfiler
|
||||
file_watcher.py — Overvåger mapper og holder SQLite opdateret
|
||||
|
||||
Typisk brug ved app-start:
|
||||
|
||||
from local.local_db import init_db
|
||||
from local.file_watcher import get_watcher
|
||||
|
||||
# Initialiser database
|
||||
init_db()
|
||||
|
||||
# Start fil-overvågning (on_change kaldes ved ændringer — opdater GUI)
|
||||
def on_file_change(event_type, path, song_id):
|
||||
print(f"{event_type}: {path}")
|
||||
|
||||
watcher = get_watcher(on_change=on_file_change)
|
||||
watcher.start()
|
||||
|
||||
# Tilføj et bibliotek (scanner automatisk + starter overvågning)
|
||||
watcher.add_library("/home/carsten/Musik")
|
||||
|
||||
# Ved app-luk:
|
||||
watcher.stop()
|
||||
"""
|
||||
@@ -1,274 +0,0 @@
|
||||
"""
|
||||
file_watcher.py — Overvåger musikbiblioteker og holder SQLite opdateret.
|
||||
|
||||
Bruger watchdog til at reagere på fil-ændringer i realtid.
|
||||
Kører fuld scan ved opstart for at fange ændringer lavet mens appen var lukket.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
try:
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import (
|
||||
FileSystemEventHandler,
|
||||
FileCreatedEvent,
|
||||
FileModifiedEvent,
|
||||
FileDeletedEvent,
|
||||
FileMovedEvent,
|
||||
)
|
||||
WATCHDOG_AVAILABLE = True
|
||||
except ImportError:
|
||||
WATCHDOG_AVAILABLE = False
|
||||
print("Advarsel: watchdog ikke installeret — fil-overvågning deaktiveret")
|
||||
|
||||
from local.tag_reader import is_supported, read_tags, get_file_modified_at
|
||||
from local.local_db import (
|
||||
get_libraries, add_library, remove_library,
|
||||
upsert_song, mark_song_missing,
|
||||
get_all_song_paths_for_library, update_library_scan_time,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MusicLibraryHandler(FileSystemEventHandler):
|
||||
"""
|
||||
Reagerer på ændringer i et musikbibliotek.
|
||||
Kører i watchdog's baggrundstråd — DB-operationer er thread-safe via WAL.
|
||||
"""
|
||||
|
||||
def __init__(self, library_id: int, on_change: Callable | None = None):
|
||||
self.library_id = library_id
|
||||
self.on_change = on_change # valgfrit callback til GUI-opdatering
|
||||
self._debounce: dict[str, float] = {}
|
||||
self._debounce_lock = threading.Lock()
|
||||
|
||||
def _debounced(self, path: str) -> bool:
|
||||
"""
|
||||
Forhindrer at samme fil behandles flere gange på kort tid.
|
||||
Nogle programmer gemmer filer i flere trin (temp-fil → rename).
|
||||
"""
|
||||
now = time.time()
|
||||
with self._debounce_lock:
|
||||
last = self._debounce.get(path, 0)
|
||||
if now - last < 1.5: # 1.5 sekunder cooldown
|
||||
return False
|
||||
self._debounce[path] = now
|
||||
return True
|
||||
|
||||
def on_created(self, event):
|
||||
if event.is_directory or not is_supported(event.src_path):
|
||||
return
|
||||
if self._debounced(event.src_path):
|
||||
self._process_file(event.src_path)
|
||||
|
||||
def on_modified(self, event):
|
||||
if event.is_directory or not is_supported(event.src_path):
|
||||
return
|
||||
if self._debounced(event.src_path):
|
||||
self._process_file(event.src_path)
|
||||
|
||||
def on_deleted(self, event):
|
||||
if event.is_directory or not is_supported(event.src_path):
|
||||
return
|
||||
logger.info(f"Fil slettet: {event.src_path}")
|
||||
mark_song_missing(event.src_path)
|
||||
if self.on_change:
|
||||
self.on_change("deleted", event.src_path, None)
|
||||
|
||||
def on_moved(self, event):
|
||||
if event.is_directory:
|
||||
return
|
||||
# Behandl som slet + opret
|
||||
if is_supported(event.src_path):
|
||||
mark_song_missing(event.src_path)
|
||||
if is_supported(event.dest_path):
|
||||
if self._debounced(event.dest_path):
|
||||
self._process_file(event.dest_path)
|
||||
|
||||
def _process_file(self, path: str):
|
||||
"""Læs tags og gem i SQLite."""
|
||||
try:
|
||||
logger.debug(f"Høster tags fra: {path}")
|
||||
tags = read_tags(path)
|
||||
tags["library_id"] = self.library_id
|
||||
song_id = upsert_song(tags)
|
||||
logger.info(f"Opdateret: {Path(path).name} ({len(tags.get('dances', []))} danse)")
|
||||
if self.on_change:
|
||||
self.on_change("upserted", path, song_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Fejl ved behandling af {path}: {e}")
|
||||
|
||||
|
||||
class LibraryWatcher:
|
||||
"""
|
||||
Styrer watchdog-observere for alle aktive musikbiblioteker.
|
||||
Én instans per applikation.
|
||||
"""
|
||||
|
||||
def __init__(self, on_change: Callable | None = None):
|
||||
self.on_change = on_change
|
||||
self._observer: Observer | None = None
|
||||
self._running = False
|
||||
|
||||
def start(self):
|
||||
"""Start overvågning af alle aktive biblioteker + kør fuld scan."""
|
||||
if not WATCHDOG_AVAILABLE:
|
||||
logger.warning("watchdog ikke tilgængelig — starter kun fuld scan")
|
||||
self._full_scan_all()
|
||||
return
|
||||
|
||||
self._observer = Observer()
|
||||
libraries = get_libraries(active_only=True)
|
||||
|
||||
for lib in libraries:
|
||||
path = Path(lib["path"])
|
||||
if not path.exists():
|
||||
logger.warning(f"Bibliotek findes ikke: {path}")
|
||||
continue
|
||||
|
||||
handler = MusicLibraryHandler(lib["id"], self.on_change)
|
||||
self._observer.schedule(handler, str(path), recursive=True)
|
||||
logger.info(f"Overvåger: {path}")
|
||||
|
||||
self._observer.start()
|
||||
self._running = True
|
||||
|
||||
# Fuld scan i baggrundstråd så GUI ikke blokeres
|
||||
threading.Thread(target=self._full_scan_all, daemon=True).start()
|
||||
|
||||
def stop(self):
|
||||
if self._observer and self._running:
|
||||
self._observer.stop()
|
||||
self._observer.join()
|
||||
self._running = False
|
||||
|
||||
def add_library(self, path: str) -> int:
|
||||
"""Tilføj et nyt bibliotek og start overvågning af det med det samme."""
|
||||
library_id = add_library(path)
|
||||
|
||||
if self._observer and self._running:
|
||||
handler = MusicLibraryHandler(library_id, self.on_change)
|
||||
self._observer.schedule(handler, path, recursive=True)
|
||||
logger.info(f"Tilføjet bibliotek: {path}")
|
||||
|
||||
# Scan det nye bibliotek i baggrunden
|
||||
threading.Thread(
|
||||
target=self._full_scan_library,
|
||||
args=(library_id, path),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
return library_id
|
||||
|
||||
def remove_library(self, library_id: int):
|
||||
"""Deaktiver bibliotek. Watchdog stopper automatisk ved næste restart."""
|
||||
remove_library(library_id)
|
||||
# Genstart observer for at fjerne watch (watchdog understøtter ikke unschedule by id)
|
||||
if self._observer and self._running:
|
||||
self._observer.unschedule_all()
|
||||
self._reschedule_all()
|
||||
|
||||
def _reschedule_all(self):
|
||||
"""Genplanlæg alle aktive biblioteker på observeren."""
|
||||
for lib in get_libraries(active_only=True):
|
||||
path = Path(lib["path"])
|
||||
if path.exists():
|
||||
handler = MusicLibraryHandler(lib["id"], self.on_change)
|
||||
self._observer.schedule(handler, str(path), recursive=True)
|
||||
|
||||
def _full_scan_all(self):
|
||||
"""Kør fuld scan på alle aktive biblioteker."""
|
||||
for lib in get_libraries(active_only=True):
|
||||
path = Path(lib["path"])
|
||||
if path.exists():
|
||||
self._full_scan_library(lib["id"], str(path))
|
||||
|
||||
def _full_scan_library(self, library_id: int, library_path: str):
|
||||
"""
|
||||
Sammenligner filer på disk med SQLite og synkroniserer forskelle.
|
||||
Håndterer utilgængelige mapper og symlinks sikkert.
|
||||
"""
|
||||
logger.info(f"Fuld scan starter: {library_path}")
|
||||
base = Path(library_path)
|
||||
|
||||
# Tjek at mappen faktisk er tilgængelig — med timeout
|
||||
if not self._path_accessible(base):
|
||||
logger.warning(f"Bibliotek ikke tilgængeligt (timeout eller ingen adgang): {library_path}")
|
||||
return
|
||||
|
||||
known = get_all_song_paths_for_library(library_id)
|
||||
found_paths = set()
|
||||
processed = 0
|
||||
errors = 0
|
||||
|
||||
import os
|
||||
for dirpath, dirnames, filenames in os.walk(
|
||||
str(base), followlinks=False,
|
||||
onerror=lambda e: logger.warning(f"Adgang nægtet: {e}")
|
||||
):
|
||||
for filename in filenames:
|
||||
file_path = Path(dirpath) / filename
|
||||
try:
|
||||
if not is_supported(file_path):
|
||||
continue
|
||||
path_str = str(file_path)
|
||||
found_paths.add(path_str)
|
||||
disk_modified = get_file_modified_at(file_path)
|
||||
|
||||
if path_str not in known or known[path_str] != disk_modified:
|
||||
tags = read_tags(file_path)
|
||||
tags["library_id"] = library_id
|
||||
upsert_song(tags)
|
||||
processed += 1
|
||||
if self.on_change:
|
||||
self.on_change("upserted", path_str, None)
|
||||
except Exception as e:
|
||||
logger.error(f"Scan-fejl for {file_path}: {e}")
|
||||
errors += 1
|
||||
|
||||
# Marker forsvundne filer
|
||||
missing_count = 0
|
||||
for known_path in known:
|
||||
if known_path not in found_paths:
|
||||
mark_song_missing(known_path)
|
||||
missing_count += 1
|
||||
if self.on_change:
|
||||
self.on_change("deleted", known_path, None)
|
||||
|
||||
update_library_scan_time(library_id)
|
||||
logger.info(
|
||||
f"Scan færdig: {library_path} — "
|
||||
f"{processed} opdateret, {missing_count} mangler, {errors} fejl"
|
||||
)
|
||||
|
||||
def _path_accessible(self, path: Path, timeout_sec: float = 5.0) -> bool:
|
||||
"""Tjek om en sti er tilgængelig inden for timeout."""
|
||||
import threading
|
||||
result = [False]
|
||||
def check():
|
||||
try:
|
||||
result[0] = path.exists() and path.is_dir()
|
||||
except Exception:
|
||||
result[0] = False
|
||||
t = threading.Thread(target=check, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=timeout_sec)
|
||||
return result[0]
|
||||
|
||||
|
||||
# ── Singleton til brug i appen ────────────────────────────────────────────────
|
||||
|
||||
_watcher: LibraryWatcher | None = None
|
||||
|
||||
|
||||
def get_watcher(on_change: Callable | None = None) -> LibraryWatcher:
|
||||
"""Returnerer den globale LibraryWatcher-instans."""
|
||||
global _watcher
|
||||
if _watcher is None:
|
||||
_watcher = LibraryWatcher(on_change=on_change)
|
||||
return _watcher
|
||||
@@ -1,587 +0,0 @@
|
||||
"""
|
||||
local_db.py — Lokal SQLite database til offline brug.
|
||||
|
||||
Håndterer:
|
||||
- Musikbiblioteker (stier der overvåges)
|
||||
- Sange høstet fra filsystemet
|
||||
- Lokale afspilningslister (offline-projekter)
|
||||
- Synkroniseringsstatus mod API
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path.home() / ".linedance" / "local.db"
|
||||
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
"""Returnerer en thread-lokal forbindelse."""
|
||||
if not hasattr(_local, "conn") or _local.conn is None:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL") # bedre concurrent adgang
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
_local.conn = conn
|
||||
return _local.conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
conn = _get_conn()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Opret alle tabeller hvis de ikke findes."""
|
||||
with get_db() as conn:
|
||||
conn.executescript("""
|
||||
-- Musikbiblioteker der overvåges
|
||||
CREATE TABLE IF NOT EXISTS libraries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_full_scan TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Sange høstet fra filsystemet
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id TEXT PRIMARY KEY,
|
||||
library_id INTEGER REFERENCES libraries(id),
|
||||
local_path TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
album TEXT NOT NULL DEFAULT '',
|
||||
bpm INTEGER NOT NULL DEFAULT 0,
|
||||
duration_sec INTEGER NOT NULL DEFAULT 0,
|
||||
file_format TEXT NOT NULL DEFAULT '',
|
||||
file_modified_at TEXT NOT NULL,
|
||||
file_missing INTEGER NOT NULL DEFAULT 0,
|
||||
api_song_id TEXT, -- NULL hvis ikke synkroniseret
|
||||
last_synced_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Danse knyttet til en sang (kun MP3 kan skrive tags)
|
||||
CREATE TABLE IF NOT EXISTS song_dances (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
|
||||
dance_name TEXT NOT NULL,
|
||||
dance_order INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Alternativ-danse relationer (kun online hvis logget ind, men caches lokalt)
|
||||
CREATE TABLE IF NOT EXISTS dance_alternatives (
|
||||
id TEXT PRIMARY KEY,
|
||||
song_dance_id INTEGER NOT NULL REFERENCES song_dances(id) ON DELETE CASCADE,
|
||||
alt_song_dance_id INTEGER NOT NULL REFERENCES song_dances(id) ON DELETE CASCADE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(song_dance_id, alt_song_dance_id)
|
||||
);
|
||||
|
||||
-- Lokale afspilningslister (offline-projekter)
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
api_project_id TEXT, -- NULL hvis ikke synkroniseret
|
||||
last_synced_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Sange i en afspilningsliste
|
||||
CREATE TABLE IF NOT EXISTS playlist_songs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
song_id TEXT NOT NULL REFERENCES songs(id),
|
||||
position INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending|playing|played|skipped
|
||||
UNIQUE(playlist_id, position)
|
||||
);
|
||||
|
||||
-- Synkroniseringskø — ændringer der venter på at komme online
|
||||
CREATE TABLE IF NOT EXISTS sync_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL, -- 'song'|'playlist'|'playlist_song'
|
||||
entity_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL, -- 'create'|'update'|'delete'
|
||||
payload TEXT NOT NULL, -- JSON
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Indekser til hurtig søgning
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_missing ON songs(file_missing);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_library ON songs(library_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id);
|
||||
""")
|
||||
|
||||
# Migration: tilføj tabeller der måske mangler i ældre databaser
|
||||
_run_migrations(conn)
|
||||
|
||||
|
||||
def _run_migrations(conn):
|
||||
"""Kør migrations sikkert — CREATE IF NOT EXISTS er idempotent."""
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS dance_alternatives (
|
||||
id TEXT PRIMARY KEY,
|
||||
song_dance_id INTEGER NOT NULL REFERENCES song_dances(id) ON DELETE CASCADE,
|
||||
alt_dance_name TEXT NOT NULL,
|
||||
level_id INTEGER REFERENCES dance_levels(id),
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'local',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dance_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
source TEXT NOT NULL DEFAULT 'local',
|
||||
use_count INTEGER NOT NULL DEFAULT 1,
|
||||
synced_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dance_levels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sort_order INTEGER NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
synced_at TEXT
|
||||
);
|
||||
""")
|
||||
|
||||
# Tilføj kolonner der måske mangler i ældre databaser
|
||||
migrations = [
|
||||
"ALTER TABLE songs ADD COLUMN extra_tags TEXT NOT NULL DEFAULT '{}'",
|
||||
"ALTER TABLE song_dances ADD COLUMN level_id INTEGER REFERENCES dance_levels(id)",
|
||||
"ALTER TABLE dance_alternatives ADD COLUMN alt_dance_name TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE dance_alternatives ADD COLUMN level_id INTEGER REFERENCES dance_levels(id)",
|
||||
"ALTER TABLE dance_alternatives ADD COLUMN source TEXT NOT NULL DEFAULT 'local'",
|
||||
"ALTER TABLE dance_alternatives ADD COLUMN created_by TEXT NOT NULL DEFAULT ''",
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
conn.execute(sql)
|
||||
except Exception:
|
||||
pass # kolonnen eksisterer allerede
|
||||
|
||||
# Indlæs standard-niveauer hvis tabellen er tom
|
||||
count = conn.execute("SELECT COUNT(*) FROM dance_levels").fetchone()[0]
|
||||
if count == 0:
|
||||
defaults = [
|
||||
(1, "Begynder", "Passer til alle"),
|
||||
(2, "Let øvet", "Lidt erfaring kræves"),
|
||||
(3, "Øvet", "Kræver regelmæssig træning"),
|
||||
(4, "Erfaren", "For dedikerede dansere"),
|
||||
(5, "Ekspert", "Konkurrenceniveau"),
|
||||
]
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)",
|
||||
defaults
|
||||
)
|
||||
|
||||
|
||||
# ── Biblioteker ───────────────────────────────────────────────────────────────
|
||||
|
||||
def add_library(path: str) -> int:
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO libraries (path) VALUES (?)", (path,)
|
||||
)
|
||||
if cur.lastrowid:
|
||||
return cur.lastrowid
|
||||
row = conn.execute("SELECT id FROM libraries WHERE path=?", (path,)).fetchone()
|
||||
return row["id"]
|
||||
|
||||
|
||||
def get_libraries(active_only: bool = True) -> list[sqlite3.Row]:
|
||||
with get_db() as conn:
|
||||
if active_only:
|
||||
return conn.execute(
|
||||
"SELECT * FROM libraries WHERE is_active=1 ORDER BY path"
|
||||
).fetchall()
|
||||
return conn.execute("SELECT * FROM libraries ORDER BY path").fetchall()
|
||||
|
||||
|
||||
def remove_library(library_id: int):
|
||||
with get_db() as conn:
|
||||
# Marker sange som manglende
|
||||
conn.execute(
|
||||
"UPDATE songs SET file_missing=1 WHERE library_id=?", (library_id,)
|
||||
)
|
||||
# Slet biblioteket helt
|
||||
conn.execute("DELETE FROM libraries WHERE id=?", (library_id,))
|
||||
|
||||
|
||||
def update_library_scan_time(library_id: int):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE libraries SET last_full_scan=? WHERE id=?", (now, library_id)
|
||||
)
|
||||
|
||||
|
||||
# ── Sange ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def upsert_song(song_data: dict) -> str:
|
||||
"""
|
||||
Indsæt eller opdater en sang baseret på local_path.
|
||||
Returnerer song_id.
|
||||
"""
|
||||
import uuid, json
|
||||
with get_db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM songs WHERE local_path=?", (song_data["local_path"],)
|
||||
).fetchone()
|
||||
|
||||
extra_tags_json = json.dumps(song_data.get("extra_tags", {}), ensure_ascii=False)
|
||||
|
||||
if existing:
|
||||
song_id = existing["id"]
|
||||
conn.execute("""
|
||||
UPDATE songs SET
|
||||
library_id=?, title=?, artist=?, album=?, bpm=?, duration_sec=?,
|
||||
file_format=?, file_modified_at=?, file_missing=0, extra_tags=?
|
||||
WHERE id=?
|
||||
""", (
|
||||
song_data.get("library_id"),
|
||||
song_data.get("title", ""),
|
||||
song_data.get("artist", ""),
|
||||
song_data.get("album", ""),
|
||||
song_data.get("bpm", 0),
|
||||
song_data.get("duration_sec", 0),
|
||||
song_data.get("file_format", ""),
|
||||
song_data.get("file_modified_at", ""),
|
||||
extra_tags_json,
|
||||
song_id,
|
||||
))
|
||||
else:
|
||||
song_id = str(uuid.uuid4())
|
||||
conn.execute("""
|
||||
INSERT INTO songs
|
||||
(id, library_id, local_path, title, artist, album,
|
||||
bpm, duration_sec, file_format, file_modified_at, extra_tags)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
song_id,
|
||||
song_data.get("library_id"),
|
||||
song_data["local_path"],
|
||||
song_data.get("title", ""),
|
||||
song_data.get("artist", ""),
|
||||
song_data.get("album", ""),
|
||||
song_data.get("bpm", 0),
|
||||
song_data.get("duration_sec", 0),
|
||||
song_data.get("file_format", ""),
|
||||
song_data.get("file_modified_at", ""),
|
||||
extra_tags_json,
|
||||
))
|
||||
|
||||
# Opdater danse hvis de er med i data
|
||||
if "dances" in song_data:
|
||||
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
|
||||
for i, dance in enumerate(song_data["dances"], start=1):
|
||||
# dance kan være str eller dict med {name, level_id}
|
||||
if isinstance(dance, dict):
|
||||
name = dance.get("name", "")
|
||||
level_id = dance.get("level_id")
|
||||
else:
|
||||
name = dance
|
||||
level_id = None
|
||||
conn.execute(
|
||||
"INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) VALUES (?,?,?,?)",
|
||||
(song_id, name, i, level_id),
|
||||
)
|
||||
# Registrer navne i ordbogen
|
||||
try:
|
||||
from local.local_db import register_dance_name as _reg
|
||||
for dance in song_data["dances"]:
|
||||
nm = dance.get("name", dance) if isinstance(dance, dict) else dance
|
||||
if nm:
|
||||
_reg(nm)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return song_id
|
||||
|
||||
|
||||
def mark_song_missing(local_path: str):
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE songs SET file_missing=1 WHERE local_path=?", (local_path,)
|
||||
)
|
||||
|
||||
|
||||
def get_song_by_path(local_path: str) -> sqlite3.Row | None:
|
||||
with get_db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM songs WHERE local_path=?", (local_path,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def search_songs(query: str, limit: int = 50) -> list[sqlite3.Row]:
|
||||
"""Søg i alle tags — titel, artist, album, danse og alle øvrige tags."""
|
||||
pattern = f"%{query}%"
|
||||
with get_db() as conn:
|
||||
return conn.execute("""
|
||||
SELECT DISTINCT s.* FROM songs s
|
||||
LEFT JOIN song_dances sd ON sd.song_id = s.id
|
||||
WHERE s.file_missing = 0
|
||||
AND (
|
||||
s.title LIKE ? OR
|
||||
s.artist LIKE ? OR
|
||||
s.album LIKE ? OR
|
||||
sd.dance_name LIKE ? OR
|
||||
s.extra_tags LIKE ?
|
||||
)
|
||||
ORDER BY s.artist, s.title
|
||||
LIMIT ?
|
||||
""", (pattern, pattern, pattern, pattern, pattern, limit)).fetchall()
|
||||
|
||||
|
||||
def get_songs_for_library(library_id: int) -> list[sqlite3.Row]:
|
||||
with get_db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM songs WHERE library_id=? ORDER BY artist, title",
|
||||
(library_id,)
|
||||
).fetchall()
|
||||
|
||||
|
||||
def get_all_song_paths_for_library(library_id: int) -> dict[str, str]:
|
||||
"""Returnerer {local_path: file_modified_at} — bruges til fuld scan."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT local_path, file_modified_at FROM songs WHERE library_id=?",
|
||||
(library_id,)
|
||||
).fetchall()
|
||||
return {row["local_path"]: row["file_modified_at"] for row in rows}
|
||||
|
||||
|
||||
# ── Afspilningslister ─────────────────────────────────────────────────────────
|
||||
|
||||
def create_playlist(name: str, description: str = "") -> int:
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO playlists (name, description) VALUES (?,?)",
|
||||
(name, description)
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_playlists() -> list[sqlite3.Row]:
|
||||
with get_db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM playlists ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
|
||||
|
||||
def add_song_to_playlist(playlist_id: int, song_id: str, position: int | None = None) -> int:
|
||||
with get_db() as conn:
|
||||
if position is None:
|
||||
row = conn.execute(
|
||||
"SELECT MAX(position) as max_pos FROM playlist_songs WHERE playlist_id=?",
|
||||
(playlist_id,)
|
||||
).fetchone()
|
||||
position = (row["max_pos"] or 0) + 1
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO playlist_songs (playlist_id, song_id, position) VALUES (?,?,?)",
|
||||
(playlist_id, song_id, position)
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def update_playlist_song_status(playlist_song_id: int, status: str):
|
||||
valid = {"pending", "playing", "played", "skipped"}
|
||||
if status not in valid:
|
||||
raise ValueError(f"Ugyldig status: {status}")
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE playlist_songs SET status=? WHERE id=?",
|
||||
(status, playlist_song_id)
|
||||
)
|
||||
|
||||
|
||||
def get_playlist_with_songs(playlist_id: int) -> dict:
|
||||
with get_db() as conn:
|
||||
playlist = conn.execute(
|
||||
"SELECT * FROM playlists WHERE id=?", (playlist_id,)
|
||||
).fetchone()
|
||||
if not playlist:
|
||||
return {}
|
||||
|
||||
songs = conn.execute("""
|
||||
SELECT ps.id as ps_id, ps.position, ps.status,
|
||||
s.*, GROUP_CONCAT(sd.dance_name ORDER BY sd.dance_order) as dances
|
||||
FROM playlist_songs ps
|
||||
JOIN songs s ON s.id = ps.song_id
|
||||
LEFT JOIN song_dances sd ON sd.song_id = s.id
|
||||
WHERE ps.playlist_id = ?
|
||||
GROUP BY ps.id
|
||||
ORDER BY ps.position
|
||||
""", (playlist_id,)).fetchall()
|
||||
|
||||
return {"playlist": dict(playlist), "songs": [dict(s) for s in songs]}
|
||||
|
||||
|
||||
# ── Event-state (gemmes løbende så man kan genstarte efter strømsvigt) ────────
|
||||
|
||||
def save_event_state(current_idx: int, statuses: list[str]):
|
||||
"""Gem event-fremgang — overskrives ved hver ændring."""
|
||||
import json
|
||||
with get_db() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO event_state (key,value) VALUES ('current_idx',?)",
|
||||
(str(current_idx),))
|
||||
conn.execute("INSERT OR REPLACE INTO event_state (key,value) VALUES ('statuses',?)",
|
||||
(json.dumps(statuses),))
|
||||
|
||||
|
||||
def load_event_state() -> tuple[int, list[str]] | None:
|
||||
"""Indlæs gemt event-fremgang. Returnerer None hvis ingen gemt tilstand."""
|
||||
import json
|
||||
with get_db() as conn:
|
||||
idx_row = conn.execute(
|
||||
"SELECT value FROM event_state WHERE key='current_idx'"
|
||||
).fetchone()
|
||||
sta_row = conn.execute(
|
||||
"SELECT value FROM event_state WHERE key='statuses'"
|
||||
).fetchone()
|
||||
if not idx_row or not sta_row:
|
||||
return None
|
||||
return int(idx_row["value"]), json.loads(sta_row["value"])
|
||||
|
||||
|
||||
def clear_event_state():
|
||||
"""Nulstil gemt event-tilstand (bruges ved 'Start event')."""
|
||||
with get_db() as conn:
|
||||
conn.execute("DELETE FROM event_state")
|
||||
|
||||
|
||||
# ── Dans-navne ordbog ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_dance_name_suggestions(prefix: str, limit: int = 20) -> list[str]:
|
||||
"""Returnerer danse-navne der starter med prefix, sorteret efter popularitet."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT name FROM dance_names
|
||||
WHERE name LIKE ? COLLATE NOCASE
|
||||
ORDER BY use_count DESC, name
|
||||
LIMIT ?
|
||||
""", (f"{prefix}%", limit)).fetchall()
|
||||
return [r["name"] for r in rows]
|
||||
|
||||
|
||||
def register_dance_name(name: str, source: str = "local"):
|
||||
"""Tilføj eller opdater et dans-navn i ordbogen."""
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return
|
||||
with get_db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id, use_count FROM dance_names WHERE name=? COLLATE NOCASE",
|
||||
(name,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE dance_names SET use_count=use_count+1 WHERE id=?",
|
||||
(existing["id"],)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO dance_names (name, source, use_count) VALUES (?,?,1)",
|
||||
(name, source)
|
||||
)
|
||||
|
||||
|
||||
def sync_dance_names_from_api(names: list[dict]):
|
||||
"""Synkroniser dans-navne fra API — {name, use_count}."""
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with get_db() as conn:
|
||||
for item in names:
|
||||
conn.execute("""
|
||||
INSERT INTO dance_names (name, source, use_count, synced_at)
|
||||
VALUES (?, 'community', ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
use_count = MAX(use_count, excluded.use_count),
|
||||
synced_at = excluded.synced_at
|
||||
""", (item["name"], item.get("use_count", 1), now))
|
||||
|
||||
|
||||
# ── Dans-niveauer ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get_dance_levels() -> list[sqlite3.Row]:
|
||||
"""Hent alle niveauer sorteret efter sort_order."""
|
||||
with get_db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM dance_levels ORDER BY sort_order"
|
||||
).fetchall()
|
||||
|
||||
|
||||
def sync_dance_levels_from_api(levels: list[dict]):
|
||||
"""Synkroniser niveauer fra API — {sort_order, name, description}."""
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with get_db() as conn:
|
||||
for lvl in levels:
|
||||
conn.execute("""
|
||||
INSERT INTO dance_levels (sort_order, name, description, synced_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
sort_order = excluded.sort_order,
|
||||
description = excluded.description,
|
||||
synced_at = excluded.synced_at
|
||||
""", (lvl["sort_order"], lvl["name"], lvl.get("description", ""), now))
|
||||
|
||||
|
||||
# ── Dans-alternativer ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_alternatives_for_dance(song_dance_id: int) -> list[sqlite3.Row]:
|
||||
with get_db() as conn:
|
||||
return conn.execute("""
|
||||
SELECT da.*, dl.name as level_name, dl.sort_order as level_sort
|
||||
FROM dance_alternatives da
|
||||
LEFT JOIN dance_levels dl ON dl.id = da.level_id
|
||||
WHERE da.song_dance_id = ?
|
||||
ORDER BY da.source, dl.sort_order
|
||||
""", (song_dance_id,)).fetchall()
|
||||
|
||||
|
||||
def add_alternative(song_dance_id: int, alt_dance_name: str,
|
||||
level_id: int | None = None, note: str = "",
|
||||
source: str = "local", created_by: str = "") -> str:
|
||||
import uuid as _uuid
|
||||
alt_id = str(_uuid.uuid4())
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO dance_alternatives
|
||||
(id, song_dance_id, alt_dance_name, level_id, note, source, created_by)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
""", (alt_id, song_dance_id, alt_dance_name.strip(),
|
||||
level_id, note, source, created_by))
|
||||
# Registrer alt-dans-navne i ordbogen
|
||||
register_dance_name(alt_dance_name, source=source)
|
||||
return alt_id
|
||||
|
||||
|
||||
def remove_alternative(alt_id: str):
|
||||
with get_db() as conn:
|
||||
conn.execute("DELETE FROM dance_alternatives WHERE id=?", (alt_id,))
|
||||
@@ -1,391 +0,0 @@
|
||||
"""
|
||||
tag_reader.py — Læser og skriver metadata fra lydfiler.
|
||||
|
||||
Understøttede formater og danse-tag support:
|
||||
MP3 — læs + skriv danse (ID3 TXXX-felter)
|
||||
FLAC — læs + skriv danse (Vorbis Comments)
|
||||
OGG — læs + skriv danse (Vorbis Comments)
|
||||
OPUS — læs + skriv danse (Vorbis Comments)
|
||||
M4A — læs + skriv danse (MP4 custom felt ----:LINEDANCE:DANCE)
|
||||
WAV — læs metadata, ingen danse-tag support
|
||||
WMA — læs metadata, ingen danse-tag support
|
||||
AIFF — læs metadata, ingen danse-tag support
|
||||
|
||||
Danse gemmes ALTID i SQLite uanset format.
|
||||
Fil-skrivning er kun muligt for de formater der understøtter custom tags.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.id3 import ID3, TXXX
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.mp4 import MP4, MP4FreeForm
|
||||
MUTAGEN_AVAILABLE = True
|
||||
except ImportError:
|
||||
MUTAGEN_AVAILABLE = False
|
||||
print("Advarsel: mutagen ikke installeret — tag-læsning deaktiveret")
|
||||
|
||||
|
||||
# Filtyper vi høster metadata fra
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
".mp3", ".flac", ".wav", ".m4a", ".aac",
|
||||
".ogg", ".opus", ".wma", ".aiff", ".aif",
|
||||
}
|
||||
|
||||
# Formater der understøtter skrivning af danse-tags til fil
|
||||
WRITABLE_DANCE_FORMATS = {".mp3", ".flac", ".ogg", ".opus", ".m4a"}
|
||||
|
||||
# Tag-nøgler brugt på tværs af formater
|
||||
TXXX_DANCE_PREFIX = "LINEDANCE_DANCE_" # MP3: TXXX:LINEDANCE_DANCE_1
|
||||
VORBIS_DANCE_KEY = "linedance_dance" # FLAC/OGG: linedance_dance.1
|
||||
M4A_DANCE_FREEFORM = "----:LINEDANCE:DANCE" # M4A: ----:LINEDANCE:DANCE (liste)
|
||||
|
||||
|
||||
def is_supported(path: str | Path) -> bool:
|
||||
return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS
|
||||
|
||||
|
||||
def can_write_dances(path: str | Path) -> bool:
|
||||
"""Returnerer True hvis formatet understøtter skrivning af danse-tags til fil."""
|
||||
return Path(path).suffix.lower() in WRITABLE_DANCE_FORMATS
|
||||
|
||||
|
||||
def get_file_modified_at(path: str | Path) -> str:
|
||||
ts = os.path.getmtime(str(path))
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ── Læsning ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_tags(path: str | Path) -> dict:
|
||||
"""
|
||||
Læser metadata og danse fra en lydfil.
|
||||
Returnerer dict med: title, artist, album, bpm, duration_sec,
|
||||
file_format, file_modified_at, dances, can_write_dances,
|
||||
extra_tags (dict med alle øvrige tags som {navn: værdi}).
|
||||
"""
|
||||
path = Path(path)
|
||||
result = {
|
||||
"local_path": str(path),
|
||||
"title": path.stem,
|
||||
"artist": "",
|
||||
"album": "",
|
||||
"bpm": 0,
|
||||
"duration_sec": 0,
|
||||
"file_format": path.suffix.lower().lstrip("."),
|
||||
"file_modified_at": get_file_modified_at(path),
|
||||
"dances": [],
|
||||
"can_write_dances": can_write_dances(path),
|
||||
"extra_tags": {},
|
||||
}
|
||||
|
||||
if not MUTAGEN_AVAILABLE:
|
||||
return result
|
||||
|
||||
try:
|
||||
audio = MutagenFile(str(path), easy=False)
|
||||
if audio is None:
|
||||
return result
|
||||
|
||||
if hasattr(audio, "info") and audio.info:
|
||||
result["duration_sec"] = int(getattr(audio.info, "length", 0))
|
||||
|
||||
ext = path.suffix.lower()
|
||||
|
||||
if ext == ".mp3":
|
||||
_read_mp3(audio, result)
|
||||
elif ext == ".flac":
|
||||
_read_vorbis(audio, result)
|
||||
elif ext in (".ogg", ".opus"):
|
||||
_read_vorbis(audio, result)
|
||||
elif ext in (".m4a", ".aac", ".mp4"):
|
||||
_read_m4a(audio, result)
|
||||
else:
|
||||
_read_generic(audio, result)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Fejl ved læsning af {path}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _read_mp3(audio, result: dict):
|
||||
tags = audio.tags
|
||||
if not tags:
|
||||
return
|
||||
if "TIT2" in tags:
|
||||
result["title"] = str(tags["TIT2"].text[0])
|
||||
if "TPE1" in tags:
|
||||
result["artist"] = str(tags["TPE1"].text[0])
|
||||
if "TALB" in tags:
|
||||
result["album"] = str(tags["TALB"].text[0])
|
||||
if "TBPM" in tags:
|
||||
try:
|
||||
result["bpm"] = int(float(str(tags["TBPM"].text[0])))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
dances = {}
|
||||
extra = {}
|
||||
# Kendte ID3-felt-navne til menneskelige navne
|
||||
ID3_NAMES = {
|
||||
"TIT2": "titel", "TPE1": "artist", "TALB": "album", "TBPM": "bpm",
|
||||
"TYER": "år", "TDRC": "dato", "TCON": "genre", "TPE2": "albumartist",
|
||||
"TPOS": "disknummer", "TRCK": "spornummer", "TCOM": "komponist",
|
||||
"TLYR": "sangtekst", "TCOP": "copyright", "TPUB": "udgiver",
|
||||
"TENC": "kodet_af", "TLAN": "sprog", "TMOO": "stemning",
|
||||
"TPE3": "dirigent", "TPE4": "fortolket_af", "TOAL": "original_album",
|
||||
"TOPE": "original_artist", "TORY": "original_år",
|
||||
}
|
||||
for key, frame in tags.items():
|
||||
if key.startswith("TXXX:") and TXXX_DANCE_PREFIX in key:
|
||||
try:
|
||||
num = int(key.replace(f"TXXX:{TXXX_DANCE_PREFIX}", ""))
|
||||
dances[num] = str(frame.text[0])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
elif key.startswith("TXXX:"):
|
||||
# Custom TXXX-felt — gem under dets beskrivelse
|
||||
desc = key[5:] # fjern "TXXX:"
|
||||
try:
|
||||
extra[desc] = str(frame.text[0])
|
||||
except Exception:
|
||||
pass
|
||||
elif key in ID3_NAMES and key not in ("TIT2","TPE1","TALB","TBPM"):
|
||||
# Standardfelt vi ikke allerede har gemt
|
||||
try:
|
||||
val = str(frame.text[0]) if hasattr(frame, "text") else str(frame)
|
||||
if val:
|
||||
extra[ID3_NAMES[key]] = val
|
||||
except Exception:
|
||||
pass
|
||||
elif hasattr(frame, "text") and key not in ("TIT2","TPE1","TALB","TBPM"):
|
||||
# Alle andre tekstfelter
|
||||
try:
|
||||
val = str(frame.text[0])
|
||||
if val and not key.startswith("APIC"): # spring albumcover over
|
||||
extra[key] = val
|
||||
except Exception:
|
||||
pass
|
||||
result["dances"] = [dances[k] for k in sorted(dances.keys())]
|
||||
result["extra_tags"] = extra
|
||||
|
||||
|
||||
def _read_vorbis(audio, result: dict):
|
||||
"""FLAC og OGG/Opus bruger begge Vorbis Comments."""
|
||||
tags = audio.tags
|
||||
if not tags:
|
||||
return
|
||||
result["title"] = tags.get("title", [result["title"]])[0]
|
||||
result["artist"] = tags.get("artist", [""])[0]
|
||||
result["album"] = tags.get("album", [""])[0]
|
||||
try:
|
||||
result["bpm"] = int(tags.get("bpm", [0])[0])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Danse
|
||||
dances = {}
|
||||
for key, values in tags.items():
|
||||
if key.lower().startswith(f"{VORBIS_DANCE_KEY}."):
|
||||
try:
|
||||
num = int(key.split(".")[-1])
|
||||
dances[num] = values[0]
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if not dances and VORBIS_DANCE_KEY in tags:
|
||||
result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()]
|
||||
else:
|
||||
result["dances"] = [dances[k] for k in sorted(dances.keys())]
|
||||
# Alle øvrige tags som extra_tags
|
||||
skip = {"title", "artist", "album", "bpm", VORBIS_DANCE_KEY}
|
||||
extra = {}
|
||||
for key, values in tags.items():
|
||||
k = key.lower()
|
||||
if k not in skip and not k.startswith(VORBIS_DANCE_KEY):
|
||||
try:
|
||||
extra[k] = str(values[0])
|
||||
except Exception:
|
||||
pass
|
||||
result["extra_tags"] = extra
|
||||
|
||||
|
||||
def _read_m4a(audio, result: dict):
|
||||
tags = audio.tags
|
||||
if not tags:
|
||||
return
|
||||
if "\xa9nam" in tags:
|
||||
result["title"] = str(tags["\xa9nam"][0])
|
||||
if "\xa9ART" in tags:
|
||||
result["artist"] = str(tags["\xa9ART"][0])
|
||||
if "\xa9alb" in tags:
|
||||
result["album"] = str(tags["\xa9alb"][0])
|
||||
if "tmpo" in tags:
|
||||
try:
|
||||
result["bpm"] = int(tags["tmpo"][0])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if M4A_DANCE_FREEFORM in tags:
|
||||
result["dances"] = [
|
||||
v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v)
|
||||
for v in tags[M4A_DANCE_FREEFORM]
|
||||
]
|
||||
# Menneskelige navne til M4A-nøgler
|
||||
M4A_NAMES = {
|
||||
"\xa9nam": "titel", "\xa9ART": "artist", "\xa9alb": "album",
|
||||
"\xa9day": "år", "\xa9gen": "genre", "\xa9wrt": "komponist",
|
||||
"\xa9cmt": "kommentar", "aART": "albumartist", "trkn": "spornummer",
|
||||
"disk": "disknummer", "cprt": "copyright", "\xa9lyr": "sangtekst",
|
||||
"tmpo": "bpm",
|
||||
}
|
||||
skip_keys = {"\xa9nam", "\xa9ART", "\xa9alb", "tmpo", M4A_DANCE_FREEFORM, "covr"}
|
||||
extra = {}
|
||||
for key, values in tags.items():
|
||||
if key in skip_keys:
|
||||
continue
|
||||
label = M4A_NAMES.get(key, key)
|
||||
try:
|
||||
val = values[0]
|
||||
if isinstance(val, (bytes, MP4FreeForm)):
|
||||
val = val.decode("utf-8", errors="replace")
|
||||
extra[label] = str(val)
|
||||
except Exception:
|
||||
pass
|
||||
result["extra_tags"] = extra
|
||||
|
||||
|
||||
def _read_generic(audio, result: dict):
|
||||
try:
|
||||
easy = MutagenFile(result["local_path"], easy=True)
|
||||
if easy and easy.tags:
|
||||
result["title"] = easy.tags.get("title", [result["title"]])[0]
|
||||
result["artist"] = easy.tags.get("artist", [""])[0]
|
||||
result["album"] = easy.tags.get("album", [""])[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Skrivning ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def write_dances(path: str | Path, dances: list[str]) -> bool:
|
||||
"""
|
||||
Skriver danse til filen hvis formatet understøtter det.
|
||||
Returnerer True ved succes, False hvis formatet ikke understøtter det.
|
||||
Kaster Exception ved fejl under skrivning.
|
||||
"""
|
||||
if not MUTAGEN_AVAILABLE:
|
||||
return False
|
||||
|
||||
path = Path(path)
|
||||
ext = path.suffix.lower()
|
||||
|
||||
if ext not in WRITABLE_DANCE_FORMATS:
|
||||
return False
|
||||
|
||||
if ext == ".mp3":
|
||||
return _write_mp3_dances(path, dances)
|
||||
elif ext in (".flac", ".ogg", ".opus"):
|
||||
return _write_vorbis_dances(path, dances)
|
||||
elif ext in (".m4a", ".aac"):
|
||||
return _write_m4a_dances(path, dances)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _write_mp3_dances(path: Path, dances: list[str]) -> bool:
|
||||
try:
|
||||
tags = ID3(str(path))
|
||||
for key in [k for k in tags.keys() if TXXX_DANCE_PREFIX in k]:
|
||||
del tags[key]
|
||||
for i, name in enumerate(dances, start=1):
|
||||
tags.add(TXXX(encoding=3, desc=f"{TXXX_DANCE_PREFIX}{i}", text=name))
|
||||
tags.save(str(path))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"MP3 skrivefejl {path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _write_vorbis_dances(path: Path, dances: list[str]) -> bool:
|
||||
try:
|
||||
audio = MutagenFile(str(path), easy=False)
|
||||
if audio is None or audio.tags is None:
|
||||
return False
|
||||
# Slet eksisterende danse-felter
|
||||
keys_to_delete = [k for k in audio.tags.keys() if k.lower().startswith(f"{VORBIS_DANCE_KEY}.")]
|
||||
for key in keys_to_delete:
|
||||
del audio.tags[key]
|
||||
# Skriv nye — ét felt per dans
|
||||
for i, name in enumerate(dances, start=1):
|
||||
audio.tags[f"{VORBIS_DANCE_KEY}.{i}"] = name
|
||||
audio.save()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Vorbis skrivefejl {path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _write_m4a_dances(path: Path, dances: list[str]) -> bool:
|
||||
try:
|
||||
audio = MP4(str(path))
|
||||
audio.tags[M4A_DANCE_FREEFORM] = [
|
||||
MP4FreeForm(name.encode("utf-8")) for name in dances
|
||||
]
|
||||
audio.save()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"M4A skrivefejl {path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Hurtig læsning af kun danse (uden fuld tag-scan) ─────────────────────────
|
||||
|
||||
def read_dances_from_file(path: str | Path) -> list[str]:
|
||||
"""Læser kun danse fra en fil — hurtigere end fuld read_tags()."""
|
||||
tags = read_tags(path)
|
||||
return tags.get("dances", [])
|
||||
|
||||
|
||||
# ── BPM-analyse ───────────────────────────────────────────────────────────────
|
||||
|
||||
def analyze_bpm(path: str | Path) -> float | None:
|
||||
"""
|
||||
Analysér BPM fra lydfilen ved hjælp af librosa.
|
||||
Returnerer BPM som float eller None ved fejl.
|
||||
Tager 2-5 sekunder per sang — kør i baggrundstråd.
|
||||
"""
|
||||
try:
|
||||
import librosa
|
||||
# Indlæs kun de første 60 sekunder for hastighed
|
||||
y, sr = librosa.load(str(path), duration=60.0, mono=True)
|
||||
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
|
||||
# librosa returnerer array i nyere versioner
|
||||
if hasattr(tempo, "__len__"):
|
||||
bpm = float(tempo[0]) if len(tempo) > 0 else 0.0
|
||||
else:
|
||||
bpm = float(tempo)
|
||||
return round(bpm, 1) if bpm > 0 else None
|
||||
except ImportError:
|
||||
print("librosa ikke installeret — installer med: pip install librosa")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"BPM-analyse fejl for {path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def analyze_and_save_bpm(path: str | Path, song_id: str) -> float | None:
|
||||
"""Analysér BPM og gem i SQLite. Returnerer målt BPM."""
|
||||
bpm = analyze_bpm(path)
|
||||
if bpm and bpm > 0:
|
||||
try:
|
||||
from local.local_db import get_db
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE songs SET bpm=? WHERE id=? AND (bpm IS NULL OR bpm=0)",
|
||||
(int(round(bpm)), song_id)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"BPM gem fejl: {e}")
|
||||
return bpm
|
||||
33
main.py
33
main.py
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
main.py — Linedance afspiller.
|
||||
|
||||
Start:
|
||||
python main.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Sørg for at rodmappen er i Python-stien
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from ui.main_window import MainWindow
|
||||
from ui.themes import apply_theme
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("LineDance Player")
|
||||
app.setOrganizationName("LineDance")
|
||||
|
||||
apply_theme(app, dark=True)
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
172
player/player.py
172
player/player.py
@@ -1,172 +0,0 @@
|
||||
"""
|
||||
player.py — VLC-baseret afspiller med PyQt6 signals.
|
||||
|
||||
Sender signals til GUI:
|
||||
position_changed(float) — 0.0–1.0 progress
|
||||
time_changed(int, int) — (current_sec, total_sec)
|
||||
levels_changed(float, float) — VU-meter L/R 0.0–1.0
|
||||
song_ended() — sang færdig
|
||||
state_changed(str) — 'playing'|'paused'|'stopped'
|
||||
"""
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal, QTimer
|
||||
import random
|
||||
|
||||
try:
|
||||
import vlc
|
||||
VLC_AVAILABLE = True
|
||||
except ImportError:
|
||||
VLC_AVAILABLE = False
|
||||
print("Advarsel: python-vlc ikke installeret — afspilning deaktiveret")
|
||||
|
||||
|
||||
class Player(QObject):
|
||||
position_changed = pyqtSignal(float)
|
||||
time_changed = pyqtSignal(int, int)
|
||||
levels_changed = pyqtSignal(float, float)
|
||||
song_ended = pyqtSignal()
|
||||
state_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._path: str | None = None
|
||||
self._duration: int = 0
|
||||
self._demo_mode = False
|
||||
self._demo_stop_sec = 10
|
||||
self._volume = 78
|
||||
|
||||
if VLC_AVAILABLE:
|
||||
self._instance = vlc.Instance("--no-video", "--quiet")
|
||||
self._media_player = self._instance.media_player_new()
|
||||
self._events = self._media_player.event_manager()
|
||||
self._events.event_attach(
|
||||
vlc.EventType.MediaPlayerEndReached,
|
||||
self._on_end_reached,
|
||||
)
|
||||
else:
|
||||
self._media_player = None
|
||||
|
||||
# Timer til polling af position + VU-simulation
|
||||
self._poll_timer = QTimer(self)
|
||||
self._poll_timer.setInterval(80)
|
||||
self._poll_timer.timeout.connect(self._poll)
|
||||
|
||||
# ── Indlæsning ────────────────────────────────────────────────────────────
|
||||
|
||||
def load(self, path: str, duration_sec: int = 0):
|
||||
"""Indlæs en lydfil uden at starte afspilning."""
|
||||
self._path = path
|
||||
self._duration = duration_sec
|
||||
self._demo_mode = False
|
||||
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
media = self._instance.media_new(path)
|
||||
self._media_player.set_media(media)
|
||||
self._media_player.audio_set_volume(self._volume)
|
||||
|
||||
self.position_changed.emit(0.0)
|
||||
self.time_changed.emit(0, self._duration)
|
||||
self.state_changed.emit("stopped")
|
||||
|
||||
# ── Transport ─────────────────────────────────────────────────────────────
|
||||
|
||||
def play(self):
|
||||
self._demo_mode = False
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.play()
|
||||
self._poll_timer.start()
|
||||
self.state_changed.emit("playing")
|
||||
|
||||
def play_demo(self, stop_at_sec: int = 10):
|
||||
"""Afspil fra start og stop automatisk ved stop_at_sec."""
|
||||
self._demo_mode = True
|
||||
self._demo_stop_sec = stop_at_sec
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.set_time(0)
|
||||
self._media_player.play()
|
||||
self._poll_timer.start()
|
||||
self.state_changed.emit("playing")
|
||||
|
||||
def pause(self):
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.pause()
|
||||
self.state_changed.emit("paused")
|
||||
|
||||
def stop(self):
|
||||
self._demo_mode = False
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.stop()
|
||||
self._poll_timer.stop()
|
||||
self.position_changed.emit(0.0)
|
||||
self.time_changed.emit(0, self._duration)
|
||||
self.state_changed.emit("stopped")
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
return self._media_player.is_playing()
|
||||
return False
|
||||
|
||||
def set_volume(self, volume: int):
|
||||
"""0–100"""
|
||||
self._volume = volume
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.audio_set_volume(volume)
|
||||
|
||||
def set_position(self, fraction: float):
|
||||
"""Søg til position 0.0–1.0"""
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.set_position(fraction)
|
||||
|
||||
# ── Intern polling ────────────────────────────────────────────────────────
|
||||
|
||||
def _poll(self):
|
||||
"""Køres ~12 gange per sekund — opdaterer position og VU-meter."""
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
pos = self._media_player.get_position()
|
||||
ms = self._media_player.get_time()
|
||||
cur = max(0, ms // 1000)
|
||||
else:
|
||||
# Simuleret tilstand (til UI-test uden VLC)
|
||||
pos = getattr(self, "_sim_pos", 0.0)
|
||||
self._sim_pos = min(1.0, pos + 0.001)
|
||||
cur = int(self._sim_pos * self._duration)
|
||||
pos = self._sim_pos
|
||||
if self._sim_pos >= 1.0:
|
||||
self._on_end_reached(None)
|
||||
return
|
||||
|
||||
self.position_changed.emit(pos)
|
||||
self.time_changed.emit(cur, self._duration)
|
||||
|
||||
# Demo-stop
|
||||
if self._demo_mode and cur >= self._demo_stop_sec:
|
||||
self.stop()
|
||||
self._demo_mode = False
|
||||
self.position_changed.emit(0.0)
|
||||
self.time_changed.emit(0, self._duration)
|
||||
self.state_changed.emit("demo_ended")
|
||||
return
|
||||
|
||||
# VU-meter: brug VLC's audio-amplitude hvis tilgængelig, ellers simulér
|
||||
if VLC_AVAILABLE and self._media_player and self._media_player.is_playing():
|
||||
# VLC eksponerer ikke amplitude direkte — vi bruger en blød simulation
|
||||
# der er baseret på position så det ser organisk ud
|
||||
base = 0.55 + 0.3 * abs(pos - 0.5)
|
||||
l = min(1.0, base + random.gauss(0, 0.12))
|
||||
r = min(1.0, base + random.gauss(0, 0.12))
|
||||
else:
|
||||
l = r = 0.0
|
||||
|
||||
self.levels_changed.emit(max(0.0, l), max(0.0, r))
|
||||
|
||||
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)
|
||||
|
||||
def _handle_end_in_main_thread(self):
|
||||
"""Kaldes i main thread — her er det sikkert at røre Qt."""
|
||||
self._poll_timer.stop()
|
||||
self.song_ended.emit()
|
||||
self.state_changed.emit("stopped")
|
||||
@@ -1,7 +0,0 @@
|
||||
PyQt6>=6.6.0
|
||||
python-vlc>=3.0.18
|
||||
mutagen>=1.47.0
|
||||
watchdog>=4.0.0
|
||||
|
||||
# BPM-analyse
|
||||
librosa>=0.10.0
|
||||
@@ -1,135 +0,0 @@
|
||||
"""
|
||||
library_manager.py — Dialog til at se og fjerne musikbiblioteker.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QListWidget, QListWidgetItem, QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
|
||||
|
||||
class LibraryManagerDialog(QDialog):
|
||||
library_removed = pyqtSignal(int) # library_id
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Administrer musikbiblioteker")
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(320)
|
||||
self._build_ui()
|
||||
self._load()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
lbl = QLabel("Aktive musikbiblioteker:")
|
||||
lbl.setObjectName("track_meta")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
self._list = QListWidget()
|
||||
layout.addWidget(self._list)
|
||||
|
||||
note = QLabel(
|
||||
"Når du fjerner et bibliotek, slettes det fra overvågningen.\n"
|
||||
"Sangene forbliver i databasen men markeres som manglende (⚠)."
|
||||
)
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
layout.addWidget(note)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_add = QPushButton("+ Tilføj mappe")
|
||||
btn_add.clicked.connect(self._add_folder)
|
||||
btn_row.addWidget(btn_add)
|
||||
|
||||
btn_remove = QPushButton("✕ Fjern valgt")
|
||||
btn_remove.clicked.connect(self._remove_selected)
|
||||
btn_row.addWidget(btn_remove)
|
||||
|
||||
btn_scan = QPushButton("⟳ Scan alle")
|
||||
btn_scan.setToolTip("Scan alle mapper for nye og ændrede filer")
|
||||
btn_scan.clicked.connect(self._scan_all)
|
||||
btn_row.addWidget(btn_scan)
|
||||
|
||||
btn_row.addStretch()
|
||||
btn_close = QPushButton("Luk")
|
||||
btn_close.clicked.connect(self.accept)
|
||||
btn_row.addWidget(btn_close)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
def _load(self):
|
||||
self._list.clear()
|
||||
try:
|
||||
from local.local_db import get_libraries, get_db
|
||||
libs = get_libraries(active_only=True) # kun aktive
|
||||
for lib in libs:
|
||||
from pathlib import Path
|
||||
path = lib["path"]
|
||||
exists = Path(path).exists()
|
||||
last_scan = lib["last_full_scan"] or "aldrig"
|
||||
if isinstance(last_scan, str) and len(last_scan) > 10:
|
||||
last_scan = last_scan[:10]
|
||||
with get_db() as conn:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0",
|
||||
(lib["id"],)
|
||||
).fetchone()[0]
|
||||
exist_icon = "" if exists else " ⚠ mappe ikke fundet"
|
||||
label = f"{path}{exist_icon}\n {count} sange · senest scannet: {last_scan}"
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.ItemDataRole.UserRole, dict(lib))
|
||||
if not exists:
|
||||
from PyQt6.QtGui import QColor
|
||||
item.setForeground(QColor("#5a6070"))
|
||||
self._list.addItem(item)
|
||||
except Exception as e:
|
||||
print(f"Library manager load fejl: {e}")
|
||||
|
||||
def _scan_all(self):
|
||||
mw = self.parent()
|
||||
if hasattr(mw, "start_scan"):
|
||||
mw.start_scan()
|
||||
self._set_status("Scanning startet...")
|
||||
|
||||
def _set_status(self, text: str):
|
||||
pass # kan udvides med statuslinje i dialogen
|
||||
|
||||
def _add_folder(self):
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||||
if folder:
|
||||
mw = self.parent()
|
||||
if hasattr(mw, "add_library_path"):
|
||||
mw.add_library_path(folder)
|
||||
# Genindlæs listen efter kort pause så DB er opdateret
|
||||
from PyQt6.QtCore import QTimer
|
||||
QTimer.singleShot(600, self._load)
|
||||
|
||||
def _remove_selected(self):
|
||||
item = self._list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
lib = item.data(Qt.ItemDataRole.UserRole)
|
||||
reply = QMessageBox.question(
|
||||
self, "Fjern bibliotek",
|
||||
f"Fjern overvågningen af:\n{lib['path']}\n\n"
|
||||
"Sange i biblioteket forbliver i databasen men markeres som manglende.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
try:
|
||||
mw = self.parent()
|
||||
if hasattr(mw, "_watcher") and mw._watcher:
|
||||
mw._watcher.remove_library(lib["id"])
|
||||
else:
|
||||
from local.local_db import remove_library
|
||||
remove_library(lib["id"])
|
||||
self.library_removed.emit(lib["id"])
|
||||
if hasattr(mw, "_reload_library"):
|
||||
mw._reload_library()
|
||||
self._load()
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}")
|
||||
@@ -1,274 +0,0 @@
|
||||
"""
|
||||
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, QProgressBar,
|
||||
QAbstractItemView,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray
|
||||
from PyQt6.QtGui import QColor, QDrag
|
||||
|
||||
|
||||
class DraggableLibraryList(QListWidget):
|
||||
"""QListWidget der understøtter drag-start med sang-data som mime."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setDragEnabled(True)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
|
||||
self.setDefaultDropAction(Qt.DropAction.CopyAction)
|
||||
|
||||
def startDrag(self, supported_actions):
|
||||
item = self.currentItem()
|
||||
if not item:
|
||||
return
|
||||
song = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not song:
|
||||
return
|
||||
|
||||
import json
|
||||
data = json.dumps(song).encode("utf-8")
|
||||
|
||||
mime = QMimeData()
|
||||
mime.setData("application/x-linedance-song", QByteArray(data))
|
||||
mime.setText(song.get("title", ""))
|
||||
|
||||
drag = QDrag(self)
|
||||
drag.setMimeData(mime)
|
||||
drag.exec(Qt.DropAction.CopyAction)
|
||||
|
||||
|
||||
class LibraryPanel(QWidget):
|
||||
song_selected = pyqtSignal(dict)
|
||||
add_to_playlist = pyqtSignal(dict)
|
||||
scan_requested = pyqtSignal()
|
||||
edit_tags_requested = pyqtSignal(dict)
|
||||
send_mail_requested = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._all_songs: list[dict] = []
|
||||
self._filtered: list[dict] = []
|
||||
self._search_timer = QTimer(self)
|
||||
self._search_timer.setSingleShot(True)
|
||||
self._search_timer.setInterval(150)
|
||||
self._search_timer.timeout.connect(self._do_search)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Header
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(10, 6, 10, 6)
|
||||
lbl = QLabel("BIBLIOTEK")
|
||||
lbl.setObjectName("section_title")
|
||||
header.addWidget(lbl)
|
||||
header.addStretch()
|
||||
|
||||
btn_manage = QPushButton("⚙ Mapper")
|
||||
btn_manage.setFixedHeight(24)
|
||||
btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
|
||||
btn_manage.clicked.connect(self._manage_libraries)
|
||||
header.addWidget(btn_manage)
|
||||
layout.addLayout(header)
|
||||
|
||||
# Scan status
|
||||
self._scan_bar = QProgressBar()
|
||||
self._scan_bar.setObjectName("scan_bar")
|
||||
self._scan_bar.setTextVisible(True)
|
||||
self._scan_bar.setFormat("Scanner...")
|
||||
self._scan_bar.setFixedHeight(16)
|
||||
self._scan_bar.setRange(0, 0)
|
||||
self._scan_bar.hide()
|
||||
layout.addWidget(self._scan_bar)
|
||||
|
||||
self._scan_label = QLabel("")
|
||||
self._scan_label.setObjectName("result_count")
|
||||
self._scan_label.hide()
|
||||
layout.addWidget(self._scan_label)
|
||||
|
||||
# Søgefelt
|
||||
self._search = QLineEdit()
|
||||
self._search.setPlaceholderText("Søg i titel, artist, album, dans...")
|
||||
self._search.textChanged.connect(self._on_search_changed)
|
||||
layout.addWidget(self._search)
|
||||
|
||||
# Resultat-tæller + drag-hint
|
||||
hint_row = QHBoxLayout()
|
||||
hint_row.setContentsMargins(8, 2, 8, 2)
|
||||
self._count_label = QLabel("0 sange")
|
||||
self._count_label.setObjectName("result_count")
|
||||
hint_row.addWidget(self._count_label)
|
||||
hint_row.addStretch()
|
||||
drag_hint = QLabel("træk til danseliste →")
|
||||
drag_hint.setObjectName("result_count")
|
||||
hint_row.addWidget(drag_hint)
|
||||
layout.addLayout(hint_row)
|
||||
|
||||
# Liste — draggable
|
||||
self._list = DraggableLibraryList()
|
||||
self._list.setObjectName("library_list")
|
||||
self._list.itemDoubleClicked.connect(self._on_double_click)
|
||||
self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self._list.customContextMenuRequested.connect(self._show_context_menu)
|
||||
layout.addWidget(self._list)
|
||||
|
||||
# ── Scanning ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_scan_clicked(self):
|
||||
self.scan_requested.emit()
|
||||
|
||||
def set_scanning(self, scanning: bool, status_text: str = ""):
|
||||
if scanning:
|
||||
self._scan_bar.show()
|
||||
self._scan_label.setText(status_text or "Starter...")
|
||||
self._scan_label.show()
|
||||
else:
|
||||
self._scan_bar.hide()
|
||||
self._scan_label.hide()
|
||||
|
||||
def update_scan_status(self, text: str):
|
||||
self._scan_label.setText(text)
|
||||
|
||||
# ── Sange ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def load_songs(self, songs: list[dict]):
|
||||
self._all_songs = songs
|
||||
self._do_search()
|
||||
|
||||
# ── Søgning ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_search_changed(self):
|
||||
self._search_timer.start()
|
||||
|
||||
def _do_search(self):
|
||||
q = self._search.text().strip().lower()
|
||||
self._filtered = [s for s in self._all_songs if self._matches(s, q)] if q else list(self._all_songs)
|
||||
total = len(self._all_songs)
|
||||
found = len(self._filtered)
|
||||
q_text = self._search.text().strip()
|
||||
self._count_label.setText(
|
||||
f"{found} resultat{'er' if found != 1 else ''} for \"{q_text}\"" if q_text
|
||||
else f"{total} sang{'e' if total != 1 else ''}"
|
||||
)
|
||||
self._render()
|
||||
|
||||
def _matches(self, song: dict, q: str) -> bool:
|
||||
return any(q in f.lower() for f in [
|
||||
song.get("title", ""), song.get("artist", ""),
|
||||
song.get("album", ""), song.get("file_format", ""),
|
||||
] + song.get("dances", []))
|
||||
|
||||
def _render(self):
|
||||
self._list.clear()
|
||||
q = self._search.text().strip().lower()
|
||||
for song in self._filtered:
|
||||
dances = song.get("dances", [])
|
||||
dance_levels = song.get("dance_levels", [])
|
||||
missing = song.get("file_missing", False)
|
||||
|
||||
# Byg dans-streng med niveau hvis tilgængeligt
|
||||
dance_parts = []
|
||||
for i, d in enumerate(dances):
|
||||
lvl = dance_levels[i] if i < len(dance_levels) else ""
|
||||
dance_parts.append(f"{d} / {lvl}" if lvl else d)
|
||||
dance_str = " · " + " | ".join(dance_parts) if dance_parts else ""
|
||||
|
||||
line1 = ("⚠ " if missing else "") + song.get("title", "—")
|
||||
bpm = song.get("bpm", 0)
|
||||
bpm_str = f"{bpm} BPM" if bpm else "? BPM"
|
||||
line2 = f" {song.get('artist','—')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}"
|
||||
item = QListWidgetItem(f"{line1}\n{line2}")
|
||||
item.setData(Qt.ItemDataRole.UserRole, song)
|
||||
if missing:
|
||||
item.setForeground(QColor("#5a6070"))
|
||||
elif q and any(q in d.lower() for d in dances):
|
||||
item.setForeground(QColor("#e8a020"))
|
||||
self._list.addItem(item)
|
||||
|
||||
# ── 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)
|
||||
if not item:
|
||||
return
|
||||
song = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not song:
|
||||
return
|
||||
menu = QMenu(self)
|
||||
act_add = menu.addAction("Tilføj til danseliste")
|
||||
act_play = menu.addAction("Afspil")
|
||||
menu.addSeparator()
|
||||
act_tags = menu.addAction("✎ Rediger dans-tags...")
|
||||
act_bpm = menu.addAction("♩ Analysér BPM")
|
||||
menu.addSeparator()
|
||||
send_menu = menu.addMenu("Send til")
|
||||
act_mail = send_menu.addAction("✉ Send som mail")
|
||||
action = menu.exec(self._list.mapToGlobal(pos))
|
||||
if action == act_add:
|
||||
self.add_to_playlist.emit(song)
|
||||
elif action == act_play:
|
||||
self.song_selected.emit(song)
|
||||
elif action == act_tags:
|
||||
self.edit_tags_requested.emit(song)
|
||||
elif action == act_bpm:
|
||||
self._analyze_bpm(song)
|
||||
elif action == act_mail:
|
||||
self.send_mail_requested.emit(song)
|
||||
|
||||
def _analyze_bpm(self, song: dict):
|
||||
"""Analysér BPM i baggrundstråd og opdater biblioteket."""
|
||||
path = song.get("local_path", "")
|
||||
song_id = song.get("id", "")
|
||||
if not path or not song_id:
|
||||
return
|
||||
from PyQt6.QtCore import QThread, pyqtSignal as _sig
|
||||
|
||||
class BpmWorker(QThread):
|
||||
done = _sig(float)
|
||||
def __init__(self, p, sid):
|
||||
super().__init__()
|
||||
self._p, self._sid = p, sid
|
||||
def run(self):
|
||||
from local.tag_reader import analyze_and_save_bpm
|
||||
bpm = analyze_and_save_bpm(self._p, self._sid)
|
||||
if bpm:
|
||||
self.done.emit(bpm)
|
||||
|
||||
self._bpm_worker = BpmWorker(path, song_id)
|
||||
|
||||
def on_bpm_done(bpm):
|
||||
# Opdater sangen i _all_songs listen direkte
|
||||
for s in self._all_songs:
|
||||
if s.get("id") == song_id:
|
||||
s["bpm"] = int(round(bpm))
|
||||
break
|
||||
self._do_search()
|
||||
|
||||
self._bpm_worker.done.connect(on_bpm_done)
|
||||
self._bpm_worker.start()
|
||||
|
||||
def _manage_libraries(self):
|
||||
from ui.library_manager import LibraryManagerDialog
|
||||
dialog = LibraryManagerDialog(parent=self.window())
|
||||
dialog.library_removed.connect(lambda _: self.scan_requested.emit())
|
||||
dialog.exec()
|
||||
|
||||
def _add_folder(self):
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||||
if folder:
|
||||
mw = self.window()
|
||||
if hasattr(mw, "add_library_path"):
|
||||
mw.add_library_path(folder)
|
||||
@@ -1,139 +0,0 @@
|
||||
"""
|
||||
login_dialog.py — Login-dialog til at gå online.
|
||||
Server-URL er hardcodet i config.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QLineEdit, QPushButton, QFrame, QCheckBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QSettings
|
||||
|
||||
# ── Hardcodet server-URL ──────────────────────────────────────────────────────
|
||||
API_URL = "http://din-server:8000"
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class LoginDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Gå online")
|
||||
self.setFixedWidth(340)
|
||||
self.setModal(True)
|
||||
|
||||
self._token: str | None = None
|
||||
self._username: str | None = None
|
||||
self._api_url = API_URL
|
||||
|
||||
self._build_ui()
|
||||
self._load_saved_settings()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
title = QLabel("Log ind på LineDance")
|
||||
title.setObjectName("track_title")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
sub = QLabel("Synkroniser projekter og alternativ-danse med andre brugere")
|
||||
sub.setObjectName("track_meta")
|
||||
sub.setWordWrap(True)
|
||||
sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(sub)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
layout.addWidget(line)
|
||||
|
||||
layout.addWidget(QLabel("Brugernavn:"))
|
||||
self._user_input = QLineEdit()
|
||||
self._user_input.setPlaceholderText("dit-brugernavn")
|
||||
layout.addWidget(self._user_input)
|
||||
|
||||
layout.addWidget(QLabel("Kodeord:"))
|
||||
self._pass_input = QLineEdit()
|
||||
self._pass_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self._pass_input.setPlaceholderText("••••••••")
|
||||
self._pass_input.returnPressed.connect(self._on_login)
|
||||
layout.addWidget(self._pass_input)
|
||||
|
||||
self._remember = QCheckBox("Husk brugernavn")
|
||||
self._remember.setChecked(True)
|
||||
layout.addWidget(self._remember)
|
||||
|
||||
self._status_label = QLabel("")
|
||||
self._status_label.setObjectName("track_meta")
|
||||
self._status_label.setWordWrap(True)
|
||||
layout.addWidget(self._status_label)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_cancel = QPushButton("Annuller")
|
||||
btn_cancel.clicked.connect(self.reject)
|
||||
btn_row.addWidget(btn_cancel)
|
||||
|
||||
self._btn_login = QPushButton("Log ind")
|
||||
self._btn_login.setObjectName("btn_play")
|
||||
self._btn_login.setDefault(True)
|
||||
self._btn_login.clicked.connect(self._on_login)
|
||||
btn_row.addWidget(self._btn_login)
|
||||
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
def _load_saved_settings(self):
|
||||
settings = QSettings("LineDance", "Player")
|
||||
self._user_input.setText(settings.value("username", ""))
|
||||
|
||||
def _save_settings(self):
|
||||
if self._remember.isChecked():
|
||||
settings = QSettings("LineDance", "Player")
|
||||
settings.setValue("username", self._user_input.text().strip())
|
||||
|
||||
def _on_login(self):
|
||||
username = self._user_input.text().strip()
|
||||
password = self._pass_input.text()
|
||||
|
||||
if not username or not password:
|
||||
self._set_status("Udfyld brugernavn og kodeord", error=True)
|
||||
return
|
||||
|
||||
self._btn_login.setEnabled(False)
|
||||
self._set_status("Forbinder...")
|
||||
|
||||
try:
|
||||
import urllib.request, urllib.parse, json
|
||||
|
||||
data = urllib.parse.urlencode({
|
||||
"username": username,
|
||||
"password": password,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/auth/login",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
body = json.loads(resp.read())
|
||||
self._token = body.get("access_token")
|
||||
self._username = username
|
||||
|
||||
self._save_settings()
|
||||
self._set_status("Logget ind!", error=False)
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
self._set_status(f"Fejl: {e}", error=True)
|
||||
self._btn_login.setEnabled(True)
|
||||
|
||||
def _set_status(self, text: str, error: bool = False):
|
||||
self._status_label.setText(text)
|
||||
color = "#e74c3c" if error else "#2ecc71"
|
||||
self._status_label.setStyleSheet(f"color: {color};")
|
||||
|
||||
def get_credentials(self) -> tuple[str, str, str]:
|
||||
"""Returnerer (api_url, username, token) efter succesfuldt login."""
|
||||
return self._api_url, self._username, self._token
|
||||
@@ -1,927 +0,0 @@
|
||||
"""
|
||||
main_window.py — Linedance afspiller hovedvindue.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QSlider, QLabel, QFrame, QSplitter,
|
||||
QSizePolicy, QMenuBar, QMenu, QStatusBar, QFileDialog,
|
||||
QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QAction
|
||||
|
||||
from ui.vu_meter import VUMeter
|
||||
from ui.playlist_panel import PlaylistPanel
|
||||
from ui.library_panel import LibraryPanel
|
||||
from ui.themes import apply_theme
|
||||
from ui.scan_worker import ScanWorker
|
||||
from ui.login_dialog import LoginDialog, API_URL
|
||||
from ui.playlist_manager import PlaylistManagerDialog
|
||||
from ui.settings_dialog import SettingsDialog, load_settings
|
||||
from player.player import Player
|
||||
|
||||
|
||||
class ProgressBar(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._fraction = 0.0
|
||||
self._demo_fraction = 0.0
|
||||
self.setFixedHeight(10)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
def set_fraction(self, f: float):
|
||||
self._fraction = max(0.0, min(1.0, f))
|
||||
self.update()
|
||||
|
||||
def set_demo_marker(self, f: float):
|
||||
self._demo_fraction = max(0.0, min(1.0, f))
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
from PyQt6.QtGui import QPainter, QColor
|
||||
p = QPainter(self)
|
||||
w, h = self.width(), self.height()
|
||||
p.fillRect(0, 0, w, h, QColor("#2c3038"))
|
||||
fill_w = int(w * self._fraction)
|
||||
if fill_w > 0:
|
||||
p.fillRect(0, 0, fill_w, h, QColor("#e8a020"))
|
||||
if self._demo_fraction > 0:
|
||||
mx = int(w * self._demo_fraction)
|
||||
p.fillRect(mx - 1, 0, 2, h, QColor("#3b8fd4"))
|
||||
p.end()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
fraction = event.position().x() / self.width()
|
||||
mw = self.window()
|
||||
if hasattr(mw, "_on_seek"):
|
||||
mw._on_seek(fraction)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("LineDance Player")
|
||||
self.setMinimumSize(1000, 680)
|
||||
self.resize(1600, 820)
|
||||
|
||||
self._dark_theme = True
|
||||
self._player = Player(self)
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._demo_active = False
|
||||
self._watcher = None
|
||||
self._scan_worker = None
|
||||
self._api_url: str | None = None
|
||||
self._api_token: str | None = None
|
||||
self._api_username: str | None = None
|
||||
|
||||
# Indlæs indstillinger
|
||||
self._settings = load_settings()
|
||||
self._dark_theme = self._settings.get("dark_theme", True)
|
||||
self._demo_seconds = self._settings.get("demo_seconds", 10)
|
||||
|
||||
self._connect_player_signals()
|
||||
self._build_menu()
|
||||
self._build_ui()
|
||||
self._build_statusbar()
|
||||
apply_theme(self._app_ref(), dark=self._dark_theme)
|
||||
self._theme_btn.setText("☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA")
|
||||
|
||||
# Gendan gemt vinduestørrelse og splitter-position
|
||||
self._restore_window_state()
|
||||
|
||||
# Start DB og scanning ved opstart
|
||||
QTimer.singleShot(200, self._init_local_db)
|
||||
|
||||
# Auto-login hvis aktiveret i indstillinger
|
||||
if self._settings.get("auto_login") and self._settings.get("password"):
|
||||
QTimer.singleShot(800, self._auto_login)
|
||||
|
||||
def _app_ref(self):
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
return QApplication.instance()
|
||||
|
||||
def _connect_player_signals(self):
|
||||
self._player.position_changed.connect(self._on_position)
|
||||
self._player.time_changed.connect(self._on_time)
|
||||
self._player.levels_changed.connect(self._on_levels)
|
||||
self._player.song_ended.connect(self._on_song_ended)
|
||||
self._player.state_changed.connect(self._on_state_changed)
|
||||
|
||||
# ── Menu ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_menu(self):
|
||||
menubar = self.menuBar()
|
||||
|
||||
# ── Filer ─────────────────────────────────────────────────────────────
|
||||
file_menu = menubar.addMenu("Filer")
|
||||
|
||||
self._act_go_online = QAction("Gå online...", self)
|
||||
self._act_go_online.setShortcut("Ctrl+L")
|
||||
self._act_go_online.triggered.connect(self._go_online)
|
||||
file_menu.addAction(self._act_go_online)
|
||||
|
||||
self._act_go_offline = QAction("Gå offline", self)
|
||||
self._act_go_offline.triggered.connect(self._go_offline)
|
||||
self._act_go_offline.setEnabled(False)
|
||||
file_menu.addAction(self._act_go_offline)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
act_settings = QAction("Indstillinger...", self)
|
||||
act_settings.setShortcut("Ctrl+,")
|
||||
act_settings.triggered.connect(self._open_settings)
|
||||
file_menu.addAction(act_settings)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
act_quit = QAction("Afslut", self)
|
||||
act_quit.setShortcut("Ctrl+Q")
|
||||
act_quit.triggered.connect(self.close)
|
||||
file_menu.addAction(act_quit)
|
||||
|
||||
# ── Ingen Danseliste- eller Visning-menu ──────────────────────────────
|
||||
# Ny/Gem/Hent ligger direkte i danseliste-panelet
|
||||
# Tema-skift ligger i topbar-knappen
|
||||
# Mapper og scan ligger i ⚙ Mapper dialogen
|
||||
|
||||
# Gem reference til scan-action (bruges stadig internt)
|
||||
self._act_scan = QAction("Scan", self)
|
||||
self._act_scan.triggered.connect(self.start_scan)
|
||||
|
||||
# ── Statuslinje ───────────────────────────────────────────────────────────
|
||||
|
||||
def _build_statusbar(self):
|
||||
self._statusbar = QStatusBar()
|
||||
self.setStatusBar(self._statusbar)
|
||||
self._statusbar.showMessage("Klar")
|
||||
|
||||
def _set_status(self, text: str, timeout_ms: int = 0):
|
||||
"""Vis besked i statuslinjen. timeout_ms=0 = permanent."""
|
||||
self._statusbar.showMessage(text, timeout_ms)
|
||||
|
||||
# ── UI byggeri ────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QWidget()
|
||||
root.setObjectName("root")
|
||||
self.setCentralWidget(root)
|
||||
main_layout = QVBoxLayout(root)
|
||||
main_layout.setContentsMargins(10, 6, 10, 10)
|
||||
main_layout.setSpacing(4)
|
||||
|
||||
main_layout.addWidget(self._build_topbar())
|
||||
main_layout.addWidget(self._build_now_playing())
|
||||
main_layout.addWidget(self._build_progress())
|
||||
main_layout.addWidget(self._build_transport())
|
||||
main_layout.addWidget(self._build_panels(), stretch=1)
|
||||
|
||||
def _build_topbar(self) -> QFrame:
|
||||
bar = QFrame()
|
||||
bar.setObjectName("topbar")
|
||||
layout = QHBoxLayout(bar)
|
||||
layout.setContentsMargins(12, 6, 12, 6)
|
||||
|
||||
logo = QLabel("LINE<span style='color:#9aa0b0;font-weight:400'>DANCE</span> PLAYER")
|
||||
logo.setObjectName("logo")
|
||||
logo.setTextFormat(Qt.TextFormat.RichText)
|
||||
layout.addWidget(logo)
|
||||
layout.addStretch()
|
||||
|
||||
self._conn_label = QLabel("● OFFLINE")
|
||||
self._conn_label.setObjectName("conn_label")
|
||||
layout.addWidget(self._conn_label)
|
||||
|
||||
self._theme_btn = QPushButton("☀ LYS TEMA")
|
||||
self._theme_btn.setFixedHeight(26)
|
||||
self._theme_btn.clicked.connect(self._toggle_theme)
|
||||
layout.addWidget(self._theme_btn)
|
||||
|
||||
return bar
|
||||
|
||||
def _build_now_playing(self) -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setObjectName("now_playing_frame")
|
||||
layout = QHBoxLayout(frame)
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
track_frame = QFrame()
|
||||
track_frame.setObjectName("track_display")
|
||||
track_layout = QVBoxLayout(track_frame)
|
||||
track_layout.setContentsMargins(10, 8, 10, 8)
|
||||
track_layout.setSpacing(3)
|
||||
|
||||
self._lbl_title = QLabel("—")
|
||||
self._lbl_title.setObjectName("track_title")
|
||||
track_layout.addWidget(self._lbl_title)
|
||||
|
||||
self._lbl_meta = QLabel("—")
|
||||
self._lbl_meta.setObjectName("track_meta")
|
||||
track_layout.addWidget(self._lbl_meta)
|
||||
|
||||
self._lbl_dances = QLabel("")
|
||||
self._lbl_dances.setObjectName("track_meta")
|
||||
self._lbl_dances.setWordWrap(True)
|
||||
track_layout.addWidget(self._lbl_dances)
|
||||
|
||||
layout.addWidget(track_frame, stretch=1)
|
||||
|
||||
self._vu = VUMeter()
|
||||
layout.addWidget(self._vu)
|
||||
|
||||
return frame
|
||||
|
||||
def _build_progress(self) -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setObjectName("progress_frame")
|
||||
layout = QHBoxLayout(frame)
|
||||
layout.setContentsMargins(12, 6, 12, 6)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self._lbl_cur = QLabel("0:00")
|
||||
self._lbl_cur.setObjectName("track_meta")
|
||||
self._lbl_cur.setFixedWidth(36)
|
||||
layout.addWidget(self._lbl_cur)
|
||||
|
||||
self._progress = ProgressBar(self)
|
||||
self._progress.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
layout.addWidget(self._progress, stretch=1)
|
||||
|
||||
self._lbl_tot = QLabel("0:00")
|
||||
self._lbl_tot.setObjectName("track_meta")
|
||||
self._lbl_tot.setFixedWidth(36)
|
||||
self._lbl_tot.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
layout.addWidget(self._lbl_tot)
|
||||
|
||||
return frame
|
||||
|
||||
def _build_transport(self) -> QFrame:
|
||||
frame = QFrame()
|
||||
frame.setObjectName("transport_frame")
|
||||
layout = QHBoxLayout(frame)
|
||||
layout.setContentsMargins(14, 10, 14, 10)
|
||||
layout.setSpacing(8)
|
||||
|
||||
def btn(text, name=None, size=52, checkable=False):
|
||||
b = QPushButton(text)
|
||||
if name:
|
||||
b.setObjectName(name)
|
||||
b.setFixedSize(size, size)
|
||||
if checkable:
|
||||
b.setCheckable(True)
|
||||
return b
|
||||
|
||||
self._btn_prev = btn("⏮", size=52)
|
||||
self._btn_play = btn("▶", "btn_play", size=72)
|
||||
self._btn_stop = btn("⏹", "btn_stop", size=52)
|
||||
self._btn_next = btn("⏭", size=52)
|
||||
self._btn_demo = btn(f"▶\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True)
|
||||
|
||||
self._btn_prev.clicked.connect(self._prev_song)
|
||||
self._btn_play.clicked.connect(self._toggle_play)
|
||||
self._btn_stop.clicked.connect(self._stop)
|
||||
self._btn_next.clicked.connect(self._next_song)
|
||||
self._btn_demo.clicked.connect(self._toggle_demo)
|
||||
|
||||
layout.addWidget(self._btn_prev)
|
||||
layout.addWidget(self._btn_play)
|
||||
layout.addWidget(self._btn_stop)
|
||||
layout.addWidget(self._btn_next)
|
||||
|
||||
sep1 = QFrame()
|
||||
sep1.setFrameShape(QFrame.Shape.VLine)
|
||||
sep1.setFixedWidth(1)
|
||||
layout.addWidget(sep1)
|
||||
|
||||
layout.addWidget(self._btn_demo)
|
||||
layout.addStretch()
|
||||
|
||||
lbl_vol = QLabel("VOL")
|
||||
lbl_vol.setObjectName("vol_label")
|
||||
layout.addWidget(lbl_vol)
|
||||
|
||||
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._vol_slider.setRange(0, 100)
|
||||
self._vol_slider.setValue(78)
|
||||
self._vol_slider.setFixedWidth(100)
|
||||
self._vol_slider.valueChanged.connect(self._on_volume)
|
||||
layout.addWidget(self._vol_slider)
|
||||
|
||||
self._lbl_vol = QLabel("78")
|
||||
self._lbl_vol.setObjectName("vol_val")
|
||||
layout.addWidget(self._lbl_vol)
|
||||
|
||||
return frame
|
||||
|
||||
def _build_panels(self) -> QSplitter:
|
||||
self._splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
|
||||
self._playlist_panel = PlaylistPanel()
|
||||
self._playlist_panel.song_selected.connect(self._load_song_by_idx)
|
||||
self._playlist_panel.song_dropped.connect(self._on_song_dropped)
|
||||
self._playlist_panel.event_started.connect(self._on_event_started)
|
||||
self._playlist_panel.next_song_ready.connect(self._load_song)
|
||||
|
||||
self._library_panel = LibraryPanel()
|
||||
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)
|
||||
self._library_panel.edit_tags_requested.connect(self._open_tag_editor)
|
||||
self._library_panel.send_mail_requested.connect(self._send_mail)
|
||||
|
||||
self._splitter.addWidget(self._playlist_panel)
|
||||
self._splitter.addWidget(self._library_panel)
|
||||
self._splitter.setSizes([700, 900])
|
||||
|
||||
return self._splitter
|
||||
|
||||
def _restore_window_state(self):
|
||||
from PyQt6.QtCore import QSettings, QByteArray
|
||||
settings = QSettings("LineDance", "Player")
|
||||
geom = settings.value("window/geometry")
|
||||
if geom:
|
||||
self.restoreGeometry(geom)
|
||||
splitter_state = settings.value("window/splitter")
|
||||
if splitter_state and hasattr(self, "_splitter"):
|
||||
self._splitter.restoreState(splitter_state)
|
||||
|
||||
def _save_window_state(self):
|
||||
from PyQt6.QtCore import QSettings
|
||||
settings = QSettings("LineDance", "Player")
|
||||
settings.setValue("window/geometry", self.saveGeometry())
|
||||
if hasattr(self, "_splitter"):
|
||||
settings.setValue("window/splitter", self._splitter.saveState())
|
||||
|
||||
# ── Lokal DB + scanning ───────────────────────────────────────────────────
|
||||
|
||||
def _init_local_db(self):
|
||||
try:
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from local.local_db import init_db
|
||||
from local.file_watcher import get_watcher
|
||||
|
||||
init_db()
|
||||
|
||||
# Brug et Qt signal til thread-safe reload fra watcher-tråden
|
||||
from PyQt6.QtCore import QMetaObject, Q_ARG
|
||||
def on_file_change(event_type, path, song_id):
|
||||
QTimer.singleShot(0, self._reload_library)
|
||||
|
||||
self._watcher = get_watcher(on_change=on_file_change)
|
||||
self._watcher.start()
|
||||
|
||||
# Indlæs hvad vi allerede kender fra SQLite
|
||||
self._reload_library()
|
||||
|
||||
# Gendan sidst aktive danseliste
|
||||
restored = self._playlist_panel.restore_active_playlist()
|
||||
|
||||
# Gendan event-fremgang hvis liste blev gendannet
|
||||
if restored:
|
||||
if self._playlist_panel.restore_event_state():
|
||||
# Indlæs den sang vi var nået til
|
||||
idx = self._playlist_panel._current_idx
|
||||
song = self._playlist_panel.get_song(idx)
|
||||
if song:
|
||||
self._current_idx = idx
|
||||
self._load_song(song)
|
||||
self._set_status(
|
||||
f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte",
|
||||
6000,
|
||||
)
|
||||
|
||||
# Kør automatisk scanning ved opstart
|
||||
self._set_status("Starter scanning af biblioteker...")
|
||||
QTimer.singleShot(100, self.start_scan)
|
||||
|
||||
except Exception as e:
|
||||
self._set_status(f"DB fejl: {e}")
|
||||
print(f"DB init fejl: {e}")
|
||||
|
||||
def start_scan(self):
|
||||
"""Start fuld scanning af alle biblioteker i baggrundstråd."""
|
||||
if self._scan_worker and self._scan_worker.isRunning():
|
||||
return # Scanning kører allerede
|
||||
|
||||
if not self._watcher:
|
||||
self._set_status("Ingen biblioteker at scanne — tilføj en mappe først")
|
||||
return
|
||||
|
||||
self._library_panel.set_scanning(True, "Forbereder scanning...")
|
||||
self._act_scan.setEnabled(False)
|
||||
|
||||
self._scan_worker = ScanWorker(self._watcher, parent=self)
|
||||
self._scan_worker.status_update.connect(self._on_scan_status)
|
||||
self._scan_worker.scan_done.connect(self._on_scan_done)
|
||||
self._scan_worker.start()
|
||||
|
||||
def _on_scan_status(self, text: str):
|
||||
self._set_status(text)
|
||||
self._library_panel.update_scan_status(text)
|
||||
|
||||
def _on_scan_done(self, count: int):
|
||||
self._library_panel.set_scanning(False)
|
||||
self._act_scan.setEnabled(True)
|
||||
msg = f"Scanning færdig — {count} filer gennemgået"
|
||||
self._set_status(msg, timeout_ms=5000)
|
||||
# Genindlæs biblioteket
|
||||
QTimer.singleShot(200, self._reload_library)
|
||||
|
||||
def _reload_library(self):
|
||||
try:
|
||||
from local.local_db import search_songs, get_db
|
||||
songs_raw = search_songs("", limit=5000)
|
||||
songs = []
|
||||
for row in songs_raw:
|
||||
with get_db() as conn:
|
||||
dances_raw = conn.execute(
|
||||
"SELECT sd.dance_name, dl.name as level_name "
|
||||
"FROM song_dances sd "
|
||||
"LEFT JOIN dance_levels dl ON dl.id = sd.level_id "
|
||||
"WHERE sd.song_id=? ORDER BY sd.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_raw],
|
||||
"dance_levels": [d["level_name"] or "" for d in dances_raw],
|
||||
})
|
||||
self._library_panel.load_songs(songs)
|
||||
count = len(songs)
|
||||
self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000)
|
||||
except Exception as e:
|
||||
print(f"Bibliotek reload fejl: {e}")
|
||||
|
||||
def add_library_path(self, path: str):
|
||||
try:
|
||||
if not self._watcher:
|
||||
self._set_status("Watcher ikke klar endnu — prøv igen om et øjeblik", 3000)
|
||||
return
|
||||
self._watcher.add_library(path)
|
||||
self._set_status(f"Tilføjet: {path} — scanner...")
|
||||
# Genindlæs bibliotekslisten og start scan
|
||||
QTimer.singleShot(500, self._reload_library)
|
||||
QTimer.singleShot(1000, self.start_scan)
|
||||
except Exception as e:
|
||||
self._set_status(f"Fejl ved tilføjelse: {e}")
|
||||
|
||||
def _open_settings(self):
|
||||
dialog = SettingsDialog(parent=self)
|
||||
if dialog.exec():
|
||||
self._settings = dialog.get_values()
|
||||
self._demo_seconds = self._settings.get("demo_seconds", 10)
|
||||
# Opdater tema hvis ændret
|
||||
new_dark = self._settings.get("dark_theme", True)
|
||||
if new_dark != self._dark_theme:
|
||||
self._dark_theme = new_dark
|
||||
apply_theme(self._app_ref(), dark=self._dark_theme)
|
||||
self._theme_btn.setText(
|
||||
"☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA"
|
||||
)
|
||||
self._vu.set_dark(self._dark_theme)
|
||||
# Opdater demo-knap tekst
|
||||
self._btn_demo.setText(f"▶\n{self._demo_seconds} SEK")
|
||||
# Opdater demo-markør hvis en sang er indlæst
|
||||
if hasattr(self, "_current_song") and self._current_song:
|
||||
dur = self._current_song.get("duration_sec", 0)
|
||||
if dur > 0:
|
||||
self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0))
|
||||
self._set_status("Indstillinger gemt", 2000)
|
||||
|
||||
def _auto_login(self):
|
||||
"""Forsøg automatisk login med gemte oplysninger."""
|
||||
username = self._settings.get("username", "")
|
||||
password = self._settings.get("password", "")
|
||||
if not username or not password:
|
||||
return
|
||||
try:
|
||||
import urllib.request, urllib.parse, json
|
||||
data = urllib.parse.urlencode({"username": username, "password": password}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/auth/login", data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
body = json.loads(resp.read())
|
||||
self._api_token = body.get("access_token")
|
||||
self._api_url = API_URL
|
||||
self._api_username = username
|
||||
self._set_online_state(True)
|
||||
self._set_status(f"Automatisk logget ind som {username}", 4000)
|
||||
# Synkroniser dans-niveauer og navne
|
||||
QTimer.singleShot(500, self._sync_dance_data)
|
||||
except Exception:
|
||||
self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000)
|
||||
|
||||
def _go_online(self):
|
||||
dialog = LoginDialog(self)
|
||||
if dialog.exec():
|
||||
url, username, token = dialog.get_credentials()
|
||||
self._api_url = url
|
||||
self._api_token = token
|
||||
self._api_username = username
|
||||
self._set_online_state(True)
|
||||
self._set_status(f"Online som {username}", 5000)
|
||||
QTimer.singleShot(500, self._sync_dance_data)
|
||||
|
||||
def _sync_dance_data(self):
|
||||
"""Synkroniser dans-niveauer og navne fra API."""
|
||||
if not self._api_token:
|
||||
return
|
||||
try:
|
||||
import urllib.request, json
|
||||
headers = {"Authorization": f"Bearer {self._api_token}"}
|
||||
|
||||
# Hent niveauer
|
||||
req = urllib.request.Request(f"{API_URL}/dances/levels", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
levels = json.loads(resp.read())
|
||||
from local.local_db import sync_dance_levels_from_api
|
||||
sync_dance_levels_from_api(levels)
|
||||
|
||||
# Hent populære dans-navne
|
||||
req = urllib.request.Request(f"{API_URL}/dances/names?limit=500", headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
names = json.loads(resp.read())
|
||||
from local.local_db import sync_dance_names_from_api
|
||||
sync_dance_names_from_api(names)
|
||||
|
||||
self._set_status(f"Synkroniseret {len(levels)} niveauer og {len(names)} dans-navne", 4000)
|
||||
except Exception as e:
|
||||
print(f"Dans-sync fejl: {e}")
|
||||
|
||||
def _go_offline(self):
|
||||
self._api_url = self._api_token = self._api_username = None
|
||||
self._set_online_state(False)
|
||||
self._set_status("Offline — arbejder lokalt", 3000)
|
||||
|
||||
def _set_online_state(self, online: bool):
|
||||
self._act_go_online.setEnabled(not online)
|
||||
self._act_go_offline.setEnabled(online)
|
||||
if online:
|
||||
name = self._api_username or "?"
|
||||
self._conn_label.setText(f"● ONLINE ({name})")
|
||||
self._conn_label.setStyleSheet("color: #2ecc71;")
|
||||
else:
|
||||
self._conn_label.setText("● OFFLINE")
|
||||
self._conn_label.setStyleSheet("color: #5a6070;")
|
||||
|
||||
def _new_playlist(self):
|
||||
self._stop()
|
||||
self._playlist_panel.load_songs([])
|
||||
self._playlist_panel.set_playlist_name("Ny liste")
|
||||
self._set_status("Ny danseliste oprettet", 2000)
|
||||
|
||||
def _open_playlist_manager(self):
|
||||
dialog = PlaylistManagerDialog(
|
||||
current_songs=self._playlist_panel.get_songs(),
|
||||
parent=self,
|
||||
)
|
||||
dialog.playlist_loaded.connect(self._on_playlist_loaded)
|
||||
dialog.exec()
|
||||
|
||||
def _on_playlist_loaded(self, name: str, songs: list[dict]):
|
||||
self._stop()
|
||||
self._playlist_panel.load_songs(songs)
|
||||
self._playlist_panel.set_playlist_name(name)
|
||||
self._set_status(f"Indlæst: {name} ({len(songs)} sange)", 3000)
|
||||
|
||||
def _open_tag_editor(self, song: dict):
|
||||
from ui.tag_editor import TagEditorDialog
|
||||
dialog = TagEditorDialog(song, parent=self)
|
||||
if dialog.exec():
|
||||
# Genindlæs biblioteket så ændringer vises
|
||||
QTimer.singleShot(200, self._reload_library)
|
||||
|
||||
def _send_mail(self, song: dict):
|
||||
import subprocess, sys, shutil, urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
path = song.get("local_path", "")
|
||||
title = song.get("title", "")
|
||||
artist = song.get("artist", "")
|
||||
|
||||
if not path or not Path(path).exists():
|
||||
self._set_status("Filen blev ikke fundet — kan ikke sende mail", 4000)
|
||||
return
|
||||
|
||||
# ── Auto-detekter mailklient ───────────────────────────────────────────
|
||||
|
||||
def try_thunderbird() -> bool:
|
||||
"""Thunderbird: thunderbird -compose attachment='file:///sti'"""
|
||||
candidates = []
|
||||
if sys.platform == "win32":
|
||||
import winreg
|
||||
for base in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
||||
try:
|
||||
key = winreg.OpenKey(base,
|
||||
r"SOFTWARE\Mozilla\Mozilla Thunderbird")
|
||||
inst, _ = winreg.QueryValueEx(key, "Install Directory")
|
||||
candidates.append(str(Path(inst) / "thunderbird.exe"))
|
||||
except Exception:
|
||||
pass
|
||||
candidates += [
|
||||
r"C:\Program Files\Mozilla Thunderbird\thunderbird.exe",
|
||||
r"C:\Program Files (x86)\Mozilla Thunderbird\thunderbird.exe",
|
||||
]
|
||||
elif sys.platform == "darwin":
|
||||
candidates = [
|
||||
"/Applications/Thunderbird.app/Contents/MacOS/thunderbird",
|
||||
]
|
||||
else:
|
||||
candidates = [shutil.which("thunderbird") or "",
|
||||
"/usr/bin/thunderbird",
|
||||
"/usr/local/bin/thunderbird",
|
||||
"/snap/bin/thunderbird"]
|
||||
|
||||
tb = next((c for c in candidates if c and Path(c).exists()), None)
|
||||
if not tb:
|
||||
return False
|
||||
|
||||
file_uri = Path(path).as_uri()
|
||||
subject = f"Linedance sang: {title} — {artist}"
|
||||
compose = (
|
||||
f"subject='{subject}',"
|
||||
f"attachment='{file_uri}'"
|
||||
)
|
||||
subprocess.Popen([tb, "-compose", compose])
|
||||
return True
|
||||
|
||||
def try_outlook() -> bool:
|
||||
"""Outlook: outlook.exe /a 'filsti' (kun Windows)"""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
candidates = [
|
||||
shutil.which("outlook") or "",
|
||||
r"C:\Program Files\Microsoft Office\root\Office16\OUTLOOK.EXE",
|
||||
r"C:\Program Files (x86)\Microsoft Office\root\Office16\OUTLOOK.EXE",
|
||||
r"C:\Program Files\Microsoft Office\Office16\OUTLOOK.EXE",
|
||||
]
|
||||
ol = next((c for c in candidates if c and Path(c).exists()), None)
|
||||
if not ol:
|
||||
return False
|
||||
subprocess.Popen([ol, "/a", path])
|
||||
return True
|
||||
|
||||
def fallback_mailto():
|
||||
"""Ingen vedhæftning — åbn standard-mailprogram via mailto:"""
|
||||
subject = urllib.parse.quote(f"Linedance sang: {title} — {artist}")
|
||||
body = urllib.parse.quote(
|
||||
f"Sang: {title}\nArtist: {artist}\nFil: {path}\n\n"
|
||||
f"(Vedhæft filen manuelt fra ovenstående sti)"
|
||||
)
|
||||
mailto = f"mailto:?subject={subject}&body={body}"
|
||||
if sys.platform == "win32":
|
||||
import os; os.startfile(mailto)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", mailto])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", mailto])
|
||||
|
||||
# ── Prøv i rækkefølge ─────────────────────────────────────────────────
|
||||
if try_thunderbird():
|
||||
self._set_status(f"Thunderbird åbnet med {Path(path).name} vedh.", 4000)
|
||||
elif try_outlook():
|
||||
self._set_status(f"Outlook åbnet med {Path(path).name} vedh.", 4000)
|
||||
else:
|
||||
fallback_mailto()
|
||||
self._set_status(
|
||||
f"Ingen kendt mailklient fundet — åbnet mailto: (uden vedhæftning)", 5000
|
||||
)
|
||||
|
||||
def _on_event_started(self):
|
||||
"""Start event — indlæs første sang i afspilleren klar til afspilning."""
|
||||
first = self._playlist_panel.get_song(0)
|
||||
if not first:
|
||||
return
|
||||
self._stop()
|
||||
self._current_idx = 0
|
||||
self._song_ended = False
|
||||
self._load_song(first)
|
||||
self._set_status("Event klar — tryk ▶ for at starte", 5000)
|
||||
|
||||
def _on_song_dropped(self, song: dict):
|
||||
self._set_status(f"Tilføjet: {song.get('title','')}", 2000)
|
||||
|
||||
def _menu_add_folder(self):
|
||||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||||
if folder:
|
||||
self.add_library_path(folder)
|
||||
|
||||
# ── Afspilning ────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_song(self, song: dict):
|
||||
self._current_song = song
|
||||
self._song_ended = False
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
|
||||
dur = song.get("duration_sec", 0)
|
||||
self._player.load(song.get("local_path", ""), dur)
|
||||
|
||||
self._lbl_title.setText(song.get("title", "—"))
|
||||
bpm = song.get("bpm", 0)
|
||||
fmt_dur = f"{dur//60}:{dur%60:02d}"
|
||||
self._lbl_meta.setText(f"{song.get('artist','')} · {bpm} BPM · {fmt_dur}")
|
||||
|
||||
dances = song.get("dances", [])
|
||||
self._lbl_dances.setText(
|
||||
" · ".join(f"[{d}]" for d in dances) if dances else "ingen danse tagget"
|
||||
)
|
||||
|
||||
if dur > 0:
|
||||
self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0))
|
||||
|
||||
self._set_status(f"Indlæst: {song.get('title','—')}", 3000)
|
||||
|
||||
def _load_song_by_idx(self, idx: int):
|
||||
song = self._playlist_panel.get_song(idx)
|
||||
if not song:
|
||||
return
|
||||
self._current_idx = idx
|
||||
self._load_song(song)
|
||||
self._playlist_panel.set_current(idx)
|
||||
|
||||
def _toggle_play(self):
|
||||
if self._demo_active:
|
||||
self._player.stop()
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
return
|
||||
if self._player.is_playing():
|
||||
self._player.pause()
|
||||
else:
|
||||
self._song_ended = False
|
||||
self._player.play()
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
def _stop(self):
|
||||
self._player.stop()
|
||||
self._song_ended = False
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
self._vu.reset()
|
||||
|
||||
def _toggle_demo(self):
|
||||
if self._demo_active:
|
||||
self._player.stop()
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
else:
|
||||
self._demo_active = True
|
||||
self._btn_demo.setChecked(True)
|
||||
self._player.play_demo(stop_at_sec=self._demo_seconds)
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
def _prev_song(self):
|
||||
if self._current_idx > 0:
|
||||
self._stop()
|
||||
self._load_song_by_idx(self._current_idx - 1)
|
||||
|
||||
def _next_song(self):
|
||||
if self._current_idx < self._playlist_panel.count() - 1:
|
||||
self._stop()
|
||||
self._playlist_panel.mark_played(self._current_idx)
|
||||
self._load_song_by_idx(self._current_idx + 1)
|
||||
|
||||
def _play_next(self):
|
||||
self._song_ended = False
|
||||
self._player.play()
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
def _on_library_song_selected(self, song: dict):
|
||||
self._load_song(song)
|
||||
self._player.play()
|
||||
self._btn_play.setText("⏸")
|
||||
|
||||
def _add_song_to_playlist(self, song: dict):
|
||||
songs = [self._playlist_panel.get_song(i)
|
||||
for i in range(self._playlist_panel.count())]
|
||||
songs = [s for s in songs if s]
|
||||
songs.append(song)
|
||||
self._playlist_panel.load_songs(songs)
|
||||
self._set_status(f"Tilføjet til danseliste: {song.get('title','')}", 2000)
|
||||
|
||||
# ── Player signals ────────────────────────────────────────────────────────
|
||||
|
||||
def _on_position(self, fraction: float):
|
||||
self._progress.set_fraction(fraction)
|
||||
|
||||
def _on_time(self, cur: int, tot: int):
|
||||
self._lbl_cur.setText(f"{cur//60}:{cur%60:02d}")
|
||||
self._lbl_tot.setText(f"{tot//60}:{tot%60:02d}")
|
||||
|
||||
def _on_levels(self, left: float, right: float):
|
||||
self._vu.set_levels(left, right)
|
||||
|
||||
def _on_song_ended(self):
|
||||
self._song_ended = True
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
self._vu.reset()
|
||||
|
||||
# Markér den afspillede sang
|
||||
self._playlist_panel.mark_played(self._current_idx)
|
||||
|
||||
# Synkroniser event-status til den gemte navngivne liste
|
||||
self._sync_event_status_to_playlist()
|
||||
|
||||
# Find første ikke-afspillede og ikke-skippede sang fra TOPPEN
|
||||
ni = self._playlist_panel.next_playable_idx()
|
||||
next_song = self._playlist_panel.get_song(ni) if ni is not None else None
|
||||
if next_song:
|
||||
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")
|
||||
else:
|
||||
self._lbl_title.setText("— Danseliste afsluttet —")
|
||||
self._lbl_meta.setText("")
|
||||
self._lbl_dances.setText("")
|
||||
self._set_status("Danselisten er afsluttet")
|
||||
|
||||
def _sync_event_status_to_playlist(self):
|
||||
"""Gem event-fremgang i den aktive navngivne liste."""
|
||||
try:
|
||||
from local.local_db import get_db
|
||||
songs = self._playlist_panel.get_songs()
|
||||
statuses = self._playlist_panel.get_statuses()
|
||||
with get_db() as conn:
|
||||
# Find den aktive liste (ikke __aktiv__)
|
||||
pl = conn.execute(
|
||||
"SELECT id FROM playlists WHERE name != '__aktiv__' "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if not pl:
|
||||
return
|
||||
# Opdater status for hver sang i listen
|
||||
for i, (song, status) in enumerate(zip(songs, statuses)):
|
||||
conn.execute("""
|
||||
UPDATE playlist_songs SET status=?
|
||||
WHERE playlist_id=? AND song_id=?
|
||||
""", (status, pl["id"], song.get("id")))
|
||||
except Exception as e:
|
||||
print(f"Event-status sync fejl: {e}")
|
||||
|
||||
def _on_state_changed(self, state: str):
|
||||
if state == "playing":
|
||||
self._btn_play.setText("⏸")
|
||||
elif state in ("paused", "stopped"):
|
||||
self._btn_play.setText("▶")
|
||||
if state == "stopped" and not self._song_ended:
|
||||
self._vu.reset()
|
||||
elif state == "demo_ended":
|
||||
self._demo_active = False
|
||||
self._btn_demo.setChecked(False)
|
||||
self._btn_play.setText("▶")
|
||||
self._vu.reset()
|
||||
|
||||
def _on_seek(self, fraction: float):
|
||||
self._player.set_position(fraction)
|
||||
|
||||
def _on_volume(self, value: int):
|
||||
self._lbl_vol.setText(str(value))
|
||||
self._player.set_volume(value)
|
||||
|
||||
# ── Tema ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _toggle_theme(self):
|
||||
self._dark_theme = not self._dark_theme
|
||||
apply_theme(self._app_ref(), dark=self._dark_theme)
|
||||
self._theme_btn.setText(
|
||||
"● MØRKT TEMA" if not self._dark_theme else "☀ LYS TEMA"
|
||||
)
|
||||
self._vu.set_dark(self._dark_theme)
|
||||
|
||||
# ── Luk ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._save_window_state()
|
||||
self._player.stop()
|
||||
if self._scan_worker and self._scan_worker.isRunning():
|
||||
self._scan_worker.quit()
|
||||
self._scan_worker.wait(2000)
|
||||
try:
|
||||
if self._watcher:
|
||||
self._watcher.stop()
|
||||
except Exception:
|
||||
pass
|
||||
event.accept()
|
||||
@@ -1,59 +0,0 @@
|
||||
"""
|
||||
next_up_bar.py — Banner der vises når en sang er færdig.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QFrame, QHBoxLayout, QVBoxLayout, QLabel, QPushButton,
|
||||
)
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
|
||||
|
||||
class NextUpBar(QFrame):
|
||||
play_next_clicked = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("next_up_frame")
|
||||
self.hide()
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(16, 10, 16, 10)
|
||||
|
||||
# Tekst
|
||||
text_layout = QVBoxLayout()
|
||||
text_layout.setSpacing(2)
|
||||
|
||||
self._label = QLabel("NÆSTE SANG KLAR")
|
||||
self._label.setObjectName("next_up_label")
|
||||
text_layout.addWidget(self._label)
|
||||
|
||||
self._title = QLabel("—")
|
||||
self._title.setObjectName("next_up_title")
|
||||
text_layout.addWidget(self._title)
|
||||
|
||||
self._sub = QLabel("—")
|
||||
self._sub.setObjectName("next_up_sub")
|
||||
text_layout.addWidget(self._sub)
|
||||
|
||||
layout.addLayout(text_layout)
|
||||
layout.addStretch()
|
||||
|
||||
# Knap
|
||||
self._btn = QPushButton("▶ AFSPIL NÆSTE")
|
||||
self._btn.setObjectName("btn_play_next")
|
||||
self._btn.setFixedHeight(44)
|
||||
self._btn.setMinimumWidth(160)
|
||||
self._btn.clicked.connect(self.play_next_clicked.emit)
|
||||
layout.addWidget(self._btn)
|
||||
|
||||
def show_next(self, title: str, artist: str, dances: list[str]):
|
||||
dance_str = "Dans: " + ", ".join(dances) if dances else ""
|
||||
sub = f"{artist}{' · ' + dance_str if dance_str else ''}"
|
||||
self._title.setText(title)
|
||||
self._sub.setText(sub)
|
||||
self.show()
|
||||
|
||||
def hide_bar(self):
|
||||
self.hide()
|
||||
@@ -1,324 +0,0 @@
|
||||
"""
|
||||
playlist_manager.py — Dialog til danseliste-administration.
|
||||
Ny liste, gem, load og importer M3U/M3U8/tekst.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QListWidget, QListWidgetItem, QFileDialog,
|
||||
QMessageBox, QTabWidget, QWidget, QTextEdit,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
|
||||
|
||||
class PlaylistManagerDialog(QDialog):
|
||||
"""
|
||||
Fanebaseret dialog med tre faner:
|
||||
1. Gem aktuel liste
|
||||
2. Indlæs gemt liste
|
||||
3. Importer fra fil (M3U / M3U8 / tekst)
|
||||
"""
|
||||
playlist_loaded = pyqtSignal(str, list) # (navn, liste af dict)
|
||||
|
||||
def __init__(self, current_songs: list[dict], parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Danseliste-administration")
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(460)
|
||||
self._current_songs = current_songs
|
||||
self._build_ui()
|
||||
self._load_saved_playlists()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self._build_save_tab(), "💾 Gem liste")
|
||||
tabs.addTab(self._build_load_tab(), "📂 Indlæs liste")
|
||||
tabs.addTab(self._build_import_tab(), "📥 Importer")
|
||||
layout.addWidget(tabs)
|
||||
|
||||
btn_close = QPushButton("Luk")
|
||||
btn_close.clicked.connect(self.accept)
|
||||
row = QHBoxLayout()
|
||||
row.addStretch()
|
||||
row.addWidget(btn_close)
|
||||
layout.addLayout(row)
|
||||
|
||||
# ── Fane 1: Gem ───────────────────────────────────────────────────────────
|
||||
|
||||
def _build_save_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(10)
|
||||
|
||||
layout.addWidget(QLabel(f"Aktuel liste har {len(self._current_songs)} sange."))
|
||||
|
||||
layout.addWidget(QLabel("Navn på danselisten:"))
|
||||
self._save_name = QLineEdit()
|
||||
self._save_name.setPlaceholderText("f.eks. Sommer Event 2025")
|
||||
layout.addWidget(self._save_name)
|
||||
|
||||
btn_save = QPushButton("💾 Gem")
|
||||
btn_save.clicked.connect(self._save_playlist)
|
||||
layout.addWidget(btn_save)
|
||||
|
||||
self._save_status = QLabel("")
|
||||
self._save_status.setObjectName("result_count")
|
||||
layout.addWidget(self._save_status)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _save_playlist(self):
|
||||
name = self._save_name.text().strip()
|
||||
if not name:
|
||||
self._save_status.setText("Angiv et navn")
|
||||
return
|
||||
if not self._current_songs:
|
||||
self._save_status.setText("Danselisten er tom")
|
||||
return
|
||||
try:
|
||||
from local.local_db import create_playlist, add_song_to_playlist, get_db
|
||||
pl_id = create_playlist(name)
|
||||
for i, song in enumerate(self._current_songs, start=1):
|
||||
add_song_to_playlist(pl_id, song["id"], position=i)
|
||||
self._save_status.setText(f"✓ Gemt som \"{name}\"")
|
||||
self._load_saved_playlists()
|
||||
except Exception as e:
|
||||
self._save_status.setText(f"Fejl: {e}")
|
||||
|
||||
# ── Fane 2: Indlæs ────────────────────────────────────────────────────────
|
||||
|
||||
def _build_load_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
layout.addWidget(QLabel("Gemte danselister:"))
|
||||
self._pl_list = QListWidget()
|
||||
self._pl_list.itemDoubleClicked.connect(self._load_selected)
|
||||
layout.addWidget(self._pl_list)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_load = QPushButton("📂 Indlæs valgte")
|
||||
btn_load.clicked.connect(self._load_selected_btn)
|
||||
btn_delete = QPushButton("🗑 Slet valgte")
|
||||
btn_delete.clicked.connect(self._delete_selected)
|
||||
btn_row.addWidget(btn_load)
|
||||
btn_row.addWidget(btn_delete)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self._load_status = QLabel("")
|
||||
self._load_status.setObjectName("result_count")
|
||||
layout.addWidget(self._load_status)
|
||||
return tab
|
||||
|
||||
def _load_saved_playlists(self):
|
||||
if not hasattr(self, "_pl_list"):
|
||||
return
|
||||
self._pl_list.clear()
|
||||
try:
|
||||
from local.local_db import get_playlists
|
||||
for pl in get_playlists():
|
||||
item = QListWidgetItem(pl["name"])
|
||||
item.setData(Qt.ItemDataRole.UserRole, dict(pl))
|
||||
self._pl_list.addItem(item)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_selected_btn(self):
|
||||
item = self._pl_list.currentItem()
|
||||
if item:
|
||||
self._load_selected(item)
|
||||
|
||||
def _load_selected(self, item: QListWidgetItem):
|
||||
pl = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not pl:
|
||||
return
|
||||
try:
|
||||
from local.local_db import get_playlist_with_songs, get_db
|
||||
data = get_playlist_with_songs(pl["id"])
|
||||
songs = []
|
||||
for row in data.get("songs", []):
|
||||
with get_db() as conn:
|
||||
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.get("title", ""),
|
||||
"artist": row.get("artist", ""),
|
||||
"album": row.get("album", ""),
|
||||
"bpm": row.get("bpm", 0),
|
||||
"duration_sec": row.get("duration_sec", 0),
|
||||
"local_path": row.get("local_path", ""),
|
||||
"file_format": row.get("file_format", ""),
|
||||
"file_missing": bool(row.get("file_missing", False)),
|
||||
"dances": [d["dance_name"] for d in dances],
|
||||
})
|
||||
self.playlist_loaded.emit(pl["name"], songs)
|
||||
self._load_status.setText(f"✓ Indlæst: {pl['name']} ({len(songs)} sange)")
|
||||
except Exception as e:
|
||||
self._load_status.setText(f"Fejl: {e}")
|
||||
|
||||
def _delete_selected(self):
|
||||
item = self._pl_list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
pl = item.data(Qt.ItemDataRole.UserRole)
|
||||
reply = QMessageBox.question(
|
||||
self, "Slet liste",
|
||||
f"Slet danselisten \"{pl['name']}\"?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
try:
|
||||
from local.local_db import get_db
|
||||
with get_db() as conn:
|
||||
conn.execute("DELETE FROM playlists WHERE id=?", (pl["id"],))
|
||||
self._load_saved_playlists()
|
||||
except Exception as e:
|
||||
self._load_status.setText(f"Fejl: {e}")
|
||||
|
||||
# ── Fane 3: Importer ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_import_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(8)
|
||||
|
||||
lbl = QLabel(
|
||||
"Importer fra M3U, M3U8 eller en tekstfil med én filsti per linje.\n"
|
||||
"Sange der ikke er i biblioteket forsøges tilføjet automatisk."
|
||||
)
|
||||
lbl.setWordWrap(True)
|
||||
lbl.setObjectName("result_count")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
btn_browse = QPushButton("📂 Vælg fil...")
|
||||
btn_browse.clicked.connect(self._browse_import)
|
||||
layout.addWidget(btn_browse)
|
||||
|
||||
layout.addWidget(QLabel("Eller indsæt filstier direkte (én per linje):"))
|
||||
self._import_text = QTextEdit()
|
||||
self._import_text.setPlaceholderText(
|
||||
"/sti/til/sang1.mp3\n/sti/til/sang2.flac\n..."
|
||||
)
|
||||
self._import_text.setMaximumHeight(120)
|
||||
layout.addWidget(self._import_text)
|
||||
|
||||
layout.addWidget(QLabel("Navn på den importerede liste:"))
|
||||
self._import_name = QLineEdit()
|
||||
self._import_name.setPlaceholderText("Importeret liste")
|
||||
layout.addWidget(self._import_name)
|
||||
|
||||
btn_import = QPushButton("📥 Importer")
|
||||
btn_import.clicked.connect(self._do_import)
|
||||
layout.addWidget(btn_import)
|
||||
|
||||
self._import_status = QLabel("")
|
||||
self._import_status.setObjectName("result_count")
|
||||
self._import_status.setWordWrap(True)
|
||||
layout.addWidget(self._import_status)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _browse_import(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Vælg afspilningsliste",
|
||||
filter="Afspilningslister (*.m3u *.m3u8 *.txt);;Alle filer (*)"
|
||||
)
|
||||
if path:
|
||||
self._import_name.setText(Path(path).stem)
|
||||
paths = self._parse_playlist_file(path)
|
||||
self._import_text.setPlainText("\n".join(paths))
|
||||
|
||||
def _parse_playlist_file(self, path: str) -> list[str]:
|
||||
"""Parser M3U, M3U8 og tekst — returnerer liste af filstier."""
|
||||
paths = []
|
||||
base_dir = str(Path(path).parent)
|
||||
try:
|
||||
enc = "utf-8-sig" if path.lower().endswith(".m3u8") else "latin-1"
|
||||
with open(path, encoding=enc, errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# Gør relativ sti absolut
|
||||
if not os.path.isabs(line):
|
||||
line = os.path.join(base_dir, line)
|
||||
paths.append(line)
|
||||
except Exception as e:
|
||||
self._import_status.setText(f"Læsefejl: {e}")
|
||||
return paths
|
||||
|
||||
def _do_import(self):
|
||||
raw = self._import_text.toPlainText().strip()
|
||||
if not raw:
|
||||
self._import_status.setText("Ingen filstier angivet")
|
||||
return
|
||||
|
||||
name = self._import_name.text().strip() or "Importeret liste"
|
||||
paths = [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
|
||||
found = []
|
||||
missing = []
|
||||
|
||||
try:
|
||||
from local.local_db import get_song_by_path, upsert_song, get_db
|
||||
from local.tag_reader import read_tags, is_supported
|
||||
|
||||
for p in paths:
|
||||
row = get_song_by_path(p)
|
||||
if row:
|
||||
# Hent danse
|
||||
with get_db() as conn:
|
||||
dances = conn.execute(
|
||||
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
|
||||
(row["id"],)
|
||||
).fetchall()
|
||||
found.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],
|
||||
})
|
||||
elif os.path.exists(p) and is_supported(p):
|
||||
# Filen er ikke scannet endnu — høst tags og tilføj
|
||||
tags = read_tags(p)
|
||||
song_id = upsert_song(tags)
|
||||
found.append({
|
||||
"id": song_id,
|
||||
"title": tags.get("title", Path(p).stem),
|
||||
"artist": tags.get("artist", ""),
|
||||
"album": tags.get("album", ""),
|
||||
"bpm": tags.get("bpm", 0),
|
||||
"duration_sec": tags.get("duration_sec", 0),
|
||||
"local_path": p,
|
||||
"file_format": tags.get("file_format", ""),
|
||||
"file_missing": False,
|
||||
"dances": tags.get("dances", []),
|
||||
})
|
||||
else:
|
||||
missing.append(p)
|
||||
|
||||
if found:
|
||||
self.playlist_loaded.emit(name, found)
|
||||
status = f"✓ Importeret {len(found)} sange som \"{name}\""
|
||||
if missing:
|
||||
status += f"\n⚠ {len(missing)} filer ikke fundet"
|
||||
self._import_status.setText(status)
|
||||
else:
|
||||
self._import_status.setText("Ingen filer fundet — tjek stierne")
|
||||
|
||||
except Exception as e:
|
||||
self._import_status.setText(f"Importfejl: {e}")
|
||||
@@ -1,523 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,64 +0,0 @@
|
||||
"""
|
||||
scan_worker.py — Kører fuld biblioteks-scanning i en baggrundstråd
|
||||
så GUI ikke fryser.
|
||||
"""
|
||||
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
|
||||
class ScanWorker(QThread):
|
||||
"""
|
||||
Kører _full_scan_all() i en baggrundstråd.
|
||||
Sender status-opdateringer undervejs.
|
||||
"""
|
||||
status_update = pyqtSignal(str) # løbende statusbeskeder
|
||||
scan_done = pyqtSignal(int) # antal behandlede filer
|
||||
|
||||
def __init__(self, watcher, parent=None):
|
||||
super().__init__(parent)
|
||||
self._watcher = watcher
|
||||
self._total = 0
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from local.local_db import get_libraries
|
||||
from local.tag_reader import is_supported
|
||||
import os
|
||||
libraries = get_libraries(active_only=True)
|
||||
|
||||
if not libraries:
|
||||
self.status_update.emit("Ingen biblioteker konfigureret")
|
||||
self.scan_done.emit(0)
|
||||
return
|
||||
|
||||
total_processed = 0
|
||||
for lib in libraries:
|
||||
from pathlib import Path
|
||||
path = Path(lib["path"])
|
||||
name = path.name
|
||||
|
||||
if not path.exists():
|
||||
self.status_update.emit(f"⚠ Mappe ikke fundet: {path}")
|
||||
continue
|
||||
|
||||
self.status_update.emit(f"Scanner: {name}...")
|
||||
|
||||
# Tæl filer med os.walk — håndterer permission-fejl sikkert
|
||||
count = 0
|
||||
for dirpath, _, filenames in os.walk(str(path), followlinks=False):
|
||||
for f in filenames:
|
||||
if is_supported(f):
|
||||
count += 1
|
||||
|
||||
self.status_update.emit(f"Scanner: {name} ({count} filer)...")
|
||||
|
||||
# Kør scanning
|
||||
self._watcher._full_scan_library(lib["id"], str(path))
|
||||
total_processed += count
|
||||
|
||||
self.status_update.emit(f"Scan færdig — {total_processed} filer gennemgået")
|
||||
self.scan_done.emit(total_processed)
|
||||
|
||||
except Exception as e:
|
||||
self.status_update.emit(f"Scan fejl: {e}")
|
||||
self.scan_done.emit(0)
|
||||
@@ -1,262 +0,0 @@
|
||||
"""
|
||||
settings_dialog.py — Indstillinger for LineDance Player.
|
||||
Gemmes via QSettings og læses ved opstart.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QComboBox, QSpinBox, QCheckBox, QFrame,
|
||||
QTabWidget, QWidget, QFileDialog, QGroupBox, QFormLayout,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QSettings
|
||||
|
||||
|
||||
SETTINGS_KEY_THEME = "appearance/dark_theme"
|
||||
SETTINGS_KEY_DEMO_SEC = "playback/demo_seconds"
|
||||
SETTINGS_KEY_MAIL_CLIENT = "mail/client" # "auto"|"thunderbird"|"outlook"|"mailto"
|
||||
SETTINGS_KEY_MAIL_PATH = "mail/custom_path"
|
||||
SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
|
||||
SETTINGS_KEY_USERNAME = "online/username"
|
||||
SETTINGS_KEY_PASSWORD = "online/password" # gemt i klartekst — ikke ideelt, men funktionelt
|
||||
|
||||
|
||||
def load_settings() -> dict:
|
||||
"""Indlæs alle indstillinger med fornuftige standardværdier."""
|
||||
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),
|
||||
"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, ""),
|
||||
}
|
||||
|
||||
|
||||
def save_settings(values: dict):
|
||||
s = QSettings("LineDance", "Player")
|
||||
s.setValue(SETTINGS_KEY_THEME, values.get("dark_theme", True))
|
||||
s.setValue(SETTINGS_KEY_DEMO_SEC, values.get("demo_seconds", 10))
|
||||
s.setValue(SETTINGS_KEY_MAIL_CLIENT, values.get("mail_client", "auto"))
|
||||
s.setValue(SETTINGS_KEY_MAIL_PATH, values.get("mail_path", ""))
|
||||
s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False))
|
||||
s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
|
||||
s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", ""))
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Indstillinger")
|
||||
self.setMinimumWidth(480)
|
||||
self.setModal(True)
|
||||
self._values = load_settings()
|
||||
self._build_ui()
|
||||
self._populate()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(12)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(self._build_appearance_tab(), "🎨 Udseende")
|
||||
tabs.addTab(self._build_playback_tab(), "▶ Afspilning")
|
||||
tabs.addTab(self._build_mail_tab(), "✉ Mail")
|
||||
tabs.addTab(self._build_online_tab(), "🌐 Online")
|
||||
layout.addWidget(tabs)
|
||||
|
||||
# Knapper
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
btn_cancel = QPushButton("Annuller")
|
||||
btn_cancel.clicked.connect(self.reject)
|
||||
btn_row.addWidget(btn_cancel)
|
||||
btn_save = QPushButton("💾 Gem indstillinger")
|
||||
btn_save.setObjectName("btn_play")
|
||||
btn_save.setDefault(True)
|
||||
btn_save.clicked.connect(self._save_and_close)
|
||||
btn_row.addWidget(btn_save)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
# ── Fane: Udseende ────────────────────────────────────────────────────────
|
||||
|
||||
def _build_appearance_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(12)
|
||||
|
||||
grp = QGroupBox("Standard tema")
|
||||
grp_layout = QVBoxLayout(grp)
|
||||
|
||||
self._chk_dark = QCheckBox("Start med mørkt tema")
|
||||
grp_layout.addWidget(self._chk_dark)
|
||||
|
||||
note = QLabel("Du kan altid skifte tema mens programmet kører via topbar-knappen.")
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
grp_layout.addWidget(note)
|
||||
layout.addWidget(grp)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
# ── Fane: Afspilning ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_playback_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(12)
|
||||
|
||||
grp = QGroupBox("Forspil (▶ N SEK knappen)")
|
||||
grp_layout = QFormLayout(grp)
|
||||
|
||||
self._spin_demo = QSpinBox()
|
||||
self._spin_demo.setRange(3, 60)
|
||||
self._spin_demo.setSuffix(" sekunder")
|
||||
self._spin_demo.setFixedWidth(140)
|
||||
grp_layout.addRow("Forspil-længde:", self._spin_demo)
|
||||
|
||||
note = QLabel(
|
||||
"Forspillet afspiller begyndelsen af sangen så arrangøren kan bekræfte\n"
|
||||
"at det er den rigtige sang og dans inden eventet starter."
|
||||
)
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
grp_layout.addRow(note)
|
||||
layout.addWidget(grp)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
# ── Fane: Mail ────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_mail_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(12)
|
||||
|
||||
grp = QGroupBox("Mailklient")
|
||||
grp_layout = QFormLayout(grp)
|
||||
|
||||
self._mail_combo = QComboBox()
|
||||
self._mail_combo.addItem("Auto-detekter (Thunderbird → Outlook → mailto:)", "auto")
|
||||
self._mail_combo.addItem("Thunderbird", "thunderbird")
|
||||
self._mail_combo.addItem("Outlook (Windows)", "outlook")
|
||||
self._mail_combo.addItem("Brugerdefineret sti", "custom")
|
||||
self._mail_combo.addItem("Kun mailto: (ingen vedhæftning)", "mailto")
|
||||
self._mail_combo.currentIndexChanged.connect(self._on_mail_combo_changed)
|
||||
grp_layout.addRow("Klient:", self._mail_combo)
|
||||
|
||||
path_row = QHBoxLayout()
|
||||
self._mail_path = QLineEdit()
|
||||
self._mail_path.setPlaceholderText("/usr/bin/thunderbird eller C:\\...\\thunderbird.exe")
|
||||
path_row.addWidget(self._mail_path)
|
||||
btn_browse = QPushButton("...")
|
||||
btn_browse.setFixedWidth(32)
|
||||
btn_browse.clicked.connect(self._browse_mail_path)
|
||||
path_row.addWidget(btn_browse)
|
||||
self._mail_path_row_widget = QWidget()
|
||||
self._mail_path_row_widget.setLayout(path_row)
|
||||
grp_layout.addRow("Sti:", self._mail_path_row_widget)
|
||||
|
||||
note = QLabel(
|
||||
"Med Thunderbird og Outlook åbnes et nyt compose-vindue med filen vedhæftet.\n"
|
||||
"mailto: åbner standard-mailprogrammet men uden automatisk vedhæftning."
|
||||
)
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
grp_layout.addRow(note)
|
||||
layout.addWidget(grp)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _on_mail_combo_changed(self, idx: int):
|
||||
is_custom = self._mail_combo.currentData() == "custom"
|
||||
self._mail_path_row_widget.setVisible(is_custom)
|
||||
|
||||
def _browse_mail_path(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Vælg mailklient")
|
||||
if path:
|
||||
self._mail_path.setText(path)
|
||||
|
||||
# ── Fane: Online ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build_online_tab(self) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(12)
|
||||
|
||||
grp = QGroupBox("Automatisk login ved opstart")
|
||||
grp_layout = QFormLayout(grp)
|
||||
|
||||
self._chk_auto_login = QCheckBox("Log automatisk ind når programmet starter")
|
||||
self._chk_auto_login.stateChanged.connect(self._on_auto_login_changed)
|
||||
grp_layout.addRow(self._chk_auto_login)
|
||||
|
||||
self._user_input = QLineEdit()
|
||||
self._user_input.setPlaceholderText("dit-brugernavn")
|
||||
grp_layout.addRow("Brugernavn:", self._user_input)
|
||||
|
||||
self._pass_input = QLineEdit()
|
||||
self._pass_input.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self._pass_input.setPlaceholderText("••••••••")
|
||||
grp_layout.addRow("Kodeord:", self._pass_input)
|
||||
|
||||
note = QLabel(
|
||||
"⚠ Kodeordet gemmes lokalt på denne computer.\n"
|
||||
"Brug kun dette på en personlig maskine."
|
||||
)
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
grp_layout.addRow(note)
|
||||
layout.addWidget(grp)
|
||||
layout.addStretch()
|
||||
return tab
|
||||
|
||||
def _on_auto_login_changed(self, state: int):
|
||||
enabled = state == Qt.CheckState.Checked.value
|
||||
self._user_input.setEnabled(enabled)
|
||||
self._pass_input.setEnabled(enabled)
|
||||
|
||||
# ── Populer fra gemte værdier ─────────────────────────────────────────────
|
||||
|
||||
def _populate(self):
|
||||
v = self._values
|
||||
self._chk_dark.setChecked(v.get("dark_theme", True))
|
||||
self._spin_demo.setValue(v.get("demo_seconds", 10))
|
||||
|
||||
# Mail
|
||||
client = v.get("mail_client", "auto")
|
||||
for i in range(self._mail_combo.count()):
|
||||
if self._mail_combo.itemData(i) == client:
|
||||
self._mail_combo.setCurrentIndex(i)
|
||||
break
|
||||
self._mail_path.setText(v.get("mail_path", ""))
|
||||
self._on_mail_combo_changed(self._mail_combo.currentIndex())
|
||||
|
||||
# Online
|
||||
auto = v.get("auto_login", False)
|
||||
self._chk_auto_login.setChecked(auto)
|
||||
self._user_input.setText(v.get("username", ""))
|
||||
self._pass_input.setText(v.get("password", ""))
|
||||
self._user_input.setEnabled(auto)
|
||||
self._pass_input.setEnabled(auto)
|
||||
|
||||
# ── Gem ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _save_and_close(self):
|
||||
values = {
|
||||
"dark_theme": self._chk_dark.isChecked(),
|
||||
"demo_seconds": self._spin_demo.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(),
|
||||
}
|
||||
save_settings(values)
|
||||
self._values = values
|
||||
self.accept()
|
||||
|
||||
def get_values(self) -> dict:
|
||||
return self._values
|
||||
444
ui/tag_editor.py
444
ui/tag_editor.py
@@ -1,444 +0,0 @@
|
||||
"""
|
||||
tag_editor.py — Rediger danse og alternativ-danse med niveau og autoudfyld.
|
||||
|
||||
Fire sektioner:
|
||||
Mine danse | Fællesskabets danse
|
||||
Mine alternativer | Fællesskabets alternativer
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QListWidget, QListWidgetItem, QFrame,
|
||||
QSplitter, QWidget, QMessageBox, QComboBox, QCompleter,
|
||||
QGridLayout, QGroupBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, QStringListModel, pyqtSignal
|
||||
from PyQt6.QtGui import QColor
|
||||
|
||||
|
||||
class AutoCompleteLineEdit(QLineEdit):
|
||||
"""QLineEdit med autoudfyld fra dans-navne databasen."""
|
||||
|
||||
def __init__(self, placeholder: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
self.setPlaceholderText(placeholder)
|
||||
self._completer_model = QStringListModel()
|
||||
self._completer = QCompleter(self._completer_model, self)
|
||||
self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
|
||||
self._completer.setMaxVisibleItems(12)
|
||||
self.setCompleter(self._completer)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.setInterval(150)
|
||||
self._timer.timeout.connect(self._update_suggestions)
|
||||
self.textChanged.connect(lambda _: self._timer.start())
|
||||
|
||||
def _update_suggestions(self):
|
||||
prefix = self.text().strip()
|
||||
if len(prefix) < 1:
|
||||
return
|
||||
try:
|
||||
from local.local_db import get_dance_name_suggestions
|
||||
names = get_dance_name_suggestions(prefix, limit=20)
|
||||
self._completer_model.setStringList(names)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class DanceRow(QWidget):
|
||||
"""Én dans med navn og niveau-dropdown."""
|
||||
removed = pyqtSignal()
|
||||
|
||||
def __init__(self, dance_name: str = "", level_id: int | None = None,
|
||||
levels: list = [], readonly: bool = False, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 2, 0, 2)
|
||||
layout.setSpacing(6)
|
||||
|
||||
if readonly:
|
||||
self._name_lbl = QLabel(dance_name)
|
||||
self._name_lbl.setObjectName("track_meta")
|
||||
layout.addWidget(self._name_lbl, stretch=1)
|
||||
else:
|
||||
self._name_edit = AutoCompleteLineEdit("Dansenavn...", self)
|
||||
self._name_edit.setText(dance_name)
|
||||
layout.addWidget(self._name_edit, stretch=1)
|
||||
|
||||
self._level_combo = QComboBox()
|
||||
self._level_combo.addItem("— intet niveau —", None)
|
||||
self._level_data = [None]
|
||||
for lvl in levels:
|
||||
self._level_combo.addItem(lvl["name"], lvl["id"])
|
||||
self._level_data.append(lvl["id"])
|
||||
if level_id is not None:
|
||||
for i, lid in enumerate(self._level_data):
|
||||
if lid == level_id:
|
||||
self._level_combo.setCurrentIndex(i)
|
||||
break
|
||||
self._level_combo.setFixedWidth(130)
|
||||
self._level_combo.setEnabled(not readonly)
|
||||
layout.addWidget(self._level_combo)
|
||||
|
||||
if not readonly:
|
||||
btn_rm = QPushButton("✕")
|
||||
btn_rm.setFixedSize(24, 24)
|
||||
btn_rm.clicked.connect(self.removed.emit)
|
||||
layout.addWidget(btn_rm)
|
||||
|
||||
def get_name(self) -> str:
|
||||
if hasattr(self, "_name_edit"):
|
||||
return self._name_edit.text().strip()
|
||||
return self._name_lbl.text()
|
||||
|
||||
def get_level_id(self) -> int | None:
|
||||
return self._level_combo.currentData()
|
||||
|
||||
|
||||
class AltRow(QWidget):
|
||||
"""Én alternativ-dans med navn, niveau og note."""
|
||||
removed = pyqtSignal()
|
||||
copy_to_mine = pyqtSignal(str, object, str) # name, level_id, note
|
||||
|
||||
def __init__(self, alt_name: str = "", level_id: int | None = None,
|
||||
note: str = "", levels: list = [],
|
||||
readonly: bool = False, source: str = "local",
|
||||
rating: float = 0, rating_count: int = 0, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 2, 0, 2)
|
||||
layout.setSpacing(6)
|
||||
|
||||
if readonly:
|
||||
lbl = QLabel(f"→ {alt_name}")
|
||||
lbl.setObjectName("track_meta")
|
||||
layout.addWidget(lbl, stretch=1)
|
||||
if rating_count > 0:
|
||||
stars = "★" * round(rating) + "☆" * (5 - round(rating))
|
||||
lbl_r = QLabel(f"{stars} ({rating_count})")
|
||||
lbl_r.setObjectName("result_count")
|
||||
layout.addWidget(lbl_r)
|
||||
else:
|
||||
prefix_lbl = QLabel("→")
|
||||
prefix_lbl.setObjectName("track_meta")
|
||||
layout.addWidget(prefix_lbl)
|
||||
self._name_edit = AutoCompleteLineEdit("Alternativ dansenavn...", self)
|
||||
self._name_edit.setText(alt_name)
|
||||
layout.addWidget(self._name_edit, stretch=1)
|
||||
|
||||
self._level_combo = QComboBox()
|
||||
self._level_combo.addItem("— niveau —", None)
|
||||
self._level_data = [None]
|
||||
for lvl in levels:
|
||||
self._level_combo.addItem(lvl["name"], lvl["id"])
|
||||
self._level_data.append(lvl["id"])
|
||||
if level_id is not None:
|
||||
for i, lid in enumerate(self._level_data):
|
||||
if lid == level_id:
|
||||
self._level_combo.setCurrentIndex(i)
|
||||
break
|
||||
self._level_combo.setFixedWidth(120)
|
||||
self._level_combo.setEnabled(not readonly)
|
||||
layout.addWidget(self._level_combo)
|
||||
|
||||
if readonly:
|
||||
btn_copy = QPushButton("← Kopier")
|
||||
btn_copy.setFixedHeight(22)
|
||||
btn_copy.clicked.connect(
|
||||
lambda: self.copy_to_mine.emit(alt_name, self._level_combo.currentData(), note)
|
||||
)
|
||||
layout.addWidget(btn_copy)
|
||||
else:
|
||||
self._note_edit = QLineEdit()
|
||||
self._note_edit.setPlaceholderText("note...")
|
||||
self._note_edit.setText(note)
|
||||
self._note_edit.setFixedWidth(100)
|
||||
layout.addWidget(self._note_edit)
|
||||
btn_rm = QPushButton("✕")
|
||||
btn_rm.setFixedSize(24, 24)
|
||||
btn_rm.clicked.connect(self.removed.emit)
|
||||
layout.addWidget(btn_rm)
|
||||
|
||||
def get_name(self) -> str:
|
||||
if hasattr(self, "_name_edit"):
|
||||
return self._name_edit.text().strip()
|
||||
return ""
|
||||
|
||||
def get_level_id(self) -> int | None:
|
||||
return self._level_combo.currentData()
|
||||
|
||||
def get_note(self) -> str:
|
||||
if hasattr(self, "_note_edit"):
|
||||
return self._note_edit.text().strip()
|
||||
return ""
|
||||
|
||||
|
||||
class TagEditorDialog(QDialog):
|
||||
def __init__(self, song: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self._song = song
|
||||
self._levels = []
|
||||
self._my_dance_rows: list[DanceRow] = []
|
||||
self._my_alt_rows: list[AltRow] = []
|
||||
self.setWindowTitle(f"Rediger tags — {song.get('title','')}")
|
||||
self.setMinimumSize(860, 620)
|
||||
self._load_levels()
|
||||
self._build_ui()
|
||||
self._load_data()
|
||||
|
||||
def _load_levels(self):
|
||||
try:
|
||||
from local.local_db import get_dance_levels
|
||||
self._levels = [dict(r) for r in get_dance_levels()]
|
||||
except Exception:
|
||||
self._levels = []
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
# ── Sang-info ─────────────────────────────────────────────────────────
|
||||
info = QFrame()
|
||||
info.setObjectName("track_display")
|
||||
info_layout = QHBoxLayout(info)
|
||||
info_layout.setContentsMargins(10, 8, 10, 8)
|
||||
title_col = QVBoxLayout()
|
||||
lbl_title = QLabel(self._song.get("title", "—"))
|
||||
lbl_title.setObjectName("track_title")
|
||||
title_col.addWidget(lbl_title)
|
||||
meta = f"{self._song.get('artist','')} · {self._song.get('bpm',0)} BPM · {self._song.get('file_format','').upper()}"
|
||||
lbl_meta = QLabel(meta)
|
||||
lbl_meta.setObjectName("track_meta")
|
||||
title_col.addWidget(lbl_meta)
|
||||
can_write = self._song.get("file_format","").lower() in ("mp3","flac","ogg","opus","m4a")
|
||||
lbl_write = QLabel("✓ Tags skrives til filen" if can_write else "⚠ Tags gemmes kun i database")
|
||||
lbl_write.setObjectName("result_count")
|
||||
title_col.addWidget(lbl_write)
|
||||
info_layout.addLayout(title_col, stretch=1)
|
||||
layout.addWidget(info)
|
||||
|
||||
# ── Fire paneler i 2x2 grid ───────────────────────────────────────────
|
||||
grid = QWidget()
|
||||
grid_layout = QGridLayout(grid)
|
||||
grid_layout.setSpacing(8)
|
||||
|
||||
grid_layout.addWidget(self._build_my_dances_panel(), 0, 0)
|
||||
grid_layout.addWidget(self._build_community_dances_panel(), 0, 1)
|
||||
grid_layout.addWidget(self._build_my_alts_panel(), 1, 0)
|
||||
grid_layout.addWidget(self._build_community_alts_panel(), 1, 1)
|
||||
|
||||
layout.addWidget(grid, stretch=1)
|
||||
|
||||
# ── Knapper ───────────────────────────────────────────────────────────
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
btn_cancel = QPushButton("Annuller")
|
||||
btn_cancel.clicked.connect(self.reject)
|
||||
btn_row.addWidget(btn_cancel)
|
||||
btn_save = QPushButton("💾 Gem tags")
|
||||
btn_save.setObjectName("btn_play")
|
||||
btn_save.clicked.connect(self._save)
|
||||
btn_row.addWidget(btn_save)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
# ── Mine danse ────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_my_dances_panel(self) -> QGroupBox:
|
||||
grp = QGroupBox("Mine danse")
|
||||
layout = QVBoxLayout(grp)
|
||||
layout.setSpacing(4)
|
||||
|
||||
self._my_dances_container = QVBoxLayout()
|
||||
layout.addLayout(self._my_dances_container)
|
||||
layout.addStretch()
|
||||
|
||||
add_row = QHBoxLayout()
|
||||
self._new_dance_input = AutoCompleteLineEdit("Ny dans...", self)
|
||||
self._new_dance_input.returnPressed.connect(self._add_my_dance)
|
||||
add_row.addWidget(self._new_dance_input)
|
||||
btn_add = QPushButton("+ Tilføj")
|
||||
btn_add.clicked.connect(self._add_my_dance)
|
||||
add_row.addWidget(btn_add)
|
||||
layout.addLayout(add_row)
|
||||
return grp
|
||||
|
||||
def _add_my_dance(self, name: str = "", level_id=None):
|
||||
n = name or self._new_dance_input.text().strip()
|
||||
if not n:
|
||||
return
|
||||
row = DanceRow(n, level_id, self._levels, readonly=False, parent=self)
|
||||
row.removed.connect(lambda r=row: self._remove_dance_row(r))
|
||||
self._my_dance_rows.append(row)
|
||||
self._my_dances_container.addWidget(row)
|
||||
self._new_dance_input.clear()
|
||||
|
||||
def _remove_dance_row(self, row: DanceRow):
|
||||
self._my_dance_rows.remove(row)
|
||||
self._my_dances_container.removeWidget(row)
|
||||
row.deleteLater()
|
||||
|
||||
# ── Fællesskabets danse ───────────────────────────────────────────────────
|
||||
|
||||
def _build_community_dances_panel(self) -> QGroupBox:
|
||||
grp = QGroupBox("Fællesskabets danse")
|
||||
layout = QVBoxLayout(grp)
|
||||
self._community_dances_container = QVBoxLayout()
|
||||
layout.addLayout(self._community_dances_container)
|
||||
layout.addStretch()
|
||||
lbl = QLabel("Kræver online forbindelse")
|
||||
lbl.setObjectName("result_count")
|
||||
layout.addWidget(lbl)
|
||||
return grp
|
||||
|
||||
# ── Mine alternativer ─────────────────────────────────────────────────────
|
||||
|
||||
def _build_my_alts_panel(self) -> QGroupBox:
|
||||
grp = QGroupBox("Mine alternativ-danse")
|
||||
layout = QVBoxLayout(grp)
|
||||
layout.setSpacing(4)
|
||||
self._my_alts_container = QVBoxLayout()
|
||||
layout.addLayout(self._my_alts_container)
|
||||
layout.addStretch()
|
||||
|
||||
add_row = QHBoxLayout()
|
||||
self._new_alt_input = AutoCompleteLineEdit("Alternativ dansenavn...", self)
|
||||
self._new_alt_input.returnPressed.connect(self._add_my_alt)
|
||||
add_row.addWidget(self._new_alt_input)
|
||||
btn_add = QPushButton("+ Tilføj")
|
||||
btn_add.clicked.connect(self._add_my_alt)
|
||||
add_row.addWidget(btn_add)
|
||||
layout.addLayout(add_row)
|
||||
return grp
|
||||
|
||||
def _add_my_alt(self, name: str = "", level_id=None, note: str = ""):
|
||||
n = name or self._new_alt_input.text().strip()
|
||||
if not n:
|
||||
return
|
||||
row = AltRow(n, level_id, note, self._levels, readonly=False, parent=self)
|
||||
row.removed.connect(lambda r=row: self._remove_alt_row(r))
|
||||
self._my_alt_rows.append(row)
|
||||
self._my_alts_container.addWidget(row)
|
||||
self._new_alt_input.clear()
|
||||
|
||||
def _remove_alt_row(self, row: AltRow):
|
||||
self._my_alt_rows.remove(row)
|
||||
self._my_alts_container.removeWidget(row)
|
||||
row.deleteLater()
|
||||
|
||||
# ── Fællesskabets alternativer ────────────────────────────────────────────
|
||||
|
||||
def _build_community_alts_panel(self) -> QGroupBox:
|
||||
grp = QGroupBox("Fællesskabets alternativ-danse")
|
||||
layout = QVBoxLayout(grp)
|
||||
self._community_alts_container = QVBoxLayout()
|
||||
layout.addLayout(self._community_alts_container)
|
||||
layout.addStretch()
|
||||
lbl = QLabel("Kræver online forbindelse")
|
||||
lbl.setObjectName("result_count")
|
||||
layout.addWidget(lbl)
|
||||
return grp
|
||||
|
||||
# ── Indlæs eksisterende data ──────────────────────────────────────────────
|
||||
|
||||
def _load_data(self):
|
||||
try:
|
||||
from local.local_db import get_db, get_alternatives_for_dance
|
||||
song_id = self._song.get("id")
|
||||
with get_db() as conn:
|
||||
dances = conn.execute(
|
||||
"SELECT id, dance_name, dance_order, level_id FROM song_dances "
|
||||
"WHERE song_id=? ORDER BY dance_order",
|
||||
(song_id,)
|
||||
).fetchall()
|
||||
|
||||
for d in dances:
|
||||
self._add_my_dance(d["dance_name"], d["level_id"])
|
||||
# Indlæs alternativer for denne dans
|
||||
alts = get_alternatives_for_dance(d["id"])
|
||||
for alt in alts:
|
||||
if alt["source"] == "local":
|
||||
self._add_my_alt(
|
||||
alt["alt_dance_name"],
|
||||
alt["level_id"],
|
||||
alt["note"],
|
||||
)
|
||||
else:
|
||||
# Community-alternativ
|
||||
row = AltRow(
|
||||
alt["alt_dance_name"], alt["level_id"],
|
||||
alt["note"], self._levels,
|
||||
readonly=True, source="community",
|
||||
parent=self,
|
||||
)
|
||||
row.copy_to_mine.connect(self._add_my_alt)
|
||||
self._community_alts_container.addWidget(row)
|
||||
except Exception as e:
|
||||
print(f"Tag editor load fejl: {e}")
|
||||
|
||||
# ── Gem ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _save(self):
|
||||
song_id = self._song.get("id")
|
||||
local_path = self._song.get("local_path", "")
|
||||
|
||||
try:
|
||||
from local.local_db import get_db, register_dance_name, add_alternative
|
||||
from local.tag_reader import write_dances, can_write_dances
|
||||
|
||||
# Saml danse fra UI
|
||||
dances = [(r.get_name(), r.get_level_id())
|
||||
for r in self._my_dance_rows if r.get_name()]
|
||||
|
||||
dance_ids = []
|
||||
with get_db() as conn:
|
||||
# Slet eksisterende danse og alternativer
|
||||
old_dances = conn.execute(
|
||||
"SELECT id FROM song_dances WHERE song_id=?", (song_id,)
|
||||
).fetchall()
|
||||
for od in old_dances:
|
||||
conn.execute("DELETE FROM dance_alternatives WHERE song_dance_id=?", (od["id"],))
|
||||
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
|
||||
|
||||
# Indsæt nye danse og hent IDs
|
||||
for i, (name, level_id) in enumerate(dances, start=1):
|
||||
conn.execute(
|
||||
"INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(song_id, name, i, level_id)
|
||||
)
|
||||
new_id = conn.execute(
|
||||
"SELECT id FROM song_dances WHERE song_id=? AND dance_order=?",
|
||||
(song_id, i)
|
||||
).fetchone()["id"]
|
||||
dance_ids.append(new_id)
|
||||
register_dance_name(name)
|
||||
|
||||
# Indsæt alternativer knyttet til første dans
|
||||
if dance_ids and self._my_alt_rows:
|
||||
first_dance_id = dance_ids[0]
|
||||
for row in self._my_alt_rows:
|
||||
name = row.get_name()
|
||||
if name:
|
||||
import uuid as _uuid
|
||||
conn.execute("""
|
||||
INSERT INTO dance_alternatives
|
||||
(id, song_dance_id, alt_dance_name, level_id, note, source)
|
||||
VALUES (?,?,?,?,?,'local')
|
||||
""", (str(_uuid.uuid4()), first_dance_id,
|
||||
name, row.get_level_id(), row.get_note()))
|
||||
register_dance_name(name)
|
||||
|
||||
# Skriv til fil
|
||||
if local_path and can_write_dances(local_path):
|
||||
dance_names = [n for n, _ in dances]
|
||||
ok = write_dances(local_path, dance_names)
|
||||
if not ok:
|
||||
QMessageBox.warning(self, "Advarsel",
|
||||
"Tags gemt i database, men kunne ikke skrives til filen.")
|
||||
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme tags: {e}")
|
||||
334
ui/themes.py
334
ui/themes.py
@@ -1,334 +0,0 @@
|
||||
"""
|
||||
themes.py — Lyst og mørkt tema til PyQt6.
|
||||
"""
|
||||
|
||||
DARK = """
|
||||
QWidget {
|
||||
background-color: #1a1c1f;
|
||||
color: #e8eaf0;
|
||||
font-family: 'Barlow', 'Segoe UI', sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
QMainWindow, #root {
|
||||
background-color: #111214;
|
||||
}
|
||||
|
||||
/* Knapper */
|
||||
QPushButton {
|
||||
background-color: #30343c;
|
||||
color: #9aa0b0;
|
||||
border: 1px solid #4a5060;
|
||||
border-radius: 4px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #454a56;
|
||||
color: #e8eaf0;
|
||||
border-color: #e8a020;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #22252a;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: #e8a020;
|
||||
color: #111214;
|
||||
border-color: #c47a10;
|
||||
}
|
||||
QPushButton#btn_play {
|
||||
background-color: #e8a020;
|
||||
color: #111214;
|
||||
border-color: #c47a10;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton#btn_play:hover {
|
||||
background-color: #c47a10;
|
||||
}
|
||||
QPushButton#btn_stop {
|
||||
color: #e74c3c;
|
||||
}
|
||||
QPushButton#btn_stop:hover {
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
QPushButton#btn_demo {
|
||||
color: #3b8fd4;
|
||||
border-color: #3b8fd4;
|
||||
font-size: 11px;
|
||||
}
|
||||
QPushButton#btn_demo:hover, QPushButton#btn_demo:checked {
|
||||
background-color: #3b8fd4;
|
||||
color: #111214;
|
||||
border-color: #3b8fd4;
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
QSlider::groove:horizontal {
|
||||
height: 4px;
|
||||
background: #2c3038;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::sub-page:horizontal {
|
||||
background: #e8a020;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::handle:horizontal {
|
||||
background: #e8a020;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -4px 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Lister */
|
||||
QListWidget {
|
||||
background-color: #1a1c1f;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
QListWidget::item {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #22252a;
|
||||
}
|
||||
QListWidget::item:selected {
|
||||
background-color: #2c3038;
|
||||
color: #e8eaf0;
|
||||
border-left: 2px solid #e8a020;
|
||||
}
|
||||
QListWidget::item:hover {
|
||||
background-color: #22252a;
|
||||
}
|
||||
|
||||
/* Søgefelt */
|
||||
QLineEdit {
|
||||
background-color: #111214;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 3px;
|
||||
padding: 5px 8px;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border-color: #e8a020;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
QLabel#track_title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #e8eaf0;
|
||||
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
|
||||
}
|
||||
QLabel#track_meta {
|
||||
font-size: 11px;
|
||||
color: #9aa0b0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
QLabel#section_title {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #5a6070;
|
||||
letter-spacing: 2px;
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 6px 10px;
|
||||
background-color: #22252a;
|
||||
border-bottom: 1px solid #3a3e46;
|
||||
}
|
||||
QLabel#next_up_label {
|
||||
color: #e8a020;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
QLabel#next_up_title {
|
||||
font-size: 17px;
|
||||
font-weight: bold;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
QLabel#next_up_sub {
|
||||
font-size: 11px;
|
||||
color: #9aa0b0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
QLabel#vol_label {
|
||||
font-size: 10px;
|
||||
color: #5a6070;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
QLabel#vol_val {
|
||||
font-size: 11px;
|
||||
color: #9aa0b0;
|
||||
font-family: 'Courier New', monospace;
|
||||
min-width: 28px;
|
||||
}
|
||||
QLabel#result_count {
|
||||
font-size: 10px;
|
||||
color: #5a6070;
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
/* Frames / paneler */
|
||||
QFrame#panel {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QFrame#now_playing_frame {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
QFrame#track_display {
|
||||
background-color: #111214;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 3px;
|
||||
padding: 4px;
|
||||
}
|
||||
QFrame#transport_frame {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
QFrame#next_up_frame {
|
||||
background-color: #22252a;
|
||||
border: 1px solid #e8a020;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
QFrame#progress_frame {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
QScrollBar:vertical {
|
||||
background: #1a1c1f;
|
||||
width: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #4a5060;
|
||||
border-radius: 3px;
|
||||
min-height: 20px;
|
||||
}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||
|
||||
/* Højreklik-menu */
|
||||
QMenu {
|
||||
background-color: #22252a;
|
||||
color: #e8eaf0;
|
||||
border: 1px solid #4a5060;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
QMenu::item {
|
||||
padding: 8px 24px;
|
||||
border-radius: 0;
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: #e8a020;
|
||||
color: #111214;
|
||||
}
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background: #3a3e46;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* Topbar */
|
||||
QFrame#topbar {
|
||||
background-color: #1a1c1f;
|
||||
border: 1px solid #3a3e46;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QLabel#logo {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 3px;
|
||||
color: #e8a020;
|
||||
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
|
||||
}
|
||||
QLabel#conn_label {
|
||||
font-size: 11px;
|
||||
color: #5a6070;
|
||||
font-family: 'Courier New', monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
"""
|
||||
|
||||
LIGHT = DARK + """
|
||||
QWidget {
|
||||
background-color: #d8dae0;
|
||||
color: #1a1c22;
|
||||
}
|
||||
QMainWindow, #root {
|
||||
background-color: #c8cad0;
|
||||
}
|
||||
QPushButton {
|
||||
background-color: #b0b4bc;
|
||||
color: #1a1c22;
|
||||
border-color: #8890a0;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #c8ccd4;
|
||||
color: #1a1c22;
|
||||
border-color: #c07010;
|
||||
}
|
||||
QPushButton#btn_play {
|
||||
background-color: #c07010;
|
||||
color: #fff;
|
||||
border-color: #a05808;
|
||||
}
|
||||
QListWidget {
|
||||
background-color: #d8dae0;
|
||||
color: #1a1c22;
|
||||
}
|
||||
QListWidget::item {
|
||||
color: #1a1c22;
|
||||
}
|
||||
QListWidget::item:selected {
|
||||
background-color: #c07010;
|
||||
color: #ffffff;
|
||||
border-left: 2px solid #a05808;
|
||||
}
|
||||
QListWidget::item:hover {
|
||||
background-color: #c8ccd4;
|
||||
color: #1a1c22;
|
||||
}
|
||||
QLineEdit {
|
||||
background-color: #c8cad0;
|
||||
border-color: #aab0bc;
|
||||
color: #1a1c22;
|
||||
}
|
||||
QLineEdit:focus { border-color: #c07010; }
|
||||
QFrame#panel, QFrame#now_playing_frame,
|
||||
QFrame#transport_frame, QFrame#progress_frame {
|
||||
background-color: #d8dae0;
|
||||
border-color: #aab0bc;
|
||||
}
|
||||
QFrame#track_display { background-color: #c8cad0; border-color: #aab0bc; }
|
||||
QFrame#topbar { background-color: #d8dae0; border-color: #aab0bc; }
|
||||
QLabel#section_title { background-color: #e4e6ec; color: #1a1c22; border-color: #aab0bc; }
|
||||
QLabel#track_title { color: #1a1c22; }
|
||||
QLabel#track_meta { color: #4a5060; }
|
||||
QLabel#result_count { color: #5a6070; }
|
||||
QSlider::groove:horizontal { background: #b0b4bc; }
|
||||
QScrollBar:vertical { background: #d8dae0; }
|
||||
QScrollBar::handle:vertical { background: #8890a0; }
|
||||
QMenu {
|
||||
background-color: #e4e6ec;
|
||||
color: #1a1c22;
|
||||
border: 1px solid #aab0bc;
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: #c07010;
|
||||
color: #ffffff;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def apply_theme(app, dark: bool = True):
|
||||
app.setStyleSheet(DARK if dark else LIGHT)
|
||||
@@ -1,96 +0,0 @@
|
||||
"""
|
||||
vu_meter.py — VU-meter widget der tegner L og R kanaler.
|
||||
Opdateres via set_levels(left, right) med værdier 0.0–1.0.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QPainter, QColor
|
||||
import random
|
||||
|
||||
|
||||
NUM_BARS = 14
|
||||
BAR_W = 14
|
||||
BAR_H = 4
|
||||
BAR_GAP = 2
|
||||
CHAN_GAP = 6
|
||||
PADDING = 4
|
||||
|
||||
COLOR_OFF = QColor("#1a2218")
|
||||
COLOR_GREEN = QColor("#28a050")
|
||||
COLOR_YELLOW = QColor("#c8a020")
|
||||
COLOR_RED = QColor("#c83020")
|
||||
|
||||
# Grænser for farver (bar-indeks fra bunden)
|
||||
YELLOW_FROM = NUM_BARS - 4
|
||||
RED_FROM = NUM_BARS - 2
|
||||
|
||||
|
||||
class VUMeter(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._left = 0.0
|
||||
self._right = 0.0
|
||||
self._peak_l = 0.0
|
||||
self._peak_r = 0.0
|
||||
self._dark = True
|
||||
|
||||
total_h = NUM_BARS * (BAR_H + BAR_GAP) + PADDING * 2 + 16 # +16 til label
|
||||
total_w = (BAR_W + CHAN_GAP) * 2 + PADDING * 2
|
||||
self.setFixedSize(total_w, total_h)
|
||||
|
||||
def set_dark(self, dark: bool):
|
||||
self._dark = dark
|
||||
self.update()
|
||||
|
||||
def set_levels(self, left: float, right: float):
|
||||
"""Sæt niveauer 0.0–1.0. Kaldes fra afspiller-tråden via signal."""
|
||||
self._left = max(0.0, min(1.0, left))
|
||||
self._right = max(0.0, min(1.0, right))
|
||||
self._peak_l = max(self._peak_l * 0.92, self._left)
|
||||
self._peak_r = max(self._peak_r * 0.92, self._right)
|
||||
self.update()
|
||||
|
||||
def reset(self):
|
||||
self._left = self._right = self._peak_l = self._peak_r = 0.0
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
off_color = QColor("#d0d8cc") if not self._dark else COLOR_OFF
|
||||
|
||||
for ch_idx, level in enumerate([self._left, self._right]):
|
||||
x = PADDING + ch_idx * (BAR_W + CHAN_GAP)
|
||||
active_bars = int(level * NUM_BARS)
|
||||
|
||||
for bar_idx in range(NUM_BARS):
|
||||
y = PADDING + (NUM_BARS - 1 - bar_idx) * (BAR_H + BAR_GAP)
|
||||
|
||||
if bar_idx < active_bars:
|
||||
if bar_idx >= RED_FROM:
|
||||
color = COLOR_RED
|
||||
elif bar_idx >= YELLOW_FROM:
|
||||
color = COLOR_YELLOW
|
||||
else:
|
||||
color = COLOR_GREEN
|
||||
else:
|
||||
color = off_color
|
||||
|
||||
painter.fillRect(x, y, BAR_W, BAR_H,
|
||||
QColor(color.red(), color.green(), color.blue(), 220))
|
||||
|
||||
# Kanal-labels
|
||||
label_y = PADDING + NUM_BARS * (BAR_H + BAR_GAP) + 4
|
||||
painter.setPen(QColor("#5a6070"))
|
||||
font = painter.font()
|
||||
font.setPointSize(8)
|
||||
font.setFamily("Courier New")
|
||||
painter.setFont(font)
|
||||
|
||||
for ch_idx, label in enumerate(["L", "R"]):
|
||||
x = PADDING + ch_idx * (BAR_W + CHAN_GAP) + BAR_W // 2
|
||||
painter.drawText(x - 4, label_y + 10, label)
|
||||
|
||||
painter.end()
|
||||
@@ -1 +0,0 @@
|
||||
lib
|
||||
@@ -1,5 +0,0 @@
|
||||
home = /usr/bin
|
||||
include-system-site-packages = false
|
||||
version = 3.12.3
|
||||
executable = /usr/bin/python3.12
|
||||
command = /usr/bin/python3 -m venv /home/carsten/Dokumenter/GitClone/LinedanceAfspiller/venv
|
||||
Reference in New Issue
Block a user