diff --git a/.env b/.env deleted file mode 100644 index 507d2fcb..00000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -DATABASE_URL=mysql+pymysql://linedance:20gorm66@mysql.ckvist.lan:3306/linedance -SECRET_KEY=e0a15d5a35d1091261cbdf0fd6310492ebd23d66a6d4a8c4253ab33e2594c67a -ACCESS_TOKEN_EXPIRE_MINUTES=10080 diff --git a/.env.example b/.env.example deleted file mode 100644 index 4bf3033c..00000000 --- a/.env.example +++ /dev/null @@ -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 diff --git a/LineDancePlayer.spec b/LineDancePlayer.spec deleted file mode 100644 index ef1bc0ac..00000000 --- a/LineDancePlayer.spec +++ /dev/null @@ -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", -) diff --git a/README.md b/README.md deleted file mode 100644 index 57877f5f..00000000 --- a/README.md +++ /dev/null @@ -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. diff --git a/build.bat b/build.bat deleted file mode 100644 index c3b050b9..00000000 --- a/build.bat +++ /dev/null @@ -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 diff --git a/linedance-app/BUILD_VEJLEDNING.md b/linedance-app/BUILD_VEJLEDNING.md deleted file mode 100644 index e22b5a92..00000000 --- a/linedance-app/BUILD_VEJLEDNING.md +++ /dev/null @@ -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. diff --git a/linedance-app/LineDancePlayer.spec b/linedance-app/LineDancePlayer.spec deleted file mode 100644 index ef1bc0ac..00000000 --- a/linedance-app/LineDancePlayer.spec +++ /dev/null @@ -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", -) diff --git a/linedance-app/README.md b/linedance-app/README.md deleted file mode 100644 index 57877f5f..00000000 --- a/linedance-app/README.md +++ /dev/null @@ -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. diff --git a/linedance-app/app_logger.py b/linedance-app/app_logger.py deleted file mode 100644 index a1249700..00000000 --- a/linedance-app/app_logger.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -app_logger.py — Central logging til fil i stedet for konsol. -P 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") diff --git a/linedance-app/build.bat b/linedance-app/build.bat deleted file mode 100644 index 3a5a5585..00000000 --- a/linedance-app/build.bat +++ /dev/null @@ -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 diff --git a/linedance-app/build_linux.sh b/linedance-app/build_linux.sh deleted file mode 100755 index 1df1469b..00000000 --- a/linedance-app/build_linux.sh +++ /dev/null @@ -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 diff --git a/linedance-app/build_windows.spec b/linedance-app/build_windows.spec deleted file mode 100644 index e56deb62..00000000 --- a/linedance-app/build_windows.spec +++ /dev/null @@ -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', -) diff --git a/linedance-app/main.py b/linedance-app/main.py deleted file mode 100644 index ad5f9af2..00000000 --- a/linedance-app/main.py +++ /dev/null @@ -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() diff --git a/linedance-app/player/__init__.py b/linedance-app/player/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/linedance-app/player/__pycache__/__init__.cpython-312.pyc b/linedance-app/player/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index e8894209..00000000 Binary files a/linedance-app/player/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/player/__pycache__/__init__.cpython-313.pyc b/linedance-app/player/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index ad7a4ac8..00000000 Binary files a/linedance-app/player/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/linedance-app/player/__pycache__/player.cpython-312.pyc b/linedance-app/player/__pycache__/player.cpython-312.pyc deleted file mode 100644 index 9e97056f..00000000 Binary files a/linedance-app/player/__pycache__/player.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/player/player.py b/linedance-app/player/player.py deleted file mode 100644 index 758077e8..00000000 --- a/linedance-app/player/player.py +++ /dev/null @@ -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") diff --git a/linedance-app/requirements.txt b/linedance-app/requirements.txt deleted file mode 100644 index b005ffb1..00000000 --- a/linedance-app/requirements.txt +++ /dev/null @@ -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 diff --git a/linedance-app/ui/__init__.py b/linedance-app/ui/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/linedance-app/ui/__pycache__/__init__.cpython-312.pyc b/linedance-app/ui/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 41204d22..00000000 Binary files a/linedance-app/ui/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/__init__.cpython-313.pyc b/linedance-app/ui/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 6d337996..00000000 Binary files a/linedance-app/ui/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/library_manager.cpython-312.pyc b/linedance-app/ui/__pycache__/library_manager.cpython-312.pyc deleted file mode 100644 index d5931b44..00000000 Binary files a/linedance-app/ui/__pycache__/library_manager.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc b/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc deleted file mode 100644 index a645a86a..00000000 Binary files a/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/login_dialog.cpython-312.pyc b/linedance-app/ui/__pycache__/login_dialog.cpython-312.pyc deleted file mode 100644 index b00b51cb..00000000 Binary files a/linedance-app/ui/__pycache__/login_dialog.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/main_window.cpython-312.pyc b/linedance-app/ui/__pycache__/main_window.cpython-312.pyc deleted file mode 100644 index 79e44ac2..00000000 Binary files a/linedance-app/ui/__pycache__/main_window.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/next_up_bar.cpython-312.pyc b/linedance-app/ui/__pycache__/next_up_bar.cpython-312.pyc deleted file mode 100644 index 2116db55..00000000 Binary files a/linedance-app/ui/__pycache__/next_up_bar.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/playlist_manager.cpython-312.pyc b/linedance-app/ui/__pycache__/playlist_manager.cpython-312.pyc deleted file mode 100644 index 495b239d..00000000 Binary files a/linedance-app/ui/__pycache__/playlist_manager.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc b/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc deleted file mode 100644 index 91523fd8..00000000 Binary files a/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc b/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc deleted file mode 100644 index 16196ef1..00000000 Binary files a/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc b/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc deleted file mode 100644 index 11666ca1..00000000 Binary files a/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc b/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc deleted file mode 100644 index 0b10e7f7..00000000 Binary files a/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/themes.cpython-312.pyc b/linedance-app/ui/__pycache__/themes.cpython-312.pyc deleted file mode 100644 index 577b576d..00000000 Binary files a/linedance-app/ui/__pycache__/themes.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/__pycache__/vu_meter.cpython-312.pyc b/linedance-app/ui/__pycache__/vu_meter.cpython-312.pyc deleted file mode 100644 index 389bc6f4..00000000 Binary files a/linedance-app/ui/__pycache__/vu_meter.cpython-312.pyc and /dev/null differ diff --git a/linedance-app/ui/library_manager.py b/linedance-app/ui/library_manager.py deleted file mode 100644 index 3fdf047f..00000000 --- a/linedance-app/ui/library_manager.py +++ /dev/null @@ -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}") diff --git a/linedance-app/ui/library_panel.py b/linedance-app/ui/library_panel.py deleted file mode 100644 index b30407da..00000000 --- a/linedance-app/ui/library_panel.py +++ /dev/null @@ -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) diff --git a/linedance-app/ui/login_dialog.py b/linedance-app/ui/login_dialog.py deleted file mode 100644 index f87847b1..00000000 --- a/linedance-app/ui/login_dialog.py +++ /dev/null @@ -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 diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py deleted file mode 100644 index c28c622f..00000000 --- a/linedance-app/ui/main_window.py +++ /dev/null @@ -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("LINEDANCE 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() diff --git a/linedance-app/ui/next_up_bar.py b/linedance-app/ui/next_up_bar.py deleted file mode 100644 index 345a7465..00000000 --- a/linedance-app/ui/next_up_bar.py +++ /dev/null @@ -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() diff --git a/linedance-app/ui/playlist_manager.py b/linedance-app/ui/playlist_manager.py deleted file mode 100644 index bfab4021..00000000 --- a/linedance-app/ui/playlist_manager.py +++ /dev/null @@ -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}") diff --git a/linedance-app/ui/playlist_panel.py b/linedance-app/ui/playlist_panel.py deleted file mode 100644 index ba1808d7..00000000 --- a/linedance-app/ui/playlist_panel.py +++ /dev/null @@ -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) diff --git a/linedance-app/ui/scan_worker.py b/linedance-app/ui/scan_worker.py deleted file mode 100644 index 13ae61ba..00000000 --- a/linedance-app/ui/scan_worker.py +++ /dev/null @@ -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) diff --git a/linedance-app/ui/settings_dialog.py b/linedance-app/ui/settings_dialog.py deleted file mode 100644 index c273519c..00000000 --- a/linedance-app/ui/settings_dialog.py +++ /dev/null @@ -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 diff --git a/linedance-app/ui/tag_editor.py b/linedance-app/ui/tag_editor.py deleted file mode 100644 index 1fd49040..00000000 --- a/linedance-app/ui/tag_editor.py +++ /dev/null @@ -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}") diff --git a/linedance-app/ui/themes.py b/linedance-app/ui/themes.py deleted file mode 100644 index f5dff76a..00000000 --- a/linedance-app/ui/themes.py +++ /dev/null @@ -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) diff --git a/linedance-app/ui/vu_meter.py b/linedance-app/ui/vu_meter.py deleted file mode 100644 index b85fcadb..00000000 --- a/linedance-app/ui/vu_meter.py +++ /dev/null @@ -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() diff --git a/local/__init__.py b/local/__init__.py deleted file mode 100644 index 4c57abf7..00000000 --- a/local/__init__.py +++ /dev/null @@ -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() -""" diff --git a/local/file_watcher.py b/local/file_watcher.py deleted file mode 100644 index db739ae2..00000000 --- a/local/file_watcher.py +++ /dev/null @@ -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 diff --git a/local/local_db.py b/local/local_db.py deleted file mode 100644 index 04ba88d1..00000000 --- a/local/local_db.py +++ /dev/null @@ -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,)) diff --git a/local/tag_reader.py b/local/tag_reader.py deleted file mode 100644 index 3df1ee8e..00000000 --- a/local/tag_reader.py +++ /dev/null @@ -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 diff --git a/main.py b/main.py deleted file mode 100644 index ad5f9af2..00000000 --- a/main.py +++ /dev/null @@ -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() diff --git a/player/__init__.py b/player/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/player/player.py b/player/player.py deleted file mode 100644 index 2fec70d1..00000000 --- a/player/player.py +++ /dev/null @@ -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") diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index b005ffb1..00000000 --- a/requirements.txt +++ /dev/null @@ -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 diff --git a/ui/__init__.py b/ui/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ui/library_manager.py b/ui/library_manager.py deleted file mode 100644 index 3fdf047f..00000000 --- a/ui/library_manager.py +++ /dev/null @@ -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}") diff --git a/ui/library_panel.py b/ui/library_panel.py deleted file mode 100644 index 62f6fa63..00000000 --- a/ui/library_panel.py +++ /dev/null @@ -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) diff --git a/ui/login_dialog.py b/ui/login_dialog.py deleted file mode 100644 index f87847b1..00000000 --- a/ui/login_dialog.py +++ /dev/null @@ -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 diff --git a/ui/main_window.py b/ui/main_window.py deleted file mode 100644 index e5c829cb..00000000 --- a/ui/main_window.py +++ /dev/null @@ -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("LINEDANCE 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() diff --git a/ui/next_up_bar.py b/ui/next_up_bar.py deleted file mode 100644 index 345a7465..00000000 --- a/ui/next_up_bar.py +++ /dev/null @@ -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() diff --git a/ui/playlist_manager.py b/ui/playlist_manager.py deleted file mode 100644 index bfab4021..00000000 --- a/ui/playlist_manager.py +++ /dev/null @@ -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}") diff --git a/ui/playlist_panel.py b/ui/playlist_panel.py deleted file mode 100644 index 3e378989..00000000 --- a/ui/playlist_panel.py +++ /dev/null @@ -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) diff --git a/ui/scan_worker.py b/ui/scan_worker.py deleted file mode 100644 index 13ae61ba..00000000 --- a/ui/scan_worker.py +++ /dev/null @@ -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) diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py deleted file mode 100644 index dcd7a3dc..00000000 --- a/ui/settings_dialog.py +++ /dev/null @@ -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 diff --git a/ui/tag_editor.py b/ui/tag_editor.py deleted file mode 100644 index 07e9d88d..00000000 --- a/ui/tag_editor.py +++ /dev/null @@ -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}") diff --git a/ui/themes.py b/ui/themes.py deleted file mode 100644 index f5dff76a..00000000 --- a/ui/themes.py +++ /dev/null @@ -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) diff --git a/ui/vu_meter.py b/ui/vu_meter.py deleted file mode 100644 index b85fcadb..00000000 --- a/ui/vu_meter.py +++ /dev/null @@ -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() diff --git a/venv/lib64 b/venv/lib64 deleted file mode 120000 index 7951405f..00000000 --- a/venv/lib64 +++ /dev/null @@ -1 +0,0 @@ -lib \ No newline at end of file diff --git a/venv/pyvenv.cfg b/venv/pyvenv.cfg deleted file mode 100644 index 46675874..00000000 --- a/venv/pyvenv.cfg +++ /dev/null @@ -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