From e5fbf543026bc2c14cae3a7352a43c1378e844fd Mon Sep 17 00:00:00 2001 From: Carsten Kvist Date: Sat, 11 Apr 2026 00:36:21 +0200 Subject: [PATCH] Tomt --- .env | 3 - .env.example | 3 - LineDancePlayer.spec | 161 --- README.md | 57 -- build.bat | 68 -- linedance-app/BUILD_VEJLEDNING.md | 47 - linedance-app/LineDancePlayer.spec | 161 --- linedance-app/README.md | 57 -- linedance-app/app_logger.py | 33 - linedance-app/build.bat | 35 - linedance-app/build_linux.sh | 30 - linedance-app/build_windows.spec | 84 -- linedance-app/main.py | 33 - linedance-app/player/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 182 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 184 -> 0 bytes .../player/__pycache__/player.cpython-312.pyc | Bin 10987 -> 0 bytes linedance-app/player/player.py | 200 ---- linedance-app/requirements.txt | 7 - linedance-app/ui/__init__.py | 0 .../ui/__pycache__/__init__.cpython-312.pyc | Bin 178 -> 0 bytes .../ui/__pycache__/__init__.cpython-313.pyc | Bin 180 -> 0 bytes .../library_manager.cpython-312.pyc | Bin 8173 -> 0 bytes .../__pycache__/library_panel.cpython-312.pyc | Bin 24159 -> 0 bytes .../__pycache__/login_dialog.cpython-312.pyc | Bin 8498 -> 0 bytes .../__pycache__/main_window.cpython-312.pyc | Bin 58629 -> 0 bytes .../__pycache__/next_up_bar.cpython-312.pyc | Bin 3600 -> 0 bytes .../playlist_manager.cpython-312.pyc | Bin 18806 -> 0 bytes .../playlist_panel.cpython-312.pyc | Bin 32095 -> 0 bytes .../__pycache__/scan_worker.cpython-312.pyc | Bin 3101 -> 0 bytes .../settings_dialog.cpython-312.pyc | Bin 17360 -> 0 bytes .../ui/__pycache__/tag_editor.cpython-312.pyc | Bin 21946 -> 0 bytes .../ui/__pycache__/themes.cpython-312.pyc | Bin 7358 -> 0 bytes .../ui/__pycache__/vu_meter.cpython-312.pyc | Bin 5057 -> 0 bytes linedance-app/ui/library_manager.py | 135 --- linedance-app/ui/library_panel.py | 364 ------- linedance-app/ui/login_dialog.py | 139 --- linedance-app/ui/main_window.py | 943 ------------------ linedance-app/ui/next_up_bar.py | 59 -- linedance-app/ui/playlist_manager.py | 324 ------ linedance-app/ui/playlist_panel.py | 538 ---------- linedance-app/ui/scan_worker.py | 64 -- linedance-app/ui/settings_dialog.py | 281 ------ linedance-app/ui/tag_editor.py | 427 -------- linedance-app/ui/themes.py | 334 ------- linedance-app/ui/vu_meter.py | 96 -- local/__init__.py | 29 - local/file_watcher.py | 274 ----- local/local_db.py | 587 ----------- local/tag_reader.py | 391 -------- main.py | 33 - player/__init__.py | 0 player/player.py | 172 ---- requirements.txt | 7 - ui/__init__.py | 0 ui/library_manager.py | 135 --- ui/library_panel.py | 274 ----- ui/login_dialog.py | 139 --- ui/main_window.py | 927 ----------------- ui/next_up_bar.py | 59 -- ui/playlist_manager.py | 324 ------ ui/playlist_panel.py | 523 ---------- ui/scan_worker.py | 64 -- ui/settings_dialog.py | 262 ----- ui/tag_editor.py | 444 --------- ui/themes.py | 334 ------- ui/vu_meter.py | 96 -- venv/lib64 | 1 - venv/pyvenv.cfg | 5 - 69 files changed, 9763 deletions(-) delete mode 100644 .env delete mode 100644 .env.example delete mode 100644 LineDancePlayer.spec delete mode 100644 README.md delete mode 100644 build.bat delete mode 100644 linedance-app/BUILD_VEJLEDNING.md delete mode 100644 linedance-app/LineDancePlayer.spec delete mode 100644 linedance-app/README.md delete mode 100644 linedance-app/app_logger.py delete mode 100644 linedance-app/build.bat delete mode 100755 linedance-app/build_linux.sh delete mode 100644 linedance-app/build_windows.spec delete mode 100644 linedance-app/main.py delete mode 100644 linedance-app/player/__init__.py delete mode 100644 linedance-app/player/__pycache__/__init__.cpython-312.pyc delete mode 100644 linedance-app/player/__pycache__/__init__.cpython-313.pyc delete mode 100644 linedance-app/player/__pycache__/player.cpython-312.pyc delete mode 100644 linedance-app/player/player.py delete mode 100644 linedance-app/requirements.txt delete mode 100644 linedance-app/ui/__init__.py delete mode 100644 linedance-app/ui/__pycache__/__init__.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/__init__.cpython-313.pyc delete mode 100644 linedance-app/ui/__pycache__/library_manager.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/library_panel.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/login_dialog.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/main_window.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/next_up_bar.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/playlist_manager.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/themes.cpython-312.pyc delete mode 100644 linedance-app/ui/__pycache__/vu_meter.cpython-312.pyc delete mode 100644 linedance-app/ui/library_manager.py delete mode 100644 linedance-app/ui/library_panel.py delete mode 100644 linedance-app/ui/login_dialog.py delete mode 100644 linedance-app/ui/main_window.py delete mode 100644 linedance-app/ui/next_up_bar.py delete mode 100644 linedance-app/ui/playlist_manager.py delete mode 100644 linedance-app/ui/playlist_panel.py delete mode 100644 linedance-app/ui/scan_worker.py delete mode 100644 linedance-app/ui/settings_dialog.py delete mode 100644 linedance-app/ui/tag_editor.py delete mode 100644 linedance-app/ui/themes.py delete mode 100644 linedance-app/ui/vu_meter.py delete mode 100644 local/__init__.py delete mode 100644 local/file_watcher.py delete mode 100644 local/local_db.py delete mode 100644 local/tag_reader.py delete mode 100644 main.py delete mode 100644 player/__init__.py delete mode 100644 player/player.py delete mode 100644 requirements.txt delete mode 100644 ui/__init__.py delete mode 100644 ui/library_manager.py delete mode 100644 ui/library_panel.py delete mode 100644 ui/login_dialog.py delete mode 100644 ui/main_window.py delete mode 100644 ui/next_up_bar.py delete mode 100644 ui/playlist_manager.py delete mode 100644 ui/playlist_panel.py delete mode 100644 ui/scan_worker.py delete mode 100644 ui/settings_dialog.py delete mode 100644 ui/tag_editor.py delete mode 100644 ui/themes.py delete mode 100644 ui/vu_meter.py delete mode 120000 venv/lib64 delete mode 100644 venv/pyvenv.cfg 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 e8894209be4c55f3486ff2bc947acd66e92c6d19..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 182 zcmX@j%ge<81lJc`&jitrK?FMZ%mNgd&QQsq$>_I|p@<2{`wUX^D^x!tKQ~oBIkBj? zBsEXpB|p0~H#M&$wMgGRv&1u*uC&Da}c>D`ExO!U)90AjU^#Mn=XWW*`dyV*W7} 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 ad7a4ac82e03186e3bc4c7732cff85be4b8cd8f5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 184 zcmey&%ge<81fJh+WP<3&AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkqYkXRDad;?$zz znB>Hw;*!+77?=Fy(%jU%lHwTm%o69E{Jhi{pUk|}l*GK`RL8X9g3O$p)S{Rin2>H_ zK|xGGPGTicFg`vrFS8^*Uaz3?7Kcr4eoARhs$CH)&>oOGib0Hz%#4hTMa)1J00IIs AmH+?% 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 9e97056f18123c667be476b15844f868627e33d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10987 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 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 41204d2266416524c5e5044b6f4ec70153417f03..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178 zcmX@j%ge<81XmVa&jitrK?FMZ%mNgd&QQsq$>_I|p@<2{`wUX^D^NcpKQ~oBIkBj? zBsEXpB|p0~H#M&$wMgGRv&1Hw;*!+77?=Fy(%jU%lHwTm%o69E{Jhi{pUk|}l*GK`RL8X9g3O$p)S{Rin2>H_ xK|xGuW=wp1W?p7Ve7s&kyADI~$8H<>KEC3dDFyjCK 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 d5931b4485555ea5e816de1df3bf06c2f1cd7cfc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8173 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 O>7^(CNEsPJ-v0v0`wcq) 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 a645a86a7933a2a22c677ca2f0677a651824c6aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24159 zcmcJ1d2k!onP)f71_|DWcoRHC5tK-Ywq(haBI}?o>#$_eM`$}R$ZnCKK!R=nmPCUY z)s7}1v(A``lZcA!F`UgzsIs$WcBU%I?pDPa|KqKx-6afYKy@iQ9*<{doUNUuCB?D5 z|LpI3jqV1Zre$YpTjIm(cc0(;zTfw~{+ptr0uIkRqc;MJTRH9@=tX<%8PDxGcrI}w zH^_;+XbJM;mO;z7bS?E1-U7vW;)#GbB1M$K z6OZo+oju||7mCu$5hTmRlw`deib&&%3jq_-J~0qc94C(Whom4f9L&yrJQ^O|6OBYd z6N>x9aXB<1OX2VyzpRv<*gX`E$o}C7mGpQ(I^(q~mJ<=BVDj8^k&}Uu34aiU4g|&} z8TA~+Z@)j{R|-z-ITw+3%d-ER*MbClL&1=&*iZDMlf&vLs%~aTx!YZM|28I6;s$w4 zszu}nt)gv)JB7);;2N}v)(hOAeTF;B4?0eAXhkKJHR2x%N=MYurXh&#LNQtW_lT10 ziB7-`N8Y&>iQtWmb2#FcBkC0RCq}wOwAm95jeEw?fu3QD+BW7=1L^PfZ{hrazr;mY zFgdf9824SQ#~A+xuLkm5j2qKdsGg3)ipxpi%!}rv8q(bKmJLr^J%G z<0GY7*jTydGk(V^4Zr3yN<7DTEyo7Dg^E2Koy5!vK3`xW5b^nx;;s%_d|{dvkLUd7k8d0ejY}Jc{g`BFVq<^kbaWg^ zC3)k4KxA()G$C!I4S?-5EbTrOo(u$ohzn{#-TukRjnM!CayPA9gYu^usqy*CtVbJ< zG9(F~fb;2j?xs*Q>r4nW^VKQAvn;sNLVZH0pYKlzE$po!AvB~Kd(w^D6OG%`js1y6 zMD1fyEeWCJC#^s2e!V;0+LvhUyDn_MjY7GcmFPeKTZ!*uwOyi>mRWU_QhVOWc&05e zOPD{$#Vli#C+~^aw16=xHSZbI*P;=jt&M4G%u3~ghH^psv@K>Eqv^|gVpc8vn90f% zEn4ifJwhYM9=+v`m}9I$3pIX4Yotnhi`lgj-pK4#>^#TV8ZF-VjakQPjW^9F+C;mS zQ!9z*BK2B)Oy6D8&X{woQ42SIV-6!9QZySenlEdNJzALYJJxFWHJ?!;_MhXJOkJC@ zM@!A80EJSVN>^)n*xY7 zKN>+Zj@9kyUP*w(z)4l zuav*me7QMY+ncEEO_toJ#r}g(K?$a2r{>#}CC%xQjzmdE{7kZB8BXK!d)s+&~e^^pEH;~Db7HWtzRHlR` zO6{6;eZTcm_r>mXc~_#m>$wL6m9xFsGOzXpTRJt{5zY? zzT*>jnSBFm1WPiESpCsx#BC0xWgpTOPO^e8%Fv-fd)~W)-nD%_=*){1^4?uyEj#Xc z9QOq{^tr)8cIdmsI?*N-iFT>@ludM8un(4qPWUaNaKScMD!QaH$wr$TAq5C27YmWM zLUhBe6pJLASS%HbB~sBThggdEDzOYP)nYl^HDZNSgM5_;uNA8hQb#!uQjL&$aScKm z#2UDbjP+;&>rt<`GaScpV()TjG|^i6yZt1_Evh#95r4K5fAY^7+fbMAwJtV9GaMrhQa<~_hs^< zuAyikDEguS*@J>LwnW`?jEAVoUN1WDa33Z9H*oIa9XdK^Y%i89v!CMSmW~-4$i~ga zws`-Qr^#Q(n1;H9P?r`u6GG<#$VyLESVuzWh=)Oevcg&uLTg&+N(fyGo8Nlq>O(1E zI}xJd+2ZR$4cqs_nQe>~ah%R<$Sz_wB6^&${HFOBZ^m4JHDLeV$Sht=DSt1fIHI$} z+C&orV_LdJw2JsMN{^t0>Lco;h}e`deamR^SjIZzfL2+)m~~+(OxPE#gcTX0^*a*Sfr7@G83RnagXEtLUD&%x0zXJ%rGsP(N>fc+YUDDZ{`Q#Nl=_#8 z6(asjseh$bB1TCU+0b9&j`N;-&ar)!ewdV{)rys2H)FW8xdWZhPX@6{%Qa2nZed>Y z@G54nIP=!%XAavRW;816w2ORAt3zuKMoAZ2F-kkNc;nZ!UDaC4*-kLawaX|$^R2R$ z_Gt0O?>$T3XQb79k^P!)?4af|e#ag*{F?8cwH(n>8o#Ub;h2#^^JSI3+I}z@HzOBV z+%>BB{}F+K9}zRAxb_^{bL7w?1N#mu;8mF{4b~N6|L=%0esKA!$Ft}7Q4e@v=_5KD zqub%^20nW({LWRG#ZA8R=Pce62IwhyqM|gx!aGmDb4>;`8t@DO>jIaI2yc`YtgdQePwTJAZLH4>R|C@U8DDZVl8U7~R_`6HBU;gSX!s0&_nR2_N;#BLRRC1;qYO z`iDt8pj|3cO*GJvM~22Q+Q%S}D=xn%WZ2jq6WiB_Xo~O z;z21eG75nKB@Ki^!GXXeqzE86rzKH7N*ODC47-335j<_=FY}H2C;TIlPwhZJf=q)d zRlHxOse#;~4Kk~Zz?sJbVdxjoP}H_RB#-+emKIN5;wpk; zdVPOly^30+8!Doeu3ev~U7r#*d|2g)Hzun#&J-*Q<(W>Rg0<|eF(EWAH62I^2idDy zAIYTg&FQuW6KxNsgg&Dabt$3Ucq2R=qf*y#r3rFzaYk%S3+)M^JwBKcHt8YMfw-s& zz9ibF1y4fo#2ZxcSPfxmhEjr8&()j|n&W&NQ-QWJkywT9wUJc&`C8NErj*e6VSP)y z^-3QNXAM%TqmUMu$o!1)me2=>rXM((c;IMC zIA*k>^0m6lb=QUVyRi*AKNv_ytSnQc;5Yv<<32Uci9jfsj@PRekY0u?=`_bCjRztC zUx^)K;HyVPuo>xNX*1M+sxg;r5NV~NGSLJKg-J;V99z>RsUvt@Xk~p;4_N}VfL2BP zk!aXQWNuhT5RsYAJJ!`2066F>FAsp#Lty^%#oa|7Dk<+7BXXGc#4Km|u!Z#K20OD# z?(BAEO35DJRobbu&_=~Zu2pLK!aPjZ2`+^+!i-Dt+T{QWhlT=&hdcqVOmdh!4##U{ z6D!Y9z$}~$+LxJRH%?GCi!>`!wK)@<9rY)aa|q6P?nWWh5~VL4n?06xHz(Z9DR&Fr z)9!|ZyCLmvPq^FTPrbKx*W!_sdv{huOTyi9-QD*7>RB@>)TLr$D}xrk_R#3Fl|d5C zSJ?#F+V0FY@LG&vf}fVY{9QO%gZzSS0sYdTei_BP3SH#NTV3nu9;5Qw710 zUsN&YsWmyBF(O{3Or$VFC+eCRvI~M(GFx(8sQv#*oY3e)s1-VVTH5lrs2QLFoH`$} z3~$~9+|Axyrc7B>NIkddSkkCa4r)wHGaL8)n1QddsI)C?h7;>?CgetoBP?3(#>`R+ z@}7R~CCjj71j_VhfcyYgHbr=17A)+{1_|>oR>v$CEicyEkZam9Q5hi_i#_IbXv7Zd zOmC%uetvPDX=_f(Vy!0QS1TRZ=~pJ+Uo@iAc9f^%ltz7HCdgmRE|g|MStWYTEL1$l9?1aMP@0g#l#NI|L?WorAr^QYjF>6>a|$1ZmOT`KHpqSoGOMUK zo@2*tn1pjxCQvm#L@)AafuSsV1(D%@fdg6q(tTw1NZQ?)a5pC1&1v`AgnMn$y)NzU zPPn_1?u|42jpHZc13&e>?)%%?BXRzk?N>#=EPC7jZrSy1hkw+PtUZ!?;;E&lpG`k~ zI`Q=BQckShICnLqO3Jp)}Aiw zN|bda%Qld(0S;7U3`shM|7Ba8A#BA)iU#h=|O13fY@nZ6&+zIo#>-bwn%AmWdNwKiDK_XiI?B&oJ4U1l>FlJubE-4v z4AXW>&yHYd*dGkHJ3pqN&%)HF zKW=E|<#q6=*pEX6cN9AVca%b?Y9${wGQkxNW~CK|(V*2oaZaZ5TjRO(C6j6Wv+91M zEj5*=Va@FD1w@63+eHg+mXzP77SXmlID2sJiTRD=#r#^|->-Qhf+AK|7QS*N3ZhV*mKZ05M9vD2rW(?ZM{A^wUzFD*$GmTuY|bE7|Qw6pkC3(78;E)q*u$ya8S&(7l_=9-UqxnT@v zW-O9S+AuLYxUC0W94JrTDb8m)@dnFdimH!BS!Oy^*JeYjh_||O;AHl3SG06m7*~x z1rg1>$4o02!%4~vu9`0B+WA$-pxme-LWGz-`s`Q4D#jkvLXF=@pXMLiq4|toGmR6p z>Y39UEh!n>ZKT$GTAeI@uNG?jYVp&RvC1(sT@mqn>_I%qM|qEyUu@P|V)PunJe-#t zuQ6Tk$msOl3AnMNcL&C@cE&0v0)fkUPpl$VqP2?$MSk&b=&<*Cj>-QXDHS(6J$*sx zc_|oH-0WZ-4}`-&BlfEtv(>ZF6IEH(zy5{C1Gd={^LV0m$UlwaK zP?eJ^v=m>f{wt<@jm%=_?UQ-%gqTpg8uO#(Z6%5BbS*|MVc&L zE|V^Wa>8^(f=jw)OK&@#*mgX^L4_8f_d)>r$bUwe9Hd8tUv!(hl{G-gcCb~18~Z0{sk z7Zd9uKSRzp$RU}Y8RR{I*?mIxPs-0yFfms0FvXFYjw#!qoQsUgB1K5#(7uytwac5x zDI#Y*IUC?8(BX|id2|x%09Y#AgV6CX-C0IyXC3#SRiGha8a%QHq@*m76S_DusAg4@ zM^y`Yt5onSPIWMTmA0k)bE?gm*@#M^x=FD~B4LFOD7*;q^Z6k3Omsm3+CW^CA^YxL z`7c>MX1=He-lr5mC*?b%UIJo%Rg)!ulM=f~gGKvClF6cjbbe4<5zF(eDx>DQDhK@< zqNgg&77MjL{NF&4u{3m4ovCnyB?zClxo+3inf~RfT4o?Ux?myA!2ad> z=GV4e-WET$xF=b^b>_%&b={@Wi=*@3T!<#Cx6B-Pzpi2C@XeZ*ct^6vdyBIa?dC7q z=J>hZWpCG;Cl?!%-8|k}PkZ*?XhIJ6|zxpC7mwTsSjxV7VG*-LKVL zu8DUpY)RJkF4f$(*l}&mHP_qK@7n&m@Lz_!`I85S(g#Nq2S<|!$CeJ9UJ6E+ zPCvg?d1hwchqaz7wKESdyQ@{acWLcIDfh0Mwa{%PYd2*gya~6L6grFiVsFa5g(W70 zH{OzRw|`h(GrwItb zX6zt&9nw0Ts(Fub_a$7hN(!B24MXAw2-#@^Xo>PH;Re)9EzNt(M7?ab_8i2J*(?D3 zE0p5MvOCue9!Zoi7;DD#n6-+iZQhe@uqwL5f*@{ZPFu%a-*KupEoYnncM2~Eq8m4e zXg=^v+bl5mG|Vm06Qca=VeD(s1z{Q&1Cg^BJ_mYNsl^+=W;W+$!j5sXi1o{?q&lNi z&1YItmU-4HZ8BlyHTHAW+I1mHs~Zfcb`(L`XEJ6u5IteeEZLI>p z4FPZBOzqoEuq&;fq%~>dj7z`moNVQ}uX8Uxc7{7^{W^Ds_ZFx&g;#l{lI`#*$%p&q zzOes!i44y&5}N#dv`~R!6NoP^ut!D}OF&e2vku{Ui335|+t-`Qb_#Vf*O=JGj{b5t=xIEA~%;ZaG< zXk*lheoS>Nk;C|GVwLk%F^UzYj8i-Q;QfHypHi{Egma$zP3i8NHMq|E+TqKGlQo@l z_M6SPc=J~M)%t6@Q_VYXajvq1{KW%vyXPXyYwFW$?oX__KecAdjhe<4i@mDxM$>(Z zo3FVO_wRc<@^1gHr;<&N&RcKPH{I;&ep6c9`^MNC8|VAuRhJI~Wd@&o^y1NUWk;g2 z19m)X7b|~J^Ya>LU+zox?!R7nV5NfUTB+oq&ndcCq%ytNOWQxaRRaq)1y1v0H-nT*uro#gx$j$#Etm;X1t{tG!|TPgn^ za(2PFYGE`-vH8M*5vB&;b)HEON>L6Wl;20<75s(i2>R>-mO=ge-grfPIJu^4<{;i) zetzcQ9h;@7{S%?6Xf2~C@@=HeQ35#;z{tu?c$hS88-p+ZF;nK#-8pb6;Gf`HU(GUu zp0$|)=^!Xw;56vc5Y?LZ7`#->a=H`U;$N&;C8b^y(sY6F@HP%L5`oNnvbQbn`lmd#gtp^dDxKED;k7dzcbk7&_;Ec;M%Vo9-biSPBy4Rf29Yy&uv;)(35I<}V znHg~A(+TQ)9;2vl!C|HuSyQDEy{|e|T0VTsgbUN|rm0%nHPf%kNZOP!d9pp_-pHgV zl9|+LjQ>%}y_F=VtXc4ut3B9HZDniESdRqk$e+Pc+{e$Ih&<490;bilCI253PJA0| zes#;{GmKAC=?ih3Dv@F54WTQ?RqpYeBIv&2BT(Fj^_a}9at3|jYB^o?1QZe(#obJH zg0WhOkjTF$=W}vy!3h(D^ek-#k7t((dYnSYRLecL!TXeZ2gdTV^W6J|CF#PJ_X=U4 zRleN0;jNOZC7*D7<8hddF1N4!>Au(ZEv)^?k!xkYs`_QswQr^#Ja)^iPQ;Pu`F(t?Ns8?YGu0z*_hd;YD&eQmWi4^_W z`hEA?7%Fuaqf9&d_V?hBsg{|JJEHGVGwS`tjAhIsH-Md@6_EFsU2ieuOk$`sR@}k9 zQIL-x7SRHPJ$w-N17OYnCn#QH&W}Ku2!E5|rvZ|YIY-H^2a8)N0v()}j zvT7Hiw0zr=RSzOxUdpF0J{`vmi*DS?sA@`AbtbAhm%Lv~R_#Pi$c42RYv(t=)_1ut zUEiCi?_F$5)<2Le+X_p+!m_z1-YaZiM+iatDr_GYue;YT7gfxieXpq5?0CtEBS<~% zR+-4_U?P$4%zH&mEFY-?=22SP7v;cVRqXkK!)gPDA2_^&ka%;RfM6yKJb_WKDc?Y! ziqO)0#|cirGV8=40~knj&=&)NJzKxy$Y9S7AjnQ&&%)i<^Q==V1rmpTWX^EFt1Y67 z@MlOLW*mlo9Ae#N9@C4(`ccDV3$v1RNJ=ZmFy{ghw&BZzw2N{mN7jAL3{H#tnjvO| zFMIS45dy50|0l9C`vCc$;8O{k{2O?O@uxq;MbLAb?#-B^uG+aWsD8N*cAA5BhOh#0 z#h3e>3}M}n%XMebi!5YSM^&|2sb=ys5pWkgd8n#Y{TN@?sScINFn9?AS%qIOO=InB zStzT;H_5;a(Q8vl6h8?>1n5^AsteP+gPN!^nu@h<`6@lp@zsZkzR*Dl(JUKZUE8r> zT`Fyzvwhz=U-_!Z-2iF<>plsLS*#pSE0{oJGj zV#e1Sy9%5SrkuzGn?ax(`^nE>f!~tvf00A51~w7Rv0omaP#LdX;j zbXdtxNK-vuV*azzKSm=?Ex=yV;U;S~+*JAU_V~JFh4*H2>rb{URQ$WXD}MxxR=RkQ zP$wsn!dx9@z1et&pmyi~QtJ&MqmVT+gJG~W%`gmtFEm_&plHQLpt+ilrdnQ04xtco zJ2jSw`?H`UKq7XjOi2-f^Dc{JGf}%J*Ef;tb952R$l2Mm^X~Xivc!w8(PeBCHpk1! zHo*zTrH^qKc)Yw1lxhNEvinGh+n zmB=)AwhOvc1S!^HCdg~Gkc8s3>%6*V=5iFL4s6Z(1O8xW#LLUAXk^Z&BoUfzO7*bA zrsT)+Izq2z=(ARJ#*$0^W2D1ntp9``cC4s3$TH{1e@19N(aXR3Zybx7Z5K6_5gIJ} z#maPVf1>A`DTAW2FetY&&k9YE6!1W*dKwerWbD+Zbi-H zIK@8RpjgJwXdH_=U-dg*$cu%#PU?3}w?J@vMu zNbED5Oq{nl@5ohwVw)Hkj;NBP8;y`JASsh6I@ix8Pa&NM4y(RK}_QU*2jJMjm;y&C>v3K1nsJCxg>1(pL#4B%e__?)tB@))bz9OrBm6U3~s*){6Ts{|es2@JvDR0NT;6E3pmO-b<^B-F* yyzTdbjko-s)13m2Kfqt-_WvEX_Ku^D-!Q-Z4u_vReY@@ahPkc3U1aDez{`m4pZoBn8lY9Ja^2L|eH{U`qvdlz10`=jT~ zaAqim+HCuyLuu~0uXE2m_uPBGbLL-yK_3Ip-!bc{zYjCae__UaxC-$i4#Yi1VB(Cx z3J!_QIO2|sGw#f|;;sxA=Q8fNJL8FaG9*qi-nf^=c23Ec@yGp{Ks*3>S3Kxo&N2db zixJ$SY$x|Vj6GSDFiX?N{iPZI&?`s?=N;J=v(>iC(f91WC zxtR<6Y_6a{=A4~zU%9}Ki<0KOasim15mH!xxgbxTEGSAYtGTa?Qa&R>{i#VYIRzb~ z4$W~zfr2qnQBv9Ka&gC`qU47M2L%vY)`c5>88wwCfQ3 z3(N#7u(!N%r{K87#9e|D-kjiqw;OdA_Xq*d9}zsFcfuu*TU^{HctyWR@HgRX1CA38 z!FS6Q4=gYKyx!H40f~ zj!B!sTkRNF>`g5^RaF&IaBOPft<%D3w-8{Qgf)(ybDKSskV!!sV$*3b-rDD3r9hW^ zdu=Vl*!8na)OD$VJK?NlT&b%t?Ws`eNHvm3q_Qa`kbV!>D*Ky15HIbcs8Y+l5$yb5N%rEvq|y9gq%-F64c=y(u)T8e15Qy8m!rZ zv=P`Bo2!f`W-6fxYGkzP-``}Okl><6C9O+4N+hyQ0%g*nl8&VdB@)$h?J8+sdaXqE zK<;*MF?dHPk&dTbdly_k^{@9vAqhQc-2+MRDKrQzhDxMW@7Sr5&c~$t1q{O^D@KSk zgXW-{?Z1MtfzeuZbBt+5^Xwd(#x^z2CSzsmnzKXDe-B+oSC&yMlNKy9op;PR()A44 zWl8{M=Scf&hKH;YFw4@GF`Fd}lip_2CRN3et!Ls zI$K2WY-;iUGaF0Cp!pU{2Nh!8HRnp>F<0{_JIsP~yGhw;dZ$gBRMpJtYqfeM>$a%U zH8HuFDagLZ8mU6Sk!7te9HkMn-cHlqHf>UZ*X9W}UFV>y&+GeoRY~tN+nVVX(&Mhz z9~iO6r0q1_XVWI7>^7x|98q&8Vb{jvUZUHtJq zP+u2y$E=zOn?7sGYNvD7EyXeb!x}GrjWF!%W_T6QcmRAv0Bc3^0L&1=oJI(v`4!41 zrxFU{uj0tqY<7y~vMB&rkvz?%#VJ5O5$MQEKxBC(bz=Z|Uv~XRd zqUJL=WkiL)TJ)dPOSAlq>~Jvz4#0rX3=4|nJh&D)DhwA|L9XB2{AJz0V#B$DJXK+j z{WL}+C$iZBBCevRG98VKE7?R|;%B2h6mf>;1GqPq=ab+?XiY$$$^m@KD)L31f(s;5 zggiRZe0w}CCY4JFCIis!gp|6TMXX2ha@K-YepKSGYkoa@N{51)mlqJCUKhcyfu7f3 zF4riZr-)(<5R2~3*d(79HLjAJL!DE^Kbl`hu>gYe;5ea(mnA+aPUa**q*uimV4BDy zDm5D52xpR$IY7dqMr`UbG?|8xwICIh0?l5gqAZJo<^hK-3ljr$o67w#V&r`wh0lndjU3qi8xK^8POt@)5hq76ziY9(Sk6mL0-u%x&h zmN=C)e65%T?GX#e{|2rXz~#oBOIOz#yB6H*BvdACDrsBlDv@1!4k7Py*YfyE*UET_ zyz*?@j@6df+P1+3Zk+@x!1`i|^y@hU)*roE?%1z(>@Rl=sU1Tl@`lc)72)`HfZWeQ z&8yqrSqqIVIM<24GTMO>>4V(uhQ)>@mp(GoV3&`Y%571#En03nthOC4k=JczVQBZP zVf#;J7F-sn|LFBkj@&;|?if%z21+DmH*PMG-A{3HI@2A?!L_zni3~pJ==pGD0Y)_7 zJyw>s@ zT0P2WiJYw}+^dqkWfD_KY(@Fvg9jg!$gz4dJZtM(?LM^Db{IVs=#DN*ndmFkqieD< z(WT~3cHiG!BKw~0>RvwiVH{`LZYirw_Nipw%1&LY;3MJUl?jTltiY@`4ZL5~XZoyZ z*UtwQJWsYqmfP31A6W3MlP0rcmrA;p4}CWB=?ExxoitRIw!cJrpSAZcr`FmJg18MR zE-Z7I^s1zHISnRdl^7gAT&rY{O7@gVpGx{xTwjD9fFCwwbME%FUc7MA2k?+fsYn^XBCda?4OFY$d zKlfKa+{5q-q_s~yFW1CH^o)W$^ruGuTWA%=u)->4Pw-0da;bUDG19@B3dovu<_h86 zt_NA@d8nlq@O2ShaPl63MDwQP%E7KLj2=UI4_CujBA0^q3nwcym8X}XL-m?yAw6JX z#Y>9qn@(U1rdW0FV_y{ECbRBuxP5H#SlQpL`nya1-X|@Q<&KrfwU(iUbL)ZTa$uJl z*tL{i=_&UQtNp{}{5)B{b$$ufABc)!wrrJ?EUVtS>cIa!s7j3maX14 zFjcB=9V;FNQ>i?E%nTq{!BPlV$jz|4^@0|#H9vP&FBY`J+qdSax74r=E8!O%tk&aR z1@XKP{uLO>Gw;+>Qc1nw);rS(`f8rE6#%d+=GZxh;Q7c3dejRCSlZ%%ujOXhJkq;t z+N5k(^+QG=A3Nqa!7KPutlo2IInY_ zG#*Mdk2wQVyYpm@)N`}a)ev>)N0~+t{UWq2`Um-fGHFCpi-8J~AGkW37mJ+`X$5==E=evsI5RLk zJv}gyqnQB+1dCbxb|Gl4%Wsce#h|aiE4=2J6nPj-*4*HtOy-2I@pJ}xJW47fB3KRZ z$s|B%Bh*~PNIw94beTGf?olx<4M(DF`YB5BR-*6W>oB|kuEPyiLDBRCmLR@9ljX-H zkRk*UNvZJ|6@Oe1Wf-QChZwXhYOb^lA4E7K+{Pp{kJ-)hj?tguKGr$;|Ky^SFfoxol{!V)J8 zi%jLb<{Hllvl=VPXet$y4vBG#WBi~7sGCm5UqF@oF`Q^{Lxfw(;chkDT@LS4!*GT7 zm%|6u@WHk4t8kZpr*eV(XG6>Vnf1W7a$u(#*txoEL26mPLaGx|`Xj=zjI_v^$ zZN$bA4J;vM+;Fvd2mT{)cr9@FX}Im)k-JCA;iwvpuJ7!Dzi&9_{?H4?+1&8b&1~DT zc;hEC8y+UuxZz`>fekZy#vbwEreS6z_Ti5UM3uU z!EoWw!l{h_)6{xz_ubv)#y+*NZ^ilO%4%cZTI0|L;|%tHd1$?*<6iM@ak=NS=%>-Y z-oMroyW?8#?ES3u)7F*PTIZX0T)*(IcSa!zz4Sm2kRjMu4(w3_drE;$Yx+xn{-{@< z{Tonp`~2ehpIv;}fX#7~Km4|d+1B=)ad-#p4mXzh<*}t7J!;mwy^h`9UVOV8*sBHr zBbNgQ)WCs1Y;e%$Tlp1OreBd0M@N0kKXFIT9CQBKF#`0jk8z_O&sRzqzhAeHwF6hrnjxB86yseVk??F>_$}bOw=m zxNKY>SH-+_3|sVci$jN!uO)$aRZJ#_UMYfyEam8MZBOTp_kyLd+uY(tOTCB zC@KJL(U-NSs&RTh*#)Jz4~!i!4!L5>W}sx92B-%aaT)9Z{G4OBDwe-V>q-#PqT&-3;}T-S4nb#N!x=fugKXP^6exPfmjuuksivJ zUf#Yeubg@mUY=Jw55HufM4zM1{8F?*^CS{NE(z0WxIBABc|CRozNKPJN~UNNdN9n0 z<{-w^BTsry86Hk;yxNTng;;hBzOrVz(XRz3%Y1YNE6i{8*BhnbweaNpd{N$ z49IdwT6PFVQUoTk1&x##T4!U-OlG3P_G-r2*)Dd|-RxDyjHB5Me>*!vn^GjpGn@T? zug-21cz9-i+20n(*RS4P@A%$#z3=@bH`ist^X#eXLHqBUOn*l&%43&3w>h)Pbjie< zj+uBfZwZ-4EXOP()??NY+cDdS{g{0u=UC2&Ay^P~}L~u`1^0Le(QR$7-108LAzrJ66a1u8?=6{#gA;!?6bT?hZAMG#zVVeov@* zq~%!4Nb9jy_MRJR8}S|UnN1ec9uuGUf{D)$Sl_Zr-+XL23oSrsVZfq>u9ztD6_2k+ z8rO(FI6819ILeQmX+J&T{rU6X^6n3g26+GIV8H7i3ZD*!LIJ^ha_pG^{|u6i2ijdX zsc>I@+I{!{azDy4ryYkM4e}=f5rjUnbL{MX|HRlhz3kP$92yUw+&LbJjEy4w(NK^N z2(B`2#k*J^b2V_^QB#f zk4F5F@h}Cs5AP0!0zE;0XzT=D4g|ts|A_#q_F2-F!x0qT9~=n?J~Qg=9*hLXM#=lg z!v_MW2j1L6A^$`u7>*qBj|M^r@azvB7yQD6^6vJZJ{_7Eh@1?J1Q6{yI_MvLbWAvf z+?KR^|JaG(s9d|F`_R6DhmY(>s%1*42mGUGjv%E#hP*;APh>wD7or9eh}|p!%PmCj+rr=ExdEeG-T$@FF21`c}u`HWZ|tZ*pAtG7jFyL zcsoLDe9jB@V>!Iz1=BGH&%x!UOlQrB!X z1xIP*_xeY9DZ*z>+roj+P&((dUkHq%fPsO*kUtzA7zk7EdA-kn(cf`$Y$VVz=oi9~ zz-UL$*s1XmBn=21dxDW&p|R0Ghq`LJl||bTQWo+mOx}+1U`OU+7g|tyv_v=d?Mf$9 zA}0J0oX?*(eZ=KXJ4CL0rZmBMQx(-ywwLl!we?fB1Xq=6Y@uIoit{A7Dv_(2*_q(# z@OD0LI`3P;rDrcbn=IQTmTkJmZN81-Olk8_#sqU9j7_j(@L+n$Q;DWaM4LJmOfq>| zDZl!OhN(j3WIb=m?`G44=}l{&(1>?=^l$C(+0xb#|5-S}QK1zv>74P?ygw4~S%h}H z3TxqnsZOs~SWWRB468cm(Q0=rwUmYBDTt)TF(LZUN9h_VJ$1CC$?X2U_HSTHm zH*$qFRA`PgztTBFY)TEPpNvSgYcAmBH|?4wJJh0dr1FanRcl9wI#FrXqwi3GHV>a& z*oYrt6FF_<_{iA|$7hwgaSH`(g(FX8xij;r%h zZ||D7&OZ`gxi#Lnjm<*CP@0pvuFjZs^l|jDx3^=2fe{ckU>Y%>vy52IS!pMo1aMOT z$2r@b=hvM(>s-z``=ref*a2cs+9z{Ro0YdJwY?=nd9x{^f_NGat`kr3yld; zN8_5owVi7>!4ufz?_AS)li=G;N{^ro{*9fRI^kJ+eA5uWj_tz}ha(%>4@dTl2ZaZa zO6Z1@wx0>|k(241lhOvm4FM-R5*Uo6t*DMcFKOY?5=>hIqrA_>R$tm4cm}(Kt|as6 zwE0ZhjIyYVfir2#(Al(QFC!dXZm0N(&aC`^5oQN1ai*vu4&hW)ffCTg)`lkpP3ED>pBzM z>W>ObE*zdIdA;^>t+=c;Uf4F3lX4ec=zg&)<;gw&;PitRo}Q_nd1UtRn>lk6@wUwg zPuC51e$w42x*KQxSKY000m+KhBK|E~9WPunwNuF}%yeITX6EVH`bGI`MR)CN;Z=8o znt!EOu~J;NGG4gqF8S$O?9=vdI2ahufGsxk27@i780$+wkA~Gf4Kebg%%BKO1@;V6 zzN}|BCo2H2r!dIEQz-Qg0NM!JC^cf0t8hgtU5SmlO{Q~zeZ%=$oZ?f`oU=v>l`udq zb!QfeCauH8N{se9sYB`}uonXjv-APq8XE<|8#o0NEax~Q31oEXz}R>=AQK6${ehu~ z?B`C8g@c5-r_E=DUX-8Ck?5AR0Sk^VQH&wXoQq{$(museWew1H&L73@EiY~b? zx@Y!YD`>uDH5Ihpa+o|D*{pig+~+ehtOZ>J^mG)&5ztfDz`)2DKOQ2#XJFvzaeqjP zVdz}iJ_J%CB6Ojt!gg{Flk+5;bgqn74hFFF2i`Obod^xn?0kUrh)_>S$*G_S!g@Y` z!Stym$KG|Tz+x|*3EVc}=NFvSUOF4NWx~(=!?)?@m#zYP!9O(F?Jcb3tc*cqS08y( zGu^gf!s|&FnQ-BVZ~;+b1L0x<;bMcE!`tCH_`Q5ifa4tjCx{phM2w4fBHYcp0v-@L zZV)=Tvfl%Lp6t(sKVSCe!CwG>A=2l=U&MnTK32>Z!Y$#8;4b5f;g(WONK=9|W&ASK zXyHrYFXzh;TfvvJ*b0PH@|6gw;;Z0R^VM)`_!_vid@bBMzAoTJUN2JC^YsX6;2YpJ z@{RbCra)7mnR*!C(~J}?d`qAisaxT1mHln-xA8vY@yQ{}5we_Lfshq)$V!B)PZ%rq;mTXv1X>k8c5?s7n zSAx1B+(!u>K37*liPAQdutM=`zsmQrQCy{kE53~Q4kc9k&4?$Mm-T4z5uyp%gO(8e zll3Sm5vz`_mgMa`<(pVbH)t`6Z8%ok+Law4^|9;HPfCTIg=&oJ!@ zSr1=`dLGCM#A`;2?{)l(lvWAbQ0l$rDpqsV-=(c3D0SzZ+A1R%%le`P#1!datW1fY zcJn_1H4DyJ;5-22D!2}>{Wt#+&+fE~2PrBs_|hImeue{sW21anXhLo2LWxu-I$aLG zNvq>cbJ|83Kc!{*^O@(p-u;gs_4fB3=>C)z=g%*l_a69Q=Ey_+EbQioRFum<9vK?| zr!qL2=1zmtb7oB7gI=`7*C5cMl(uqWEJ@7O{$L~&NYncPFsVkyM~E$&&H?H|zwQC8 zv<{kdd1^5DuOg^@qY}=<)Gjh#vrtYa9hjFXO>NM1><`LO|wo4^Z}iK#(cz3WFCH3LHH-76G@9aR5VO zgZ|I}e;lpQ#z|odF_a0@$K-up!kHKktytU2R2JH9Bntm2HZ~m#Ex}c)oU&MTdxGmw zUuSw|8xvfM%(S@BGlRF<8&!40;3)s7tY(IPc@hjHPpZ0>f+|u~UKW+AW}LW+R5>x` za#PhTxFXfm#$H>N<0n7mTSbX0Kglsww!VTg!NIGldwu)m?a8WDV%4g-&_YkVs#_|s zrd~p8JH(of`Mh^6@tR#MbE=``jrJ?;$%b`e!@Ah|y&u-c8}?I7-3{XYHHlo4^Z`k( z9PC2rTZs>d4`82JBXTuJ{SjAuq36@PjYo4S2w>d zxpvppwY#q5$Mzjda9_HWV{+yuxdxGINN`Q~3MUd)h+M@?<4h!3w??d6li=1Om8anR z_UY|Oca7+-nF%G_E9PxK$a^O**|}5f-1%--qOVBGkmxx*Izoeum1(+7Y(X8b$P6%UHbOoWpbI1iTt~_de!k~$H{=JwK|d2c zSkw83WJ+HWnuLXKB3+sbj$&>?@}f*P3e|HVg%=SJ&6sZT)E}jkv{gQD`Wvn@m0y+2 zZxZvH;`uExPQuDFkSzKl_El+oE2vb{FaollVQPHVWAw$yZ9u6i0G(}jS*6hHu!=L^#kWM;4fs=cEnP)!(RY>P~%E;+7p4SOijcnbqY!6g}_e=h1D1r z<&wexNVTmckJ0wpdoBaGYPLcaO)yvq0xe3bi_xMIbuRoa-lGkAxN5vt2$b(za6p&? zgm|U_ikd~PImxXOxm9y~vVDixz9YdspvKmVTz!)BiJWh)>3gf*T$SK9sd0@W*O=s1 zh}?>~p6?xe^I(G8s>TsuN^-3t*E(lQwrvsHw&-ICHYK^`BDZ{QU2;XYxT5Am_}0Ftkdu9*w*?XNjouf+wD}ykdd6tNDyqrxE{1<3h7F zYp|w`5L+XD66(D3si?d|Q+oLvziv||6bTZtJO zqNv@`f_5$`A0uB}(klC3K<~N!=oyua+S-59l=&D8IN+ zV(+N`c!($yWI7svFx(HM9d(bL4iKwYQkY2F2Sa0FXo;}mh*ZJW6Y&cXMz%mANgIju z9iR`=PRKa|5*^{8Z%~Wj=^1Vs%G?>-@N`W%k@WAFta&g;uiyszt6k$+d`FOOk6Bx%Rn8 zvSWwXA@6ocPKswcmOa6>XB68hR+H@I231unaeh?ptAbM`mmh>0Ai&zPG>$h?MbI{EQbr*0hwY(|Ux28BAP80r zAxPt4ny{p1aFo5#GIWPej-8Q}nS?7yt4EQh@gA*OJX6)e{)93R%L{_@%BELVGE$Ic zBO{%fUhlY!nT4^HDKA2PW_MrXBqAtnixBbbBFqn(TmvKF4DAG?`jAovWe;rYI%9^GXM06Iz4H8^!aJDUP(dgh|8!&AP zSr4z<+)$FrVb+piZmXM{7OJcr-5OXbw*g{sAGuYo@o!Z39Ypr`&~gKGPH8!Be_Q2_ zvz6??N*2b(B8)|NlV-$C>Uv=(9B>95;fN3bTMZN#1*dZ{-3}ff2FI(9G#v}@x{Cz# z{*c555NIx>izs*uiUgyP@BzO7E@2pZ9Kgb9{~&hqG;OM~u4S&w5Q~hRCg4#dzmATb zVJtODoiB%-mR0;zCK{?84WGs)4pyDC#hoVI$FQ=NKA^U^M@T;u%tU~Ij5Q@pAv#*J z82XF{zlWIczkvg=lVAGvi76Y~tq9=HY)Eho?2VW~Gkk(;{HU~kwkclPI^|Ar#q#zd z%&nd~J`Y~cdKN|-&&<;auKot4ka8w#JH^`0cr7u0SRy5Oy;!?GUb~S|b-L7R#M(9S z+I32iO6s*@?b>+l`UJNjqY%05h3*(vbB){17C-S@Bjdto1|LNQ0)GjR_L~9$4VTYa z;_A{zXUY!=u9)yjUE&cHQ`VL>qXP_pZw6grHPRD^sC*M63eHGFC}T+(EZoUVdf*O= z6PUL4EjdcL*r-(y2C!tn@i3&3^%RfAP%pu-ETvYoMWtMLRWk@}QGq4eVn7`Y*C->U z{qlOc#W=^P1++@vWW{18rb*ZzHd zy<5Wo4A_<@LV<0qgQVEh)wtR3?>yeQWe8wu)fuLi)U~d&vvqqZ6+zMR#jsH7PeM?_boK!7Ft6n6C#S3_ z&MhNhZ3F=ep(h$ItcThY2lSTsQ?m`T{<)&L!;lbsoWCqq`lWdO6NX@vfa5_K6^H>Vv@OAYGbl>~6HBy31=(ne0ZYrUEqZ{sR9g(nGHi=W2}QrCf&#R~fR%UG zwixF!zzCt^AkUXO^rk@zbh(V#PFCLYwyOQ0--MJ|nXv^KmvJxPKCdrbs6!J;vmKJ$ zpJD|_;_h6ahl8gE_+Xes$u~(>bCVE?Pszb4p|o2{5MiQem-I3ci1^c{eun-EKSn}d zo*_IZG*|cuISjJ9213U}1FR$ob~{-BE6vTk>^KtS!oY%Y=!Sn(@She4kw{wyo*7q| zK6dFF{*J=s*0B)CGuXGtSS(epaqw6^F0Fp~q>SD&;0~&L%|~Sovs>b29WtkdF=;Gw-E+b` zU^viTa9;?d&YFQRe_ZLEUG@q@xRB&3q$#nDW=9yzMpA|$M_zyK@^iFfRMgGdU#VeI zULB~`qm`5OK-o$IBgVN5$Z%~dUt*(fQC4!$Vx8NSYrvV_0 zkOl^O46$JY&ih?*7+}I0y9i@r{Iv$4mgnAfYB?juW2VY+(PLiuLv%m)FlI)%?lxw| zbv5cLmc5ZCC2Y7w@oB%qZJJ;4DWiFZnc-E^YQMT%GA)p&@C)=i1JMM+-@}R8I=ebM zh5r-Yo76-d#9w9z@qa>z7_jFUK!;iqL+IJTIWRLKCmG~tn#17jE0wb}+8I!RmKDxM zjiGk9WhKsJH{cu)jl8wqrIh>hAEf;tf$lapwLT=ji-$bTa3Vy{Q&$ezOS z_$4)xwvS9s$Nz?qXyxJ^LTThDlw;cx+r!EPx5{{XP!N0-L!YS!WB=pw+L>ow$pg^L zC0L(3jg5w}A;DfJv4JiGN9|hmfVJi_)N5Klu4n?&y@K?9@+iFu{y*}RB|WvGr#9(n z6FqHjuShQ6DlXreT)tCWzVj2S#pxn78z|Pu2s@5r%oj2^zV^NwB{!3V;(xM6(y~c5 zZWkN3C%7Gpo4mwcKsX1%%-f#;-TkK_3hura@HIGDYd&XXr??zo5ONC40X$V%yDZj; z^SExr%lm+S$6)bLdu92xcoofO@p)OLQ~$7s#pVNo8KBIsnD~O%tb8F~#23F^qL3b0 zuwF%*l@hUA8Sz(hW$K`)Iq-Zlm9k)%`*H;`=Dw ztL0LB8ErS%weF+zyK#b=l{&RwjF!Pz>)K>7QVK^d1AOKlav6-xJ>)Xzb1j#4p!F^J zvlzMEs(qv4izptG(LOm$%%7}Bs|5t|s;o%7^2>B7jC)FlB?5LBkAT6bXO!EqxMt;B zK@hH1V);@f7fzVk4KbB6B?ZFJOruvJE5L0#iq^2v$R&gp^l73+ZN-fYVWmgWqR%5?d?bj3pK!4 zQ2{v81ywUW@q+rP?(6x*se-~w`4{tNZHac*r5CYTEk9!To9HGh-oHb^y7X=_XGxIS7PV zjGKM*;-D1I_(P>VZH!V6Tol!2Nl+`zk{ z_(UbJGZs*&mE{P;Jxkl6dw^1015`p*0#?XhQ=uds-c4*c$yA74{&{3SRIY?yQ8oXb z9HOEG!u{m)vIm7AXdI?ko_6gW6M|6)W&9xsJ*W%9l93$_Wgj7>-!%Ci@rT9(!dIw_ zufj=t&{5-oU9wg%Q|p!bGHrzfTPCXz;>%&`Rf0+=33=#RGGB6fb=pC+7ZQbu55gF1 zvMO&3??!qm>fEG4!XINGX*y=}*Px%bA|{T=t71Q+3_U_}rRA6*IeW zy{dL*e0F$VNK}E*^-`YUyFT^oGSYF{EVgcrwR9~s#p-svTNyjjANwsnQ5B#PNJoT` zR(h?dH=er!2HZxK?9#_2TQ`ZVn-bh+!`L^XSE7tN_nVfgao^ZM`UKaer?WuI-D3#E zOj3OcQ#WAf=LRD@4O&*Rsdd+(qP?WwDaCeG8pT-mb7_so5)Ji37stu3ry%0P#~l21idRgeB4k=dmNULg49f98AUM z{W;ZB9N=MpF5*8CMoZ)v`XthRN9X`WLm$34jPtt#BmQ7Wmkb)jN~k13B`lA~itr+> zVG(s1>8h}P5AP*Z?DydS6|2bHiz~1fzm)fJ-O5*<%T__BV^2wLjmWK`lbid*wb=h2 zWV@`Sf=-96lH6*MTRq>DT(d`9vnRpr)y3Z}|4NZtndH`q+`9Sx9}K)Rkl=bhu3i4h zD78bcU!PgeA#u&21b3L#L$X@wU{{h`D{^b+dy?z=#C3fM?w~GS-;N*bcxQ)#KS;Sd zM6M&rZ4|kU3%2B@1LCFw39e6d`Tsm0->71VOq0GOS-)Pamy``?_h%Mz-2-U^_&PeBYQf(P&+%oe) zoN96lwnCM2A+EzaCnAAvK_JSFNG5jfpPh&QB408T)NvvJD~b_eLJd%%&<0F-AD8wh z$8vY!xVZ2T0)_o>(so85rSs*30J#I>wVXh!kMP*U+Nh;$pG>(pcFVC0>bo7R3uvq1ax&Gv; z?c%EK@l_8bxbBbKMHkl1R3+Tt^^{15;$yWP^Cds1eWy0LdY`y@UwrjL*F5_%{2qfN zrM}SwB%3V5!Px#9-3rXPe!~qD-y56jf=ySJm?HOi+lceVUV zKH=#l^0a7a72i4M5@~%(3hj4N)&4u@nsf=f3HkSo`HbnT^>Nb~v#&rkJKOeV8dBNoN4vh(3 ze}sM_;SatG8|T5(pfREi*c3pZ2wtKKMxoFG%j@3b!Q-LeSR?>5JUF^5zg?I+ z9SRJGy1damckM`37ERy0h6_NV_?Ri3w9QpvM zv06I=2upKv?L17S!T^$us!N*#VIr=7OX5*8SF+`E9-+~SCFjr6C^1ZxVxHWxEO)m3 z%u|{24P)fGs5dHh_zRz+@xtl(*m~*%MBF9wIHs7A8?w;y-=>Xwg2c{1sJ$0g|F5#(%Mow;_ zgtjF-4R|ZBPL{0@%T^@Ic8O)X;$^*4dr?9`d8)W9S==fXw-*a5UV1;p==J=|`E&5E zf%7CqImn(l;W zXNHBHH+Edvam};#^N;h(e`&Hf+ZYx~X=f(p3g@gd0}0Qnk35AJ`es+pHpYrtB&_q- zx9mv!MVL5wU)}B6ooD)So^y}Y`s1>O-P^4{-fo9K0})lJpeDekrB0*5FtV|k8^U6; z9(4~iPr&A&5o8M&86&QG8}=-(5u?VPRUKaSRN9h^(|m(`I{HXwnANb&lP2x_BACEx zVN3Lj0c+RD95Qm>jZLf8qI#%XV5aMwFD9^x}V zuOD|_=;1F;B_H>nI3bLaJq6)|@9^z?FP0pxeX5DuX*zI@?X{G^@1@R4!0AuzBj!N>;)urX`& z%5EWiABlzUkwe=Um}l$&`G926UJ_2`Gu{|kc9SA;O#!{|xHYc>>sbKsUB5x$? z3Q3N*F!Q!#XOGy~13r;1UT@qdxpKR>Qqo3*5J}2UJeO-+y~JbD<2VE#=>P2};4Edd zM?1R7R+r#7`$9twzVEGLTlTZ+HJV!KI@LZZ`D6cVVva z1Y6Xz$!6i;Y2LxveX zr6?nR6)AB&MfgMpYe0A&!I|t^Wsqqpe;={o-$Hrv5KDu~7S%Gz+MsSlHIe>sLGx^2 z_R-kN?XkQabTCmCsm>J7l`!4k_K!)bOAAD@wYOHRTdPcuj4hZtf-REMYzt=00O?i* z+_R)-MZT>X)jelDVFJ6++GBe1HLxb_Af+wJ#=G8h4mSiC&WM~zJMSPsH>BDgH!_Bx zlyuI)gGAz0>q1zbhR5!dQt``C>G*ZZzb^ULE&qDt-(2}OkAAh|amblZAqo-soTJBd zo*T5BFb!Is1h0z-a=w64T(rF8v>_BB`ZSoeEKMP$IcpAsk;_|=Hdji^7rm-Ge)iJq zHj)?Jt87rm!quGuQA5u;QI^3r%ok5OvGz(Po&2&>*!y6DwiG64tsq!!e3^d5G@4e! zOvxFv`EipOe=jXaWa%J6=u6MQReT_gRn!5barrry?$mYpq$`sgW+bocSr;RD@v9?w zD^!*+c1B!cbdpgP;dBlW8T@g9=rQ4UusT4+*+apRU?lp=(cb;NyZXIhzMUN}V+Ph& zdihX0DP4K}VJ|an$AZ1Pj~qPUWmKqi6b-q&`+IlydmlWwug@#xmn`9-0PjHrh1!EW z43?mDsTjOHy0`a8FNMhD?6w`=gGYLLk9c=JuC)Tk+hBLq0%G?BL4X|4SrCh*?f%g5 z@e$~GogRU8+Hu%Mf+c)NeFoDmiH>80vYU}_ATM!3I_+UEBf&7)I3@G8Ff7Tw23bIj zg_z&x7QT*pSY^UX(3~aFLvE!oiI-R7@CcV8uz_9Chzhh*eOgc5fJ( zM$(axv&eIE0rNRprY7-96u&~a<_aTJ`V=|SRD~mOHUPs?P*oZt;~7B4&YTwLOfcn-&p@e#B(%w3$qde@Qt{!68Di*17s;o-NnQn`V#9MH1!fruL;u zN~d~LW!_(!mgN>-uwGwQ_3~r4Ope0(*^*R!^BdJys&84W6}{$LoT;*ArvGxqjOVsH zr=j#SkEyKg;>nkXZ{?bn)qQ3~j#~w$!qQ8gi=I>y%o1MNM?a1$`{{?fazOWHzw#ix z<%vxj>DMDRt*75yv1!9cO>J|aQ5)*HBHZ z!G)(O;L5<&=B@>c*xYrs>EJKQJherinmmQYw~Fx{w^lMbkOX6M7AYGJ z5Fb^fVx)4wgf1#?Tx+V$$w4E8O3TlHlv<#Oj-=UyyaO71M~RR zfqAZv-CFplWUJa6JasDIJq1fzps@JSafXqd7Cu;b#v3HUY;42}z8na!061x!kQ%5LcIalB3(yPpeqBK29c8)DwElfAEA)&>&QVdj05pu15bO9}5@+kh(^ zl~qPoz)-ik%Ct{l2wIe4HA@j0ydfevvL2=Pj7!vxwK3|pBr7vshe=r`>oG11sG}38 zqfVlObVl5us;uJhYFwfQ_YiNSpl8rF-i@>dFc3(t=QW+lq6l&E*#oh&YMDWJ0lB?$ zgziT;YaDJkpYr&8bu3}HmCt9_w>^py8gxacp+jE=eho%?1M*A00T<(lBv)ai0(+(02;ONe4JWQCv78qGVLNtfT?kWHO+UX%`f~8 z6?K*znuW~9x?~{eC3^h@9HuqTu2~T3sZ~txhv3aGMl9W6H+ zIK%R$tw7ur@s+F`{|+)_${aGxozjJI;qSnKXsWPoHj*e@$!8L!_n)syhlrdpQ2F?wY**|JG&*%aHn|3iPg z3sm2u-a3sXb&WgN#;PQc2 z4w4?%PI1l7YX!To=g^^%WI?M~(3)ypk!;;~wRPjggRyOWI8|I&1gQmXzTz%&vS6(# z*|0`zSd%DNdqdGQX%IaPvk#Nu>}}$TZL#Is7x-A?u7s!OV_cLKfsi176;;@CbxlvI zv@%)RCYH7(OIM1eE9ahCI2`K7*#eaVtFV#%66UppU3Zrmqs z-1p%wapS?*;r{sAhZBz;i#_pM$tOm|Cq`pWkAH?iGw-utkma8z@pj8(I$$|OflrtZ z(c2Nr!u}1#BLxfrn?%?=VFNz~*J_1uza{&%%D*<^zZii*BRv^1-O1;?s&eFv)U}jt z1nOT_BWrO+_Q@jZb^_q*c+El_7~UDg7V)-QIk60Gka2kipnWn&Z$t;2BnLP?#7fC} zCLO~DTa5Ar%BDh`7{i{YtVD1fdZv^jAo@&Ud>9S3?TG4 zLx!@AT3O7OXyrX_ny{&g3CqSwx&hfo_=5$=xN!>(_!PnLDKEPV$2$Tfo1k~VJBGWq z!z2g?LZ0SuO`mkp4CCTNP=vzm5s2X_P)O9>f$~mvu#%#lU6TFGRsB#Rh}MI9pxnLD zady?2GiO#2i)0n9wF!(4j?ry6wnGPx_R~$wxC7Xa3k9JH91y@hg1X?zF`gYYBzvB+ zHCmXc9t0C4EM14wAFb@p_!QCxz@_Nm7mavt&SDY>-$gUWX@j7ItE8_bIX(nHjQBVO zxsZ1^<0Ak3cfaY??0Z9QG&&v#MI_!;nYMGK^U!*b9PP5MaM~lk!)`XrW7~$YQ>N|G zi&Op3bdL1FX@`={aYWKJ7HB1;9Y{(04|rtYu7qLS6dfTvk0{|ea=uK73c+@YKx9Ow zz$NuXw(V(z544uU6QhIJ^N8a_Jt`4EPKhO>jv4M;X5S$EYZ}G>84g%7fv2g~@M+@p z@DpKT@`FZ_9!X4p0YRBod}$_0_bHiy7K9%|NsvW>B-(y)d$PD$EN;fxuZDQ>dMHL3 zhF;GvyL=Xss9TmI=YaWVp7n9h`c!enrL7mYCX0Pyv2XtDhmVQNABh(~nk;@wEPg6p z{998!siN{r?HAjVMQvhH+egKf7ko2YVg;>pe5#@~=Gz}DKaeV`!G(^+zS|~Sao*If zTP_o_)LyJj7Pg9ot#j7-!?8l#c)IbHiOb!-ur7toIC?QU+w_L-iVt^2m$hH8rJ8+j zJa*-=xzTv@whOjz<$u(?5cX+deBqt)EfVzaZz=pA^Y#mI1SFe|t3c_>({NJVjiV0rMB^ zTFAfZ-qu@ZdVlNAyxwB#k6avHepGDlt#hl!;Wpe%(B+6Q#!iNZx&87(?7ED{Q5*aXs0ux>Fm36*YpiMDD{Hq!H zuc9SK)e(wXJ<}gAY*10um$s!EeaXhnV&mp`;}$3%Pwl!;KC?bm-^8?C>Ra%0Fjeo9 zkW}I&FjR0|!lTm^`R%z)bIW5j>*pg0&sL2a!R~L`BsOlk=GlBJ-w^v!RaE~g+PD<< z1?_s zE>7Bbhen74udzcF)?h}$8bo1st09e&gVZ&a1EwVYv{{F5cZQrJu5%8i{_1#6Rcggv zZ`?`)8EnSay8b`&wVc7%y79H{b58cP&gb+7GEGMKBJYW)_~NA6HyG1gUAS5b!t-8( zBv9+$33E1X_0qpkaH}kt*+C^dqOvn&`IyR%05-T-K4v~g@-d7m{puK}h0zs9Cq_>R zW1~SJQzWnouNsA-o*(DHy<;c5qyA?`p`If6z1@fQwM*z#Iv3aqZutmCnXoD9*a^uR zl1KA8Bq30TB>jP$`!5K_{5>X^ut1Q-oUYU%08%QLZx|b)djq3oN_LX7g?BJ%+_v?d zoyb<9%f@20TzR_o1HX#qdiCv(y5(=8?+{4-5-sSVkE8+>NmGG@$^0cXmZ2Q)lkWp^ zXk3LKlJ7_4{FodjmWq=vLC$-W3wEld^FMeVxDL*BAmLdFmVqordiL$X`Ifg%%{{rW>aVRI_Ws26 z?quxfQ?Z(Xm_KmMGxS*nns>_t=ZnwsPzHHBfBlI^#&DK1K~fWhTD#D=q{ksdw{yckcnd3*r z%wOs3-D3SwV_xqD>yI|r;WtKoh-ATm83^}*g1|35>oLx!Y@M_n3J;-10|Y=;*Pzoc#>ai# zSykZ`SGPe1##{A#=OuC*paq)Qe;VN>@)^~t(;QtQmr-r%#>eK9qwfpz4t6OOGw$9; zw%xtnwlB=SXuf1C-rKk)FgUnz^%fz8Pt`)#`!{rM?A(ImQ*#KH)EEDhd|xK#XXFt7 zRWbod#^;Zo3=bh{1{1>Ph_qHqg>#mXFpj)(Y?W) zDAzaPr6b;fXeoSzf&|zrxIMU8BZ7mOw7vj%nrLh{lJhE@bRn@D)bruAiAsC%6*Nl+ zh9D&%M!S+QQ%O!AbBJa@4>GeEaNafT!nL{tg>?62SrjuNQW#T<#N_DG!6)MTa+~fj zRtu;ile{%LIb$>`%1AP)r#vQ%BsPFIMv^70W>@x@Y3gVMy^PqqO0Du)9UKzM)r(!i zl4`O;AdFNx_2DGaQ_`i$SNfeDpI46T7n5djb_`LiiV`c+Nu@7T#NegUfp;|z_+L0( zBSh=*oyPs zxF$63ynEXHja)Xc48jPnqrn-6qg1*wf$IK8E=TA;qAq%tT*L;;QLjXdp*LENxJeWn zj78Ij)oNlJ4`c&6It7cc?^BvVkgSD1_-+`A5>bn{E$U#JT>+njA>?E&RN>#F?HZb_ z=sOUQW?h&GsXs<=v^HZ@7fqF?Xh4)hj75GrQ=DDr?s)Kl{ks8sLk~ zZw=2qu}~JP=zbS#S2AEGoIyh}5DObBWWe@!;nAIHhSnl!s1h&#ZL88->_ENBLqZ1m zQ5KU>e-~96;xZU5R(aXeV#JkkcJ)YKe!u@jFEa(v!^De{P?hNz=cIGTgYBBF2!W)B zj7g;NW+feC>0T4|=~5!L4(Y>*(JHZ7chdl6TrZbpih`%nS&uw)=6Ln>LYyq0;}eBz zb=)5VjS`ZT*;Q_h{FSWJ)K%4k(68CQmh&~o*SN1azvlXy`)eLH4me!=q7!sH4i~@Z zV!xIbiLN(=tS@@-YYEv#>@V7v0&w~xHZMAifgnl8l(Bu$y+oQEU7DO}hfr!UP1~o{ z8;6W|GRk~l3StJ_8Qy_ghIC`jJ0qk~&7L94lzl2^$}z=FIj3Ax?jZ;7dcgx8RdyOV z#gLtMBi_pLupCpKA;`pVT-m1Au@+h}_l(+-oAo^ z+otp(gN;V*F3?NHuxg6bRbx8@jVei(l?jnZx0b29j<@5RNb-VnJRsI3m9ilm^Z}0n z*Ial{;p!u%W<~~90uaXpLyWy3#F1}M>kpHOrZ4H$++K-c{I=tg3CiuJ>HJ}R8rjdA zPqm@`2vKLmbi_2HwU_lE)L_3KvOrz_0I|2BgJ?Da_7KvlQ<$+S-B|_W71}1M8vYY# z?>QToi6$egAfhrDF>o%;0tlZ&x+ThXrTjwO`yi>K({w&ziGqGOXBu6(L@zj5FW7k3 z2{TevOy?R2hqaqlWXg)nWJ7c2gzcOuqAIFSxrWSq!3(%VxJPAl;yP=2LD5kHuz3sV z3#Qex-9{Qq$`r$)fuiXGO!DGsI+JZX$3gX-!~3#H2i(%v>^SkBXT&6g@VRW#c|xZ_ zG*J%GsVe*h8_!Cr@h|siRwy*M>WE4R41aw?h`~hC2@U2M-?Y(6s=!zGywiRQ{tS9pk`Tj|WHf>7tb^ zl37iGR6n@j1wzPMq&4a>!LQygz^*r0B zzi@7au4~}zX<>V`TaQku7NN9r?>L8!i_O%TrbVbld5ggda2L+NBG_SMFCmeVVq%xCU$v0 zCA^41(Wo;#ew^;T+}0Y+Yh9_I%U_sVqnt9!`+d1-4v2k(DE&y z)`k=Hk+gXrZHXMm*#xXXme(PF8g7!4mMR+~=`00Jn{-b2(FFHRtJvnpcYJSWA zUQol-$2N#H8y0esn+}Pa4#lbu{}Q_QH5YnrS#p-uq-vX9AH6&}*Pp81b=zvL>ow0< zZ#1+e8`g^r>*oh!Tl(S+2QNEjY%~5;c|)??Czku(ZhVuUU-#BDetA)NBC4IN7mRc#iuKD%Q z<zoy?9xw ztom=uVAS~KXtJzLENhFGEr&u|g>SBTu58{uKQ#YXtZe(#o*P9~Gkaz>gD>1%m#S-> z8J;sk#9g=o)^2i3(5hVAmch;|9G_i2cOq7^A>r8w67us;HkzuNq5Hh7=6ZQGaxAO) zB21Xt_q$y^4$BW6&YoiX4~xujGq3|%3Jg1#hv(b&OQz|ZB`^apO9k_UW!lg@HTE=& zCq43R2#sPMI>Br=RLPJj3wA>;Yd2J_phz$ZHQ0@K%9BvLnzm-_Uv8f#+Hr6^5*iyj z)#b%au;igl!rS5Z(q=^a5SV_a#z(!6Lh)tnOxP#xPER+8?3b9fgs03WU z2?xws7&tP)sz^wJYSa}%0Yw9ul1y-fi6i0lGQcIlEnL1fF(6kTU4HLPy@Dc{IWvM+ zI#7ibeWQS#a+-NOUeJ2AVAb53R6$>=pg&cx@0QbEnK$(yBs)t0gOsOKUCB*zwph(7 zNUN}tCC(jNOR__Z6ShVl!&iPg2W!-6v__3&1F(NX*dbOeVT;Nd1^$2UC0JPrHY$O) zPut<3lpSE0FIqq~qJ|v`x)RN4AvLsZ44g?gw(wt2aoR=* zLzFwqpIF6EhaBOrDA@k=I863PODRYJPJE0IA!I>XP%a^l7S3N&T#i(mWW(kqgs@|0 z+RBm+Ws-0??83(ohLC|C3;~5dPPa5l<=hoL$V&eXEjMCP({d}Vz7)C`l2of>E4D23 zE^K_a`NN!d&&GU*;-!bD_N4SFp!qhlY{oGYooksZpD$Y|S;)agy5j*e+`?}2ZL75` zZ>k5oXnEV5V|HRbXFf7N6kE1!YIi0IQZciJ%!|+ET&wCx6_%)YK&qfpI>$8IGS?ET zUwy4$%?&kZ`P@*fe*LwA4QvIElLD}gMqpD2#5*aIMsw)k*2(s9TGa=oeM=ud-h*&y zCPL?lMiQ)0Ot)_67>xW^G|CP-%teR8 z2z8e%9Ni}0d#GD@g?z*z_2o*+k;IJ@ifD?IkyAqs%c#wdOz^9lBqXh)N#cD#ei%3w z_M*s4-7S?gI7L(7zaiT{K5zONw+=Q||AzD4fI>gi^=GPQg?It(5hDeEdM}%8h!-^D z@=K`mmtQQOX`9^{FKnI4`P=;B%j;%suXH7=R*F?CVRB!UYQ<^}d#mm(4RUQRd7KEIi z>S7N}T<8@PO-8U+qbnIBj7B}{fl9Z^-2sHR_;SYgAjwezMP6ozi&14j)rh+-i;^h!Ek)GQVovkxC|Hs=V||w+ztj)-#YO;M#0idJwC4 z;N7Nc+#YF;pa%6Jw1>@+K6sWA0%3H`hVw+HF3OZgRgN8#zJc9Ohkm5B%@~$6to$uzb&w$$_)c#M5_1ocUek#rNBKvltz=njDnBp9p*{hVe34@=)TvCJO%;Jcv{ z-jVTe@D#J41fdcIr%NDt>?PI+CZx*566T~%B9?ACXJ{-$I{Y+big2-D0Q|FYW|Lcr z&FrYG$m`TmE0CQjwk8W#i-oXX9ozI^yzrr^98w7&6@YrtQ$O2s&Eq38sYQ2~CLv4~ zOp^?SAGY|_4rFiY6hlWDuz!uvxeRf<*$8Cl)qt2{3UP0;9wVs*Ca)3lrvooQCFK?~ zU_^tH^%yaAmnc_vxK#n8OqOHR22RJ`wSwjCjG`StkX_7=p5xuGkfk7!uNlZM?xR&6 z4Va?*T+;UD^Ck(=*DKH%_f$RX*4Fc(oZ;0 zTd4TaX8)I4Qm}U#r&F8X6|~x+_eyM*Ju60{G~6< zPxie@ApKL6Df}@UJ*bk%{EQ2|RC^}|Y};FyW6WBj#I>zhJNq=uQTk_&&YE9s6D!vK zik&+BNN}V^CzcI zUU+u)>3E)x*}yvQna1|dj9xXp-gdcdcI$jmymHXCVyxC|w_(`0(*q-J=Qau^U-9w~C)6Il4mN`bHeuCCi~0>$%;DinUNJ zi|u+;y49C6dGaq5zPJ(Fm1NkRRMOsFI~PfIbc-F`@9q*i_Iy|zUvc2C!!W{<>>m>Q zhho8z&j6at-E;_6{&^U0w}A3l_EKQKc`v;^XgNS9V-K18=wxi}K?^hH$8ZYhUA%Gf z%E`Gi^W*WBZL#LjZV8tyQ~a% z0dEW~%VL+D3efz%W~e)Y249yGdZd+ZaSVd&0 zELH$6=~9(rm?600Yuu{ESe@GWe}=X!)%n6>=qR;YweB*)=-!f}WeP`4j1maGW<5q- zoq^UED#;kht26Ac($E{VSCwgN%~0CQti&4SF;F;A^3YUZ$eYS*TcTc7UcI1sqFYU|RyvboHQQwV(r^CDhUsBv74p1;b1?4;uoV z6s1$P1oB~X8+Y*tSK!rYR|<{D7cD#B4~|MK{&xKhhqP|P-$D*dPsIc%ft&r)`=yHo z5}vx0x0#N45d|)RA!d$-l={^WcK8csqkJ@#-TCLYPH#=R8$@?Q!ri16M^~6%^LTI6 z;S5>ALo9;KYLw!o!ho3HVX^~cEjihZ$Tn@jGjVnHMoWl27<^I&$$AViX;nnbhW{S1 za{UxXUH6D{Yi$Q^WR<6k&&9TJVIxhA%ne`2oGy|b3_FhI0%6FW97NAY3-ptNW?df5 zihgthqcpWml`id3XznGq<%|t-Mm`&Go}1=q`@Y7NFCJAJYN9c?O=RHze?S;x38`}l z<9)B-QwJoL07nL3(0D!}RMkdPn__xf$Fx9-dj(!PstGeON1lne>J?iiX3c;fI>j*9 z!VTdJKKXb2RNF&^5rWRDA*MlrYWg$=X7b)d-SNzEYaAe~7&Q zBOn=o&kZjFQ&~`tmyB>pfQtYXtf?of+r{d39JNYTbcz+7P?F4T{KRC<%_CuSp#(DT z0Mpdj|2!JM)a-}iSr7FZMoWcONG?Abgh0Fzlg1EB;w@2(yheL6_6=^`r9D*G2?xjz z!-^Qwk5HXO;G=;?L~mT0-NnneR?RdX^7$dQvxx5MnWwM1y#)6koPO}7{qQKqhLer! z#Kv{=Ps@jbp=K&ub4K~Q;b45kRvKIv|B4ogfx-+wb)!NP*JLnHx@l52>Cq9$ z3S|3?5-(i2S#mvfVJALTLQ#nQFVjr--Hoa|;52Ig?>qOStWlVaw#a6qVabd!ZuJ8v zkC73q(TH1^jjprd-aq7qq3!cFof&G}^hdgilQyJ*8ER%O!&qNHHzZ38z7Z74L_mz> zR3sfSKtlBF>c!F#*?hD`H6#rKLpUHYUWxutO?#5HPTd)^E^oFhg-hE7x)5EW-5JPZ z1Uyq0q;qGQtOW!FlKCY~rwI#jl zMDMz!w@dVP#l2hOmD~OSYG^xB%Ss_oC@M`BHj9PL$-;KAuzl|7YlU>Cxp2pno&DUX zuK#I4>xHNP==a zt3A|#hY%}uVD5CTbY=T&@$B&{WwTD)-LrO1cyq&i3^?m zhRj)bk8FkvyyqU74EPdScUe!y8X$e0tmm#ZRb*wrtCEML%3r3Nos3#owK$&!a3C_H zJ=G#S&V;fm`MN||WJ@&b(aK`0yDlplui8G$Qq(U_0W(7uLvw)8>s16}fGQ3>BmuV( zCtU{A7So&2_v=!S9*RK^4X6z=jyE#!r}RAgqPvY|i&}x|F``&-N29zNSg9mU%X$oR z>1+|8{mZkm;PoDI8I6g~HZkC=yRTnjutswpg0XQuF2QVRFlCx0R9}K<#CSCvQjZ~v zH1e5uDA|O@>Cii6j+SdNqh1)|P-A_i_rR4Cwh}aBq}wj?MNwV4R@ebGsq7N|vo#Sv7?;F!?@$u)mFI00>*fp$$Da$a|rq`VCZGh4czFX`zJJstBF$)*Fl4-j|$wJ@5M9V2*VHu%QKm67>9 z3p?X2J7Ucb#2)xktmcVpo+r_O`bMd=&NWj#%5{IMQXT8GH)4`2HIcCz#fm%CihCA_ ze8wGwNl&BbX^b`POn7!t5WB~jF^$&f8zUQ_IHEVR7OUBaqq&Q>G1_9-mi!1tX6cI$ zjL;YbP{;=kKzCk$2E`~y+|rr;kJ?txx4m1RXzTf<$zE70mNZ@1J2ONE zs`p>qf6HR7*mk3$Zg$xm7b{==sl&Fc^cPkn`^<{WpHT_FAm`Vg-&v%_}Oi)k$^EhN8~UNajXb-9H4rMHB1m9>(QgCB$4N>&zOXL_mN*c zMgf$-%-dQ0ZOCtcDu)RHB;qaWF%l~)P>DpR^oO!@SlN%!5sM6ap!bL<<4Okf5rS+>0IZny31f)0EeE2hJ!hu=^uNwXxNRQ6`UP zH+L!^RoI{1r$?zzvq|!BAaLsUs4F#|24nKFi!s}-aVuEUg~J%=j8#m`Y+9MOWB1wq zh{^`nNF42p?tQIkxk(KK_Bre$Idm5w5nM(Wm+)31{F#*%!+5O?68i(aN7g2D!+zX3 z4aAkyfx|*2`T^5>4bA(PkU~2B%X*)b*an^%3t`tu7r|JHl4n4=t3hD$o3Bu*GNXX{ zl!a!ZvYiNC#3+!;y+{MK2SsG@PF@|HZGL4q!TE?)!iMkvY3yo(;wYkU_wK&QE-YjT zuo41ng`Xj8RtS-`LZSqLXiOC*rC4RWn0P1S3Miq~96t3b}%X+LU z6YKi%P?tVM@LRAtJ#=$;dHBY8EwwW7lRpPfq0T+6K{n~V-j@8SEtv^7-aG9o>0!D8 z?S_Kd;d*^MQy(vU+Pl7Lull$9=92q0#a@nI+IHn}fxX{z4rD3K>|z0;w4TjXE}FtbHye0&X*!h+TppWFx$eZYi2?)O1r}0;e)k+l-`Vf7 zk%Aev2%?ALyJS!?_zN`9TYR6EL9VXqTJW$Zd7#>g-NAycRta~_JlkyK_UZ1pu7#n0 zwKK$mjc(f)2vxB@qIT=dr^G6jE7l{;nMm_mq!p9wO31nEm5;V)dk|aoYd8W!@>TAc z`TP6Kfj)7+FEm)@|0X0EdG4ZR`hbG$Qf~mp046|xz8``681M<88}IfkxUT`%01zkY zJHYpV9{}hA6&jt&0Imacy?hStE5KDG^#NcE05u|Y7Z=h1)VwyFuD%4PPwst-C>wwW z47{$*Q!Dl%F+HAAe;^b#wDkEqh2u*KXLFRv59qF{=neH2glW|X=%pyALMUviD;TO& z&w<0nl6n)FV%J0624JOHVL?S;Dqb~|lN=-0mUA}&ITJ#nI0cUW* z<~tsJD7JhiC7lb>N9|6KXiz!_+UTV7vh-78v&=_NTYg(Gxl^47al&CbZ8y|X^~$BZ zU}ASRQLXiSUNEuyWgY*C<^>bG=LGX+UNEs!vWA*g-pdOnP9jJroEoBG&g&>0aW2aJ z)ND8VC}|BMlcXIqn13oSm^hIbb=w27oK9J<hy(-ua`BRUNCt zF=G5|12RI#?bdQ?v=;GV8tr;X39A+fC2WJpwETE9(>Y*J;;))=au2`jCh$^6BD-&t z9l?)}j;b4I7%V&31ONa71!VxR6`?S{R!~xLKQR3G!wXyYZZMXdvmp-F zeet61+1VWm59Zi-%jo5K^$w?b5Rvxd-ubC%_H*yr%Hw4r*VcEonlE|TZ5x?%EnCjw zd%|MBYC;+nh{bMbT%xeXuMPwBN7RYP$MLtD;qTxxv-1;6Gn0MleZIgCFBZobSma5$ z>GR3J!yrlDL$M1s_200NtvL~UAlh@{$(%^!#L=9HZ-}0pNN$K{bHdy*I;5Fy-{KLk Ux%C>G@RU!a5BB`&kY1OiHCTsBfiDB!uY~B40lXXkBRclcO?84pOGR;_=NN%IPoajk2XPH{j zy@8qYr>j?{)q2%+VDp1$Bh5^!^STMhi>|$N+I5_&mE*i*t$x{=H5M(^ECsd&bG) zY~6WnzO0v=0>(mEGBn*wDD$piYKm**VxC~@=7J~HRHj=Fkd%_C+P0$D=&vlVd@+|_ zs#f%TNoBU9Tlq89OKt^Nb(TM8I8$cT((}`XrE98H(oZeeHN!N4W`?L|)mkm@8u`%F zAg9<6XkXsjJ?)9mV#M3e!SwkG`72GW$6Iu?Int)`eLA>B547lk=F4sRP1yZ1v7Y$F z00KknLm$)89S|a=P7DwxaASg~{TY0Ln009l62|H(*Wk+N=_3Pg68`Q80f8ej5Wm{S|XH!lkG+kyg3kZL~CDflA*j`1NupgMH)g#8~948!y#|9 zYJ*=XB}d#LR6ZI)(F*>JN4pRzKOI8R+Ve&3F*)4F=ST|bUaGK-skM#wW*dvNi`&B8 zH)iKvk!Md8&dJ}KKK1>mHk#ODT)gAZ$0D?2I3_fwuq@f`yo*`#>97AF%csuHUVL?0 z4s?2{dB;*}rdn5!dHWgQ+TMUEmu6TUyuDCkr>am9Eyuo~vPHwP8D4NN35402S~9Ff zhPB9(6yF$zm)A?En)X9OThtxE2i6D*nBfyT?J@~Gvkudp(h|c;;|U5F;|+o0v&I!& zJFgpyOOCe(_7|YySKJC{be6n$$uvrrbPa$i^zD-44R;-Y0w2%>y<#}I7(=%`3H|XkXFDu*ArjuRua*N6vd)supi}8=*Xq!IW#h}uT zo8w#A`QHW(*LM$Li$$bM&@IBl#O7J#2`&1l{tnDY2)`?yVU7Xz(EMY znCm+pOxH;x#?siw%H7@v=+0<#`_wXEEO;MY6&n0a(h!)a2@N5{-X!nvS4Ftvy)UAU zj(+c)M_xjh`HzggQoa3+l)IfE5{>FDQ~|NDES*s;`-HsAKO=j@#m~s{hkul1cn1mP zs$sFG0OiHuZp=YWGpt7N+F7dpG$;8v&z=Vi`xZ{eke2X|Q?VW9#o!*=fkqjIe*wG% z%K9~+_ZoDOsL1{?Oe^G*V<)biZ#uW?H|n=X?j4@|C_VWBzm~W^y#E^gU8Xs@@#;sJ zXFuRKN2fN2&aA~SCH$N}*rErUue51)SCTX#Z4PxXZw~mGu*2NmVAtC|6Y;e&%s;DnDozplW6!>J1zoNrIeI6d*FX)TGiJWBLg)RDFV~ReAaUAzgp67)BP@dx-5}YK$jejOha_MW*LjsGh F=D(U|-l6~i 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 495b239dfca26b3cf28185687d98e45193664b68..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18806 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 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 91523fd8388002324f5bf652e650c56d05ff0f72..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32095 zcmd6Q33waVedpjJ1|Ud)BzTMD@FpaZx)1B7NKv9CQ5WObv=bKMKoTfk9)Pk)leXh^ z5~^_`GIlDeaXwQ`8q>A6q1$w$r292?6mRA1_iZ^M6ws);ab54WZnynt$yujL_xSza zU}gYtC@F2)uOsozn|Gi8`#t>Ej0`7-=lq#>gMa%N$Nd$3sE;k~x&16W7desZYZj#qo%zGO z&oT56zMO--{^8zWDBLyZ?+^4g4h{?NKl7r{;_nXyC^#VW1w^5Jc=f(O-|GE={;+VS z-#<7Qkm?2hPkm~WJ$LvQx9TZOm!m;$D5A7K^ zchEmPFhm~*QF1)So$%2X4)n#+jvn-%2=pS={=_UthC-+J428o3{W06oLxKLGSk}>| z6QQu=?+#PGhk}78kmpb!6!M=6AZN^Vw5@+|DBKeC_YR!$S!3p-;h1x9`0?=Z;HiFp zFDmK?_64Mv^XQ)8aG*(&{KGyALYoJA2c($$Xp7`OwYNVUkoHpVkgP=-7*za@t}g$X zaPVwbSGO{viffO5++H+s+!ry)0j|@8i8hSkyEm_eC$M=# z_xf%`tv|8lq`0BIJHEu&TB7|=f49Ga(65;;a$y!s&Uy1F_hmG8)TAy#6L*Rm<+`-| z>?;mIup~@~v#QxeDJy~ju{#KW|iQ;qgaMdST^j3v))zDE+ z4Mh#et^rw6;>7#8OeOQLf^YEcg_)jm%nWlxxM(Ts>t=!%(NNG*s=AiCd

_7DFbp4N__fJGLp2e9=)zvYhRNvy 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 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 11666ca1c3fef09febfffb097967e665d1bb0f68..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17360 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 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 0b10e7f705610c69fef7dc8b6107cdb6d8292edd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21946 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 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 577b576d7450aaf4df13fd2e9ff4492166b22039..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7358 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 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