diff --git a/.env b/.env
deleted file mode 100644
index 507d2fcb..00000000
--- a/.env
+++ /dev/null
@@ -1,3 +0,0 @@
-DATABASE_URL=mysql+pymysql://linedance:20gorm66@mysql.ckvist.lan:3306/linedance
-SECRET_KEY=e0a15d5a35d1091261cbdf0fd6310492ebd23d66a6d4a8c4253ab33e2594c67a
-ACCESS_TOKEN_EXPIRE_MINUTES=10080
diff --git a/.env.example b/.env.example
deleted file mode 100644
index 4bf3033c..00000000
--- a/.env.example
+++ /dev/null
@@ -1,3 +0,0 @@
-DATABASE_URL=mysql+pymysql://bruger:kodeord@localhost:3306/linedance
-SECRET_KEY=skift-denne-til-en-lang-tilfaeldig-streng
-ACCESS_TOKEN_EXPIRE_MINUTES=10080
diff --git a/LineDancePlayer.spec b/LineDancePlayer.spec
deleted file mode 100644
index ef1bc0ac..00000000
--- a/LineDancePlayer.spec
+++ /dev/null
@@ -1,161 +0,0 @@
-# -*- mode: python ; coding: utf-8 -*-
-#
-# LineDancePlayer.spec
-#
-# Byg med: pyinstaller LineDancePlayer.spec
-# Output: dist\LineDancePlayer.exe
-#
-# Kræver: VLC installeret på byggemaskinen
-# (typisk C:\Program Files\VideoLAN\VLC)
-
-import os
-import sys
-from pathlib import Path
-
-# ── Find VLC-installation ─────────────────────────────────────────────────────
-
-def find_vlc_path() -> Path | None:
- """Find VLC på Windows — tjekker de mest almindelige installationsstier."""
- candidates = [
- Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) / "VideoLAN" / "VLC",
- Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)")) / "VideoLAN" / "VLC",
- Path("C:/Program Files/VideoLAN/VLC"),
- Path("C:/Program Files (x86)/VideoLAN/VLC"),
- ]
- # Tjek også PYTHONPATH og registry via python-vlc
- try:
- import vlc
- vlc_path = Path(vlc.plugin_path).parent if vlc.plugin_path else None
- if vlc_path and vlc_path.exists():
- candidates.insert(0, vlc_path)
- except Exception:
- pass
-
- for path in candidates:
- if path.exists() and (path / "libvlc.dll").exists():
- return path
- return None
-
-
-VLC_PATH = find_vlc_path()
-if VLC_PATH is None:
- print("=" * 60)
- print("ADVARSEL: VLC ikke fundet!")
- print("Installer VLC fra https://www.videolan.org/vlc/")
- print("og kør pyinstaller igen.")
- print("=" * 60)
- VLC_PATH = Path("C:/Program Files/VideoLAN/VLC") # fallback
-
-print(f"VLC fundet: {VLC_PATH}")
-
-# ── Saml VLC binære filer ─────────────────────────────────────────────────────
-
-vlc_binaries = []
-vlc_datas = []
-
-if VLC_PATH.exists():
- # Hoved-DLL filer
- for dll in ["libvlc.dll", "libvlccore.dll", "libvlc.lib"]:
- dll_path = VLC_PATH / dll
- if dll_path.exists():
- vlc_binaries.append((str(dll_path), "."))
-
- # Plugins-mappe — indeholder codecs, demuxers osv.
- plugins_dir = VLC_PATH / "plugins"
- if plugins_dir.exists():
- vlc_datas.append((str(plugins_dir), "plugins"))
-
- # Locale-filer
- locale_dir = VLC_PATH / "locale"
- if locale_dir.exists():
- vlc_datas.append((str(locale_dir), "locale"))
-
-# ── PyInstaller konfiguration ─────────────────────────────────────────────────
-
-block_cipher = None
-
-a = Analysis(
- ["main.py"],
- pathex=["."],
- binaries=vlc_binaries,
- datas=[
- ("ui", "ui"),
- ("local", "local"),
- ("player", "player"),
- ] + vlc_datas,
- hiddenimports=[
- # PyQt6
- "PyQt6.sip",
- "PyQt6.QtCore",
- "PyQt6.QtGui",
- "PyQt6.QtWidgets",
- # Lyd og tags
- "vlc",
- "mutagen",
- "mutagen.mp3",
- "mutagen.id3",
- "mutagen.flac",
- "mutagen.mp4",
- "mutagen.oggvorbis",
- "mutagen.oggopus",
- # Fil-overvågning
- "watchdog",
- "watchdog.observers",
- "watchdog.observers.polling",
- "watchdog.events",
- # Database
- "sqlite3",
- # Standard
- "json",
- "pathlib",
- "threading",
- "urllib.request",
- "urllib.parse",
- ],
- hookspath=[],
- hooksconfig={},
- runtime_hooks=[],
- excludes=[
- # Ting vi ikke bruger — reducerer filstørrelse
- "tkinter",
- "matplotlib",
- "numpy",
- "pandas",
- "scipy",
- "PIL",
- "cv2",
- "pytest",
- ],
- win_no_prefer_redirects=False,
- win_private_assemblies=False,
- cipher=block_cipher,
- noarchive=False,
-)
-
-pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
-
-exe = EXE(
- pyz,
- a.scripts,
- a.binaries,
- a.zipfiles,
- a.datas,
- [],
- name="LineDancePlayer",
- debug=False,
- bootloader_ignore_signals=False,
- strip=False,
- upx=True, # komprimer med UPX hvis tilgængeligt
- upx_exclude=[
- "libvlc.dll", # VLC må ikke komprimeres — den loader plugins dynamisk
- "libvlccore.dll",
- ],
- runtime_tmpdir=None,
- console=False, # ingen konsol-vindue
- disable_windowed_traceback=False,
- target_arch=None,
- codesign_identity=None,
- entitlements_file=None,
- # Ikon — kommenter ud hvis du ikke har en .ico fil endnu
- # icon="assets/icon.ico",
-)
diff --git a/README.md b/README.md
deleted file mode 100644
index 57877f5f..00000000
--- a/README.md
+++ /dev/null
@@ -1,57 +0,0 @@
-# LineDance Player — Desktop App
-
-PyQt6-baseret afspiller til linedance-events.
-
-## Installation
-
-```bash
-python -m venv venv
-source venv/bin/activate # Linux/Mac
-venv\Scripts\activate # Windows
-
-pip install -r requirements.txt
-```
-
-VLC skal også være installeret på systemet:
-- **Linux**: `sudo apt install vlc`
-- **Windows**: Download fra https://www.videolan.org/vlc/
-- **Mac**: `brew install vlc`
-
-## Start
-
-```bash
-python main.py
-```
-
-## Mappestruktur
-
-```
-linedance-app/
-├── main.py # Entry point
-├── requirements.txt
-├── local/ # Lokal SQLite + fil-scanning
-│ ├── local_db.py # Database operationer
-│ ├── tag_reader.py # Læs/skriv MP3-tags
-│ └── file_watcher.py # Overvåg mapper med watchdog
-├── player/
-│ └── player.py # VLC afspiller wrapper
-└── ui/
- ├── main_window.py # Hoved-vindue
- ├── playlist_panel.py # Danseliste
- ├── library_panel.py # Musikbibliotek med søgning
- ├── next_up_bar.py # "Næste sang klar" banner
- ├── vu_meter.py # VU-meter widget
- └── themes.py # Lyst / mørkt tema
-```
-
-## Brug
-
-1. Klik **+ MAPPE** i biblioteks-panelet og peg på din musikmappe
-2. Appen scanner automatisk alle undermapper og høster tags
-3. Dobbeltklik på en sang for at afspille, eller højreklik → Tilføj til danseliste
-4. Brug **▶ 10 SEK** knappen til at høre introen inden dansen starter
-5. Sangen stopper automatisk når den er færdig — tryk **▶ AFSPIL NÆSTE** for at fortsætte
-
-## Lokal database
-
-Gemmes i `~/.linedance/local.db` — bevares mellem sessioner.
diff --git a/build.bat b/build.bat
deleted file mode 100644
index c3b050b9..00000000
--- a/build.bat
+++ /dev/null
@@ -1,68 +0,0 @@
-@echo off
-echo ================================================
-echo LineDance Player - Byg EXE
-echo ================================================
-echo.
-
-REM Tjek at vi er i det rigtige bibliotek
-if not exist main.py (
- echo FEJL: Kør build.bat fra LinedanceAfspiller\linedance-app mappen
- pause
- exit /b 1
-)
-
-REM Aktiver venv
-if not exist venv\Scripts\activate.bat (
- echo Opretter virtuelt miljø...
- python -m venv venv
-)
-call venv\Scripts\activate.bat
-
-REM Installer/opdater pakker
-echo Installerer pakker...
-pip install -r requirements.txt --quiet
-pip install pyinstaller --quiet
-
-REM Tjek VLC
-if not exist "C:\Program Files\VideoLAN\VLC\libvlc.dll" (
- if not exist "C:\Program Files (x86)\VideoLAN\VLC\libvlc.dll" (
- echo.
- echo ADVARSEL: VLC ser ikke ud til at vaere installeret!
- echo Download VLC fra: https://www.videolan.org/vlc/
- echo Vaelg 64-bit versionen.
- echo.
- pause
- exit /b 1
- )
-)
-
-REM Ryd gamle build-filer
-echo Rydder gamle build-filer...
-if exist build rmdir /s /q build
-if exist dist rmdir /s /q dist
-
-REM Byg EXE
-echo.
-echo Bygger LineDancePlayer.exe ...
-echo (Dette tager typisk 1-3 minutter)
-echo.
-pyinstaller LineDancePlayer.spec
-
-if %ERRORLEVEL% neq 0 (
- echo.
- echo FEJL under build! Se fejlbesked ovenfor.
- pause
- exit /b 1
-)
-
-echo.
-echo ================================================
-echo BUILD FAERDIG!
-echo Filen ligger i: dist\LineDancePlayer.exe
-echo ================================================
-echo.
-
-REM Vis filstoerrelse
-for %%A in (dist\LineDancePlayer.exe) do echo Filstoerrelse: %%~zA bytes
-
-pause
diff --git a/linedance-app/BUILD_VEJLEDNING.md b/linedance-app/BUILD_VEJLEDNING.md
deleted file mode 100644
index e22b5a92..00000000
--- a/linedance-app/BUILD_VEJLEDNING.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Byg LineDance Player til Windows .exe
-
-## Krav
-
-1. **Python 3.11+** installeret
-2. **VLC** installeret (skal også være på den maskine der kører .exe)
-3. Alle Python-pakker installeret (`pip install -r requirements.txt`)
-
-## Bygge på Windows
-
-```cmd
-cd linedance-app
-build.bat
-```
-
-Det færdige program ligger i `dist\LineDancePlayer\LineDancePlayer.exe`
-
-## Bygge på Linux (til Linux)
-
-```bash
-cd linedance-app
-./build_linux.sh
-```
-
-## Distribuere til andre
-
-Kopiér hele `dist\LineDancePlayer\` mappen — IKKE kun .exe filen!
-Mappen indeholder alle nødvendige DLL-filer og biblioteker.
-
-Modtageren skal stadig have **VLC installeret**:
-- Windows: https://www.videolan.org/vlc/
-- Linux: `sudo apt install vlc`
-
-## Hvis VLC ikke kan findes
-
-PyInstaller kan ikke automatisk inkludere VLC da det er et system-program.
-Alternativt kan du kopiere `libvlc.dll` og `libvlccore.dll` fra
-`C:\Program Files\VideoLAN\VLC\` ind i `dist\LineDancePlayer\`-mappen.
-
-## Fejlsøgning
-
-Hvis .exe crasher uden fejlbesked, byg med `console=True` i spec-filen
-og kør fra kommandoprompten for at se fejlbeskeder.
-
-## Størrelse
-
-Den færdige mappe er typisk 80-150 MB med PyQt6.
diff --git a/linedance-app/LineDancePlayer.spec b/linedance-app/LineDancePlayer.spec
deleted file mode 100644
index ef1bc0ac..00000000
--- a/linedance-app/LineDancePlayer.spec
+++ /dev/null
@@ -1,161 +0,0 @@
-# -*- mode: python ; coding: utf-8 -*-
-#
-# LineDancePlayer.spec
-#
-# Byg med: pyinstaller LineDancePlayer.spec
-# Output: dist\LineDancePlayer.exe
-#
-# Kræver: VLC installeret på byggemaskinen
-# (typisk C:\Program Files\VideoLAN\VLC)
-
-import os
-import sys
-from pathlib import Path
-
-# ── Find VLC-installation ─────────────────────────────────────────────────────
-
-def find_vlc_path() -> Path | None:
- """Find VLC på Windows — tjekker de mest almindelige installationsstier."""
- candidates = [
- Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) / "VideoLAN" / "VLC",
- Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)")) / "VideoLAN" / "VLC",
- Path("C:/Program Files/VideoLAN/VLC"),
- Path("C:/Program Files (x86)/VideoLAN/VLC"),
- ]
- # Tjek også PYTHONPATH og registry via python-vlc
- try:
- import vlc
- vlc_path = Path(vlc.plugin_path).parent if vlc.plugin_path else None
- if vlc_path and vlc_path.exists():
- candidates.insert(0, vlc_path)
- except Exception:
- pass
-
- for path in candidates:
- if path.exists() and (path / "libvlc.dll").exists():
- return path
- return None
-
-
-VLC_PATH = find_vlc_path()
-if VLC_PATH is None:
- print("=" * 60)
- print("ADVARSEL: VLC ikke fundet!")
- print("Installer VLC fra https://www.videolan.org/vlc/")
- print("og kør pyinstaller igen.")
- print("=" * 60)
- VLC_PATH = Path("C:/Program Files/VideoLAN/VLC") # fallback
-
-print(f"VLC fundet: {VLC_PATH}")
-
-# ── Saml VLC binære filer ─────────────────────────────────────────────────────
-
-vlc_binaries = []
-vlc_datas = []
-
-if VLC_PATH.exists():
- # Hoved-DLL filer
- for dll in ["libvlc.dll", "libvlccore.dll", "libvlc.lib"]:
- dll_path = VLC_PATH / dll
- if dll_path.exists():
- vlc_binaries.append((str(dll_path), "."))
-
- # Plugins-mappe — indeholder codecs, demuxers osv.
- plugins_dir = VLC_PATH / "plugins"
- if plugins_dir.exists():
- vlc_datas.append((str(plugins_dir), "plugins"))
-
- # Locale-filer
- locale_dir = VLC_PATH / "locale"
- if locale_dir.exists():
- vlc_datas.append((str(locale_dir), "locale"))
-
-# ── PyInstaller konfiguration ─────────────────────────────────────────────────
-
-block_cipher = None
-
-a = Analysis(
- ["main.py"],
- pathex=["."],
- binaries=vlc_binaries,
- datas=[
- ("ui", "ui"),
- ("local", "local"),
- ("player", "player"),
- ] + vlc_datas,
- hiddenimports=[
- # PyQt6
- "PyQt6.sip",
- "PyQt6.QtCore",
- "PyQt6.QtGui",
- "PyQt6.QtWidgets",
- # Lyd og tags
- "vlc",
- "mutagen",
- "mutagen.mp3",
- "mutagen.id3",
- "mutagen.flac",
- "mutagen.mp4",
- "mutagen.oggvorbis",
- "mutagen.oggopus",
- # Fil-overvågning
- "watchdog",
- "watchdog.observers",
- "watchdog.observers.polling",
- "watchdog.events",
- # Database
- "sqlite3",
- # Standard
- "json",
- "pathlib",
- "threading",
- "urllib.request",
- "urllib.parse",
- ],
- hookspath=[],
- hooksconfig={},
- runtime_hooks=[],
- excludes=[
- # Ting vi ikke bruger — reducerer filstørrelse
- "tkinter",
- "matplotlib",
- "numpy",
- "pandas",
- "scipy",
- "PIL",
- "cv2",
- "pytest",
- ],
- win_no_prefer_redirects=False,
- win_private_assemblies=False,
- cipher=block_cipher,
- noarchive=False,
-)
-
-pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
-
-exe = EXE(
- pyz,
- a.scripts,
- a.binaries,
- a.zipfiles,
- a.datas,
- [],
- name="LineDancePlayer",
- debug=False,
- bootloader_ignore_signals=False,
- strip=False,
- upx=True, # komprimer med UPX hvis tilgængeligt
- upx_exclude=[
- "libvlc.dll", # VLC må ikke komprimeres — den loader plugins dynamisk
- "libvlccore.dll",
- ],
- runtime_tmpdir=None,
- console=False, # ingen konsol-vindue
- disable_windowed_traceback=False,
- target_arch=None,
- codesign_identity=None,
- entitlements_file=None,
- # Ikon — kommenter ud hvis du ikke har en .ico fil endnu
- # icon="assets/icon.ico",
-)
diff --git a/linedance-app/README.md b/linedance-app/README.md
deleted file mode 100644
index 57877f5f..00000000
--- a/linedance-app/README.md
+++ /dev/null
@@ -1,57 +0,0 @@
-# LineDance Player — Desktop App
-
-PyQt6-baseret afspiller til linedance-events.
-
-## Installation
-
-```bash
-python -m venv venv
-source venv/bin/activate # Linux/Mac
-venv\Scripts\activate # Windows
-
-pip install -r requirements.txt
-```
-
-VLC skal også være installeret på systemet:
-- **Linux**: `sudo apt install vlc`
-- **Windows**: Download fra https://www.videolan.org/vlc/
-- **Mac**: `brew install vlc`
-
-## Start
-
-```bash
-python main.py
-```
-
-## Mappestruktur
-
-```
-linedance-app/
-├── main.py # Entry point
-├── requirements.txt
-├── local/ # Lokal SQLite + fil-scanning
-│ ├── local_db.py # Database operationer
-│ ├── tag_reader.py # Læs/skriv MP3-tags
-│ └── file_watcher.py # Overvåg mapper med watchdog
-├── player/
-│ └── player.py # VLC afspiller wrapper
-└── ui/
- ├── main_window.py # Hoved-vindue
- ├── playlist_panel.py # Danseliste
- ├── library_panel.py # Musikbibliotek med søgning
- ├── next_up_bar.py # "Næste sang klar" banner
- ├── vu_meter.py # VU-meter widget
- └── themes.py # Lyst / mørkt tema
-```
-
-## Brug
-
-1. Klik **+ MAPPE** i biblioteks-panelet og peg på din musikmappe
-2. Appen scanner automatisk alle undermapper og høster tags
-3. Dobbeltklik på en sang for at afspille, eller højreklik → Tilføj til danseliste
-4. Brug **▶ 10 SEK** knappen til at høre introen inden dansen starter
-5. Sangen stopper automatisk når den er færdig — tryk **▶ AFSPIL NÆSTE** for at fortsætte
-
-## Lokal database
-
-Gemmes i `~/.linedance/local.db` — bevares mellem sessioner.
diff --git a/linedance-app/app_logger.py b/linedance-app/app_logger.py
deleted file mode 100644
index a1249700..00000000
--- a/linedance-app/app_logger.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""
-app_logger.py — Central logging til fil i stedet for konsol.
-P Windows uden konsol skrives alt til ~/.linedance/app.log
-"""
-
-import logging
-import sys
-from pathlib import Path
-
-LOG_PATH = Path.home() / ".linedance" / "app.log"
-
-
-def setup_logging():
- LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
- handlers = [logging.FileHandler(LOG_PATH, encoding="utf-8")]
- # Kun tilføj konsol-handler hvis vi kører med konsol (development)
- if sys.stdout and hasattr(sys.stdout, 'write'):
- try:
- sys.stdout.write("") # test om konsol virker
- handlers.append(logging.StreamHandler(sys.stdout))
- except Exception:
- pass
-
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
- datefmt="%H:%M:%S",
- handlers=handlers,
- force=True,
- )
-
-
-logger = logging.getLogger("linedance")
diff --git a/linedance-app/build.bat b/linedance-app/build.bat
deleted file mode 100644
index 3a5a5585..00000000
--- a/linedance-app/build.bat
+++ /dev/null
@@ -1,35 +0,0 @@
-@echo off
-echo === LineDance Player - Windows Build ===
-echo.
-
-if exist "venv\Scripts\activate.bat" (
- call venv\Scripts\activate.bat
-) else (
- echo ADVARSEL: venv ikke fundet
-)
-
-pip install pyinstaller >nul 2>&1
-
-if exist "dist\LineDancePlayer" rmdir /s /q "dist\LineDancePlayer"
-if exist "build\LineDancePlayer" rmdir /s /q "build\LineDancePlayer"
-
-echo Bygger... (1-3 minutter)
-echo.
-
-pyinstaller build_windows.spec --clean --noconfirm
-
-if errorlevel 1 (
- echo.
- echo FEJL: Se fejlbesked ovenfor
- pause
- exit /b 1
-)
-
-echo.
-echo === FAERDIG ===
-echo Program: dist\LineDancePlayer\LineDancePlayer.exe
-echo.
-echo HUSK: Kopieer hele dist\LineDancePlayer\ mappen - ikke kun .exe!
-echo HUSK: VLC skal vaere installeret paa maskinen.
-echo.
-pause
diff --git a/linedance-app/build_linux.sh b/linedance-app/build_linux.sh
deleted file mode 100755
index 1df1469b..00000000
--- a/linedance-app/build_linux.sh
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/bin/bash
-echo "=== LineDance Player - Linux Build ==="
-echo
-
-# Aktiver venv
-source venv/bin/activate 2>/dev/null || echo "ADVARSEL: venv ikke aktiveret"
-
-# Installer PyInstaller
-pip show pyinstaller > /dev/null 2>&1 || pip install pyinstaller
-
-# Ryd tidligere build
-rm -rf dist/LineDancePlayer build/LineDancePlayer
-
-echo "Bygger LineDance Player..."
-echo "Dette tager 1-3 minutter..."
-echo
-
-pyinstaller build_windows.spec --clean
-
-if [ $? -eq 0 ]; then
- echo
- echo "=== BUILD FÆRDIG ==="
- echo "Programmet ligger i: dist/LineDancePlayer/LineDancePlayer"
- echo
- echo "HUSK: VLC skal stadig være installeret på maskinen!"
- echo " sudo apt install vlc"
-else
- echo "FEJL: Build mislykkedes!"
- exit 1
-fi
diff --git a/linedance-app/build_windows.spec b/linedance-app/build_windows.spec
deleted file mode 100644
index e56deb62..00000000
--- a/linedance-app/build_windows.spec
+++ /dev/null
@@ -1,84 +0,0 @@
-# -*- mode: python ; coding: utf-8 -*-
-from PyInstaller.utils.hooks import collect_all, collect_submodules
-
-block_cipher = None
-
-# Saml ALT fra PyQt6 inkl. plugins og DLL-filer
-pyqt6_datas, pyqt6_binaries, pyqt6_hiddenimports = collect_all('PyQt6')
-
-a = Analysis(
- ['main.py'],
- pathex=['.'],
- binaries=pyqt6_binaries,
- datas=pyqt6_datas,
- hiddenimports=pyqt6_hiddenimports + [
- 'PyQt6.sip',
- 'PyQt6.QtCore',
- 'PyQt6.QtGui',
- 'PyQt6.QtWidgets',
- # UI moduler
- 'ui.main_window',
- 'ui.playlist_panel',
- 'ui.library_panel',
- 'ui.library_manager',
- 'ui.themes',
- 'ui.vu_meter',
- 'ui.scan_worker',
- 'ui.tag_editor',
- 'ui.login_dialog',
- 'ui.settings_dialog',
- 'ui.playlist_manager',
- 'ui.next_up_bar',
- # Player + local
- 'player.player',
- 'local.local_db',
- 'local.tag_reader',
- 'local.file_watcher',
- # Biblioteker
- 'mutagen', 'mutagen.mp3', 'mutagen.id3', 'mutagen.flac',
- 'mutagen.mp4', 'mutagen.oggvorbis', 'mutagen.ogg',
- 'mutagen.wave', 'mutagen.aiff', 'mutagen.asf',
- 'watchdog', 'watchdog.observers', 'watchdog.events',
- 'watchdog.observers.winapi',
- 'vlc', 'sqlite3',
- ],
- hookspath=[],
- hooksconfig={},
- runtime_hooks=[],
- excludes=['tkinter', 'matplotlib', 'pandas', 'scipy', 'IPython'],
- win_no_prefer_redirects=False,
- win_private_assemblies=False,
- cipher=block_cipher,
- noarchive=False,
-)
-
-pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
-
-exe = EXE(
- pyz,
- a.scripts,
- [],
- exclude_binaries=True,
- name='LineDancePlayer',
- debug=False,
- bootloader_ignore_signals=False,
- strip=False,
- upx=False, # UPX kan give problemer med PyQt6 DLL-filer
- console=False, # Ingen konsol-vindue
- disable_windowed_traceback=False,
- target_arch=None,
- codesign_identity=None,
- entitlements_file=None,
- icon=None,
-)
-
-coll = COLLECT(
- exe,
- a.binaries,
- a.zipfiles,
- a.datas,
- strip=False,
- upx=False,
- upx_exclude=[],
- name='LineDancePlayer',
-)
diff --git a/linedance-app/main.py b/linedance-app/main.py
deleted file mode 100644
index ad5f9af2..00000000
--- a/linedance-app/main.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""
-main.py — Linedance afspiller.
-
-Start:
- python main.py
-"""
-
-import sys
-import os
-
-# Sørg for at rodmappen er i Python-stien
-sys.path.insert(0, os.path.dirname(__file__))
-
-from PyQt6.QtWidgets import QApplication
-from ui.main_window import MainWindow
-from ui.themes import apply_theme
-
-
-def main():
- app = QApplication(sys.argv)
- app.setApplicationName("LineDance Player")
- app.setOrganizationName("LineDance")
-
- apply_theme(app, dark=True)
-
- window = MainWindow()
- window.show()
-
- sys.exit(app.exec())
-
-
-if __name__ == "__main__":
- main()
diff --git a/linedance-app/player/__init__.py b/linedance-app/player/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/linedance-app/player/__pycache__/__init__.cpython-312.pyc b/linedance-app/player/__pycache__/__init__.cpython-312.pyc
deleted file mode 100644
index e8894209..00000000
Binary files a/linedance-app/player/__pycache__/__init__.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/player/__pycache__/__init__.cpython-313.pyc b/linedance-app/player/__pycache__/__init__.cpython-313.pyc
deleted file mode 100644
index ad7a4ac8..00000000
Binary files a/linedance-app/player/__pycache__/__init__.cpython-313.pyc and /dev/null differ
diff --git a/linedance-app/player/__pycache__/player.cpython-312.pyc b/linedance-app/player/__pycache__/player.cpython-312.pyc
deleted file mode 100644
index 9e97056f..00000000
Binary files a/linedance-app/player/__pycache__/player.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/player/player.py b/linedance-app/player/player.py
deleted file mode 100644
index 758077e8..00000000
--- a/linedance-app/player/player.py
+++ /dev/null
@@ -1,200 +0,0 @@
-"""
-player.py — VLC-baseret afspiller med PyQt6 signals.
-
-Sender signals til GUI:
- position_changed(float) — 0.0–1.0 progress
- time_changed(int, int) — (current_sec, total_sec)
- levels_changed(float, float) — VU-meter L/R 0.0–1.0
- song_ended() — sang færdig
- state_changed(str) — 'playing'|'paused'|'stopped'
-"""
-
-from PyQt6.QtCore import QObject, pyqtSignal, QTimer
-import random
-import math
-
-try:
- import vlc
- VLC_AVAILABLE = True
-except ImportError:
- VLC_AVAILABLE = False
- print("Advarsel: python-vlc ikke installeret — afspilning deaktiveret")
-
-
-class Player(QObject):
- position_changed = pyqtSignal(float)
- time_changed = pyqtSignal(int, int)
- levels_changed = pyqtSignal(float, float)
- song_ended = pyqtSignal()
- state_changed = pyqtSignal(str)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._path: str | None = None
- self._duration: int = 0
- self._demo_mode = False
- self._demo_stop_sec = 10
- self._demo_fade_sec = 5
- self._demo_fading = False
- self._volume = 78
-
- if VLC_AVAILABLE:
- self._instance = vlc.Instance("--no-video", "--quiet")
- self._media_player = self._instance.media_player_new()
- self._events = self._media_player.event_manager()
- self._events.event_attach(
- vlc.EventType.MediaPlayerEndReached,
- self._on_end_reached,
- )
- else:
- self._media_player = None
-
- # Timer til polling af position + VU-simulation
- self._poll_timer = QTimer(self)
- self._poll_timer.setInterval(80)
- self._poll_timer.timeout.connect(self._poll)
-
- # ── Indlæsning ────────────────────────────────────────────────────────────
-
- def load(self, path: str, duration_sec: int = 0):
- """Indlæs en lydfil uden at starte afspilning."""
- self._path = path
- self._duration = duration_sec
- self._demo_mode = False
-
- if VLC_AVAILABLE and self._media_player:
- media = self._instance.media_new(path)
- self._media_player.set_media(media)
- self._media_player.audio_set_volume(self._volume)
-
- self.position_changed.emit(0.0)
- self.time_changed.emit(0, self._duration)
- self.state_changed.emit("stopped")
-
- # ── Transport ─────────────────────────────────────────────────────────────
-
- def play(self):
- self._demo_mode = False
- if VLC_AVAILABLE and self._media_player:
- self._media_player.play()
- self._poll_timer.start()
- self.state_changed.emit("playing")
-
- def play_demo(self, stop_at_sec: int = 10, fade_sec: int = 5):
- """
- Afspil fra start, fade ud over fade_sec sekunder og stop.
- Total afspilningstid = stop_at_sec + fade_sec.
- fade_sec=0 giver ingen fade.
- """
- self._demo_mode = True
- self._demo_stop_sec = stop_at_sec + fade_sec # total tid inkl. fade
- self._demo_fade_sec = fade_sec
- self._demo_fading = False
- if VLC_AVAILABLE and self._media_player:
- self._media_player.set_time(0)
- self._media_player.audio_set_volume(self._volume)
- self._media_player.play()
- self._poll_timer.start()
- self.state_changed.emit("playing")
-
- def pause(self):
- if VLC_AVAILABLE and self._media_player:
- self._media_player.pause()
- self.state_changed.emit("paused")
-
- def stop(self):
- self._demo_mode = False
- self._demo_fading = False
- if VLC_AVAILABLE and self._media_player:
- self._media_player.audio_set_volume(self._volume)
- self._media_player.stop()
- self._poll_timer.stop()
- self.position_changed.emit(0.0)
- self.time_changed.emit(0, self._duration)
- self.state_changed.emit("stopped")
-
- def is_playing(self) -> bool:
- if VLC_AVAILABLE and self._media_player:
- return self._media_player.is_playing()
- return False
-
- def set_volume(self, volume: int):
- """0–100"""
- self._volume = volume
- if VLC_AVAILABLE and self._media_player:
- self._media_player.audio_set_volume(volume)
-
- def set_position(self, fraction: float):
- """Søg til position 0.0–1.0"""
- if VLC_AVAILABLE and self._media_player:
- self._media_player.set_position(fraction)
-
- # ── Intern polling ────────────────────────────────────────────────────────
-
- def _poll(self):
- """Køres ~12 gange per sekund — opdaterer position og VU-meter."""
- if VLC_AVAILABLE and self._media_player:
- pos = self._media_player.get_position()
- ms = self._media_player.get_time()
- cur = max(0, ms // 1000)
- else:
- # Simuleret tilstand (til UI-test uden VLC)
- pos = getattr(self, "_sim_pos", 0.0)
- self._sim_pos = min(1.0, pos + 0.001)
- cur = int(self._sim_pos * self._duration)
- pos = self._sim_pos
- if self._sim_pos >= 1.0:
- self._on_end_reached(None)
- return
-
- self.position_changed.emit(pos)
- self.time_changed.emit(cur, self._duration)
-
- # Demo fade-out og stop
- if self._demo_mode and cur >= self._demo_stop_sec:
- # Færdig — gendan volumen og stop
- if VLC_AVAILABLE and self._media_player:
- self._media_player.audio_set_volume(self._volume)
- self.stop()
- self._demo_mode = False
- self._demo_fading = False
- self.position_changed.emit(0.0)
- self.time_changed.emit(0, self._duration)
- self.state_changed.emit("demo_ended")
- return
-
- # Demo fade-out — de sidste _demo_fade_sec sekunder (0 = ingen fade)
- if self._demo_mode and VLC_AVAILABLE and self._media_player and self._demo_fade_sec > 0:
- secs_left = self._demo_stop_sec - cur
- if secs_left <= self._demo_fade_sec and secs_left > 0:
- fade_fraction = secs_left / self._demo_fade_sec # 1.0 → 0.0
- log_fraction = math.log10(1 + fade_fraction * 9) / math.log10(10)
- faded_vol = int(self._volume * log_fraction)
- self._media_player.audio_set_volume(max(0, faded_vol))
- self._demo_fading = True
- elif not self._demo_fading:
- self._media_player.audio_set_volume(self._volume)
-
- # VU-meter: brug VLC's audio-amplitude hvis tilgængelig, ellers simulér
- if VLC_AVAILABLE and self._media_player and self._media_player.is_playing():
- # VLC eksponerer ikke amplitude direkte — vi bruger en blød simulation
- # der er baseret på position så det ser organisk ud
- base = 0.55 + 0.3 * abs(pos - 0.5)
- l = min(1.0, base + random.gauss(0, 0.12))
- r = min(1.0, base + random.gauss(0, 0.12))
- else:
- l = r = 0.0
-
- self.levels_changed.emit(max(0.0, l), max(0.0, r))
-
- def _on_end_reached(self, event):
- """Kaldes fra VLC's event-tråd — må IKKE røre Qt-objekter direkte."""
- # QTimer.singleShot er thread-safe og sender alt til main thread
- from PyQt6.QtCore import QTimer as _QTimer
- _QTimer.singleShot(0, self._handle_end_in_main_thread)
-
- def _handle_end_in_main_thread(self):
- """Kaldes i main thread — her er det sikkert at røre Qt."""
- self._poll_timer.stop()
- self.song_ended.emit()
- self.state_changed.emit("stopped")
diff --git a/linedance-app/requirements.txt b/linedance-app/requirements.txt
deleted file mode 100644
index b005ffb1..00000000
--- a/linedance-app/requirements.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-PyQt6>=6.6.0
-python-vlc>=3.0.18
-mutagen>=1.47.0
-watchdog>=4.0.0
-
-# BPM-analyse
-librosa>=0.10.0
diff --git a/linedance-app/ui/__init__.py b/linedance-app/ui/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/linedance-app/ui/__pycache__/__init__.cpython-312.pyc b/linedance-app/ui/__pycache__/__init__.cpython-312.pyc
deleted file mode 100644
index 41204d22..00000000
Binary files a/linedance-app/ui/__pycache__/__init__.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/__init__.cpython-313.pyc b/linedance-app/ui/__pycache__/__init__.cpython-313.pyc
deleted file mode 100644
index 6d337996..00000000
Binary files a/linedance-app/ui/__pycache__/__init__.cpython-313.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/library_manager.cpython-312.pyc b/linedance-app/ui/__pycache__/library_manager.cpython-312.pyc
deleted file mode 100644
index d5931b44..00000000
Binary files a/linedance-app/ui/__pycache__/library_manager.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc b/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc
deleted file mode 100644
index a645a86a..00000000
Binary files a/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/login_dialog.cpython-312.pyc b/linedance-app/ui/__pycache__/login_dialog.cpython-312.pyc
deleted file mode 100644
index b00b51cb..00000000
Binary files a/linedance-app/ui/__pycache__/login_dialog.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/main_window.cpython-312.pyc b/linedance-app/ui/__pycache__/main_window.cpython-312.pyc
deleted file mode 100644
index 79e44ac2..00000000
Binary files a/linedance-app/ui/__pycache__/main_window.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/next_up_bar.cpython-312.pyc b/linedance-app/ui/__pycache__/next_up_bar.cpython-312.pyc
deleted file mode 100644
index 2116db55..00000000
Binary files a/linedance-app/ui/__pycache__/next_up_bar.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/playlist_manager.cpython-312.pyc b/linedance-app/ui/__pycache__/playlist_manager.cpython-312.pyc
deleted file mode 100644
index 495b239d..00000000
Binary files a/linedance-app/ui/__pycache__/playlist_manager.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc b/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc
deleted file mode 100644
index 91523fd8..00000000
Binary files a/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc b/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc
deleted file mode 100644
index 16196ef1..00000000
Binary files a/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc b/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc
deleted file mode 100644
index 11666ca1..00000000
Binary files a/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc b/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc
deleted file mode 100644
index 0b10e7f7..00000000
Binary files a/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/themes.cpython-312.pyc b/linedance-app/ui/__pycache__/themes.cpython-312.pyc
deleted file mode 100644
index 577b576d..00000000
Binary files a/linedance-app/ui/__pycache__/themes.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/__pycache__/vu_meter.cpython-312.pyc b/linedance-app/ui/__pycache__/vu_meter.cpython-312.pyc
deleted file mode 100644
index 389bc6f4..00000000
Binary files a/linedance-app/ui/__pycache__/vu_meter.cpython-312.pyc and /dev/null differ
diff --git a/linedance-app/ui/library_manager.py b/linedance-app/ui/library_manager.py
deleted file mode 100644
index 3fdf047f..00000000
--- a/linedance-app/ui/library_manager.py
+++ /dev/null
@@ -1,135 +0,0 @@
-"""
-library_manager.py — Dialog til at se og fjerne musikbiblioteker.
-"""
-
-from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel,
- QPushButton, QListWidget, QListWidgetItem, QMessageBox,
-)
-from PyQt6.QtCore import Qt, pyqtSignal
-
-
-class LibraryManagerDialog(QDialog):
- library_removed = pyqtSignal(int) # library_id
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Administrer musikbiblioteker")
- self.setMinimumWidth(500)
- self.setMinimumHeight(320)
- self._build_ui()
- self._load()
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(16, 16, 16, 16)
- layout.setSpacing(10)
-
- lbl = QLabel("Aktive musikbiblioteker:")
- lbl.setObjectName("track_meta")
- layout.addWidget(lbl)
-
- self._list = QListWidget()
- layout.addWidget(self._list)
-
- note = QLabel(
- "Når du fjerner et bibliotek, slettes det fra overvågningen.\n"
- "Sangene forbliver i databasen men markeres som manglende (⚠)."
- )
- note.setObjectName("result_count")
- note.setWordWrap(True)
- layout.addWidget(note)
-
- btn_row = QHBoxLayout()
- btn_add = QPushButton("+ Tilføj mappe")
- btn_add.clicked.connect(self._add_folder)
- btn_row.addWidget(btn_add)
-
- btn_remove = QPushButton("✕ Fjern valgt")
- btn_remove.clicked.connect(self._remove_selected)
- btn_row.addWidget(btn_remove)
-
- btn_scan = QPushButton("⟳ Scan alle")
- btn_scan.setToolTip("Scan alle mapper for nye og ændrede filer")
- btn_scan.clicked.connect(self._scan_all)
- btn_row.addWidget(btn_scan)
-
- btn_row.addStretch()
- btn_close = QPushButton("Luk")
- btn_close.clicked.connect(self.accept)
- btn_row.addWidget(btn_close)
- layout.addLayout(btn_row)
-
- def _load(self):
- self._list.clear()
- try:
- from local.local_db import get_libraries, get_db
- libs = get_libraries(active_only=True) # kun aktive
- for lib in libs:
- from pathlib import Path
- path = lib["path"]
- exists = Path(path).exists()
- last_scan = lib["last_full_scan"] or "aldrig"
- if isinstance(last_scan, str) and len(last_scan) > 10:
- last_scan = last_scan[:10]
- with get_db() as conn:
- count = conn.execute(
- "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0",
- (lib["id"],)
- ).fetchone()[0]
- exist_icon = "" if exists else " ⚠ mappe ikke fundet"
- label = f"{path}{exist_icon}\n {count} sange · senest scannet: {last_scan}"
- item = QListWidgetItem(label)
- item.setData(Qt.ItemDataRole.UserRole, dict(lib))
- if not exists:
- from PyQt6.QtGui import QColor
- item.setForeground(QColor("#5a6070"))
- self._list.addItem(item)
- except Exception as e:
- print(f"Library manager load fejl: {e}")
-
- def _scan_all(self):
- mw = self.parent()
- if hasattr(mw, "start_scan"):
- mw.start_scan()
- self._set_status("Scanning startet...")
-
- def _set_status(self, text: str):
- pass # kan udvides med statuslinje i dialogen
-
- def _add_folder(self):
- from PyQt6.QtWidgets import QFileDialog
- folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
- if folder:
- mw = self.parent()
- if hasattr(mw, "add_library_path"):
- mw.add_library_path(folder)
- # Genindlæs listen efter kort pause så DB er opdateret
- from PyQt6.QtCore import QTimer
- QTimer.singleShot(600, self._load)
-
- def _remove_selected(self):
- item = self._list.currentItem()
- if not item:
- return
- lib = item.data(Qt.ItemDataRole.UserRole)
- reply = QMessageBox.question(
- self, "Fjern bibliotek",
- f"Fjern overvågningen af:\n{lib['path']}\n\n"
- "Sange i biblioteket forbliver i databasen men markeres som manglende.",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- )
- if reply == QMessageBox.StandardButton.Yes:
- try:
- mw = self.parent()
- if hasattr(mw, "_watcher") and mw._watcher:
- mw._watcher.remove_library(lib["id"])
- else:
- from local.local_db import remove_library
- remove_library(lib["id"])
- self.library_removed.emit(lib["id"])
- if hasattr(mw, "_reload_library"):
- mw._reload_library()
- self._load()
- except Exception as e:
- QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}")
diff --git a/linedance-app/ui/library_panel.py b/linedance-app/ui/library_panel.py
deleted file mode 100644
index b30407da..00000000
--- a/linedance-app/ui/library_panel.py
+++ /dev/null
@@ -1,364 +0,0 @@
-"""
-library_panel.py — Musikbibliotek med søgning og drag-and-drop til danseliste.
-"""
-
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
- QLineEdit, QLabel, QHBoxLayout, QPushButton, QProgressBar,
- QAbstractItemView,
-)
-from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray
-from PyQt6.QtGui import QColor, QDrag
-
-
-class DraggableLibraryList(QListWidget):
- """QListWidget der understøtter drag-start med sang-data som mime."""
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setDragEnabled(True)
- self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
- self.setDefaultDropAction(Qt.DropAction.CopyAction)
-
- def startDrag(self, supported_actions):
- item = self.currentItem()
- if not item:
- return
- song = item.data(Qt.ItemDataRole.UserRole)
- if not song:
- return
-
- import json
- data = json.dumps(song).encode("utf-8")
-
- mime = QMimeData()
- mime.setData("application/x-linedance-song", QByteArray(data))
- mime.setText(song.get("title", ""))
-
- drag = QDrag(self)
- drag.setMimeData(mime)
- drag.exec(Qt.DropAction.CopyAction)
-
-
-class LibraryPanel(QWidget):
- song_selected = pyqtSignal(dict)
- add_to_playlist = pyqtSignal(dict)
- scan_requested = pyqtSignal()
- edit_tags_requested = pyqtSignal(dict)
- send_mail_requested = pyqtSignal(dict)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._all_songs: list[dict] = []
- self._filtered: list[dict] = []
- self._bpm_scan_running = False
- self._search_timer = QTimer(self)
- self._search_timer.setSingleShot(True)
- self._search_timer.setInterval(150)
- self._search_timer.timeout.connect(self._do_search)
- self._build_ui()
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(0)
-
- # Header
- header = QHBoxLayout()
- header.setContentsMargins(10, 6, 10, 6)
- lbl = QLabel("BIBLIOTEK")
- lbl.setObjectName("section_title")
- header.addWidget(lbl)
- header.addStretch()
-
- self._btn_bpm_scan = QPushButton("♩ BPM alle")
- self._btn_bpm_scan.setFixedHeight(24)
- self._btn_bpm_scan.setToolTip("Analysér BPM på alle sange uden BPM (kører i baggrunden)")
- self._btn_bpm_scan.clicked.connect(self._start_bulk_bpm_scan)
- header.addWidget(self._btn_bpm_scan)
-
- btn_manage = QPushButton("⚙ Mapper")
- btn_manage.setFixedHeight(24)
- btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
- btn_manage.clicked.connect(self._manage_libraries)
- header.addWidget(btn_manage)
- layout.addLayout(header)
-
- # Scan status
- self._scan_bar = QProgressBar()
- self._scan_bar.setObjectName("scan_bar")
- self._scan_bar.setTextVisible(True)
- self._scan_bar.setFormat("Scanner...")
- self._scan_bar.setFixedHeight(16)
- self._scan_bar.setRange(0, 0)
- self._scan_bar.hide()
- layout.addWidget(self._scan_bar)
-
- self._scan_label = QLabel("")
- self._scan_label.setObjectName("result_count")
- self._scan_label.hide()
- layout.addWidget(self._scan_label)
-
- # Søgefelt
- self._search = QLineEdit()
- self._search.setPlaceholderText("Søg i titel, artist, album, dans...")
- self._search.textChanged.connect(self._on_search_changed)
- layout.addWidget(self._search)
-
- # Resultat-tæller + drag-hint
- hint_row = QHBoxLayout()
- hint_row.setContentsMargins(8, 2, 8, 2)
- self._count_label = QLabel("0 sange")
- self._count_label.setObjectName("result_count")
- hint_row.addWidget(self._count_label)
- hint_row.addStretch()
- drag_hint = QLabel("træk til danseliste →")
- drag_hint.setObjectName("result_count")
- hint_row.addWidget(drag_hint)
- layout.addLayout(hint_row)
-
- # Liste — draggable
- self._list = DraggableLibraryList()
- self._list.setObjectName("library_list")
- self._list.itemDoubleClicked.connect(self._on_double_click)
- self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- self._list.customContextMenuRequested.connect(self._show_context_menu)
- layout.addWidget(self._list)
-
- # ── Scanning ──────────────────────────────────────────────────────────────
-
- def _on_scan_clicked(self):
- self.scan_requested.emit()
-
- def set_scanning(self, scanning: bool, status_text: str = ""):
- if scanning:
- self._scan_bar.show()
- self._scan_label.setText(status_text or "Starter...")
- self._scan_label.show()
- else:
- self._scan_bar.hide()
- self._scan_label.hide()
-
- def update_scan_status(self, text: str):
- self._scan_label.setText(text)
-
- # ── Sange ─────────────────────────────────────────────────────────────────
-
- def load_songs(self, songs: list[dict]):
- self._all_songs = songs
- self._do_search()
-
- # ── Søgning ───────────────────────────────────────────────────────────────
-
- def _on_search_changed(self):
- self._search_timer.start()
-
- def _do_search(self):
- q = self._search.text().strip().lower()
- self._filtered = [s for s in self._all_songs if self._matches(s, q)] if q else list(self._all_songs)
- total = len(self._all_songs)
- found = len(self._filtered)
- q_text = self._search.text().strip()
- self._count_label.setText(
- f"{found} resultat{'er' if found != 1 else ''} for \"{q_text}\"" if q_text
- else f"{total} sang{'e' if total != 1 else ''}"
- )
- self._render()
-
- def _matches(self, song: dict, q: str) -> bool:
- return any(q in f.lower() for f in [
- song.get("title", ""), song.get("artist", ""),
- song.get("album", ""), song.get("file_format", ""),
- ] + song.get("dances", []))
-
- def _render(self):
- self._list.clear()
- q = self._search.text().strip().lower()
- for song in self._filtered:
- dances = song.get("dances", [])
- dance_levels = song.get("dance_levels", [])
- missing = song.get("file_missing", False)
-
- dance_parts = []
- for i, d in enumerate(dances):
- lvl = dance_levels[i] if i < len(dance_levels) else ""
- dance_parts.append(f"{d} / {lvl}" if lvl else d)
- dance_str = " · " + " | ".join(dance_parts) if dance_parts else ""
-
- line1 = ("⚠ " if missing else "") + song.get("title", "—")
- bpm = song.get("bpm", 0)
- bpm_str = f"{bpm} BPM" if bpm else "? BPM"
- line2 = f" {song.get('artist','—')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}"
-
- row_widget = QWidget()
- row_widget.setStyleSheet("background: transparent;")
- row_layout = QHBoxLayout(row_widget)
- row_layout.setContentsMargins(2, 2, 2, 2)
- row_layout.setSpacing(8)
-
- lbl = QLabel(f"{line1}\n{line2}")
- lbl.setWordWrap(False)
- row_layout.addWidget(lbl, stretch=1)
-
- btn_danse = QPushButton("Danse")
- btn_danse.setFixedHeight(30)
- btn_danse.setFixedWidth(70)
- btn_danse.setToolTip("Rediger dans-tags")
- btn_danse.setStyleSheet(
- "QPushButton { background: #e8a020; color: #111; border-radius: 4px; "
- "font-weight: bold; font-size: 12px; border: none; }"
- "QPushButton:hover { background: #f0b030; }"
- )
- btn_danse.clicked.connect(lambda _, s=song: self.edit_tags_requested.emit(s))
- row_layout.addWidget(btn_danse)
-
- item = QListWidgetItem()
- item.setData(Qt.ItemDataRole.UserRole, song)
- row_widget.adjustSize()
- hint = row_widget.sizeHint()
- hint.setHeight(max(hint.height(), 52))
- item.setSizeHint(hint)
- self._list.addItem(item)
- self._list.setItemWidget(item, row_widget)
-
- def _start_bulk_bpm_scan(self):
- """Start BPM-analyse på alle sange uden BPM i baggrundstråd med lav prioritet."""
- if self._bpm_scan_running:
- return
- songs_without_bpm = [s for s in self._all_songs
- if not s.get("bpm") and not s.get("file_missing")]
- if not songs_without_bpm:
- self._btn_bpm_scan.setText("♩ Alle har BPM")
- return
-
- self._bpm_scan_running = True
- self._btn_bpm_scan.setText(f"♩ Scanner 0/{len(songs_without_bpm)}...")
- self._btn_bpm_scan.setEnabled(False)
-
- from PyQt6.QtCore import QThread, pyqtSignal as _sig
-
- class BulkBpmWorker(QThread):
- progress = _sig(int, int, str) # done, total, title
- finished = _sig()
-
- def __init__(self, songs):
- super().__init__()
- self._songs = songs
-
- def run(self):
- from local.tag_reader import analyze_and_save_bpm
- total = len(self._songs)
- for i, song in enumerate(self._songs, start=1):
- if self.isInterruptionRequested():
- break
- try:
- bpm = analyze_and_save_bpm(song["local_path"], song["id"])
- if bpm:
- song["bpm"] = int(round(bpm))
- except Exception:
- pass
- self.progress.emit(i, total, song.get("title", ""))
- self.finished.emit()
-
- self._bulk_bpm_worker = BulkBpmWorker(songs_without_bpm)
-
- def on_progress(done, total, title):
- self._btn_bpm_scan.setText(f"♩ {done}/{total}...")
- # Opdater sangen i listen
- for s in self._all_songs:
- if s.get("title") == title and s.get("bpm"):
- break
- self._do_search()
-
- def on_finished():
- self._bpm_scan_running = False
- self._btn_bpm_scan.setEnabled(True)
- self._btn_bpm_scan.setText("♩ BPM alle")
- self._do_search()
-
- self._bulk_bpm_worker.progress.connect(on_progress)
- self._bulk_bpm_worker.finished.connect(on_finished)
- self._bulk_bpm_worker.start()
- self._bulk_bpm_worker.setPriority(QThread.Priority.LowestPriority)
-
- # ── Handlinger ────────────────────────────────────────────────────────────
-
- def _on_double_click(self, item: QListWidgetItem):
- song = item.data(Qt.ItemDataRole.UserRole)
- if song:
- self.song_selected.emit(song)
-
- def _show_context_menu(self, pos):
- from PyQt6.QtWidgets import QMenu
- item = self._list.itemAt(pos)
- if not item:
- return
- song = item.data(Qt.ItemDataRole.UserRole)
- if not song:
- return
- menu = QMenu(self)
- act_add = menu.addAction("Tilføj til danseliste")
- act_play = menu.addAction("Afspil")
- menu.addSeparator()
- act_tags = menu.addAction("✎ Rediger dans-tags...")
- act_bpm = menu.addAction("♩ Analysér BPM")
- menu.addSeparator()
- send_menu = menu.addMenu("Send til")
- act_mail = send_menu.addAction("✉ Send som mail")
- action = menu.exec(self._list.mapToGlobal(pos))
- if action == act_add:
- self.add_to_playlist.emit(song)
- elif action == act_play:
- self.song_selected.emit(song)
- elif action == act_tags:
- self.edit_tags_requested.emit(song)
- elif action == act_bpm:
- self._analyze_bpm(song)
- elif action == act_mail:
- self.send_mail_requested.emit(song)
-
- def _analyze_bpm(self, song: dict):
- """Analysér BPM i baggrundstråd og opdater biblioteket."""
- path = song.get("local_path", "")
- song_id = song.get("id", "")
- if not path or not song_id:
- return
- from PyQt6.QtCore import QThread, pyqtSignal as _sig
-
- class BpmWorker(QThread):
- done = _sig(float)
- def __init__(self, p, sid):
- super().__init__()
- self._p, self._sid = p, sid
- def run(self):
- from local.tag_reader import analyze_and_save_bpm
- bpm = analyze_and_save_bpm(self._p, self._sid)
- if bpm:
- self.done.emit(bpm)
-
- self._bpm_worker = BpmWorker(path, song_id)
-
- def on_bpm_done(bpm):
- # Opdater sangen i _all_songs listen direkte
- for s in self._all_songs:
- if s.get("id") == song_id:
- s["bpm"] = int(round(bpm))
- break
- self._do_search()
-
- self._bpm_worker.done.connect(on_bpm_done)
- self._bpm_worker.start()
-
- def _manage_libraries(self):
- from ui.library_manager import LibraryManagerDialog
- dialog = LibraryManagerDialog(parent=self.window())
- dialog.library_removed.connect(lambda _: self.scan_requested.emit())
- dialog.exec()
-
- def _add_folder(self):
- from PyQt6.QtWidgets import QFileDialog
- folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
- if folder:
- mw = self.window()
- if hasattr(mw, "add_library_path"):
- mw.add_library_path(folder)
diff --git a/linedance-app/ui/login_dialog.py b/linedance-app/ui/login_dialog.py
deleted file mode 100644
index f87847b1..00000000
--- a/linedance-app/ui/login_dialog.py
+++ /dev/null
@@ -1,139 +0,0 @@
-"""
-login_dialog.py — Login-dialog til at gå online.
-Server-URL er hardcodet i config.
-"""
-
-from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel,
- QLineEdit, QPushButton, QFrame, QCheckBox,
-)
-from PyQt6.QtCore import Qt, QSettings
-
-# ── Hardcodet server-URL ──────────────────────────────────────────────────────
-API_URL = "http://din-server:8000"
-# ─────────────────────────────────────────────────────────────────────────────
-
-
-class LoginDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Gå online")
- self.setFixedWidth(340)
- self.setModal(True)
-
- self._token: str | None = None
- self._username: str | None = None
- self._api_url = API_URL
-
- self._build_ui()
- self._load_saved_settings()
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setSpacing(10)
- layout.setContentsMargins(20, 20, 20, 20)
-
- title = QLabel("Log ind på LineDance")
- title.setObjectName("track_title")
- title.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(title)
-
- sub = QLabel("Synkroniser projekter og alternativ-danse med andre brugere")
- sub.setObjectName("track_meta")
- sub.setWordWrap(True)
- sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(sub)
-
- line = QFrame()
- line.setFrameShape(QFrame.Shape.HLine)
- layout.addWidget(line)
-
- layout.addWidget(QLabel("Brugernavn:"))
- self._user_input = QLineEdit()
- self._user_input.setPlaceholderText("dit-brugernavn")
- layout.addWidget(self._user_input)
-
- layout.addWidget(QLabel("Kodeord:"))
- self._pass_input = QLineEdit()
- self._pass_input.setEchoMode(QLineEdit.EchoMode.Password)
- self._pass_input.setPlaceholderText("••••••••")
- self._pass_input.returnPressed.connect(self._on_login)
- layout.addWidget(self._pass_input)
-
- self._remember = QCheckBox("Husk brugernavn")
- self._remember.setChecked(True)
- layout.addWidget(self._remember)
-
- self._status_label = QLabel("")
- self._status_label.setObjectName("track_meta")
- self._status_label.setWordWrap(True)
- layout.addWidget(self._status_label)
-
- btn_row = QHBoxLayout()
- btn_cancel = QPushButton("Annuller")
- btn_cancel.clicked.connect(self.reject)
- btn_row.addWidget(btn_cancel)
-
- self._btn_login = QPushButton("Log ind")
- self._btn_login.setObjectName("btn_play")
- self._btn_login.setDefault(True)
- self._btn_login.clicked.connect(self._on_login)
- btn_row.addWidget(self._btn_login)
-
- layout.addLayout(btn_row)
-
- def _load_saved_settings(self):
- settings = QSettings("LineDance", "Player")
- self._user_input.setText(settings.value("username", ""))
-
- def _save_settings(self):
- if self._remember.isChecked():
- settings = QSettings("LineDance", "Player")
- settings.setValue("username", self._user_input.text().strip())
-
- def _on_login(self):
- username = self._user_input.text().strip()
- password = self._pass_input.text()
-
- if not username or not password:
- self._set_status("Udfyld brugernavn og kodeord", error=True)
- return
-
- self._btn_login.setEnabled(False)
- self._set_status("Forbinder...")
-
- try:
- import urllib.request, urllib.parse, json
-
- data = urllib.parse.urlencode({
- "username": username,
- "password": password,
- }).encode()
-
- req = urllib.request.Request(
- f"{API_URL}/auth/login",
- data=data,
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- method="POST",
- )
- with urllib.request.urlopen(req, timeout=8) as resp:
- body = json.loads(resp.read())
- self._token = body.get("access_token")
- self._username = username
-
- self._save_settings()
- self._set_status("Logget ind!", error=False)
- self.accept()
-
- except Exception as e:
- self._set_status(f"Fejl: {e}", error=True)
- self._btn_login.setEnabled(True)
-
- def _set_status(self, text: str, error: bool = False):
- self._status_label.setText(text)
- color = "#e74c3c" if error else "#2ecc71"
- self._status_label.setStyleSheet(f"color: {color};")
-
- def get_credentials(self) -> tuple[str, str, str]:
- """Returnerer (api_url, username, token) efter succesfuldt login."""
- return self._api_url, self._username, self._token
diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py
deleted file mode 100644
index c28c622f..00000000
--- a/linedance-app/ui/main_window.py
+++ /dev/null
@@ -1,943 +0,0 @@
-"""
-main_window.py — Linedance afspiller hovedvindue.
-"""
-
-from PyQt6.QtWidgets import (
- QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
- QPushButton, QSlider, QLabel, QFrame, QSplitter,
- QSizePolicy, QMenuBar, QMenu, QStatusBar, QFileDialog,
- QMessageBox,
-)
-from PyQt6.QtCore import Qt, QTimer
-from PyQt6.QtGui import QAction
-
-from ui.vu_meter import VUMeter
-from ui.playlist_panel import PlaylistPanel
-from ui.library_panel import LibraryPanel
-from ui.themes import apply_theme
-from ui.scan_worker import ScanWorker
-from ui.login_dialog import LoginDialog, API_URL
-from ui.playlist_manager import PlaylistManagerDialog
-from ui.settings_dialog import SettingsDialog, load_settings
-from player.player import Player
-
-
-class ProgressBar(QWidget):
- def __init__(self, parent=None):
- super().__init__(parent)
- self._fraction = 0.0
- self._demo_fraction = 0.0 # hvor musikken stopper (blå)
- self._demo_fade_fraction = 0.0 # hvor fade slutter (grå)
- self.setFixedHeight(10)
- self.setCursor(Qt.CursorShape.PointingHandCursor)
-
- def set_fraction(self, f: float):
- self._fraction = max(0.0, min(1.0, f))
- self.update()
-
- def set_demo_marker(self, demo_f: float, fade_f: float = 0.0):
- self._demo_fraction = max(0.0, min(1.0, demo_f))
- self._demo_fade_fraction = max(0.0, min(1.0, fade_f))
- self.update()
-
- def paintEvent(self, event):
- from PyQt6.QtGui import QPainter, QColor
- p = QPainter(self)
- w, h = self.width(), self.height()
- p.fillRect(0, 0, w, h, QColor("#2c3038"))
- fill_w = int(w * self._fraction)
- if fill_w > 0:
- p.fillRect(0, 0, fill_w, h, QColor("#e8a020"))
- # Fade-slut markør (grå) — vises bag demo-markøren
- if self._demo_fade_fraction > 0:
- fx = int(w * self._demo_fade_fraction)
- p.fillRect(fx - 1, 0, 2, h, QColor("#6a7080"))
- # Demo-stop markør (blå)
- if self._demo_fraction > 0:
- mx = int(w * self._demo_fraction)
- p.fillRect(mx - 1, 0, 2, h, QColor("#3b8fd4"))
- p.end()
-
- def mousePressEvent(self, event):
- if event.button() == Qt.MouseButton.LeftButton:
- fraction = event.position().x() / self.width()
- mw = self.window()
- if hasattr(mw, "_on_seek"):
- mw._on_seek(fraction)
-
-
-class MainWindow(QMainWindow):
- def __init__(self):
- super().__init__()
- self.setWindowTitle("LineDance Player")
- self.setMinimumSize(1000, 680)
- self.resize(1600, 820)
-
- self._dark_theme = True
- self._player = Player(self)
- self._current_idx = -1
- self._song_ended = False
- self._demo_active = False
- self._watcher = None
- self._scan_worker = None
- self._api_url: str | None = None
- self._api_token: str | None = None
- self._api_username: str | None = None
-
- # Indlæs indstillinger
- self._settings = load_settings()
- self._dark_theme = self._settings.get("dark_theme", True)
- self._demo_seconds = self._settings.get("demo_seconds", 10)
- self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
-
- self._connect_player_signals()
- self._build_menu()
- self._build_ui()
- self._build_statusbar()
- apply_theme(self._app_ref(), dark=self._dark_theme)
- self._theme_btn.setText("☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA")
-
- # Gendan gemt vinduestørrelse og splitter-position
- self._restore_window_state()
-
- # Start DB og scanning ved opstart
- QTimer.singleShot(200, self._init_local_db)
-
- # Auto-login hvis aktiveret i indstillinger
- if self._settings.get("auto_login") and self._settings.get("password"):
- QTimer.singleShot(800, self._auto_login)
-
- def _app_ref(self):
- from PyQt6.QtWidgets import QApplication
- return QApplication.instance()
-
- def _connect_player_signals(self):
- self._player.position_changed.connect(self._on_position)
- self._player.time_changed.connect(self._on_time)
- self._player.levels_changed.connect(self._on_levels)
- self._player.song_ended.connect(self._on_song_ended)
- self._player.state_changed.connect(self._on_state_changed)
-
- # ── Menu ──────────────────────────────────────────────────────────────────
-
- def _build_menu(self):
- menubar = self.menuBar()
-
- # ── Filer ─────────────────────────────────────────────────────────────
- file_menu = menubar.addMenu("Filer")
-
- self._act_go_online = QAction("Gå online...", self)
- self._act_go_online.setShortcut("Ctrl+L")
- self._act_go_online.triggered.connect(self._go_online)
- file_menu.addAction(self._act_go_online)
-
- self._act_go_offline = QAction("Gå offline", self)
- self._act_go_offline.triggered.connect(self._go_offline)
- self._act_go_offline.setEnabled(False)
- file_menu.addAction(self._act_go_offline)
-
- file_menu.addSeparator()
-
- act_settings = QAction("Indstillinger...", self)
- act_settings.setShortcut("Ctrl+,")
- act_settings.triggered.connect(self._open_settings)
- file_menu.addAction(act_settings)
-
- file_menu.addSeparator()
-
- act_quit = QAction("Afslut", self)
- act_quit.setShortcut("Ctrl+Q")
- act_quit.triggered.connect(self.close)
- file_menu.addAction(act_quit)
-
- # ── Ingen Danseliste- eller Visning-menu ──────────────────────────────
- # Ny/Gem/Hent ligger direkte i danseliste-panelet
- # Tema-skift ligger i topbar-knappen
- # Mapper og scan ligger i ⚙ Mapper dialogen
-
- # Gem reference til scan-action (bruges stadig internt)
- self._act_scan = QAction("Scan", self)
- self._act_scan.triggered.connect(self.start_scan)
-
- # ── Statuslinje ───────────────────────────────────────────────────────────
-
- def _build_statusbar(self):
- self._statusbar = QStatusBar()
- self.setStatusBar(self._statusbar)
- self._statusbar.showMessage("Klar")
-
- def _set_status(self, text: str, timeout_ms: int = 0):
- """Vis besked i statuslinjen. timeout_ms=0 = permanent."""
- self._statusbar.showMessage(text, timeout_ms)
-
- # ── UI byggeri ────────────────────────────────────────────────────────────
-
- def _build_ui(self):
- root = QWidget()
- root.setObjectName("root")
- self.setCentralWidget(root)
- main_layout = QVBoxLayout(root)
- main_layout.setContentsMargins(10, 6, 10, 10)
- main_layout.setSpacing(4)
-
- main_layout.addWidget(self._build_topbar())
- main_layout.addWidget(self._build_now_playing())
- main_layout.addWidget(self._build_progress())
- main_layout.addWidget(self._build_transport())
- main_layout.addWidget(self._build_panels(), stretch=1)
-
- def _build_topbar(self) -> QFrame:
- bar = QFrame()
- bar.setObjectName("topbar")
- layout = QHBoxLayout(bar)
- layout.setContentsMargins(12, 6, 12, 6)
-
- logo = QLabel("LINEDANCE PLAYER")
- logo.setObjectName("logo")
- logo.setTextFormat(Qt.TextFormat.RichText)
- layout.addWidget(logo)
- layout.addStretch()
-
- self._conn_label = QLabel("● OFFLINE")
- self._conn_label.setObjectName("conn_label")
- layout.addWidget(self._conn_label)
-
- self._theme_btn = QPushButton("☀ LYS TEMA")
- self._theme_btn.setFixedHeight(26)
- self._theme_btn.clicked.connect(self._toggle_theme)
- layout.addWidget(self._theme_btn)
-
- return bar
-
- def _build_now_playing(self) -> QFrame:
- frame = QFrame()
- frame.setObjectName("now_playing_frame")
- layout = QHBoxLayout(frame)
- layout.setContentsMargins(12, 10, 12, 10)
-
- track_frame = QFrame()
- track_frame.setObjectName("track_display")
- track_layout = QVBoxLayout(track_frame)
- track_layout.setContentsMargins(10, 8, 10, 8)
- track_layout.setSpacing(3)
-
- self._lbl_title = QLabel("—")
- self._lbl_title.setObjectName("track_title")
- track_layout.addWidget(self._lbl_title)
-
- self._lbl_meta = QLabel("—")
- self._lbl_meta.setObjectName("track_meta")
- track_layout.addWidget(self._lbl_meta)
-
- self._lbl_dances = QLabel("")
- self._lbl_dances.setObjectName("track_meta")
- self._lbl_dances.setWordWrap(True)
- track_layout.addWidget(self._lbl_dances)
-
- layout.addWidget(track_frame, stretch=1)
-
- self._vu = VUMeter()
- layout.addWidget(self._vu)
-
- return frame
-
- def _build_progress(self) -> QFrame:
- frame = QFrame()
- frame.setObjectName("progress_frame")
- layout = QHBoxLayout(frame)
- layout.setContentsMargins(12, 6, 12, 6)
- layout.setSpacing(8)
-
- self._lbl_cur = QLabel("0:00")
- self._lbl_cur.setObjectName("track_meta")
- self._lbl_cur.setFixedWidth(36)
- layout.addWidget(self._lbl_cur)
-
- self._progress = ProgressBar(self)
- self._progress.setSizePolicy(
- QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
- )
- layout.addWidget(self._progress, stretch=1)
-
- self._lbl_tot = QLabel("0:00")
- self._lbl_tot.setObjectName("track_meta")
- self._lbl_tot.setFixedWidth(36)
- self._lbl_tot.setAlignment(Qt.AlignmentFlag.AlignRight)
- layout.addWidget(self._lbl_tot)
-
- return frame
-
- def _build_transport(self) -> QFrame:
- frame = QFrame()
- frame.setObjectName("transport_frame")
- layout = QHBoxLayout(frame)
- layout.setContentsMargins(14, 10, 14, 10)
- layout.setSpacing(8)
-
- def btn(text, name=None, size=52, checkable=False):
- b = QPushButton(text)
- if name:
- b.setObjectName(name)
- b.setFixedSize(size, size)
- if checkable:
- b.setCheckable(True)
- return b
-
- self._btn_prev = btn("⏮", size=52)
- self._btn_play = btn("▶", "btn_play", size=72)
- self._btn_stop = btn("⏹", "btn_stop", size=52)
- self._btn_next = btn("⏭", size=52)
- self._btn_demo = btn(f"▶\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True)
-
- self._btn_prev.clicked.connect(self._prev_song)
- self._btn_play.clicked.connect(self._toggle_play)
- self._btn_stop.clicked.connect(self._stop)
- self._btn_next.clicked.connect(self._next_song)
- self._btn_demo.clicked.connect(self._toggle_demo)
-
- layout.addWidget(self._btn_prev)
- layout.addWidget(self._btn_play)
- layout.addWidget(self._btn_stop)
- layout.addWidget(self._btn_next)
-
- sep1 = QFrame()
- sep1.setFrameShape(QFrame.Shape.VLine)
- sep1.setFixedWidth(1)
- layout.addWidget(sep1)
-
- layout.addWidget(self._btn_demo)
- layout.addStretch()
-
- lbl_vol = QLabel("VOL")
- lbl_vol.setObjectName("vol_label")
- layout.addWidget(lbl_vol)
-
- self._vol_slider = QSlider(Qt.Orientation.Horizontal)
- self._vol_slider.setRange(0, 100)
- self._vol_slider.setValue(self._settings.get("volume", 78))
- self._vol_slider.setFixedWidth(100)
- self._vol_slider.valueChanged.connect(self._on_volume)
- layout.addWidget(self._vol_slider)
-
- self._lbl_vol = QLabel(str(self._settings.get("volume", 78)))
- self._lbl_vol.setObjectName("vol_val")
- layout.addWidget(self._lbl_vol)
-
- return frame
-
- def _build_panels(self) -> QSplitter:
- self._splitter = QSplitter(Qt.Orientation.Horizontal)
-
- self._playlist_panel = PlaylistPanel()
- self._playlist_panel.song_selected.connect(self._load_song_by_idx)
- self._playlist_panel.song_dropped.connect(self._on_song_dropped)
- self._playlist_panel.event_started.connect(self._on_event_started)
- self._playlist_panel.next_song_ready.connect(self._load_song)
-
- self._library_panel = LibraryPanel()
- self._library_panel.song_selected.connect(self._on_library_song_selected)
- self._library_panel.add_to_playlist.connect(self._add_song_to_playlist)
- self._library_panel.scan_requested.connect(self.start_scan)
- self._library_panel.edit_tags_requested.connect(self._open_tag_editor)
- self._library_panel.send_mail_requested.connect(self._send_mail)
-
- self._splitter.addWidget(self._playlist_panel)
- self._splitter.addWidget(self._library_panel)
- self._splitter.setSizes([700, 900])
-
- return self._splitter
-
- def _restore_window_state(self):
- from PyQt6.QtCore import QSettings, QByteArray
- settings = QSettings("LineDance", "Player")
- geom = settings.value("window/geometry")
- if geom:
- self.restoreGeometry(geom)
- splitter_state = settings.value("window/splitter")
- if splitter_state and hasattr(self, "_splitter"):
- self._splitter.restoreState(splitter_state)
-
- def _save_window_state(self):
- from PyQt6.QtCore import QSettings
- settings = QSettings("LineDance", "Player")
- settings.setValue("window/geometry", self.saveGeometry())
- if hasattr(self, "_splitter"):
- settings.setValue("window/splitter", self._splitter.saveState())
-
- # ── Lokal DB + scanning ───────────────────────────────────────────────────
-
- def _init_local_db(self):
- try:
- import sys, os
- sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
- from local.local_db import init_db
- from local.file_watcher import get_watcher
-
- init_db()
-
- # Brug et Qt signal til thread-safe reload fra watcher-tråden
- from PyQt6.QtCore import QMetaObject, Q_ARG
- def on_file_change(event_type, path, song_id):
- QTimer.singleShot(0, self._reload_library)
-
- self._watcher = get_watcher(on_change=on_file_change)
- self._watcher.start()
-
- # Indlæs hvad vi allerede kender fra SQLite
- self._reload_library()
-
- # Gendan sidst aktive danseliste
- restored = self._playlist_panel.restore_active_playlist()
-
- # Gendan event-fremgang hvis liste blev gendannet
- if restored:
- if self._playlist_panel.restore_event_state():
- # Indlæs den sang vi var nået til
- idx = self._playlist_panel._current_idx
- song = self._playlist_panel.get_song(idx)
- if song:
- self._current_idx = idx
- self._load_song(song)
- self._set_status(
- f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte",
- 6000,
- )
-
- # Kør automatisk scanning ved opstart
- self._set_status("Starter scanning af biblioteker...")
- QTimer.singleShot(100, self.start_scan)
-
- except Exception as e:
- self._set_status(f"DB fejl: {e}")
- pass
-
- def start_scan(self):
- """Start fuld scanning af alle biblioteker i baggrundstråd."""
- if self._scan_worker and self._scan_worker.isRunning():
- return # Scanning kører allerede
-
- if not self._watcher:
- self._set_status("Ingen biblioteker at scanne — tilføj en mappe først")
- return
-
- self._library_panel.set_scanning(True, "Forbereder scanning...")
- self._act_scan.setEnabled(False)
-
- self._scan_worker = ScanWorker(self._watcher, parent=self)
- self._scan_worker.status_update.connect(self._on_scan_status)
- self._scan_worker.scan_done.connect(self._on_scan_done)
- self._scan_worker.start()
-
- def _on_scan_status(self, text: str):
- self._set_status(text)
- self._library_panel.update_scan_status(text)
-
- def _on_scan_done(self, count: int):
- self._library_panel.set_scanning(False)
- self._act_scan.setEnabled(True)
- msg = f"Scanning færdig — {count} filer gennemgået"
- self._set_status(msg, timeout_ms=5000)
- # Genindlæs biblioteket
- QTimer.singleShot(200, self._reload_library)
-
- def _reload_library(self):
- try:
- from local.local_db import search_songs, get_db
- songs_raw = search_songs("", limit=5000)
- songs = []
- for row in songs_raw:
- with get_db() as conn:
- dances_raw = conn.execute(
- "SELECT sd.dance_name, dl.name as level_name "
- "FROM song_dances sd "
- "LEFT JOIN dance_levels dl ON dl.id = sd.level_id "
- "WHERE sd.song_id=? ORDER BY sd.dance_order",
- (row["id"],)
- ).fetchall()
- songs.append({
- "id": row["id"],
- "title": row["title"],
- "artist": row["artist"],
- "album": row["album"],
- "bpm": row["bpm"],
- "duration_sec": row["duration_sec"],
- "local_path": row["local_path"],
- "file_format": row["file_format"],
- "file_missing": bool(row["file_missing"]),
- "dances": [d["dance_name"] for d in dances_raw],
- "dance_levels": [d["level_name"] or "" for d in dances_raw],
- })
- self._library_panel.load_songs(songs)
- count = len(songs)
- self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000)
- except Exception as e:
- pass
-
- def add_library_path(self, path: str):
- try:
- if not self._watcher:
- self._set_status("Watcher ikke klar endnu — prøv igen om et øjeblik", 3000)
- return
- self._watcher.add_library(path)
- self._set_status(f"Tilføjet: {path} — scanner...")
- # Genindlæs bibliotekslisten og start scan
- QTimer.singleShot(500, self._reload_library)
- QTimer.singleShot(1000, self.start_scan)
- except Exception as e:
- self._set_status(f"Fejl ved tilføjelse: {e}")
-
- def _open_settings(self):
- dialog = SettingsDialog(parent=self)
- if dialog.exec():
- self._settings = dialog.get_values()
- self._demo_seconds = self._settings.get("demo_seconds", 10)
- self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
- # Opdater tema hvis ændret
- new_dark = self._settings.get("dark_theme", True)
- if new_dark != self._dark_theme:
- self._dark_theme = new_dark
- apply_theme(self._app_ref(), dark=self._dark_theme)
- self._theme_btn.setText(
- "☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA"
- )
- self._vu.set_dark(self._dark_theme)
- # Opdater demo-knap tekst
- self._btn_demo.setText(f"▶\n{self._demo_seconds} SEK")
- # Opdater demo-markør hvis en sang er indlæst
- if hasattr(self, "_current_song") and self._current_song:
- dur = self._current_song.get("duration_sec", 0)
- if dur > 0:
- self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0), min((self._demo_seconds + self._demo_fade_seconds) / dur, 1.0))
- self._set_status("Indstillinger gemt", 2000)
-
- def _auto_login(self):
- """Forsøg automatisk login med gemte oplysninger."""
- username = self._settings.get("username", "")
- password = self._settings.get("password", "")
- if not username or not password:
- return
- try:
- import urllib.request, urllib.parse, json
- data = urllib.parse.urlencode({"username": username, "password": password}).encode()
- req = urllib.request.Request(
- f"{API_URL}/auth/login", data=data,
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- method="POST",
- )
- with urllib.request.urlopen(req, timeout=8) as resp:
- body = json.loads(resp.read())
- self._api_token = body.get("access_token")
- self._api_url = API_URL
- self._api_username = username
- self._set_online_state(True)
- self._set_status(f"Automatisk logget ind som {username}", 4000)
- # Synkroniser dans-niveauer og navne
- QTimer.singleShot(500, self._sync_dance_data)
- except Exception:
- self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000)
-
- def _go_online(self):
- dialog = LoginDialog(self)
- if dialog.exec():
- url, username, token = dialog.get_credentials()
- self._api_url = url
- self._api_token = token
- self._api_username = username
- self._set_online_state(True)
- self._set_status(f"Online som {username}", 5000)
- QTimer.singleShot(500, self._sync_dance_data)
-
- def _sync_dance_data(self):
- """Synkroniser dans-niveauer og navne fra API."""
- if not self._api_token:
- return
- try:
- import urllib.request, json
- headers = {"Authorization": f"Bearer {self._api_token}"}
-
- # Hent niveauer
- req = urllib.request.Request(f"{API_URL}/dances/levels", headers=headers)
- with urllib.request.urlopen(req, timeout=8) as resp:
- levels = json.loads(resp.read())
- from local.local_db import sync_dance_levels_from_api
- sync_dance_levels_from_api(levels)
-
- # Hent populære dans-navne
- req = urllib.request.Request(f"{API_URL}/dances/names?limit=500", headers=headers)
- with urllib.request.urlopen(req, timeout=8) as resp:
- names = json.loads(resp.read())
- from local.local_db import sync_dance_names_from_api
- sync_dance_names_from_api(names)
-
- self._set_status(f"Synkroniseret {len(levels)} niveauer og {len(names)} dans-navne", 4000)
- except Exception as e:
- pass
-
- def _go_offline(self):
- self._api_url = self._api_token = self._api_username = None
- self._set_online_state(False)
- self._set_status("Offline — arbejder lokalt", 3000)
-
- def _set_online_state(self, online: bool):
- self._act_go_online.setEnabled(not online)
- self._act_go_offline.setEnabled(online)
- if online:
- name = self._api_username or "?"
- self._conn_label.setText(f"● ONLINE ({name})")
- self._conn_label.setStyleSheet("color: #2ecc71;")
- else:
- self._conn_label.setText("● OFFLINE")
- self._conn_label.setStyleSheet("color: #5a6070;")
-
- def _new_playlist(self):
- self._stop()
- self._playlist_panel.load_songs([])
- self._playlist_panel.set_playlist_name("Ny liste")
- self._set_status("Ny danseliste oprettet", 2000)
-
- def _open_playlist_manager(self):
- dialog = PlaylistManagerDialog(
- current_songs=self._playlist_panel.get_songs(),
- parent=self,
- )
- dialog.playlist_loaded.connect(self._on_playlist_loaded)
- dialog.exec()
-
- def _on_playlist_loaded(self, name: str, songs: list[dict]):
- self._stop()
- self._playlist_panel.load_songs(songs)
- self._playlist_panel.set_playlist_name(name)
- self._set_status(f"Indlæst: {name} ({len(songs)} sange)", 3000)
-
- def _open_tag_editor(self, song: dict):
- from ui.tag_editor import TagEditorDialog
- dialog = TagEditorDialog(song, parent=self)
- if dialog.exec():
- # Genindlæs biblioteket så ændringer vises
- QTimer.singleShot(200, self._reload_library)
-
- def _send_mail(self, song: dict):
- import subprocess, sys, shutil, urllib.parse
- from pathlib import Path
-
- path = song.get("local_path", "")
- title = song.get("title", "")
- artist = song.get("artist", "")
-
- if not path or not Path(path).exists():
- self._set_status("Filen blev ikke fundet — kan ikke sende mail", 4000)
- return
-
- # ── Auto-detekter mailklient ───────────────────────────────────────────
-
- def try_thunderbird() -> bool:
- """Thunderbird: thunderbird -compose attachment='file:///sti'"""
- candidates = []
- if sys.platform == "win32":
- import winreg
- for base in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
- try:
- key = winreg.OpenKey(base,
- r"SOFTWARE\Mozilla\Mozilla Thunderbird")
- inst, _ = winreg.QueryValueEx(key, "Install Directory")
- candidates.append(str(Path(inst) / "thunderbird.exe"))
- except Exception:
- pass
- candidates += [
- r"C:\Program Files\Mozilla Thunderbird\thunderbird.exe",
- r"C:\Program Files (x86)\Mozilla Thunderbird\thunderbird.exe",
- ]
- elif sys.platform == "darwin":
- candidates = [
- "/Applications/Thunderbird.app/Contents/MacOS/thunderbird",
- ]
- else:
- candidates = [shutil.which("thunderbird") or "",
- "/usr/bin/thunderbird",
- "/usr/local/bin/thunderbird",
- "/snap/bin/thunderbird"]
-
- tb = next((c for c in candidates if c and Path(c).exists()), None)
- if not tb:
- return False
-
- file_uri = Path(path).as_uri()
- subject = f"Linedance sang: {title} — {artist}"
- compose = (
- f"subject='{subject}',"
- f"attachment='{file_uri}'"
- )
- subprocess.Popen([tb, "-compose", compose])
- return True
-
- def try_outlook() -> bool:
- """Outlook: outlook.exe /a 'filsti' (kun Windows)"""
- if sys.platform != "win32":
- return False
- candidates = [
- shutil.which("outlook") or "",
- r"C:\Program Files\Microsoft Office\root\Office16\OUTLOOK.EXE",
- r"C:\Program Files (x86)\Microsoft Office\root\Office16\OUTLOOK.EXE",
- r"C:\Program Files\Microsoft Office\Office16\OUTLOOK.EXE",
- ]
- ol = next((c for c in candidates if c and Path(c).exists()), None)
- if not ol:
- return False
- subprocess.Popen([ol, "/a", path])
- return True
-
- def fallback_mailto():
- """Ingen vedhæftning — åbn standard-mailprogram via mailto:"""
- subject = urllib.parse.quote(f"Linedance sang: {title} — {artist}")
- body = urllib.parse.quote(
- f"Sang: {title}\nArtist: {artist}\nFil: {path}\n\n"
- f"(Vedhæft filen manuelt fra ovenstående sti)"
- )
- mailto = f"mailto:?subject={subject}&body={body}"
- if sys.platform == "win32":
- import os; os.startfile(mailto)
- elif sys.platform == "darwin":
- subprocess.Popen(["open", mailto])
- else:
- subprocess.Popen(["xdg-open", mailto])
-
- # ── Prøv i rækkefølge ─────────────────────────────────────────────────
- if try_thunderbird():
- self._set_status(f"Thunderbird åbnet med {Path(path).name} vedh.", 4000)
- elif try_outlook():
- self._set_status(f"Outlook åbnet med {Path(path).name} vedh.", 4000)
- else:
- fallback_mailto()
- self._set_status(
- f"Ingen kendt mailklient fundet — åbnet mailto: (uden vedhæftning)", 5000
- )
-
- def _on_event_started(self):
- """Start event — indlæs første sang i afspilleren klar til afspilning."""
- first = self._playlist_panel.get_song(0)
- if not first:
- return
- self._stop()
- self._current_idx = 0
- self._song_ended = False
- self._load_song(first)
- self._set_status("Event klar — tryk ▶ for at starte", 5000)
-
- def _on_song_dropped(self, song: dict):
- self._set_status(f"Tilføjet: {song.get('title','')}", 2000)
-
- def _menu_add_folder(self):
- folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
- if folder:
- self.add_library_path(folder)
-
- # ── Afspilning ────────────────────────────────────────────────────────────
-
- def _load_song(self, song: dict):
- self._current_song = song
- self._song_ended = False
- self._demo_active = False
- self._btn_demo.setChecked(False)
-
- dur = song.get("duration_sec", 0)
- self._player.load(song.get("local_path", ""), dur)
-
- self._lbl_title.setText(song.get("title", "—"))
- bpm = song.get("bpm", 0)
- fmt_dur = f"{dur//60}:{dur%60:02d}"
- self._lbl_meta.setText(f"{song.get('artist','')} · {bpm} BPM · {fmt_dur}")
-
- dances = song.get("dances", [])
- self._lbl_dances.setText(
- " · ".join(f"[{d}]" for d in dances) if dances else "ingen danse tagget"
- )
-
- if dur > 0:
- self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0), min((self._demo_seconds + self._demo_fade_seconds) / dur, 1.0))
-
- self._set_status(f"Indlæst: {song.get('title','—')}", 3000)
-
- def _load_song_by_idx(self, idx: int):
- song = self._playlist_panel.get_song(idx)
- if not song:
- return
- self._current_idx = idx
- self._load_song(song)
- self._playlist_panel.set_current(idx)
-
- def _toggle_play(self):
- if self._demo_active:
- self._player.stop()
- self._demo_active = False
- self._btn_demo.setChecked(False)
- self._btn_play.setText("▶")
- return
- if self._player.is_playing():
- self._player.pause()
- else:
- self._song_ended = False
- self._player.play()
- self._btn_play.setText("⏸")
-
- def _stop(self):
- self._player.stop()
- self._song_ended = False
- self._demo_active = False
- self._btn_demo.setChecked(False)
- self._btn_play.setText("▶")
- self._vu.reset()
-
- def _toggle_demo(self):
- if self._demo_active:
- self._player.stop()
- self._demo_active = False
- self._btn_demo.setChecked(False)
- self._btn_play.setText("▶")
- else:
- self._demo_active = True
- self._btn_demo.setChecked(True)
- self._player.play_demo(
- stop_at_sec=self._demo_seconds,
- fade_sec=self._demo_fade_seconds,
- )
- self._btn_play.setText("⏸")
-
- def _prev_song(self):
- if self._current_idx > 0:
- self._stop()
- self._load_song_by_idx(self._current_idx - 1)
-
- def _next_song(self):
- if self._current_idx < self._playlist_panel.count() - 1:
- self._stop()
- self._playlist_panel.mark_played(self._current_idx)
- self._load_song_by_idx(self._current_idx + 1)
-
- def _play_next(self):
- self._song_ended = False
- self._player.play()
- self._btn_play.setText("⏸")
-
- def _on_library_song_selected(self, song: dict):
- self._load_song(song)
- self._player.play()
- self._btn_play.setText("⏸")
-
- def _add_song_to_playlist(self, song: dict):
- songs = [self._playlist_panel.get_song(i)
- for i in range(self._playlist_panel.count())]
- songs = [s for s in songs if s]
- songs.append(song)
- self._playlist_panel.load_songs(songs)
- self._set_status(f"Tilføjet til danseliste: {song.get('title','')}", 2000)
-
- # ── Player signals ────────────────────────────────────────────────────────
-
- def _on_position(self, fraction: float):
- self._progress.set_fraction(fraction)
-
- def _on_time(self, cur: int, tot: int):
- self._lbl_cur.setText(f"{cur//60}:{cur%60:02d}")
- self._lbl_tot.setText(f"{tot//60}:{tot%60:02d}")
-
- def _on_levels(self, left: float, right: float):
- self._vu.set_levels(left, right)
-
- def _on_song_ended(self):
- self._song_ended = True
- self._demo_active = False
- self._btn_demo.setChecked(False)
- self._btn_play.setText("▶")
- self._vu.reset()
-
- # Markér den afspillede sang
- self._playlist_panel.mark_played(self._current_idx)
-
- # Synkroniser event-status til den gemte navngivne liste
- self._sync_event_status_to_playlist()
-
- # Find første ikke-afspillede og ikke-skippede sang fra TOPPEN
- ni = self._playlist_panel.next_playable_idx()
- next_song = self._playlist_panel.get_song(ni) if ni is not None else None
- if next_song:
- self._current_idx = ni
- self._playlist_panel.set_next_ready(ni)
- self._load_song(next_song)
- self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte")
- else:
- # Danseliste afsluttet — nulstil liste-markering og synkroniser
- self._current_idx = -1
- self._playlist_panel._current_idx = -1
- self._playlist_panel._song_ended = False
- self._playlist_panel._refresh()
- self._sync_event_status_to_playlist()
- self._lbl_title.setText("— Danseliste afsluttet —")
- self._lbl_meta.setText("")
- self._lbl_dances.setText("")
- self._set_status("Danselisten er afsluttet")
-
- def _sync_event_status_to_playlist(self):
- """Gem event-fremgang (afspillet/sprunget over) til den navngivne liste."""
- try:
- pl_id = self._playlist_panel.get_named_playlist_id()
- if not pl_id:
- return
- statuses = self._playlist_panel.get_statuses()
- from local.local_db import get_db
- with get_db() as conn:
- for position, status in enumerate(statuses, start=1):
- conn.execute(
- "UPDATE playlist_songs SET status=? "
- "WHERE playlist_id=? AND position=?",
- (status, pl_id, position)
- )
- except Exception as e:
- pass
-
- def _on_state_changed(self, state: str):
- if state == "playing":
- self._btn_play.setText("⏸")
- elif state in ("paused", "stopped"):
- self._btn_play.setText("▶")
- if state == "stopped" and not self._song_ended:
- self._vu.reset()
- elif state == "demo_ended":
- self._demo_active = False
- self._btn_demo.setChecked(False)
- self._btn_play.setText("▶")
- self._vu.reset()
-
- def _on_seek(self, fraction: float):
- self._player.set_position(fraction)
-
- def _on_volume(self, value: int):
- self._lbl_vol.setText(str(value))
- self._player.set_volume(value)
- from ui.settings_dialog import save_settings
- self._settings["volume"] = value
- save_settings(self._settings)
-
- # ── Tema ──────────────────────────────────────────────────────────────────
-
- def _toggle_theme(self):
- self._dark_theme = not self._dark_theme
- apply_theme(self._app_ref(), dark=self._dark_theme)
- self._theme_btn.setText(
- "● MØRKT TEMA" if not self._dark_theme else "☀ LYS TEMA"
- )
- self._vu.set_dark(self._dark_theme)
-
- # ── Luk ───────────────────────────────────────────────────────────────────
-
- def closeEvent(self, event):
- self._save_window_state()
- self._player.stop()
- if self._scan_worker and self._scan_worker.isRunning():
- self._scan_worker.quit()
- self._scan_worker.wait(2000)
- try:
- if self._watcher:
- self._watcher.stop()
- except Exception:
- pass
- event.accept()
diff --git a/linedance-app/ui/next_up_bar.py b/linedance-app/ui/next_up_bar.py
deleted file mode 100644
index 345a7465..00000000
--- a/linedance-app/ui/next_up_bar.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""
-next_up_bar.py — Banner der vises når en sang er færdig.
-"""
-
-from PyQt6.QtWidgets import (
- QFrame, QHBoxLayout, QVBoxLayout, QLabel, QPushButton,
-)
-from PyQt6.QtCore import pyqtSignal
-
-
-class NextUpBar(QFrame):
- play_next_clicked = pyqtSignal()
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setObjectName("next_up_frame")
- self.hide()
- self._build_ui()
-
- def _build_ui(self):
- layout = QHBoxLayout(self)
- layout.setContentsMargins(16, 10, 16, 10)
-
- # Tekst
- text_layout = QVBoxLayout()
- text_layout.setSpacing(2)
-
- self._label = QLabel("NÆSTE SANG KLAR")
- self._label.setObjectName("next_up_label")
- text_layout.addWidget(self._label)
-
- self._title = QLabel("—")
- self._title.setObjectName("next_up_title")
- text_layout.addWidget(self._title)
-
- self._sub = QLabel("—")
- self._sub.setObjectName("next_up_sub")
- text_layout.addWidget(self._sub)
-
- layout.addLayout(text_layout)
- layout.addStretch()
-
- # Knap
- self._btn = QPushButton("▶ AFSPIL NÆSTE")
- self._btn.setObjectName("btn_play_next")
- self._btn.setFixedHeight(44)
- self._btn.setMinimumWidth(160)
- self._btn.clicked.connect(self.play_next_clicked.emit)
- layout.addWidget(self._btn)
-
- def show_next(self, title: str, artist: str, dances: list[str]):
- dance_str = "Dans: " + ", ".join(dances) if dances else ""
- sub = f"{artist}{' · ' + dance_str if dance_str else ''}"
- self._title.setText(title)
- self._sub.setText(sub)
- self.show()
-
- def hide_bar(self):
- self.hide()
diff --git a/linedance-app/ui/playlist_manager.py b/linedance-app/ui/playlist_manager.py
deleted file mode 100644
index bfab4021..00000000
--- a/linedance-app/ui/playlist_manager.py
+++ /dev/null
@@ -1,324 +0,0 @@
-"""
-playlist_manager.py — Dialog til danseliste-administration.
-Ny liste, gem, load og importer M3U/M3U8/tekst.
-"""
-
-import os
-from pathlib import Path
-from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
- QPushButton, QListWidget, QListWidgetItem, QFileDialog,
- QMessageBox, QTabWidget, QWidget, QTextEdit,
-)
-from PyQt6.QtCore import Qt, pyqtSignal
-
-
-class PlaylistManagerDialog(QDialog):
- """
- Fanebaseret dialog med tre faner:
- 1. Gem aktuel liste
- 2. Indlæs gemt liste
- 3. Importer fra fil (M3U / M3U8 / tekst)
- """
- playlist_loaded = pyqtSignal(str, list) # (navn, liste af dict)
-
- def __init__(self, current_songs: list[dict], parent=None):
- super().__init__(parent)
- self.setWindowTitle("Danseliste-administration")
- self.setMinimumWidth(500)
- self.setMinimumHeight(460)
- self._current_songs = current_songs
- self._build_ui()
- self._load_saved_playlists()
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(16, 16, 16, 16)
-
- tabs = QTabWidget()
- tabs.addTab(self._build_save_tab(), "💾 Gem liste")
- tabs.addTab(self._build_load_tab(), "📂 Indlæs liste")
- tabs.addTab(self._build_import_tab(), "📥 Importer")
- layout.addWidget(tabs)
-
- btn_close = QPushButton("Luk")
- btn_close.clicked.connect(self.accept)
- row = QHBoxLayout()
- row.addStretch()
- row.addWidget(btn_close)
- layout.addLayout(row)
-
- # ── Fane 1: Gem ───────────────────────────────────────────────────────────
-
- def _build_save_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(10)
-
- layout.addWidget(QLabel(f"Aktuel liste har {len(self._current_songs)} sange."))
-
- layout.addWidget(QLabel("Navn på danselisten:"))
- self._save_name = QLineEdit()
- self._save_name.setPlaceholderText("f.eks. Sommer Event 2025")
- layout.addWidget(self._save_name)
-
- btn_save = QPushButton("💾 Gem")
- btn_save.clicked.connect(self._save_playlist)
- layout.addWidget(btn_save)
-
- self._save_status = QLabel("")
- self._save_status.setObjectName("result_count")
- layout.addWidget(self._save_status)
- layout.addStretch()
- return tab
-
- def _save_playlist(self):
- name = self._save_name.text().strip()
- if not name:
- self._save_status.setText("Angiv et navn")
- return
- if not self._current_songs:
- self._save_status.setText("Danselisten er tom")
- return
- try:
- from local.local_db import create_playlist, add_song_to_playlist, get_db
- pl_id = create_playlist(name)
- for i, song in enumerate(self._current_songs, start=1):
- add_song_to_playlist(pl_id, song["id"], position=i)
- self._save_status.setText(f"✓ Gemt som \"{name}\"")
- self._load_saved_playlists()
- except Exception as e:
- self._save_status.setText(f"Fejl: {e}")
-
- # ── Fane 2: Indlæs ────────────────────────────────────────────────────────
-
- def _build_load_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
-
- layout.addWidget(QLabel("Gemte danselister:"))
- self._pl_list = QListWidget()
- self._pl_list.itemDoubleClicked.connect(self._load_selected)
- layout.addWidget(self._pl_list)
-
- btn_row = QHBoxLayout()
- btn_load = QPushButton("📂 Indlæs valgte")
- btn_load.clicked.connect(self._load_selected_btn)
- btn_delete = QPushButton("🗑 Slet valgte")
- btn_delete.clicked.connect(self._delete_selected)
- btn_row.addWidget(btn_load)
- btn_row.addWidget(btn_delete)
- layout.addLayout(btn_row)
-
- self._load_status = QLabel("")
- self._load_status.setObjectName("result_count")
- layout.addWidget(self._load_status)
- return tab
-
- def _load_saved_playlists(self):
- if not hasattr(self, "_pl_list"):
- return
- self._pl_list.clear()
- try:
- from local.local_db import get_playlists
- for pl in get_playlists():
- item = QListWidgetItem(pl["name"])
- item.setData(Qt.ItemDataRole.UserRole, dict(pl))
- self._pl_list.addItem(item)
- except Exception:
- pass
-
- def _load_selected_btn(self):
- item = self._pl_list.currentItem()
- if item:
- self._load_selected(item)
-
- def _load_selected(self, item: QListWidgetItem):
- pl = item.data(Qt.ItemDataRole.UserRole)
- if not pl:
- return
- try:
- from local.local_db import get_playlist_with_songs, get_db
- data = get_playlist_with_songs(pl["id"])
- songs = []
- for row in data.get("songs", []):
- with get_db() as conn:
- dances = conn.execute(
- "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
- (row["id"],)
- ).fetchall()
- songs.append({
- "id": row["id"],
- "title": row.get("title", ""),
- "artist": row.get("artist", ""),
- "album": row.get("album", ""),
- "bpm": row.get("bpm", 0),
- "duration_sec": row.get("duration_sec", 0),
- "local_path": row.get("local_path", ""),
- "file_format": row.get("file_format", ""),
- "file_missing": bool(row.get("file_missing", False)),
- "dances": [d["dance_name"] for d in dances],
- })
- self.playlist_loaded.emit(pl["name"], songs)
- self._load_status.setText(f"✓ Indlæst: {pl['name']} ({len(songs)} sange)")
- except Exception as e:
- self._load_status.setText(f"Fejl: {e}")
-
- def _delete_selected(self):
- item = self._pl_list.currentItem()
- if not item:
- return
- pl = item.data(Qt.ItemDataRole.UserRole)
- reply = QMessageBox.question(
- self, "Slet liste",
- f"Slet danselisten \"{pl['name']}\"?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- )
- if reply == QMessageBox.StandardButton.Yes:
- try:
- from local.local_db import get_db
- with get_db() as conn:
- conn.execute("DELETE FROM playlists WHERE id=?", (pl["id"],))
- self._load_saved_playlists()
- except Exception as e:
- self._load_status.setText(f"Fejl: {e}")
-
- # ── Fane 3: Importer ──────────────────────────────────────────────────────
-
- def _build_import_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(8)
-
- lbl = QLabel(
- "Importer fra M3U, M3U8 eller en tekstfil med én filsti per linje.\n"
- "Sange der ikke er i biblioteket forsøges tilføjet automatisk."
- )
- lbl.setWordWrap(True)
- lbl.setObjectName("result_count")
- layout.addWidget(lbl)
-
- btn_browse = QPushButton("📂 Vælg fil...")
- btn_browse.clicked.connect(self._browse_import)
- layout.addWidget(btn_browse)
-
- layout.addWidget(QLabel("Eller indsæt filstier direkte (én per linje):"))
- self._import_text = QTextEdit()
- self._import_text.setPlaceholderText(
- "/sti/til/sang1.mp3\n/sti/til/sang2.flac\n..."
- )
- self._import_text.setMaximumHeight(120)
- layout.addWidget(self._import_text)
-
- layout.addWidget(QLabel("Navn på den importerede liste:"))
- self._import_name = QLineEdit()
- self._import_name.setPlaceholderText("Importeret liste")
- layout.addWidget(self._import_name)
-
- btn_import = QPushButton("📥 Importer")
- btn_import.clicked.connect(self._do_import)
- layout.addWidget(btn_import)
-
- self._import_status = QLabel("")
- self._import_status.setObjectName("result_count")
- self._import_status.setWordWrap(True)
- layout.addWidget(self._import_status)
- layout.addStretch()
- return tab
-
- def _browse_import(self):
- path, _ = QFileDialog.getOpenFileName(
- self, "Vælg afspilningsliste",
- filter="Afspilningslister (*.m3u *.m3u8 *.txt);;Alle filer (*)"
- )
- if path:
- self._import_name.setText(Path(path).stem)
- paths = self._parse_playlist_file(path)
- self._import_text.setPlainText("\n".join(paths))
-
- def _parse_playlist_file(self, path: str) -> list[str]:
- """Parser M3U, M3U8 og tekst — returnerer liste af filstier."""
- paths = []
- base_dir = str(Path(path).parent)
- try:
- enc = "utf-8-sig" if path.lower().endswith(".m3u8") else "latin-1"
- with open(path, encoding=enc, errors="replace") as f:
- for line in f:
- line = line.strip()
- if not line or line.startswith("#"):
- continue
- # Gør relativ sti absolut
- if not os.path.isabs(line):
- line = os.path.join(base_dir, line)
- paths.append(line)
- except Exception as e:
- self._import_status.setText(f"Læsefejl: {e}")
- return paths
-
- def _do_import(self):
- raw = self._import_text.toPlainText().strip()
- if not raw:
- self._import_status.setText("Ingen filstier angivet")
- return
-
- name = self._import_name.text().strip() or "Importeret liste"
- paths = [line.strip() for line in raw.splitlines() if line.strip()]
-
- found = []
- missing = []
-
- try:
- from local.local_db import get_song_by_path, upsert_song, get_db
- from local.tag_reader import read_tags, is_supported
-
- for p in paths:
- row = get_song_by_path(p)
- if row:
- # Hent danse
- with get_db() as conn:
- dances = conn.execute(
- "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
- (row["id"],)
- ).fetchall()
- found.append({
- "id": row["id"],
- "title": row["title"],
- "artist": row["artist"],
- "album": row["album"],
- "bpm": row["bpm"],
- "duration_sec": row["duration_sec"],
- "local_path": row["local_path"],
- "file_format": row["file_format"],
- "file_missing": bool(row["file_missing"]),
- "dances": [d["dance_name"] for d in dances],
- })
- elif os.path.exists(p) and is_supported(p):
- # Filen er ikke scannet endnu — høst tags og tilføj
- tags = read_tags(p)
- song_id = upsert_song(tags)
- found.append({
- "id": song_id,
- "title": tags.get("title", Path(p).stem),
- "artist": tags.get("artist", ""),
- "album": tags.get("album", ""),
- "bpm": tags.get("bpm", 0),
- "duration_sec": tags.get("duration_sec", 0),
- "local_path": p,
- "file_format": tags.get("file_format", ""),
- "file_missing": False,
- "dances": tags.get("dances", []),
- })
- else:
- missing.append(p)
-
- if found:
- self.playlist_loaded.emit(name, found)
- status = f"✓ Importeret {len(found)} sange som \"{name}\""
- if missing:
- status += f"\n⚠ {len(missing)} filer ikke fundet"
- self._import_status.setText(status)
- else:
- self._import_status.setText("Ingen filer fundet — tjek stierne")
-
- except Exception as e:
- self._import_status.setText(f"Importfejl: {e}")
diff --git a/linedance-app/ui/playlist_panel.py b/linedance-app/ui/playlist_panel.py
deleted file mode 100644
index ba1808d7..00000000
--- a/linedance-app/ui/playlist_panel.py
+++ /dev/null
@@ -1,538 +0,0 @@
-"""
-playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik.
-"""
-
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
- QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
- QMessageBox, QInputDialog,
-)
-from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray
-from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent
-
-
-ACTIVE_PLAYLIST_NAME = "__aktiv__" # fast navn til autogem-listen
-
-
-class PlaylistPanel(QWidget):
- song_selected = pyqtSignal(int)
- status_changed = pyqtSignal(int, str)
- song_dropped = pyqtSignal(dict)
- playlist_changed = pyqtSignal()
- event_started = pyqtSignal()
- next_song_ready = pyqtSignal(dict) # udsendes når næste sang ændres — main_window indlæser den # udsendes af Start event — main_window indlæser første sang # udsendes ved enhver ændring → trigger autogem
-
- STATUS_ICON = {"pending": " ", "playing": " ▶ ", "played": " ✓ ", "skipped": " — ", "next": " ▷ "}
- STATUS_COLOR = {"pending": "#5a6070", "playing": "#e8a020", "played": "#2ecc71", "skipped": "#e74c3c", "next": "#3b8fd4"}
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._songs: list[dict] = []
- self._statuses: list[str] = []
- self._current_idx = -1
- self._song_ended = False
- self._active_playlist_id: int | None = None
- self._named_playlist_id: int | None = None # den indlæste/gemte navngivne liste
- self._build_ui()
- self.setAcceptDrops(True)
- # Autogem-timer — venter 800ms efter sidst ændring
- self._autosave_timer = QTimer(self)
- self._autosave_timer.setSingleShot(True)
- self._autosave_timer.setInterval(800)
- self._autosave_timer.timeout.connect(self._autosave)
- # Event-state gem — hurtig, kritisk for genopstart efter strømsvigt
- self._event_state_timer = QTimer(self)
- self._event_state_timer.setSingleShot(True)
- self._event_state_timer.setInterval(300)
- self._event_state_timer.timeout.connect(self._save_event_state)
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(0)
-
- # ── Header med titel ──────────────────────────────────────────────────
- header = QHBoxLayout()
- header.setContentsMargins(10, 6, 10, 6)
- self._title_label = QLabel("DANSELISTE")
- self._title_label.setObjectName("section_title")
- header.addWidget(self._title_label)
- layout.addLayout(header)
-
- # ── Ny / Gem / Hent knapper ───────────────────────────────────────────
- toolbar = QHBoxLayout()
- toolbar.setContentsMargins(8, 2, 8, 4)
- toolbar.setSpacing(4)
-
- btn_new = QPushButton("✚ Ny")
- btn_new.setFixedHeight(26)
- btn_new.setToolTip("Opret en ny tom danseliste")
- btn_new.clicked.connect(self._new_playlist)
- toolbar.addWidget(btn_new)
-
- btn_save = QPushButton("💾 Gem som...")
- btn_save.setFixedHeight(26)
- btn_save.setToolTip("Gem aktuel liste med et navn")
- btn_save.clicked.connect(self._save_as)
- toolbar.addWidget(btn_save)
-
- btn_load = QPushButton("📂 Hent...")
- btn_load.setFixedHeight(26)
- btn_load.setToolTip("Hent en tidligere gemt danseliste")
- btn_load.clicked.connect(self._load_dialog)
- toolbar.addWidget(btn_load)
-
- toolbar.addStretch()
-
- self._lbl_autosave = QLabel("")
- self._lbl_autosave.setObjectName("result_count")
- toolbar.addWidget(self._lbl_autosave)
-
- layout.addLayout(toolbar)
-
- # ── Event-kontrol ─────────────────────────────────────────────────────
- ctrl = QHBoxLayout()
- ctrl.setContentsMargins(8, 2, 8, 4)
- ctrl.setSpacing(6)
-
- self._btn_start = QPushButton("▶ START EVENT")
- self._btn_start.setFixedHeight(28)
- self._btn_start.setToolTip("Nulstil alle statusser og gør klar til event")
- self._btn_start.clicked.connect(self._start_event)
- ctrl.addWidget(self._btn_start)
- ctrl.addStretch()
-
- self._lbl_progress = QLabel("0 / 0")
- self._lbl_progress.setObjectName("result_count")
- ctrl.addWidget(self._lbl_progress)
-
- layout.addLayout(ctrl)
-
- # ── Liste ─────────────────────────────────────────────────────────────
- self._list = QListWidget()
- self._list.setObjectName("playlist_list")
- self._list.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
- self._list.setDefaultDropAction(Qt.DropAction.MoveAction)
- self._list.setAcceptDrops(True)
- self._list.itemDoubleClicked.connect(self._on_double_click)
- self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- self._list.customContextMenuRequested.connect(self._show_context_menu)
- self._list.model().rowsMoved.connect(self._on_rows_moved)
- layout.addWidget(self._list)
-
- # ── Drag & drop ───────────────────────────────────────────────────────────
-
- def dragEnterEvent(self, event: QDragEnterEvent):
- if event.mimeData().hasFormat("application/x-linedance-song"):
- event.acceptProposedAction()
- else:
- event.ignore()
-
- def dropEvent(self, event: QDropEvent):
- mime = event.mimeData()
- if mime.hasFormat("application/x-linedance-song"):
- import json
- song = json.loads(mime.data("application/x-linedance-song").data().decode())
- self._append_song(song)
- self.song_dropped.emit(song)
- event.acceptProposedAction()
-
- def _append_song(self, song: dict):
- self._songs.append(song)
- self._statuses.append("pending")
- self._refresh()
- self._trigger_autosave()
-
- # ── Data API ──────────────────────────────────────────────────────────────
-
- def load_songs(self, songs: list[dict], reset_statuses: bool = True, name: str = ""):
- self._songs = list(songs)
- if reset_statuses:
- self._statuses = ["pending"] * len(songs)
- self._current_idx = -1
- self._song_ended = False
- if name:
- self._title_label.setText(f"DANSELISTE — {name.upper()}")
- self._refresh()
- self._trigger_autosave()
-
- def set_current(self, idx: int, song_ended: bool = False):
- self._current_idx = idx
- self._song_ended = song_ended
- if 0 <= idx < len(self._statuses) and not song_ended:
- self._statuses[idx] = "playing"
- self._refresh()
- self._scroll_to(idx)
-
- def mark_played(self, idx: int):
- if 0 <= idx < len(self._statuses):
- self._statuses[idx] = "played"
- self._refresh()
- self._trigger_autosave()
- self._trigger_event_state_save()
-
- def set_next_ready(self, idx: int):
- """Sæt næste sang klar — uden at overskrive skipped/played statusser."""
- self._current_idx = idx
- self._song_ended = False
- # Ændr KUN status hvis den er pending — rør ikke skipped/played
- if 0 <= idx < len(self._statuses):
- if self._statuses[idx] not in ("skipped", "played"):
- self._statuses[idx] = "pending"
- self._refresh()
- self._scroll_to(idx)
-
- def get_song(self, idx: int) -> dict | None:
- return self._songs[idx] if 0 <= idx < len(self._songs) else None
-
- def get_songs(self) -> list[dict]:
- return list(self._songs)
-
- def get_statuses(self) -> list[str]:
- return list(self._statuses)
-
- def count(self) -> int:
- return len(self._songs)
-
- def set_playlist_name(self, name: str):
- self._title_label.setText(f"DANSELISTE — {name.upper()}")
-
- # ── Drag-flytning ─────────────────────────────────────────────────────────
-
- def _on_rows_moved(self, parent, start, end, dest, dest_row):
- """Opdater _songs og _statuses når en sang flyttes via drag."""
- new_songs = []
- new_statuses = []
- for i in range(self._list.count()):
- old_idx = self._list.item(i).data(Qt.ItemDataRole.UserRole)
- if old_idx is not None and 0 <= old_idx < len(self._songs):
- new_songs.append(self._songs[old_idx])
- new_statuses.append(self._statuses[old_idx])
- self._songs = new_songs
- self._statuses = new_statuses
- self._current_idx = -1
- self._song_ended = False
- self._refresh()
- self._trigger_autosave()
-
- # Find første afspilbare sang og udsend signal så afspilleren opdateres
- ni = self.next_playable_idx()
- if ni is not None:
- self._current_idx = ni
- self._refresh()
- self.next_song_ready.emit(self._songs[ni])
-
- # ── Event-state ───────────────────────────────────────────────────────────
-
- def _save_event_state(self):
- """Gem current_idx og statuses — overlever strømsvigt."""
- try:
- from local.local_db import save_event_state
- save_event_state(self._current_idx, self._statuses)
- except Exception as e:
- pass
-
- def _trigger_event_state_save(self):
- self._event_state_timer.start()
-
- def restore_event_state(self) -> bool:
- """Gendan gemt event-fremgang. Returnerer True hvis gendannet."""
- try:
- from local.local_db import load_event_state
- result = load_event_state()
- if not result:
- return False
- idx, statuses = result
- if len(statuses) != len(self._songs):
- return False # listen er ændret siden sidst
- self._statuses = statuses
- self._current_idx = idx
- self._song_ended = False
- self._refresh()
- return True
- except Exception as e:
- pass
- return False
-
- def get_named_playlist_id(self) -> int | None:
- return self._named_playlist_id
-
- def next_playable_idx(self) -> int | None:
- """Find første sang fra toppen der ikke er 'skipped' eller 'played'."""
- for i in range(len(self._songs)):
- if self._statuses[i] not in ("skipped", "played"):
- return i
- return None
-
- # ── Autogem ───────────────────────────────────────────────────────────────
-
- def _trigger_autosave(self):
- """Start/nulstil debounce-timer — gemmer 800ms efter sidst ændring."""
- self._autosave_timer.start()
- self._lbl_autosave.setText("● ikke gemt")
-
- def _autosave(self):
- """Gem til den faste 'Aktiv liste' i SQLite."""
- try:
- from local.local_db import get_db, create_playlist, add_song_to_playlist
- with get_db() as conn:
- # Slet den gamle aktive liste
- conn.execute(
- "DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
- )
- # Opret ny
- pl_id = create_playlist(ACTIVE_PLAYLIST_NAME)
- self._active_playlist_id = pl_id
- for i, song in enumerate(self._songs, start=1):
- if song.get("id"):
- add_song_to_playlist(pl_id, song["id"], position=i)
- self._lbl_autosave.setText("✓ gemt")
- self.playlist_changed.emit()
- except Exception as e:
- self._lbl_autosave.setText(f"⚠ gemfejl")
- pass
-
- def restore_active_playlist(self):
- """Indlæs den sidst aktive liste ved opstart."""
- try:
- from local.local_db import get_db
- with get_db() as conn:
- pl = conn.execute(
- "SELECT id FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
- ).fetchone()
- if not pl:
- return False
- songs_raw = conn.execute("""
- SELECT s.*, ps.position FROM playlist_songs ps
- JOIN songs s ON s.id = ps.song_id
- WHERE ps.playlist_id=? ORDER BY ps.position
- """, (pl["id"],)).fetchall()
- songs = []
- for row in songs_raw:
- dances = conn.execute(
- "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
- (row["id"],)
- ).fetchall()
- songs.append({
- "id": row["id"], "title": row["title"],
- "artist": row["artist"], "album": row["album"],
- "bpm": row["bpm"], "duration_sec": row["duration_sec"],
- "local_path": row["local_path"], "file_format": row["file_format"],
- "file_missing": bool(row["file_missing"]),
- "dances": [d["dance_name"] for d in dances],
- })
- if songs:
- self._songs = songs
- self._statuses = ["pending"] * len(songs)
- self._refresh()
- self._lbl_autosave.setText("✓ gendannet")
- return True
- except Exception as e:
- pass
- return False
-
- # ── Ny / Gem som / Hent ───────────────────────────────────────────────────
-
- def _new_playlist(self):
- if self._songs:
- reply = QMessageBox.question(
- self, "Ny danseliste",
- "Ryd den aktuelle liste og start forfra?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- )
- if reply != QMessageBox.StandardButton.Yes:
- return
- self._songs = []
- self._statuses = []
- self._current_idx = -1
- self._song_ended = False
- self._title_label.setText("DANSELISTE — NY")
- self._refresh()
- self._trigger_autosave()
-
- def _save_as(self):
- if not self._songs:
- QMessageBox.information(self, "Gem", "Danselisten er tom.")
- return
- name, ok = QInputDialog.getText(
- self, "Gem danseliste", "Navn på danselisten:",
- )
- if not ok or not name.strip():
- return
- name = name.strip()
- try:
- from local.local_db import create_playlist, add_song_to_playlist
- pl_id = create_playlist(name)
- for i, song in enumerate(self._songs, start=1):
- if song.get("id"):
- add_song_to_playlist(pl_id, song["id"], position=i)
- self._named_playlist_id = pl_id
- self._title_label.setText(f"DANSELISTE — {name.upper()}")
- self._lbl_autosave.setText(f"✓ gemt som \"{name}\"")
- except Exception as e:
- QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
-
- def _load_dialog(self):
- """Vis liste af gemte danselister og lad brugeren vælge."""
- try:
- from local.local_db import get_db
- with get_db() as conn:
- lists = conn.execute(
- "SELECT id, name, created_at FROM playlists "
- "WHERE name != ? ORDER BY created_at DESC",
- (ACTIVE_PLAYLIST_NAME,)
- ).fetchall()
- except Exception as e:
- QMessageBox.warning(self, "Fejl", f"Kunne ikke hente lister: {e}")
- return
-
- if not lists:
- QMessageBox.information(self, "Hent liste", "Ingen gemte danselister fundet.")
- return
-
- names = [f"{row['name']} ({row['created_at'][:10]})" for row in lists]
- choice, ok = QInputDialog.getItem(
- self, "Hent danseliste", "Vælg en liste:", names, editable=False
- )
- if not ok:
- return
-
- idx = names.index(choice)
- pl_id = lists[idx]["id"]
- pl_name = lists[idx]["name"]
-
- try:
- from local.local_db import get_db
- with get_db() as conn:
- songs_raw = conn.execute("""
- SELECT s.*, ps.position, ps.status FROM playlist_songs ps
- JOIN songs s ON s.id = ps.song_id
- WHERE ps.playlist_id=? ORDER BY ps.position
- """, (pl_id,)).fetchall()
- songs = []
- statuses = []
- for row in songs_raw:
- dances = conn.execute(
- "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
- (row["id"],)
- ).fetchall()
- songs.append({
- "id": row["id"], "title": row["title"],
- "artist": row["artist"], "album": row["album"],
- "bpm": row["bpm"], "duration_sec": row["duration_sec"],
- "local_path": row["local_path"], "file_format": row["file_format"],
- "file_missing": bool(row["file_missing"]),
- "dances": [d["dance_name"] for d in dances],
- })
- statuses.append(row["status"] or "pending")
- self._songs = songs
- self._statuses = statuses
- self._current_idx = -1
- self._song_ended = False
- self._named_playlist_id = pl_id
- self._title_label.setText(f"DANSELISTE — {pl_name.upper()}")
- self._lbl_autosave.setText("✓ gendannet")
- self._refresh()
- self._trigger_autosave()
- except Exception as e:
- QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}")
-
- # ── Start event ───────────────────────────────────────────────────────────
-
- def _start_event(self):
- if not self._songs:
- return
- reply = QMessageBox.question(
- self, "Start event",
- "Dette nulstiller alle statusser i danselisten.\nFortsæt?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- )
- if reply == QMessageBox.StandardButton.Yes:
- self._statuses = ["pending"] * len(self._songs)
- self._current_idx = -1
- self._song_ended = True
- try:
- from local.local_db import clear_event_state
- clear_event_state()
- except Exception:
- pass
- self._refresh()
- self._scroll_to(0)
- self.event_started.emit()
-
- # ── Højreklik ─────────────────────────────────────────────────────────────
-
- def _show_context_menu(self, pos):
- item = self._list.itemAt(pos)
- if not item:
- return
- idx = item.data(Qt.ItemDataRole.UserRole)
- if idx is None:
- return
- menu = QMenu(self)
- act_play = menu.addAction("▶ Afspil denne")
- menu.addSeparator()
- act_skip = menu.addAction("— Spring over")
- act_unplay = menu.addAction("↺ Sæt til ikke afspillet")
- act_played = menu.addAction("✓ Sæt til afspillet")
- menu.addSeparator()
- act_remove = menu.addAction("✕ Fjern fra liste")
- action = menu.exec(self._list.mapToGlobal(pos))
- if action == act_play:
- self.song_selected.emit(idx)
- elif action == act_skip:
- self._statuses[idx] = "skipped"
- self.status_changed.emit(idx, "skipped")
- self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
- elif action == act_unplay:
- self._statuses[idx] = "pending"
- self.status_changed.emit(idx, "pending")
- self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
- elif action == act_played:
- self._statuses[idx] = "played"
- self.status_changed.emit(idx, "played")
- self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
- elif action == act_remove:
- self._songs.pop(idx)
- self._statuses.pop(idx)
- if self._current_idx >= idx:
- self._current_idx = max(-1, self._current_idx - 1)
- self._refresh(); self._trigger_autosave()
-
- # ── Render ────────────────────────────────────────────────────────────────
-
- def _refresh(self):
- self._list.clear()
- played = sum(1 for s in self._statuses if s == "played")
- self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet")
- for i, song in enumerate(self._songs):
- is_current = (i == self._current_idx and not self._song_ended)
- is_next = (self._song_ended and i == self._current_idx + 1) or \
- (self._current_idx == -1 and self._song_ended and i == 0)
- status = "playing" if is_current else "next" if is_next else self._statuses[i]
- icon = self.STATUS_ICON.get(status, " ")
- dances = " / ".join(song.get("dances", [])) or "ingen dans tagget"
- text = f"{i+1:>2}. {song.get('title','—')}\n {song.get('artist','')} · {dances}"
- item = QListWidgetItem(f"{icon} {text}")
- item.setData(Qt.ItemDataRole.UserRole, i)
- color = self.STATUS_COLOR.get(status, "#5a6070")
- if status in ("playing", "next"):
- item.setForeground(QColor(color))
- f = item.font(); f.setBold(True); item.setFont(f)
- elif status == "played":
- item.setForeground(QColor("#2ecc71"))
- elif status == "skipped":
- item.setForeground(QColor("#e74c3c"))
- else:
- item.setForeground(QColor("#9aa0b0"))
- self._list.addItem(item)
-
- def _scroll_to(self, idx: int):
- if 0 <= idx < self._list.count():
- self._list.scrollToItem(
- self._list.item(idx), QListWidget.ScrollHint.PositionAtCenter)
-
- def _on_double_click(self, item: QListWidgetItem):
- idx = item.data(Qt.ItemDataRole.UserRole)
- if idx is not None:
- self.song_selected.emit(idx)
diff --git a/linedance-app/ui/scan_worker.py b/linedance-app/ui/scan_worker.py
deleted file mode 100644
index 13ae61ba..00000000
--- a/linedance-app/ui/scan_worker.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""
-scan_worker.py — Kører fuld biblioteks-scanning i en baggrundstråd
-så GUI ikke fryser.
-"""
-
-from PyQt6.QtCore import QThread, pyqtSignal
-
-
-class ScanWorker(QThread):
- """
- Kører _full_scan_all() i en baggrundstråd.
- Sender status-opdateringer undervejs.
- """
- status_update = pyqtSignal(str) # løbende statusbeskeder
- scan_done = pyqtSignal(int) # antal behandlede filer
-
- def __init__(self, watcher, parent=None):
- super().__init__(parent)
- self._watcher = watcher
- self._total = 0
-
- def run(self):
- try:
- from local.local_db import get_libraries
- from local.tag_reader import is_supported
- import os
- libraries = get_libraries(active_only=True)
-
- if not libraries:
- self.status_update.emit("Ingen biblioteker konfigureret")
- self.scan_done.emit(0)
- return
-
- total_processed = 0
- for lib in libraries:
- from pathlib import Path
- path = Path(lib["path"])
- name = path.name
-
- if not path.exists():
- self.status_update.emit(f"⚠ Mappe ikke fundet: {path}")
- continue
-
- self.status_update.emit(f"Scanner: {name}...")
-
- # Tæl filer med os.walk — håndterer permission-fejl sikkert
- count = 0
- for dirpath, _, filenames in os.walk(str(path), followlinks=False):
- for f in filenames:
- if is_supported(f):
- count += 1
-
- self.status_update.emit(f"Scanner: {name} ({count} filer)...")
-
- # Kør scanning
- self._watcher._full_scan_library(lib["id"], str(path))
- total_processed += count
-
- self.status_update.emit(f"Scan færdig — {total_processed} filer gennemgået")
- self.scan_done.emit(total_processed)
-
- except Exception as e:
- self.status_update.emit(f"Scan fejl: {e}")
- self.scan_done.emit(0)
diff --git a/linedance-app/ui/settings_dialog.py b/linedance-app/ui/settings_dialog.py
deleted file mode 100644
index c273519c..00000000
--- a/linedance-app/ui/settings_dialog.py
+++ /dev/null
@@ -1,281 +0,0 @@
-"""
-settings_dialog.py — Indstillinger for LineDance Player.
-Gemmes via QSettings og læses ved opstart.
-"""
-
-from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
- QPushButton, QComboBox, QSpinBox, QCheckBox, QFrame,
- QTabWidget, QWidget, QFileDialog, QGroupBox, QFormLayout,
-)
-from PyQt6.QtCore import Qt, QSettings
-
-
-SETTINGS_KEY_THEME = "appearance/dark_theme"
-SETTINGS_KEY_DEMO_SEC = "playback/demo_seconds"
-SETTINGS_KEY_DEMO_FADE = "playback/demo_fade_seconds"
-SETTINGS_KEY_VOLUME = "playback/volume"
-SETTINGS_KEY_MAIL_CLIENT = "mail/client"
-SETTINGS_KEY_MAIL_PATH = "mail/custom_path"
-SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
-SETTINGS_KEY_USERNAME = "online/username"
-SETTINGS_KEY_PASSWORD = "online/password"
-
-
-def load_settings() -> dict:
- s = QSettings("LineDance", "Player")
- return {
- "dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool),
- "demo_seconds": s.value(SETTINGS_KEY_DEMO_SEC, 10, type=int),
- "demo_fade_seconds": s.value(SETTINGS_KEY_DEMO_FADE, 5, type=int),
- "volume": s.value(SETTINGS_KEY_VOLUME, 78, type=int),
- "mail_client": s.value(SETTINGS_KEY_MAIL_CLIENT, "auto"),
- "mail_path": s.value(SETTINGS_KEY_MAIL_PATH, ""),
- "auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool),
- "username": s.value(SETTINGS_KEY_USERNAME, ""),
- "password": s.value(SETTINGS_KEY_PASSWORD, ""),
- }
-
-
-def save_settings(values: dict):
- s = QSettings("LineDance", "Player")
- s.setValue(SETTINGS_KEY_THEME, values.get("dark_theme", True))
- s.setValue(SETTINGS_KEY_DEMO_SEC, values.get("demo_seconds", 10))
- s.setValue(SETTINGS_KEY_DEMO_FADE, values.get("demo_fade_seconds", 5))
- s.setValue(SETTINGS_KEY_VOLUME, values.get("volume", 78))
- s.setValue(SETTINGS_KEY_MAIL_CLIENT, values.get("mail_client", "auto"))
- s.setValue(SETTINGS_KEY_MAIL_PATH, values.get("mail_path", ""))
- s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False))
- s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
- s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", ""))
-
-
-class SettingsDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Indstillinger")
- self.setMinimumWidth(480)
- self.setModal(True)
- self._values = load_settings()
- self._build_ui()
- self._populate()
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(16, 16, 16, 16)
- layout.setSpacing(12)
-
- tabs = QTabWidget()
- tabs.addTab(self._build_appearance_tab(), "🎨 Udseende")
- tabs.addTab(self._build_playback_tab(), "▶ Afspilning")
- tabs.addTab(self._build_mail_tab(), "✉ Mail")
- tabs.addTab(self._build_online_tab(), "🌐 Online")
- layout.addWidget(tabs)
-
- # Knapper
- btn_row = QHBoxLayout()
- btn_row.addStretch()
- btn_cancel = QPushButton("Annuller")
- btn_cancel.clicked.connect(self.reject)
- btn_row.addWidget(btn_cancel)
- btn_save = QPushButton("💾 Gem indstillinger")
- btn_save.setObjectName("btn_play")
- btn_save.setDefault(True)
- btn_save.clicked.connect(self._save_and_close)
- btn_row.addWidget(btn_save)
- layout.addLayout(btn_row)
-
- # ── Fane: Udseende ────────────────────────────────────────────────────────
-
- def _build_appearance_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(12)
-
- grp = QGroupBox("Standard tema")
- grp_layout = QVBoxLayout(grp)
-
- self._chk_dark = QCheckBox("Start med mørkt tema")
- grp_layout.addWidget(self._chk_dark)
-
- note = QLabel("Du kan altid skifte tema mens programmet kører via topbar-knappen.")
- note.setObjectName("result_count")
- note.setWordWrap(True)
- grp_layout.addWidget(note)
- layout.addWidget(grp)
- layout.addStretch()
- return tab
-
- # ── Fane: Afspilning ──────────────────────────────────────────────────────
-
- def _build_playback_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(12)
-
- grp = QGroupBox("Forspil (▶ N SEK knappen)")
- grp_layout = QFormLayout(grp)
-
- self._spin_demo = QSpinBox()
- self._spin_demo.setRange(3, 60)
- self._spin_demo.setSuffix(" sekunder")
- self._spin_demo.setFixedWidth(140)
- grp_layout.addRow("Forspil-længde:", self._spin_demo)
-
- self._spin_fade = QSpinBox()
- self._spin_fade.setRange(0, 15)
- self._spin_fade.setSuffix(" sekunder (0 = ingen fade)")
- self._spin_fade.setFixedWidth(220)
- self._spin_fade.setToolTip(
- "Fade-out tilføjes til forspillets længde.\n"
- "F.eks. 10 sek forspil + 5 sek fade = 15 sek total.\n"
- "Sæt til 0 for ingen fade."
- )
- grp_layout.addRow("Fade-ud:", self._spin_fade)
-
- note = QLabel(
- "Forspillet afspiller begyndelsen af sangen så arrangøren kan bekræfte\n"
- "at det er den rigtige sang og dans inden eventet starter.\n"
- "Fade-ud tilføjes oven i forspillets længde og fades logaritmisk."
- )
- note.setObjectName("result_count")
- note.setWordWrap(True)
- grp_layout.addRow(note)
- layout.addWidget(grp)
- layout.addStretch()
- return tab
-
- # ── Fane: Mail ────────────────────────────────────────────────────────────
-
- def _build_mail_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(12)
-
- grp = QGroupBox("Mailklient")
- grp_layout = QFormLayout(grp)
-
- self._mail_combo = QComboBox()
- self._mail_combo.addItem("Auto-detekter (Thunderbird → Outlook → mailto:)", "auto")
- self._mail_combo.addItem("Thunderbird", "thunderbird")
- self._mail_combo.addItem("Outlook (Windows)", "outlook")
- self._mail_combo.addItem("Brugerdefineret sti", "custom")
- self._mail_combo.addItem("Kun mailto: (ingen vedhæftning)", "mailto")
- self._mail_combo.currentIndexChanged.connect(self._on_mail_combo_changed)
- grp_layout.addRow("Klient:", self._mail_combo)
-
- path_row = QHBoxLayout()
- self._mail_path = QLineEdit()
- self._mail_path.setPlaceholderText("/usr/bin/thunderbird eller C:\\...\\thunderbird.exe")
- path_row.addWidget(self._mail_path)
- btn_browse = QPushButton("...")
- btn_browse.setFixedWidth(32)
- btn_browse.clicked.connect(self._browse_mail_path)
- path_row.addWidget(btn_browse)
- self._mail_path_row_widget = QWidget()
- self._mail_path_row_widget.setLayout(path_row)
- grp_layout.addRow("Sti:", self._mail_path_row_widget)
-
- note = QLabel(
- "Med Thunderbird og Outlook åbnes et nyt compose-vindue med filen vedhæftet.\n"
- "mailto: åbner standard-mailprogrammet men uden automatisk vedhæftning."
- )
- note.setObjectName("result_count")
- note.setWordWrap(True)
- grp_layout.addRow(note)
- layout.addWidget(grp)
- layout.addStretch()
- return tab
-
- def _on_mail_combo_changed(self, idx: int):
- is_custom = self._mail_combo.currentData() == "custom"
- self._mail_path_row_widget.setVisible(is_custom)
-
- def _browse_mail_path(self):
- path, _ = QFileDialog.getOpenFileName(self, "Vælg mailklient")
- if path:
- self._mail_path.setText(path)
-
- # ── Fane: Online ──────────────────────────────────────────────────────────
-
- def _build_online_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(12)
-
- grp = QGroupBox("Automatisk login ved opstart")
- grp_layout = QFormLayout(grp)
-
- self._chk_auto_login = QCheckBox("Log automatisk ind når programmet starter")
- self._chk_auto_login.stateChanged.connect(self._on_auto_login_changed)
- grp_layout.addRow(self._chk_auto_login)
-
- self._user_input = QLineEdit()
- self._user_input.setPlaceholderText("dit-brugernavn")
- grp_layout.addRow("Brugernavn:", self._user_input)
-
- self._pass_input = QLineEdit()
- self._pass_input.setEchoMode(QLineEdit.EchoMode.Password)
- self._pass_input.setPlaceholderText("••••••••")
- grp_layout.addRow("Kodeord:", self._pass_input)
-
- note = QLabel(
- "⚠ Kodeordet gemmes lokalt på denne computer.\n"
- "Brug kun dette på en personlig maskine."
- )
- note.setObjectName("result_count")
- note.setWordWrap(True)
- grp_layout.addRow(note)
- layout.addWidget(grp)
- layout.addStretch()
- return tab
-
- def _on_auto_login_changed(self, state: int):
- enabled = state == Qt.CheckState.Checked.value
- self._user_input.setEnabled(enabled)
- self._pass_input.setEnabled(enabled)
-
- # ── Populer fra gemte værdier ─────────────────────────────────────────────
-
- def _populate(self):
- v = self._values
- self._chk_dark.setChecked(v.get("dark_theme", True))
- self._spin_demo.setValue(v.get("demo_seconds", 10))
- self._spin_fade.setValue(v.get("demo_fade_seconds", 5))
-
- # Mail
- client = v.get("mail_client", "auto")
- for i in range(self._mail_combo.count()):
- if self._mail_combo.itemData(i) == client:
- self._mail_combo.setCurrentIndex(i)
- break
- self._mail_path.setText(v.get("mail_path", ""))
- self._on_mail_combo_changed(self._mail_combo.currentIndex())
-
- # Online
- auto = v.get("auto_login", False)
- self._chk_auto_login.setChecked(auto)
- self._user_input.setText(v.get("username", ""))
- self._pass_input.setText(v.get("password", ""))
- self._user_input.setEnabled(auto)
- self._pass_input.setEnabled(auto)
-
- # ── Gem ───────────────────────────────────────────────────────────────────
-
- def _save_and_close(self):
- values = {
- "dark_theme": self._chk_dark.isChecked(),
- "demo_seconds": self._spin_demo.value(),
- "demo_fade_seconds": self._spin_fade.value(),
- "mail_client": self._mail_combo.currentData(),
- "mail_path": self._mail_path.text().strip(),
- "auto_login": self._chk_auto_login.isChecked(),
- "username": self._user_input.text().strip(),
- "password": self._pass_input.text(),
- }
- save_settings(values)
- self._values = values
- self.accept()
-
- def get_values(self) -> dict:
- return self._values
diff --git a/linedance-app/ui/tag_editor.py b/linedance-app/ui/tag_editor.py
deleted file mode 100644
index 1fd49040..00000000
--- a/linedance-app/ui/tag_editor.py
+++ /dev/null
@@ -1,427 +0,0 @@
-"""
-tag_editor.py — Simpel og robust dans-tag editor.
-
-Danse gemmes til MP3-filen via mutagen.
-Niveau og alternativ-danse gemmes til SQLite.
-"""
-
-from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
- QPushButton, QComboBox, QWidget, QMessageBox, QGroupBox,
- QScrollArea, QFrame, QGridLayout,
-)
-from PyQt6.QtCore import Qt, QTimer, QStringListModel
-from PyQt6.QtWidgets import QCompleter
-
-
-# ── Autoudfyld søgefelt ───────────────────────────────────────────────────────
-
-class AutoLineEdit(QLineEdit):
- def __init__(self, placeholder="", parent=None):
- super().__init__(parent)
- self.setPlaceholderText(placeholder)
- self._model = QStringListModel()
- comp = QCompleter(self._model, self)
- comp.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
- comp.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
- comp.setMaxVisibleItems(10)
- self.setCompleter(comp)
- t = QTimer(self)
- t.setSingleShot(True)
- t.setInterval(200)
- t.timeout.connect(self._suggest)
- self.textChanged.connect(lambda _: t.start())
- self._timer = t
-
- def _suggest(self):
- prefix = self.text().strip()
- if not prefix:
- return
- try:
- from local.local_db import get_dance_name_suggestions
- self._model.setStringList(get_dance_name_suggestions(prefix))
- except Exception:
- pass
-
-
-# ── Niveau dropdown ───────────────────────────────────────────────────────────
-
-def make_level_combo(levels: list, current_id=None) -> QComboBox:
- cb = QComboBox()
- cb.addItem("— intet niveau —", None)
- for lvl in levels:
- cb.addItem(lvl["name"], lvl["id"])
- if current_id is not None:
- for i in range(cb.count()):
- if cb.itemData(i) == current_id:
- cb.setCurrentIndex(i)
- break
- cb.setFixedWidth(130)
- return cb
-
-
-# ── Hoved-dialog ─────────────────────────────────────────────────────────────
-
-class TagEditorDialog(QDialog):
- def __init__(self, song: dict, parent=None):
- super().__init__(parent)
- self._song = song
- self._levels = []
- self._dances = [] # list of {name, level_id, db_id}
- self._alts = [] # list of {name, level_id, note}
-
- self.setWindowTitle(f"Rediger tags — {song.get('title', '')}")
- self.setMinimumSize(720, 500)
- self.resize(820, 580)
-
- self._load_levels()
- self._load_existing()
- self._build_ui()
-
- # ── Indlæsning ────────────────────────────────────────────────────────────
-
- def _load_levels(self):
- try:
- from local.local_db import get_dance_levels
- self._levels = [dict(r) for r in get_dance_levels()]
- except Exception as e:
- pass # log fejl
- self._levels = []
-
- def _load_existing(self):
- """Indlæs eksisterende danse og alternativer fra DB."""
- try:
- from local.local_db import new_conn
- conn = new_conn()
- song_id = self._song.get("id")
-
- rows = conn.execute(
- "SELECT id, dance_name, level_id FROM song_dances "
- "WHERE song_id=? ORDER BY dance_order",
- (song_id,)
- ).fetchall()
- for row in rows:
-
- for row in rows:
- alts = conn.execute(
- "SELECT alt_dance_name, level_id, note FROM dance_alternatives "
- "WHERE song_dance_id=? AND source='local'",
- (row["id"],)
- ).fetchall()
- self._dances.append({
- "name": row["dance_name"],
- "level_id": row["level_id"],
- "db_id": row["id"],
- })
- for alt in alts:
- self._alts.append({
- "name": alt["alt_dance_name"],
- "level_id": alt["level_id"],
- "note": alt["note"] or "",
- })
-
- conn.close()
- except Exception as e:
- pass # log fejl
-
- # ── UI ────────────────────────────────────────────────────────────────────
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(12, 12, 12, 12)
- layout.setSpacing(8)
-
- # Sang-info
- info = QFrame()
- info.setObjectName("track_display")
- il = QHBoxLayout(info)
- il.setContentsMargins(10, 8, 10, 8)
- lbl_t = QLabel(self._song.get("title", "—"))
- lbl_t.setObjectName("track_title")
- il.addWidget(lbl_t, stretch=1)
- fmt = self._song.get("file_format", "").lower()
- can_write = fmt in ("mp3", "flac", "ogg", "opus", "m4a")
- lbl_w = QLabel("✓ Danse skrives til filen" if can_write
- else "⚠ Dette format understøtter ikke fil-skrivning")
- lbl_w.setObjectName("result_count")
- il.addWidget(lbl_w)
- layout.addWidget(info)
-
- # To kolonner
- cols = QHBoxLayout()
- cols.setSpacing(12)
- cols.addWidget(self._build_dances_panel())
- cols.addWidget(self._build_alts_panel())
- layout.addLayout(cols, stretch=1)
-
- # Knapper
- btn_row = QHBoxLayout()
- btn_row.addStretch()
- btn_cancel = QPushButton("Annuller")
- btn_cancel.clicked.connect(self.reject)
- btn_row.addWidget(btn_cancel)
- btn_save = QPushButton("💾 Gem tags")
- btn_save.setObjectName("btn_play")
- btn_save.clicked.connect(self._save)
- btn_row.addWidget(btn_save)
- layout.addLayout(btn_row)
-
- def _build_dances_panel(self) -> QGroupBox:
- grp = QGroupBox("Danse")
- layout = QVBoxLayout(grp)
-
- # Scroll-område til eksisterende danse
- scroll = QScrollArea()
- scroll.setWidgetResizable(True)
- scroll.setFrameShape(QFrame.Shape.NoFrame)
- container = QWidget()
- self._dance_layout = QVBoxLayout(container)
- self._dance_layout.setSpacing(4)
- self._dance_layout.addStretch()
- scroll.setWidget(container)
- layout.addWidget(scroll, stretch=1)
-
- # Udfyld med eksisterende
- self._dance_rows = []
- for d in self._dances:
- self._add_dance_row(d["name"], d["level_id"])
-
- # Tilføj-linje
- add_row = QHBoxLayout()
- self._new_dance = AutoLineEdit("Ny dans...", self)
- self._new_dance.returnPressed.connect(self._on_add_dance)
- add_row.addWidget(self._new_dance)
- btn = QPushButton("+ Tilføj")
- btn.setFixedWidth(70)
- btn.clicked.connect(self._on_add_dance)
- add_row.addWidget(btn)
- layout.addLayout(add_row)
-
- return grp
-
- def _add_dance_row(self, name="", level_id=None):
- row_widget = QWidget()
- row_layout = QHBoxLayout(row_widget)
- row_layout.setContentsMargins(0, 0, 0, 0)
- row_layout.setSpacing(4)
-
- name_edit = AutoLineEdit("Dans...", self)
- name_edit.setText(name)
- row_layout.addWidget(name_edit, stretch=1)
-
- level_cb = make_level_combo(self._levels, level_id)
- row_layout.addWidget(level_cb)
-
- btn_rm = QPushButton("✕")
- btn_rm.setFixedSize(24, 24)
- row_layout.addWidget(btn_rm)
-
- # Indsæt FØR stretch
- idx = self._dance_layout.count() - 1
- self._dance_layout.insertWidget(idx, row_widget)
-
- entry = {"widget": row_widget, "name": name_edit, "level": level_cb}
- self._dance_rows.append(entry)
- btn_rm.clicked.connect(lambda: self._remove_dance_row(entry))
-
- def _remove_dance_row(self, entry):
- self._dance_rows.remove(entry)
- entry["widget"].deleteLater()
-
- def _on_add_dance(self):
- name = self._new_dance.text().strip()
- if name:
- self._add_dance_row(name)
- self._new_dance.clear()
-
- def _build_alts_panel(self) -> QGroupBox:
- grp = QGroupBox("Alternativ-danse")
- layout = QVBoxLayout(grp)
-
- scroll = QScrollArea()
- scroll.setWidgetResizable(True)
- scroll.setFrameShape(QFrame.Shape.NoFrame)
- container = QWidget()
- self._alt_layout = QVBoxLayout(container)
- self._alt_layout.setSpacing(4)
- self._alt_layout.addStretch()
- scroll.setWidget(container)
- layout.addWidget(scroll, stretch=1)
-
- self._alt_rows = []
- for a in self._alts:
- self._add_alt_row(a["name"], a["level_id"], a["note"])
-
- add_row = QHBoxLayout()
- self._new_alt = AutoLineEdit("Nyt alternativ...", self)
- self._new_alt.returnPressed.connect(self._on_add_alt)
- add_row.addWidget(self._new_alt)
- btn = QPushButton("+ Tilføj")
- btn.setFixedWidth(70)
- btn.clicked.connect(self._on_add_alt)
- add_row.addWidget(btn)
- layout.addLayout(add_row)
-
- return grp
-
- def _add_alt_row(self, name="", level_id=None, note=""):
- row_widget = QWidget()
- row_layout = QHBoxLayout(row_widget)
- row_layout.setContentsMargins(0, 0, 0, 0)
- row_layout.setSpacing(4)
-
- lbl = QLabel("→")
- lbl.setObjectName("track_meta")
- row_layout.addWidget(lbl)
-
- name_edit = AutoLineEdit("Dans...", self)
- name_edit.setText(name)
- row_layout.addWidget(name_edit, stretch=1)
-
- level_cb = make_level_combo(self._levels, level_id)
- row_layout.addWidget(level_cb)
-
- note_edit = QLineEdit()
- note_edit.setPlaceholderText("note...")
- note_edit.setText(note)
- note_edit.setFixedWidth(80)
- row_layout.addWidget(note_edit)
-
- btn_rm = QPushButton("✕")
- btn_rm.setFixedSize(24, 24)
- row_layout.addWidget(btn_rm)
-
- idx = self._alt_layout.count() - 1
- self._alt_layout.insertWidget(idx, row_widget)
-
- entry = {"widget": row_widget, "name": name_edit,
- "level": level_cb, "note": note_edit}
- self._alt_rows.append(entry)
- btn_rm.clicked.connect(lambda: self._remove_alt_row(entry))
-
- def _remove_alt_row(self, entry):
- self._alt_rows.remove(entry)
- entry["widget"].deleteLater()
-
- def _on_add_alt(self):
- name = self._new_alt.text().strip()
- if name:
- self._add_alt_row(name)
- self._new_alt.clear()
-
- # ── Gem ───────────────────────────────────────────────────────────────────
-
- def _save(self):
- import uuid
- song_id = self._song.get("id")
- local_path = self._song.get("local_path", "")
-
- # Saml data fra UI
- dances = []
- for row in self._dance_rows:
- name = row["name"].text().strip()
- if name:
- dances.append((name, row["level"].currentData()))
-
- alts = []
- for row in self._alt_rows:
- name = row["name"].text().strip()
- if name:
- alts.append((name, row["level"].currentData(),
- row["note"].text().strip()))
-
- try:
- from local.local_db import new_conn
- from local.tag_reader import write_dances, can_write_dances
- import uuid
-
-
- conn = new_conn()
-
- # Slet gammelt
- old = conn.execute(
- "SELECT id FROM song_dances WHERE song_id=?", (song_id,)
- ).fetchall()
- for o in old:
- conn.execute(
- "DELETE FROM dance_alternatives WHERE song_dance_id=?",
- (o["id"],)
- )
- conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
-
- # Indsæt danse
- dance_ids = []
- for i, (name, level_id) in enumerate(dances, 1):
- conn.execute(
- "INSERT INTO song_dances "
- "(song_id, dance_name, dance_order, level_id) VALUES (?,?,?,?)",
- (song_id, name, i, level_id)
- )
- row = conn.execute(
- "SELECT id FROM song_dances "
- "WHERE song_id=? AND dance_order=?", (song_id, i)
- ).fetchone()
- dance_ids.append(row["id"])
-
- # Opdater dance_names
- existing = conn.execute(
- "SELECT id FROM dance_names WHERE name=? COLLATE NOCASE",
- (name,)
- ).fetchone()
- if existing:
- conn.execute(
- "UPDATE dance_names SET use_count=use_count+1 WHERE id=?",
- (existing["id"],)
- )
- else:
- conn.execute(
- "INSERT INTO dance_names (name, source, use_count) "
- "VALUES (?,?,1)", (name, "local")
- )
-
- # Indsæt alternativer på første dans
- if dance_ids and alts:
- fid = dance_ids[0]
- for alt_name, alt_level, alt_note in alts:
- conn.execute(
- "INSERT INTO dance_alternatives "
- "(id, song_dance_id, alt_dance_name, level_id, note, source) "
- "VALUES (?,?,?,?,?,'local')",
- (str(uuid.uuid4()), fid, alt_name, alt_level, alt_note)
- )
- existing = conn.execute(
- "SELECT id FROM dance_names WHERE name=? COLLATE NOCASE",
- (alt_name,)
- ).fetchone()
- if existing:
- conn.execute(
- "UPDATE dance_names SET use_count=use_count+1 WHERE id=?",
- (existing["id"],)
- )
- else:
- conn.execute(
- "INSERT INTO dance_names (name, source, use_count) "
- "VALUES (?,?,1)", (alt_name, "local")
- )
-
- conn.commit()
- "SELECT COUNT(*) FROM song_dances WHERE song_id=?", (song_id,)
- conn.close()
-
- # Skriv danse til filen
- if local_path:
- from local.tag_reader import write_dances, can_write_dances
- if can_write_dances(local_path):
- dance_names = [n for n, _ in dances]
- if not write_dances(local_path, dance_names):
- QMessageBox.warning(
- self, "Advarsel",
- "Gemt i database, men kunne ikke skrive til filen."
- )
-
- self.accept()
-
- except Exception as e:
- import traceback
- traceback.print_exc()
- QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
diff --git a/linedance-app/ui/themes.py b/linedance-app/ui/themes.py
deleted file mode 100644
index f5dff76a..00000000
--- a/linedance-app/ui/themes.py
+++ /dev/null
@@ -1,334 +0,0 @@
-"""
-themes.py — Lyst og mørkt tema til PyQt6.
-"""
-
-DARK = """
-QWidget {
- background-color: #1a1c1f;
- color: #e8eaf0;
- font-family: 'Barlow', 'Segoe UI', sans-serif;
- font-size: 13px;
-}
-QMainWindow, #root {
- background-color: #111214;
-}
-
-/* Knapper */
-QPushButton {
- background-color: #30343c;
- color: #9aa0b0;
- border: 1px solid #4a5060;
- border-radius: 4px;
- padding: 6px 14px;
-}
-QPushButton:hover {
- background-color: #454a56;
- color: #e8eaf0;
- border-color: #e8a020;
-}
-QPushButton:pressed {
- background-color: #22252a;
-}
-QPushButton:checked {
- background-color: #e8a020;
- color: #111214;
- border-color: #c47a10;
-}
-QPushButton#btn_play {
- background-color: #e8a020;
- color: #111214;
- border-color: #c47a10;
- font-size: 22px;
- font-weight: bold;
-}
-QPushButton#btn_play:hover {
- background-color: #c47a10;
-}
-QPushButton#btn_stop {
- color: #e74c3c;
-}
-QPushButton#btn_stop:hover {
- border-color: #e74c3c;
-}
-QPushButton#btn_demo {
- color: #3b8fd4;
- border-color: #3b8fd4;
- font-size: 11px;
-}
-QPushButton#btn_demo:hover, QPushButton#btn_demo:checked {
- background-color: #3b8fd4;
- color: #111214;
- border-color: #3b8fd4;
-}
-
-/* Slider */
-QSlider::groove:horizontal {
- height: 4px;
- background: #2c3038;
- border-radius: 2px;
-}
-QSlider::sub-page:horizontal {
- background: #e8a020;
- border-radius: 2px;
-}
-QSlider::handle:horizontal {
- background: #e8a020;
- width: 12px;
- height: 12px;
- margin: -4px 0;
- border-radius: 6px;
-}
-
-/* Lister */
-QListWidget {
- background-color: #1a1c1f;
- border: none;
- outline: none;
-}
-QListWidget::item {
- padding: 6px 10px;
- border-bottom: 1px solid #22252a;
-}
-QListWidget::item:selected {
- background-color: #2c3038;
- color: #e8eaf0;
- border-left: 2px solid #e8a020;
-}
-QListWidget::item:hover {
- background-color: #22252a;
-}
-
-/* Søgefelt */
-QLineEdit {
- background-color: #111214;
- border: 1px solid #3a3e46;
- border-radius: 3px;
- padding: 5px 8px;
- color: #e8eaf0;
-}
-QLineEdit:focus {
- border-color: #e8a020;
-}
-
-/* Labels */
-QLabel#track_title {
- font-size: 20px;
- font-weight: bold;
- color: #e8eaf0;
- font-family: 'Rajdhani', 'Segoe UI', sans-serif;
-}
-QLabel#track_meta {
- font-size: 11px;
- color: #9aa0b0;
- font-family: 'Courier New', monospace;
-}
-QLabel#section_title {
- font-size: 11px;
- font-weight: bold;
- color: #5a6070;
- letter-spacing: 2px;
- font-family: 'Courier New', monospace;
- padding: 6px 10px;
- background-color: #22252a;
- border-bottom: 1px solid #3a3e46;
-}
-QLabel#next_up_label {
- color: #e8a020;
- font-family: 'Courier New', monospace;
- font-size: 11px;
- letter-spacing: 2px;
-}
-QLabel#next_up_title {
- font-size: 17px;
- font-weight: bold;
- color: #e8eaf0;
-}
-QLabel#next_up_sub {
- font-size: 11px;
- color: #9aa0b0;
- font-family: 'Courier New', monospace;
-}
-QLabel#vol_label {
- font-size: 10px;
- color: #5a6070;
- font-family: 'Courier New', monospace;
- letter-spacing: 1px;
-}
-QLabel#vol_val {
- font-size: 11px;
- color: #9aa0b0;
- font-family: 'Courier New', monospace;
- min-width: 28px;
-}
-QLabel#result_count {
- font-size: 10px;
- color: #5a6070;
- font-family: 'Courier New', monospace;
- padding: 3px 10px;
-}
-
-/* Frames / paneler */
-QFrame#panel {
- background-color: #1a1c1f;
- border: 1px solid #3a3e46;
- border-radius: 4px;
-}
-QFrame#now_playing_frame {
- background-color: #1a1c1f;
- border: 1px solid #3a3e46;
- border-radius: 4px 4px 0 0;
-}
-QFrame#track_display {
- background-color: #111214;
- border: 1px solid #3a3e46;
- border-radius: 3px;
- padding: 4px;
-}
-QFrame#transport_frame {
- background-color: #1a1c1f;
- border: 1px solid #3a3e46;
- border-top: none;
- border-radius: 0 0 4px 4px;
-}
-QFrame#next_up_frame {
- background-color: #22252a;
- border: 1px solid #e8a020;
- border-top: none;
- border-bottom: none;
-}
-QFrame#progress_frame {
- background-color: #1a1c1f;
- border: 1px solid #3a3e46;
- border-top: none;
- border-bottom: none;
-}
-
-/* Scrollbar */
-QScrollBar:vertical {
- background: #1a1c1f;
- width: 6px;
- border-radius: 3px;
-}
-QScrollBar::handle:vertical {
- background: #4a5060;
- border-radius: 3px;
- min-height: 20px;
-}
-QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
-
-/* Højreklik-menu */
-QMenu {
- background-color: #22252a;
- color: #e8eaf0;
- border: 1px solid #4a5060;
- padding: 4px 0;
- font-size: 14px;
-}
-QMenu::item {
- padding: 8px 24px;
- border-radius: 0;
-}
-QMenu::item:selected {
- background-color: #e8a020;
- color: #111214;
-}
-QMenu::separator {
- height: 1px;
- background: #3a3e46;
- margin: 4px 8px;
-}
-
-/* Topbar */
-QFrame#topbar {
- background-color: #1a1c1f;
- border: 1px solid #3a3e46;
- border-radius: 4px;
-}
-QLabel#logo {
- font-size: 16px;
- font-weight: bold;
- letter-spacing: 3px;
- color: #e8a020;
- font-family: 'Rajdhani', 'Segoe UI', sans-serif;
-}
-QLabel#conn_label {
- font-size: 11px;
- color: #5a6070;
- font-family: 'Courier New', monospace;
- letter-spacing: 1px;
-}
-"""
-
-LIGHT = DARK + """
-QWidget {
- background-color: #d8dae0;
- color: #1a1c22;
-}
-QMainWindow, #root {
- background-color: #c8cad0;
-}
-QPushButton {
- background-color: #b0b4bc;
- color: #1a1c22;
- border-color: #8890a0;
-}
-QPushButton:hover {
- background-color: #c8ccd4;
- color: #1a1c22;
- border-color: #c07010;
-}
-QPushButton#btn_play {
- background-color: #c07010;
- color: #fff;
- border-color: #a05808;
-}
-QListWidget {
- background-color: #d8dae0;
- color: #1a1c22;
-}
-QListWidget::item {
- color: #1a1c22;
-}
-QListWidget::item:selected {
- background-color: #c07010;
- color: #ffffff;
- border-left: 2px solid #a05808;
-}
-QListWidget::item:hover {
- background-color: #c8ccd4;
- color: #1a1c22;
-}
-QLineEdit {
- background-color: #c8cad0;
- border-color: #aab0bc;
- color: #1a1c22;
-}
-QLineEdit:focus { border-color: #c07010; }
-QFrame#panel, QFrame#now_playing_frame,
-QFrame#transport_frame, QFrame#progress_frame {
- background-color: #d8dae0;
- border-color: #aab0bc;
-}
-QFrame#track_display { background-color: #c8cad0; border-color: #aab0bc; }
-QFrame#topbar { background-color: #d8dae0; border-color: #aab0bc; }
-QLabel#section_title { background-color: #e4e6ec; color: #1a1c22; border-color: #aab0bc; }
-QLabel#track_title { color: #1a1c22; }
-QLabel#track_meta { color: #4a5060; }
-QLabel#result_count { color: #5a6070; }
-QSlider::groove:horizontal { background: #b0b4bc; }
-QScrollBar:vertical { background: #d8dae0; }
-QScrollBar::handle:vertical { background: #8890a0; }
-QMenu {
- background-color: #e4e6ec;
- color: #1a1c22;
- border: 1px solid #aab0bc;
-}
-QMenu::item:selected {
- background-color: #c07010;
- color: #ffffff;
-}
-"""
-
-
-def apply_theme(app, dark: bool = True):
- app.setStyleSheet(DARK if dark else LIGHT)
diff --git a/linedance-app/ui/vu_meter.py b/linedance-app/ui/vu_meter.py
deleted file mode 100644
index b85fcadb..00000000
--- a/linedance-app/ui/vu_meter.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""
-vu_meter.py — VU-meter widget der tegner L og R kanaler.
-Opdateres via set_levels(left, right) med værdier 0.0–1.0.
-"""
-
-from PyQt6.QtWidgets import QWidget
-from PyQt6.QtCore import Qt, QTimer
-from PyQt6.QtGui import QPainter, QColor
-import random
-
-
-NUM_BARS = 14
-BAR_W = 14
-BAR_H = 4
-BAR_GAP = 2
-CHAN_GAP = 6
-PADDING = 4
-
-COLOR_OFF = QColor("#1a2218")
-COLOR_GREEN = QColor("#28a050")
-COLOR_YELLOW = QColor("#c8a020")
-COLOR_RED = QColor("#c83020")
-
-# Grænser for farver (bar-indeks fra bunden)
-YELLOW_FROM = NUM_BARS - 4
-RED_FROM = NUM_BARS - 2
-
-
-class VUMeter(QWidget):
- def __init__(self, parent=None):
- super().__init__(parent)
- self._left = 0.0
- self._right = 0.0
- self._peak_l = 0.0
- self._peak_r = 0.0
- self._dark = True
-
- total_h = NUM_BARS * (BAR_H + BAR_GAP) + PADDING * 2 + 16 # +16 til label
- total_w = (BAR_W + CHAN_GAP) * 2 + PADDING * 2
- self.setFixedSize(total_w, total_h)
-
- def set_dark(self, dark: bool):
- self._dark = dark
- self.update()
-
- def set_levels(self, left: float, right: float):
- """Sæt niveauer 0.0–1.0. Kaldes fra afspiller-tråden via signal."""
- self._left = max(0.0, min(1.0, left))
- self._right = max(0.0, min(1.0, right))
- self._peak_l = max(self._peak_l * 0.92, self._left)
- self._peak_r = max(self._peak_r * 0.92, self._right)
- self.update()
-
- def reset(self):
- self._left = self._right = self._peak_l = self._peak_r = 0.0
- self.update()
-
- def paintEvent(self, event):
- painter = QPainter(self)
- painter.setRenderHint(QPainter.RenderHint.Antialiasing)
-
- off_color = QColor("#d0d8cc") if not self._dark else COLOR_OFF
-
- for ch_idx, level in enumerate([self._left, self._right]):
- x = PADDING + ch_idx * (BAR_W + CHAN_GAP)
- active_bars = int(level * NUM_BARS)
-
- for bar_idx in range(NUM_BARS):
- y = PADDING + (NUM_BARS - 1 - bar_idx) * (BAR_H + BAR_GAP)
-
- if bar_idx < active_bars:
- if bar_idx >= RED_FROM:
- color = COLOR_RED
- elif bar_idx >= YELLOW_FROM:
- color = COLOR_YELLOW
- else:
- color = COLOR_GREEN
- else:
- color = off_color
-
- painter.fillRect(x, y, BAR_W, BAR_H,
- QColor(color.red(), color.green(), color.blue(), 220))
-
- # Kanal-labels
- label_y = PADDING + NUM_BARS * (BAR_H + BAR_GAP) + 4
- painter.setPen(QColor("#5a6070"))
- font = painter.font()
- font.setPointSize(8)
- font.setFamily("Courier New")
- painter.setFont(font)
-
- for ch_idx, label in enumerate(["L", "R"]):
- x = PADDING + ch_idx * (BAR_W + CHAN_GAP) + BAR_W // 2
- painter.drawText(x - 4, label_y + 10, label)
-
- painter.end()
diff --git a/local/__init__.py b/local/__init__.py
deleted file mode 100644
index 4c57abf7..00000000
--- a/local/__init__.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""
-local/ — Lokalt data-lag til Linedance-afspilleren.
-
-Moduler:
- local_db.py — SQLite database (sange, afspilningslister, biblioteker)
- tag_reader.py — Læser/skriver metadata fra lydfiler
- file_watcher.py — Overvåger mapper og holder SQLite opdateret
-
-Typisk brug ved app-start:
-
- from local.local_db import init_db
- from local.file_watcher import get_watcher
-
- # Initialiser database
- init_db()
-
- # Start fil-overvågning (on_change kaldes ved ændringer — opdater GUI)
- def on_file_change(event_type, path, song_id):
- print(f"{event_type}: {path}")
-
- watcher = get_watcher(on_change=on_file_change)
- watcher.start()
-
- # Tilføj et bibliotek (scanner automatisk + starter overvågning)
- watcher.add_library("/home/carsten/Musik")
-
- # Ved app-luk:
- watcher.stop()
-"""
diff --git a/local/file_watcher.py b/local/file_watcher.py
deleted file mode 100644
index db739ae2..00000000
--- a/local/file_watcher.py
+++ /dev/null
@@ -1,274 +0,0 @@
-"""
-file_watcher.py — Overvåger musikbiblioteker og holder SQLite opdateret.
-
-Bruger watchdog til at reagere på fil-ændringer i realtid.
-Kører fuld scan ved opstart for at fange ændringer lavet mens appen var lukket.
-"""
-
-import threading
-import time
-import logging
-from pathlib import Path
-from typing import Callable
-
-try:
- from watchdog.observers import Observer
- from watchdog.events import (
- FileSystemEventHandler,
- FileCreatedEvent,
- FileModifiedEvent,
- FileDeletedEvent,
- FileMovedEvent,
- )
- WATCHDOG_AVAILABLE = True
-except ImportError:
- WATCHDOG_AVAILABLE = False
- print("Advarsel: watchdog ikke installeret — fil-overvågning deaktiveret")
-
-from local.tag_reader import is_supported, read_tags, get_file_modified_at
-from local.local_db import (
- get_libraries, add_library, remove_library,
- upsert_song, mark_song_missing,
- get_all_song_paths_for_library, update_library_scan_time,
-)
-
-logger = logging.getLogger(__name__)
-
-
-class MusicLibraryHandler(FileSystemEventHandler):
- """
- Reagerer på ændringer i et musikbibliotek.
- Kører i watchdog's baggrundstråd — DB-operationer er thread-safe via WAL.
- """
-
- def __init__(self, library_id: int, on_change: Callable | None = None):
- self.library_id = library_id
- self.on_change = on_change # valgfrit callback til GUI-opdatering
- self._debounce: dict[str, float] = {}
- self._debounce_lock = threading.Lock()
-
- def _debounced(self, path: str) -> bool:
- """
- Forhindrer at samme fil behandles flere gange på kort tid.
- Nogle programmer gemmer filer i flere trin (temp-fil → rename).
- """
- now = time.time()
- with self._debounce_lock:
- last = self._debounce.get(path, 0)
- if now - last < 1.5: # 1.5 sekunder cooldown
- return False
- self._debounce[path] = now
- return True
-
- def on_created(self, event):
- if event.is_directory or not is_supported(event.src_path):
- return
- if self._debounced(event.src_path):
- self._process_file(event.src_path)
-
- def on_modified(self, event):
- if event.is_directory or not is_supported(event.src_path):
- return
- if self._debounced(event.src_path):
- self._process_file(event.src_path)
-
- def on_deleted(self, event):
- if event.is_directory or not is_supported(event.src_path):
- return
- logger.info(f"Fil slettet: {event.src_path}")
- mark_song_missing(event.src_path)
- if self.on_change:
- self.on_change("deleted", event.src_path, None)
-
- def on_moved(self, event):
- if event.is_directory:
- return
- # Behandl som slet + opret
- if is_supported(event.src_path):
- mark_song_missing(event.src_path)
- if is_supported(event.dest_path):
- if self._debounced(event.dest_path):
- self._process_file(event.dest_path)
-
- def _process_file(self, path: str):
- """Læs tags og gem i SQLite."""
- try:
- logger.debug(f"Høster tags fra: {path}")
- tags = read_tags(path)
- tags["library_id"] = self.library_id
- song_id = upsert_song(tags)
- logger.info(f"Opdateret: {Path(path).name} ({len(tags.get('dances', []))} danse)")
- if self.on_change:
- self.on_change("upserted", path, song_id)
- except Exception as e:
- logger.error(f"Fejl ved behandling af {path}: {e}")
-
-
-class LibraryWatcher:
- """
- Styrer watchdog-observere for alle aktive musikbiblioteker.
- Én instans per applikation.
- """
-
- def __init__(self, on_change: Callable | None = None):
- self.on_change = on_change
- self._observer: Observer | None = None
- self._running = False
-
- def start(self):
- """Start overvågning af alle aktive biblioteker + kør fuld scan."""
- if not WATCHDOG_AVAILABLE:
- logger.warning("watchdog ikke tilgængelig — starter kun fuld scan")
- self._full_scan_all()
- return
-
- self._observer = Observer()
- libraries = get_libraries(active_only=True)
-
- for lib in libraries:
- path = Path(lib["path"])
- if not path.exists():
- logger.warning(f"Bibliotek findes ikke: {path}")
- continue
-
- handler = MusicLibraryHandler(lib["id"], self.on_change)
- self._observer.schedule(handler, str(path), recursive=True)
- logger.info(f"Overvåger: {path}")
-
- self._observer.start()
- self._running = True
-
- # Fuld scan i baggrundstråd så GUI ikke blokeres
- threading.Thread(target=self._full_scan_all, daemon=True).start()
-
- def stop(self):
- if self._observer and self._running:
- self._observer.stop()
- self._observer.join()
- self._running = False
-
- def add_library(self, path: str) -> int:
- """Tilføj et nyt bibliotek og start overvågning af det med det samme."""
- library_id = add_library(path)
-
- if self._observer and self._running:
- handler = MusicLibraryHandler(library_id, self.on_change)
- self._observer.schedule(handler, path, recursive=True)
- logger.info(f"Tilføjet bibliotek: {path}")
-
- # Scan det nye bibliotek i baggrunden
- threading.Thread(
- target=self._full_scan_library,
- args=(library_id, path),
- daemon=True,
- ).start()
-
- return library_id
-
- def remove_library(self, library_id: int):
- """Deaktiver bibliotek. Watchdog stopper automatisk ved næste restart."""
- remove_library(library_id)
- # Genstart observer for at fjerne watch (watchdog understøtter ikke unschedule by id)
- if self._observer and self._running:
- self._observer.unschedule_all()
- self._reschedule_all()
-
- def _reschedule_all(self):
- """Genplanlæg alle aktive biblioteker på observeren."""
- for lib in get_libraries(active_only=True):
- path = Path(lib["path"])
- if path.exists():
- handler = MusicLibraryHandler(lib["id"], self.on_change)
- self._observer.schedule(handler, str(path), recursive=True)
-
- def _full_scan_all(self):
- """Kør fuld scan på alle aktive biblioteker."""
- for lib in get_libraries(active_only=True):
- path = Path(lib["path"])
- if path.exists():
- self._full_scan_library(lib["id"], str(path))
-
- def _full_scan_library(self, library_id: int, library_path: str):
- """
- Sammenligner filer på disk med SQLite og synkroniserer forskelle.
- Håndterer utilgængelige mapper og symlinks sikkert.
- """
- logger.info(f"Fuld scan starter: {library_path}")
- base = Path(library_path)
-
- # Tjek at mappen faktisk er tilgængelig — med timeout
- if not self._path_accessible(base):
- logger.warning(f"Bibliotek ikke tilgængeligt (timeout eller ingen adgang): {library_path}")
- return
-
- known = get_all_song_paths_for_library(library_id)
- found_paths = set()
- processed = 0
- errors = 0
-
- import os
- for dirpath, dirnames, filenames in os.walk(
- str(base), followlinks=False,
- onerror=lambda e: logger.warning(f"Adgang nægtet: {e}")
- ):
- for filename in filenames:
- file_path = Path(dirpath) / filename
- try:
- if not is_supported(file_path):
- continue
- path_str = str(file_path)
- found_paths.add(path_str)
- disk_modified = get_file_modified_at(file_path)
-
- if path_str not in known or known[path_str] != disk_modified:
- tags = read_tags(file_path)
- tags["library_id"] = library_id
- upsert_song(tags)
- processed += 1
- if self.on_change:
- self.on_change("upserted", path_str, None)
- except Exception as e:
- logger.error(f"Scan-fejl for {file_path}: {e}")
- errors += 1
-
- # Marker forsvundne filer
- missing_count = 0
- for known_path in known:
- if known_path not in found_paths:
- mark_song_missing(known_path)
- missing_count += 1
- if self.on_change:
- self.on_change("deleted", known_path, None)
-
- update_library_scan_time(library_id)
- logger.info(
- f"Scan færdig: {library_path} — "
- f"{processed} opdateret, {missing_count} mangler, {errors} fejl"
- )
-
- def _path_accessible(self, path: Path, timeout_sec: float = 5.0) -> bool:
- """Tjek om en sti er tilgængelig inden for timeout."""
- import threading
- result = [False]
- def check():
- try:
- result[0] = path.exists() and path.is_dir()
- except Exception:
- result[0] = False
- t = threading.Thread(target=check, daemon=True)
- t.start()
- t.join(timeout=timeout_sec)
- return result[0]
-
-
-# ── Singleton til brug i appen ────────────────────────────────────────────────
-
-_watcher: LibraryWatcher | None = None
-
-
-def get_watcher(on_change: Callable | None = None) -> LibraryWatcher:
- """Returnerer den globale LibraryWatcher-instans."""
- global _watcher
- if _watcher is None:
- _watcher = LibraryWatcher(on_change=on_change)
- return _watcher
diff --git a/local/local_db.py b/local/local_db.py
deleted file mode 100644
index 04ba88d1..00000000
--- a/local/local_db.py
+++ /dev/null
@@ -1,587 +0,0 @@
-"""
-local_db.py — Lokal SQLite database til offline brug.
-
-Håndterer:
- - Musikbiblioteker (stier der overvåges)
- - Sange høstet fra filsystemet
- - Lokale afspilningslister (offline-projekter)
- - Synkroniseringsstatus mod API
-"""
-
-import sqlite3
-import threading
-from contextlib import contextmanager
-from datetime import datetime, timezone
-from pathlib import Path
-
-DB_PATH = Path.home() / ".linedance" / "local.db"
-
-_local = threading.local()
-
-
-def _get_conn() -> sqlite3.Connection:
- """Returnerer en thread-lokal forbindelse."""
- if not hasattr(_local, "conn") or _local.conn is None:
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
- conn = sqlite3.connect(DB_PATH, check_same_thread=False)
- conn.row_factory = sqlite3.Row
- conn.execute("PRAGMA journal_mode=WAL") # bedre concurrent adgang
- conn.execute("PRAGMA foreign_keys=ON")
- _local.conn = conn
- return _local.conn
-
-
-@contextmanager
-def get_db():
- conn = _get_conn()
- try:
- yield conn
- conn.commit()
- except Exception:
- conn.rollback()
- raise
-
-
-def init_db():
- """Opret alle tabeller hvis de ikke findes."""
- with get_db() as conn:
- conn.executescript("""
- -- Musikbiblioteker der overvåges
- CREATE TABLE IF NOT EXISTS libraries (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- path TEXT NOT NULL UNIQUE,
- is_active INTEGER NOT NULL DEFAULT 1,
- last_full_scan TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
-
- -- Sange høstet fra filsystemet
- CREATE TABLE IF NOT EXISTS songs (
- id TEXT PRIMARY KEY,
- library_id INTEGER REFERENCES libraries(id),
- local_path TEXT NOT NULL UNIQUE,
- title TEXT NOT NULL DEFAULT '',
- artist TEXT NOT NULL DEFAULT '',
- album TEXT NOT NULL DEFAULT '',
- bpm INTEGER NOT NULL DEFAULT 0,
- duration_sec INTEGER NOT NULL DEFAULT 0,
- file_format TEXT NOT NULL DEFAULT '',
- file_modified_at TEXT NOT NULL,
- file_missing INTEGER NOT NULL DEFAULT 0,
- api_song_id TEXT, -- NULL hvis ikke synkroniseret
- last_synced_at TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
-
- -- Danse knyttet til en sang (kun MP3 kan skrive tags)
- CREATE TABLE IF NOT EXISTS song_dances (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
- dance_name TEXT NOT NULL,
- dance_order INTEGER NOT NULL DEFAULT 1
- );
-
- -- Alternativ-danse relationer (kun online hvis logget ind, men caches lokalt)
- CREATE TABLE IF NOT EXISTS dance_alternatives (
- id TEXT PRIMARY KEY,
- song_dance_id INTEGER NOT NULL REFERENCES song_dances(id) ON DELETE CASCADE,
- alt_song_dance_id INTEGER NOT NULL REFERENCES song_dances(id) ON DELETE CASCADE,
- note TEXT NOT NULL DEFAULT '',
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
- UNIQUE(song_dance_id, alt_song_dance_id)
- );
-
- -- Lokale afspilningslister (offline-projekter)
- CREATE TABLE IF NOT EXISTS playlists (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- description TEXT NOT NULL DEFAULT '',
- api_project_id TEXT, -- NULL hvis ikke synkroniseret
- last_synced_at TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
-
- -- Sange i en afspilningsliste
- CREATE TABLE IF NOT EXISTS playlist_songs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
- song_id TEXT NOT NULL REFERENCES songs(id),
- position INTEGER NOT NULL,
- status TEXT NOT NULL DEFAULT 'pending', -- pending|playing|played|skipped
- UNIQUE(playlist_id, position)
- );
-
- -- Synkroniseringskø — ændringer der venter på at komme online
- CREATE TABLE IF NOT EXISTS sync_queue (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- entity_type TEXT NOT NULL, -- 'song'|'playlist'|'playlist_song'
- entity_id TEXT NOT NULL,
- action TEXT NOT NULL, -- 'create'|'update'|'delete'
- payload TEXT NOT NULL, -- JSON
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
-
- -- Indekser til hurtig søgning
- CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title);
- CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist);
- CREATE INDEX IF NOT EXISTS idx_songs_missing ON songs(file_missing);
- CREATE INDEX IF NOT EXISTS idx_songs_library ON songs(library_id);
- CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id);
- """)
-
- # Migration: tilføj tabeller der måske mangler i ældre databaser
- _run_migrations(conn)
-
-
-def _run_migrations(conn):
- """Kør migrations sikkert — CREATE IF NOT EXISTS er idempotent."""
- conn.executescript("""
- CREATE TABLE IF NOT EXISTS dance_alternatives (
- id TEXT PRIMARY KEY,
- song_dance_id INTEGER NOT NULL REFERENCES song_dances(id) ON DELETE CASCADE,
- alt_dance_name TEXT NOT NULL,
- level_id INTEGER REFERENCES dance_levels(id),
- note TEXT NOT NULL DEFAULT '',
- source TEXT NOT NULL DEFAULT 'local',
- created_by TEXT NOT NULL DEFAULT '',
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
-
- CREATE TABLE IF NOT EXISTS event_state (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS dance_names (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL UNIQUE COLLATE NOCASE,
- source TEXT NOT NULL DEFAULT 'local',
- use_count INTEGER NOT NULL DEFAULT 1,
- synced_at TEXT
- );
-
- CREATE TABLE IF NOT EXISTS dance_levels (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- sort_order INTEGER NOT NULL,
- name TEXT NOT NULL UNIQUE,
- description TEXT NOT NULL DEFAULT '',
- synced_at TEXT
- );
- """)
-
- # Tilføj kolonner der måske mangler i ældre databaser
- migrations = [
- "ALTER TABLE songs ADD COLUMN extra_tags TEXT NOT NULL DEFAULT '{}'",
- "ALTER TABLE song_dances ADD COLUMN level_id INTEGER REFERENCES dance_levels(id)",
- "ALTER TABLE dance_alternatives ADD COLUMN alt_dance_name TEXT NOT NULL DEFAULT ''",
- "ALTER TABLE dance_alternatives ADD COLUMN level_id INTEGER REFERENCES dance_levels(id)",
- "ALTER TABLE dance_alternatives ADD COLUMN source TEXT NOT NULL DEFAULT 'local'",
- "ALTER TABLE dance_alternatives ADD COLUMN created_by TEXT NOT NULL DEFAULT ''",
- ]
- for sql in migrations:
- try:
- conn.execute(sql)
- except Exception:
- pass # kolonnen eksisterer allerede
-
- # Indlæs standard-niveauer hvis tabellen er tom
- count = conn.execute("SELECT COUNT(*) FROM dance_levels").fetchone()[0]
- if count == 0:
- defaults = [
- (1, "Begynder", "Passer til alle"),
- (2, "Let øvet", "Lidt erfaring kræves"),
- (3, "Øvet", "Kræver regelmæssig træning"),
- (4, "Erfaren", "For dedikerede dansere"),
- (5, "Ekspert", "Konkurrenceniveau"),
- ]
- conn.executemany(
- "INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)",
- defaults
- )
-
-
-# ── Biblioteker ───────────────────────────────────────────────────────────────
-
-def add_library(path: str) -> int:
- with get_db() as conn:
- cur = conn.execute(
- "INSERT OR IGNORE INTO libraries (path) VALUES (?)", (path,)
- )
- if cur.lastrowid:
- return cur.lastrowid
- row = conn.execute("SELECT id FROM libraries WHERE path=?", (path,)).fetchone()
- return row["id"]
-
-
-def get_libraries(active_only: bool = True) -> list[sqlite3.Row]:
- with get_db() as conn:
- if active_only:
- return conn.execute(
- "SELECT * FROM libraries WHERE is_active=1 ORDER BY path"
- ).fetchall()
- return conn.execute("SELECT * FROM libraries ORDER BY path").fetchall()
-
-
-def remove_library(library_id: int):
- with get_db() as conn:
- # Marker sange som manglende
- conn.execute(
- "UPDATE songs SET file_missing=1 WHERE library_id=?", (library_id,)
- )
- # Slet biblioteket helt
- conn.execute("DELETE FROM libraries WHERE id=?", (library_id,))
-
-
-def update_library_scan_time(library_id: int):
- now = datetime.now(timezone.utc).isoformat()
- with get_db() as conn:
- conn.execute(
- "UPDATE libraries SET last_full_scan=? WHERE id=?", (now, library_id)
- )
-
-
-# ── Sange ─────────────────────────────────────────────────────────────────────
-
-def upsert_song(song_data: dict) -> str:
- """
- Indsæt eller opdater en sang baseret på local_path.
- Returnerer song_id.
- """
- import uuid, json
- with get_db() as conn:
- existing = conn.execute(
- "SELECT id FROM songs WHERE local_path=?", (song_data["local_path"],)
- ).fetchone()
-
- extra_tags_json = json.dumps(song_data.get("extra_tags", {}), ensure_ascii=False)
-
- if existing:
- song_id = existing["id"]
- conn.execute("""
- UPDATE songs SET
- library_id=?, title=?, artist=?, album=?, bpm=?, duration_sec=?,
- file_format=?, file_modified_at=?, file_missing=0, extra_tags=?
- WHERE id=?
- """, (
- song_data.get("library_id"),
- song_data.get("title", ""),
- song_data.get("artist", ""),
- song_data.get("album", ""),
- song_data.get("bpm", 0),
- song_data.get("duration_sec", 0),
- song_data.get("file_format", ""),
- song_data.get("file_modified_at", ""),
- extra_tags_json,
- song_id,
- ))
- else:
- song_id = str(uuid.uuid4())
- conn.execute("""
- INSERT INTO songs
- (id, library_id, local_path, title, artist, album,
- bpm, duration_sec, file_format, file_modified_at, extra_tags)
- VALUES (?,?,?,?,?,?,?,?,?,?,?)
- """, (
- song_id,
- song_data.get("library_id"),
- song_data["local_path"],
- song_data.get("title", ""),
- song_data.get("artist", ""),
- song_data.get("album", ""),
- song_data.get("bpm", 0),
- song_data.get("duration_sec", 0),
- song_data.get("file_format", ""),
- song_data.get("file_modified_at", ""),
- extra_tags_json,
- ))
-
- # Opdater danse hvis de er med i data
- if "dances" in song_data:
- conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
- for i, dance in enumerate(song_data["dances"], start=1):
- # dance kan være str eller dict med {name, level_id}
- if isinstance(dance, dict):
- name = dance.get("name", "")
- level_id = dance.get("level_id")
- else:
- name = dance
- level_id = None
- conn.execute(
- "INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) VALUES (?,?,?,?)",
- (song_id, name, i, level_id),
- )
- # Registrer navne i ordbogen
- try:
- from local.local_db import register_dance_name as _reg
- for dance in song_data["dances"]:
- nm = dance.get("name", dance) if isinstance(dance, dict) else dance
- if nm:
- _reg(nm)
- except Exception:
- pass
-
- return song_id
-
-
-def mark_song_missing(local_path: str):
- with get_db() as conn:
- conn.execute(
- "UPDATE songs SET file_missing=1 WHERE local_path=?", (local_path,)
- )
-
-
-def get_song_by_path(local_path: str) -> sqlite3.Row | None:
- with get_db() as conn:
- return conn.execute(
- "SELECT * FROM songs WHERE local_path=?", (local_path,)
- ).fetchone()
-
-
-def search_songs(query: str, limit: int = 50) -> list[sqlite3.Row]:
- """Søg i alle tags — titel, artist, album, danse og alle øvrige tags."""
- pattern = f"%{query}%"
- with get_db() as conn:
- return conn.execute("""
- SELECT DISTINCT s.* FROM songs s
- LEFT JOIN song_dances sd ON sd.song_id = s.id
- WHERE s.file_missing = 0
- AND (
- s.title LIKE ? OR
- s.artist LIKE ? OR
- s.album LIKE ? OR
- sd.dance_name LIKE ? OR
- s.extra_tags LIKE ?
- )
- ORDER BY s.artist, s.title
- LIMIT ?
- """, (pattern, pattern, pattern, pattern, pattern, limit)).fetchall()
-
-
-def get_songs_for_library(library_id: int) -> list[sqlite3.Row]:
- with get_db() as conn:
- return conn.execute(
- "SELECT * FROM songs WHERE library_id=? ORDER BY artist, title",
- (library_id,)
- ).fetchall()
-
-
-def get_all_song_paths_for_library(library_id: int) -> dict[str, str]:
- """Returnerer {local_path: file_modified_at} — bruges til fuld scan."""
- with get_db() as conn:
- rows = conn.execute(
- "SELECT local_path, file_modified_at FROM songs WHERE library_id=?",
- (library_id,)
- ).fetchall()
- return {row["local_path"]: row["file_modified_at"] for row in rows}
-
-
-# ── Afspilningslister ─────────────────────────────────────────────────────────
-
-def create_playlist(name: str, description: str = "") -> int:
- with get_db() as conn:
- cur = conn.execute(
- "INSERT INTO playlists (name, description) VALUES (?,?)",
- (name, description)
- )
- return cur.lastrowid
-
-
-def get_playlists() -> list[sqlite3.Row]:
- with get_db() as conn:
- return conn.execute(
- "SELECT * FROM playlists ORDER BY created_at DESC"
- ).fetchall()
-
-
-def add_song_to_playlist(playlist_id: int, song_id: str, position: int | None = None) -> int:
- with get_db() as conn:
- if position is None:
- row = conn.execute(
- "SELECT MAX(position) as max_pos FROM playlist_songs WHERE playlist_id=?",
- (playlist_id,)
- ).fetchone()
- position = (row["max_pos"] or 0) + 1
-
- cur = conn.execute(
- "INSERT INTO playlist_songs (playlist_id, song_id, position) VALUES (?,?,?)",
- (playlist_id, song_id, position)
- )
- return cur.lastrowid
-
-
-def update_playlist_song_status(playlist_song_id: int, status: str):
- valid = {"pending", "playing", "played", "skipped"}
- if status not in valid:
- raise ValueError(f"Ugyldig status: {status}")
- with get_db() as conn:
- conn.execute(
- "UPDATE playlist_songs SET status=? WHERE id=?",
- (status, playlist_song_id)
- )
-
-
-def get_playlist_with_songs(playlist_id: int) -> dict:
- with get_db() as conn:
- playlist = conn.execute(
- "SELECT * FROM playlists WHERE id=?", (playlist_id,)
- ).fetchone()
- if not playlist:
- return {}
-
- songs = conn.execute("""
- SELECT ps.id as ps_id, ps.position, ps.status,
- s.*, GROUP_CONCAT(sd.dance_name ORDER BY sd.dance_order) as dances
- FROM playlist_songs ps
- JOIN songs s ON s.id = ps.song_id
- LEFT JOIN song_dances sd ON sd.song_id = s.id
- WHERE ps.playlist_id = ?
- GROUP BY ps.id
- ORDER BY ps.position
- """, (playlist_id,)).fetchall()
-
- return {"playlist": dict(playlist), "songs": [dict(s) for s in songs]}
-
-
-# ── Event-state (gemmes løbende så man kan genstarte efter strømsvigt) ────────
-
-def save_event_state(current_idx: int, statuses: list[str]):
- """Gem event-fremgang — overskrives ved hver ændring."""
- import json
- with get_db() as conn:
- conn.execute("INSERT OR REPLACE INTO event_state (key,value) VALUES ('current_idx',?)",
- (str(current_idx),))
- conn.execute("INSERT OR REPLACE INTO event_state (key,value) VALUES ('statuses',?)",
- (json.dumps(statuses),))
-
-
-def load_event_state() -> tuple[int, list[str]] | None:
- """Indlæs gemt event-fremgang. Returnerer None hvis ingen gemt tilstand."""
- import json
- with get_db() as conn:
- idx_row = conn.execute(
- "SELECT value FROM event_state WHERE key='current_idx'"
- ).fetchone()
- sta_row = conn.execute(
- "SELECT value FROM event_state WHERE key='statuses'"
- ).fetchone()
- if not idx_row or not sta_row:
- return None
- return int(idx_row["value"]), json.loads(sta_row["value"])
-
-
-def clear_event_state():
- """Nulstil gemt event-tilstand (bruges ved 'Start event')."""
- with get_db() as conn:
- conn.execute("DELETE FROM event_state")
-
-
-# ── Dans-navne ordbog ─────────────────────────────────────────────────────────
-
-def get_dance_name_suggestions(prefix: str, limit: int = 20) -> list[str]:
- """Returnerer danse-navne der starter med prefix, sorteret efter popularitet."""
- with get_db() as conn:
- rows = conn.execute("""
- SELECT name FROM dance_names
- WHERE name LIKE ? COLLATE NOCASE
- ORDER BY use_count DESC, name
- LIMIT ?
- """, (f"{prefix}%", limit)).fetchall()
- return [r["name"] for r in rows]
-
-
-def register_dance_name(name: str, source: str = "local"):
- """Tilføj eller opdater et dans-navn i ordbogen."""
- name = name.strip()
- if not name:
- return
- with get_db() as conn:
- existing = conn.execute(
- "SELECT id, use_count FROM dance_names WHERE name=? COLLATE NOCASE",
- (name,)
- ).fetchone()
- if existing:
- conn.execute(
- "UPDATE dance_names SET use_count=use_count+1 WHERE id=?",
- (existing["id"],)
- )
- else:
- conn.execute(
- "INSERT INTO dance_names (name, source, use_count) VALUES (?,?,1)",
- (name, source)
- )
-
-
-def sync_dance_names_from_api(names: list[dict]):
- """Synkroniser dans-navne fra API — {name, use_count}."""
- from datetime import datetime, timezone
- now = datetime.now(timezone.utc).isoformat()
- with get_db() as conn:
- for item in names:
- conn.execute("""
- INSERT INTO dance_names (name, source, use_count, synced_at)
- VALUES (?, 'community', ?, ?)
- ON CONFLICT(name) DO UPDATE SET
- use_count = MAX(use_count, excluded.use_count),
- synced_at = excluded.synced_at
- """, (item["name"], item.get("use_count", 1), now))
-
-
-# ── Dans-niveauer ─────────────────────────────────────────────────────────────
-
-def get_dance_levels() -> list[sqlite3.Row]:
- """Hent alle niveauer sorteret efter sort_order."""
- with get_db() as conn:
- return conn.execute(
- "SELECT * FROM dance_levels ORDER BY sort_order"
- ).fetchall()
-
-
-def sync_dance_levels_from_api(levels: list[dict]):
- """Synkroniser niveauer fra API — {sort_order, name, description}."""
- from datetime import datetime, timezone
- now = datetime.now(timezone.utc).isoformat()
- with get_db() as conn:
- for lvl in levels:
- conn.execute("""
- INSERT INTO dance_levels (sort_order, name, description, synced_at)
- VALUES (?, ?, ?, ?)
- ON CONFLICT(name) DO UPDATE SET
- sort_order = excluded.sort_order,
- description = excluded.description,
- synced_at = excluded.synced_at
- """, (lvl["sort_order"], lvl["name"], lvl.get("description", ""), now))
-
-
-# ── Dans-alternativer ─────────────────────────────────────────────────────────
-
-def get_alternatives_for_dance(song_dance_id: int) -> list[sqlite3.Row]:
- with get_db() as conn:
- return conn.execute("""
- SELECT da.*, dl.name as level_name, dl.sort_order as level_sort
- FROM dance_alternatives da
- LEFT JOIN dance_levels dl ON dl.id = da.level_id
- WHERE da.song_dance_id = ?
- ORDER BY da.source, dl.sort_order
- """, (song_dance_id,)).fetchall()
-
-
-def add_alternative(song_dance_id: int, alt_dance_name: str,
- level_id: int | None = None, note: str = "",
- source: str = "local", created_by: str = "") -> str:
- import uuid as _uuid
- alt_id = str(_uuid.uuid4())
- with get_db() as conn:
- conn.execute("""
- INSERT INTO dance_alternatives
- (id, song_dance_id, alt_dance_name, level_id, note, source, created_by)
- VALUES (?,?,?,?,?,?,?)
- """, (alt_id, song_dance_id, alt_dance_name.strip(),
- level_id, note, source, created_by))
- # Registrer alt-dans-navne i ordbogen
- register_dance_name(alt_dance_name, source=source)
- return alt_id
-
-
-def remove_alternative(alt_id: str):
- with get_db() as conn:
- conn.execute("DELETE FROM dance_alternatives WHERE id=?", (alt_id,))
diff --git a/local/tag_reader.py b/local/tag_reader.py
deleted file mode 100644
index 3df1ee8e..00000000
--- a/local/tag_reader.py
+++ /dev/null
@@ -1,391 +0,0 @@
-"""
-tag_reader.py — Læser og skriver metadata fra lydfiler.
-
-Understøttede formater og danse-tag support:
- MP3 — læs + skriv danse (ID3 TXXX-felter)
- FLAC — læs + skriv danse (Vorbis Comments)
- OGG — læs + skriv danse (Vorbis Comments)
- OPUS — læs + skriv danse (Vorbis Comments)
- M4A — læs + skriv danse (MP4 custom felt ----:LINEDANCE:DANCE)
- WAV — læs metadata, ingen danse-tag support
- WMA — læs metadata, ingen danse-tag support
- AIFF — læs metadata, ingen danse-tag support
-
-Danse gemmes ALTID i SQLite uanset format.
-Fil-skrivning er kun muligt for de formater der understøtter custom tags.
-"""
-
-import os
-from datetime import datetime, timezone
-from pathlib import Path
-
-try:
- from mutagen import File as MutagenFile
- from mutagen.id3 import ID3, TXXX
- from mutagen.flac import FLAC
- from mutagen.mp4 import MP4, MP4FreeForm
- MUTAGEN_AVAILABLE = True
-except ImportError:
- MUTAGEN_AVAILABLE = False
- print("Advarsel: mutagen ikke installeret — tag-læsning deaktiveret")
-
-
-# Filtyper vi høster metadata fra
-SUPPORTED_EXTENSIONS = {
- ".mp3", ".flac", ".wav", ".m4a", ".aac",
- ".ogg", ".opus", ".wma", ".aiff", ".aif",
-}
-
-# Formater der understøtter skrivning af danse-tags til fil
-WRITABLE_DANCE_FORMATS = {".mp3", ".flac", ".ogg", ".opus", ".m4a"}
-
-# Tag-nøgler brugt på tværs af formater
-TXXX_DANCE_PREFIX = "LINEDANCE_DANCE_" # MP3: TXXX:LINEDANCE_DANCE_1
-VORBIS_DANCE_KEY = "linedance_dance" # FLAC/OGG: linedance_dance.1
-M4A_DANCE_FREEFORM = "----:LINEDANCE:DANCE" # M4A: ----:LINEDANCE:DANCE (liste)
-
-
-def is_supported(path: str | Path) -> bool:
- return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS
-
-
-def can_write_dances(path: str | Path) -> bool:
- """Returnerer True hvis formatet understøtter skrivning af danse-tags til fil."""
- return Path(path).suffix.lower() in WRITABLE_DANCE_FORMATS
-
-
-def get_file_modified_at(path: str | Path) -> str:
- ts = os.path.getmtime(str(path))
- return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
-
-
-# ── Læsning ───────────────────────────────────────────────────────────────────
-
-def read_tags(path: str | Path) -> dict:
- """
- Læser metadata og danse fra en lydfil.
- Returnerer dict med: title, artist, album, bpm, duration_sec,
- file_format, file_modified_at, dances, can_write_dances,
- extra_tags (dict med alle øvrige tags som {navn: værdi}).
- """
- path = Path(path)
- result = {
- "local_path": str(path),
- "title": path.stem,
- "artist": "",
- "album": "",
- "bpm": 0,
- "duration_sec": 0,
- "file_format": path.suffix.lower().lstrip("."),
- "file_modified_at": get_file_modified_at(path),
- "dances": [],
- "can_write_dances": can_write_dances(path),
- "extra_tags": {},
- }
-
- if not MUTAGEN_AVAILABLE:
- return result
-
- try:
- audio = MutagenFile(str(path), easy=False)
- if audio is None:
- return result
-
- if hasattr(audio, "info") and audio.info:
- result["duration_sec"] = int(getattr(audio.info, "length", 0))
-
- ext = path.suffix.lower()
-
- if ext == ".mp3":
- _read_mp3(audio, result)
- elif ext == ".flac":
- _read_vorbis(audio, result)
- elif ext in (".ogg", ".opus"):
- _read_vorbis(audio, result)
- elif ext in (".m4a", ".aac", ".mp4"):
- _read_m4a(audio, result)
- else:
- _read_generic(audio, result)
-
- except Exception as e:
- print(f"Fejl ved læsning af {path}: {e}")
-
- return result
-
-
-def _read_mp3(audio, result: dict):
- tags = audio.tags
- if not tags:
- return
- if "TIT2" in tags:
- result["title"] = str(tags["TIT2"].text[0])
- if "TPE1" in tags:
- result["artist"] = str(tags["TPE1"].text[0])
- if "TALB" in tags:
- result["album"] = str(tags["TALB"].text[0])
- if "TBPM" in tags:
- try:
- result["bpm"] = int(float(str(tags["TBPM"].text[0])))
- except (ValueError, TypeError):
- pass
- dances = {}
- extra = {}
- # Kendte ID3-felt-navne til menneskelige navne
- ID3_NAMES = {
- "TIT2": "titel", "TPE1": "artist", "TALB": "album", "TBPM": "bpm",
- "TYER": "år", "TDRC": "dato", "TCON": "genre", "TPE2": "albumartist",
- "TPOS": "disknummer", "TRCK": "spornummer", "TCOM": "komponist",
- "TLYR": "sangtekst", "TCOP": "copyright", "TPUB": "udgiver",
- "TENC": "kodet_af", "TLAN": "sprog", "TMOO": "stemning",
- "TPE3": "dirigent", "TPE4": "fortolket_af", "TOAL": "original_album",
- "TOPE": "original_artist", "TORY": "original_år",
- }
- for key, frame in tags.items():
- if key.startswith("TXXX:") and TXXX_DANCE_PREFIX in key:
- try:
- num = int(key.replace(f"TXXX:{TXXX_DANCE_PREFIX}", ""))
- dances[num] = str(frame.text[0])
- except (ValueError, IndexError):
- pass
- elif key.startswith("TXXX:"):
- # Custom TXXX-felt — gem under dets beskrivelse
- desc = key[5:] # fjern "TXXX:"
- try:
- extra[desc] = str(frame.text[0])
- except Exception:
- pass
- elif key in ID3_NAMES and key not in ("TIT2","TPE1","TALB","TBPM"):
- # Standardfelt vi ikke allerede har gemt
- try:
- val = str(frame.text[0]) if hasattr(frame, "text") else str(frame)
- if val:
- extra[ID3_NAMES[key]] = val
- except Exception:
- pass
- elif hasattr(frame, "text") and key not in ("TIT2","TPE1","TALB","TBPM"):
- # Alle andre tekstfelter
- try:
- val = str(frame.text[0])
- if val and not key.startswith("APIC"): # spring albumcover over
- extra[key] = val
- except Exception:
- pass
- result["dances"] = [dances[k] for k in sorted(dances.keys())]
- result["extra_tags"] = extra
-
-
-def _read_vorbis(audio, result: dict):
- """FLAC og OGG/Opus bruger begge Vorbis Comments."""
- tags = audio.tags
- if not tags:
- return
- result["title"] = tags.get("title", [result["title"]])[0]
- result["artist"] = tags.get("artist", [""])[0]
- result["album"] = tags.get("album", [""])[0]
- try:
- result["bpm"] = int(tags.get("bpm", [0])[0])
- except (ValueError, TypeError):
- pass
- # Danse
- dances = {}
- for key, values in tags.items():
- if key.lower().startswith(f"{VORBIS_DANCE_KEY}."):
- try:
- num = int(key.split(".")[-1])
- dances[num] = values[0]
- except (ValueError, IndexError):
- pass
- if not dances and VORBIS_DANCE_KEY in tags:
- result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()]
- else:
- result["dances"] = [dances[k] for k in sorted(dances.keys())]
- # Alle øvrige tags som extra_tags
- skip = {"title", "artist", "album", "bpm", VORBIS_DANCE_KEY}
- extra = {}
- for key, values in tags.items():
- k = key.lower()
- if k not in skip and not k.startswith(VORBIS_DANCE_KEY):
- try:
- extra[k] = str(values[0])
- except Exception:
- pass
- result["extra_tags"] = extra
-
-
-def _read_m4a(audio, result: dict):
- tags = audio.tags
- if not tags:
- return
- if "\xa9nam" in tags:
- result["title"] = str(tags["\xa9nam"][0])
- if "\xa9ART" in tags:
- result["artist"] = str(tags["\xa9ART"][0])
- if "\xa9alb" in tags:
- result["album"] = str(tags["\xa9alb"][0])
- if "tmpo" in tags:
- try:
- result["bpm"] = int(tags["tmpo"][0])
- except (ValueError, TypeError):
- pass
- if M4A_DANCE_FREEFORM in tags:
- result["dances"] = [
- v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v)
- for v in tags[M4A_DANCE_FREEFORM]
- ]
- # Menneskelige navne til M4A-nøgler
- M4A_NAMES = {
- "\xa9nam": "titel", "\xa9ART": "artist", "\xa9alb": "album",
- "\xa9day": "år", "\xa9gen": "genre", "\xa9wrt": "komponist",
- "\xa9cmt": "kommentar", "aART": "albumartist", "trkn": "spornummer",
- "disk": "disknummer", "cprt": "copyright", "\xa9lyr": "sangtekst",
- "tmpo": "bpm",
- }
- skip_keys = {"\xa9nam", "\xa9ART", "\xa9alb", "tmpo", M4A_DANCE_FREEFORM, "covr"}
- extra = {}
- for key, values in tags.items():
- if key in skip_keys:
- continue
- label = M4A_NAMES.get(key, key)
- try:
- val = values[0]
- if isinstance(val, (bytes, MP4FreeForm)):
- val = val.decode("utf-8", errors="replace")
- extra[label] = str(val)
- except Exception:
- pass
- result["extra_tags"] = extra
-
-
-def _read_generic(audio, result: dict):
- try:
- easy = MutagenFile(result["local_path"], easy=True)
- if easy and easy.tags:
- result["title"] = easy.tags.get("title", [result["title"]])[0]
- result["artist"] = easy.tags.get("artist", [""])[0]
- result["album"] = easy.tags.get("album", [""])[0]
- except Exception:
- pass
-
-
-# ── Skrivning ─────────────────────────────────────────────────────────────────
-
-def write_dances(path: str | Path, dances: list[str]) -> bool:
- """
- Skriver danse til filen hvis formatet understøtter det.
- Returnerer True ved succes, False hvis formatet ikke understøtter det.
- Kaster Exception ved fejl under skrivning.
- """
- if not MUTAGEN_AVAILABLE:
- return False
-
- path = Path(path)
- ext = path.suffix.lower()
-
- if ext not in WRITABLE_DANCE_FORMATS:
- return False
-
- if ext == ".mp3":
- return _write_mp3_dances(path, dances)
- elif ext in (".flac", ".ogg", ".opus"):
- return _write_vorbis_dances(path, dances)
- elif ext in (".m4a", ".aac"):
- return _write_m4a_dances(path, dances)
-
- return False
-
-
-def _write_mp3_dances(path: Path, dances: list[str]) -> bool:
- try:
- tags = ID3(str(path))
- for key in [k for k in tags.keys() if TXXX_DANCE_PREFIX in k]:
- del tags[key]
- for i, name in enumerate(dances, start=1):
- tags.add(TXXX(encoding=3, desc=f"{TXXX_DANCE_PREFIX}{i}", text=name))
- tags.save(str(path))
- return True
- except Exception as e:
- print(f"MP3 skrivefejl {path}: {e}")
- return False
-
-
-def _write_vorbis_dances(path: Path, dances: list[str]) -> bool:
- try:
- audio = MutagenFile(str(path), easy=False)
- if audio is None or audio.tags is None:
- return False
- # Slet eksisterende danse-felter
- keys_to_delete = [k for k in audio.tags.keys() if k.lower().startswith(f"{VORBIS_DANCE_KEY}.")]
- for key in keys_to_delete:
- del audio.tags[key]
- # Skriv nye — ét felt per dans
- for i, name in enumerate(dances, start=1):
- audio.tags[f"{VORBIS_DANCE_KEY}.{i}"] = name
- audio.save()
- return True
- except Exception as e:
- print(f"Vorbis skrivefejl {path}: {e}")
- return False
-
-
-def _write_m4a_dances(path: Path, dances: list[str]) -> bool:
- try:
- audio = MP4(str(path))
- audio.tags[M4A_DANCE_FREEFORM] = [
- MP4FreeForm(name.encode("utf-8")) for name in dances
- ]
- audio.save()
- return True
- except Exception as e:
- print(f"M4A skrivefejl {path}: {e}")
- return False
-
-
-# ── Hurtig læsning af kun danse (uden fuld tag-scan) ─────────────────────────
-
-def read_dances_from_file(path: str | Path) -> list[str]:
- """Læser kun danse fra en fil — hurtigere end fuld read_tags()."""
- tags = read_tags(path)
- return tags.get("dances", [])
-
-
-# ── BPM-analyse ───────────────────────────────────────────────────────────────
-
-def analyze_bpm(path: str | Path) -> float | None:
- """
- Analysér BPM fra lydfilen ved hjælp af librosa.
- Returnerer BPM som float eller None ved fejl.
- Tager 2-5 sekunder per sang — kør i baggrundstråd.
- """
- try:
- import librosa
- # Indlæs kun de første 60 sekunder for hastighed
- y, sr = librosa.load(str(path), duration=60.0, mono=True)
- tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
- # librosa returnerer array i nyere versioner
- if hasattr(tempo, "__len__"):
- bpm = float(tempo[0]) if len(tempo) > 0 else 0.0
- else:
- bpm = float(tempo)
- return round(bpm, 1) if bpm > 0 else None
- except ImportError:
- print("librosa ikke installeret — installer med: pip install librosa")
- return None
- except Exception as e:
- print(f"BPM-analyse fejl for {path}: {e}")
- return None
-
-
-def analyze_and_save_bpm(path: str | Path, song_id: str) -> float | None:
- """Analysér BPM og gem i SQLite. Returnerer målt BPM."""
- bpm = analyze_bpm(path)
- if bpm and bpm > 0:
- try:
- from local.local_db import get_db
- with get_db() as conn:
- conn.execute(
- "UPDATE songs SET bpm=? WHERE id=? AND (bpm IS NULL OR bpm=0)",
- (int(round(bpm)), song_id)
- )
- except Exception as e:
- print(f"BPM gem fejl: {e}")
- return bpm
diff --git a/main.py b/main.py
deleted file mode 100644
index ad5f9af2..00000000
--- a/main.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""
-main.py — Linedance afspiller.
-
-Start:
- python main.py
-"""
-
-import sys
-import os
-
-# Sørg for at rodmappen er i Python-stien
-sys.path.insert(0, os.path.dirname(__file__))
-
-from PyQt6.QtWidgets import QApplication
-from ui.main_window import MainWindow
-from ui.themes import apply_theme
-
-
-def main():
- app = QApplication(sys.argv)
- app.setApplicationName("LineDance Player")
- app.setOrganizationName("LineDance")
-
- apply_theme(app, dark=True)
-
- window = MainWindow()
- window.show()
-
- sys.exit(app.exec())
-
-
-if __name__ == "__main__":
- main()
diff --git a/player/__init__.py b/player/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/player/player.py b/player/player.py
deleted file mode 100644
index 2fec70d1..00000000
--- a/player/player.py
+++ /dev/null
@@ -1,172 +0,0 @@
-"""
-player.py — VLC-baseret afspiller med PyQt6 signals.
-
-Sender signals til GUI:
- position_changed(float) — 0.0–1.0 progress
- time_changed(int, int) — (current_sec, total_sec)
- levels_changed(float, float) — VU-meter L/R 0.0–1.0
- song_ended() — sang færdig
- state_changed(str) — 'playing'|'paused'|'stopped'
-"""
-
-from PyQt6.QtCore import QObject, pyqtSignal, QTimer
-import random
-
-try:
- import vlc
- VLC_AVAILABLE = True
-except ImportError:
- VLC_AVAILABLE = False
- print("Advarsel: python-vlc ikke installeret — afspilning deaktiveret")
-
-
-class Player(QObject):
- position_changed = pyqtSignal(float)
- time_changed = pyqtSignal(int, int)
- levels_changed = pyqtSignal(float, float)
- song_ended = pyqtSignal()
- state_changed = pyqtSignal(str)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._path: str | None = None
- self._duration: int = 0
- self._demo_mode = False
- self._demo_stop_sec = 10
- self._volume = 78
-
- if VLC_AVAILABLE:
- self._instance = vlc.Instance("--no-video", "--quiet")
- self._media_player = self._instance.media_player_new()
- self._events = self._media_player.event_manager()
- self._events.event_attach(
- vlc.EventType.MediaPlayerEndReached,
- self._on_end_reached,
- )
- else:
- self._media_player = None
-
- # Timer til polling af position + VU-simulation
- self._poll_timer = QTimer(self)
- self._poll_timer.setInterval(80)
- self._poll_timer.timeout.connect(self._poll)
-
- # ── Indlæsning ────────────────────────────────────────────────────────────
-
- def load(self, path: str, duration_sec: int = 0):
- """Indlæs en lydfil uden at starte afspilning."""
- self._path = path
- self._duration = duration_sec
- self._demo_mode = False
-
- if VLC_AVAILABLE and self._media_player:
- media = self._instance.media_new(path)
- self._media_player.set_media(media)
- self._media_player.audio_set_volume(self._volume)
-
- self.position_changed.emit(0.0)
- self.time_changed.emit(0, self._duration)
- self.state_changed.emit("stopped")
-
- # ── Transport ─────────────────────────────────────────────────────────────
-
- def play(self):
- self._demo_mode = False
- if VLC_AVAILABLE and self._media_player:
- self._media_player.play()
- self._poll_timer.start()
- self.state_changed.emit("playing")
-
- def play_demo(self, stop_at_sec: int = 10):
- """Afspil fra start og stop automatisk ved stop_at_sec."""
- self._demo_mode = True
- self._demo_stop_sec = stop_at_sec
- if VLC_AVAILABLE and self._media_player:
- self._media_player.set_time(0)
- self._media_player.play()
- self._poll_timer.start()
- self.state_changed.emit("playing")
-
- def pause(self):
- if VLC_AVAILABLE and self._media_player:
- self._media_player.pause()
- self.state_changed.emit("paused")
-
- def stop(self):
- self._demo_mode = False
- if VLC_AVAILABLE and self._media_player:
- self._media_player.stop()
- self._poll_timer.stop()
- self.position_changed.emit(0.0)
- self.time_changed.emit(0, self._duration)
- self.state_changed.emit("stopped")
-
- def is_playing(self) -> bool:
- if VLC_AVAILABLE and self._media_player:
- return self._media_player.is_playing()
- return False
-
- def set_volume(self, volume: int):
- """0–100"""
- self._volume = volume
- if VLC_AVAILABLE and self._media_player:
- self._media_player.audio_set_volume(volume)
-
- def set_position(self, fraction: float):
- """Søg til position 0.0–1.0"""
- if VLC_AVAILABLE and self._media_player:
- self._media_player.set_position(fraction)
-
- # ── Intern polling ────────────────────────────────────────────────────────
-
- def _poll(self):
- """Køres ~12 gange per sekund — opdaterer position og VU-meter."""
- if VLC_AVAILABLE and self._media_player:
- pos = self._media_player.get_position()
- ms = self._media_player.get_time()
- cur = max(0, ms // 1000)
- else:
- # Simuleret tilstand (til UI-test uden VLC)
- pos = getattr(self, "_sim_pos", 0.0)
- self._sim_pos = min(1.0, pos + 0.001)
- cur = int(self._sim_pos * self._duration)
- pos = self._sim_pos
- if self._sim_pos >= 1.0:
- self._on_end_reached(None)
- return
-
- self.position_changed.emit(pos)
- self.time_changed.emit(cur, self._duration)
-
- # Demo-stop
- if self._demo_mode and cur >= self._demo_stop_sec:
- self.stop()
- self._demo_mode = False
- self.position_changed.emit(0.0)
- self.time_changed.emit(0, self._duration)
- self.state_changed.emit("demo_ended")
- return
-
- # VU-meter: brug VLC's audio-amplitude hvis tilgængelig, ellers simulér
- if VLC_AVAILABLE and self._media_player and self._media_player.is_playing():
- # VLC eksponerer ikke amplitude direkte — vi bruger en blød simulation
- # der er baseret på position så det ser organisk ud
- base = 0.55 + 0.3 * abs(pos - 0.5)
- l = min(1.0, base + random.gauss(0, 0.12))
- r = min(1.0, base + random.gauss(0, 0.12))
- else:
- l = r = 0.0
-
- self.levels_changed.emit(max(0.0, l), max(0.0, r))
-
- def _on_end_reached(self, event):
- """Kaldes fra VLC's event-tråd — må IKKE røre Qt-objekter direkte."""
- # QTimer.singleShot er thread-safe og sender alt til main thread
- from PyQt6.QtCore import QTimer as _QTimer
- _QTimer.singleShot(0, self._handle_end_in_main_thread)
-
- def _handle_end_in_main_thread(self):
- """Kaldes i main thread — her er det sikkert at røre Qt."""
- self._poll_timer.stop()
- self.song_ended.emit()
- self.state_changed.emit("stopped")
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index b005ffb1..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-PyQt6>=6.6.0
-python-vlc>=3.0.18
-mutagen>=1.47.0
-watchdog>=4.0.0
-
-# BPM-analyse
-librosa>=0.10.0
diff --git a/ui/__init__.py b/ui/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/ui/library_manager.py b/ui/library_manager.py
deleted file mode 100644
index 3fdf047f..00000000
--- a/ui/library_manager.py
+++ /dev/null
@@ -1,135 +0,0 @@
-"""
-library_manager.py — Dialog til at se og fjerne musikbiblioteker.
-"""
-
-from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel,
- QPushButton, QListWidget, QListWidgetItem, QMessageBox,
-)
-from PyQt6.QtCore import Qt, pyqtSignal
-
-
-class LibraryManagerDialog(QDialog):
- library_removed = pyqtSignal(int) # library_id
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Administrer musikbiblioteker")
- self.setMinimumWidth(500)
- self.setMinimumHeight(320)
- self._build_ui()
- self._load()
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(16, 16, 16, 16)
- layout.setSpacing(10)
-
- lbl = QLabel("Aktive musikbiblioteker:")
- lbl.setObjectName("track_meta")
- layout.addWidget(lbl)
-
- self._list = QListWidget()
- layout.addWidget(self._list)
-
- note = QLabel(
- "Når du fjerner et bibliotek, slettes det fra overvågningen.\n"
- "Sangene forbliver i databasen men markeres som manglende (⚠)."
- )
- note.setObjectName("result_count")
- note.setWordWrap(True)
- layout.addWidget(note)
-
- btn_row = QHBoxLayout()
- btn_add = QPushButton("+ Tilføj mappe")
- btn_add.clicked.connect(self._add_folder)
- btn_row.addWidget(btn_add)
-
- btn_remove = QPushButton("✕ Fjern valgt")
- btn_remove.clicked.connect(self._remove_selected)
- btn_row.addWidget(btn_remove)
-
- btn_scan = QPushButton("⟳ Scan alle")
- btn_scan.setToolTip("Scan alle mapper for nye og ændrede filer")
- btn_scan.clicked.connect(self._scan_all)
- btn_row.addWidget(btn_scan)
-
- btn_row.addStretch()
- btn_close = QPushButton("Luk")
- btn_close.clicked.connect(self.accept)
- btn_row.addWidget(btn_close)
- layout.addLayout(btn_row)
-
- def _load(self):
- self._list.clear()
- try:
- from local.local_db import get_libraries, get_db
- libs = get_libraries(active_only=True) # kun aktive
- for lib in libs:
- from pathlib import Path
- path = lib["path"]
- exists = Path(path).exists()
- last_scan = lib["last_full_scan"] or "aldrig"
- if isinstance(last_scan, str) and len(last_scan) > 10:
- last_scan = last_scan[:10]
- with get_db() as conn:
- count = conn.execute(
- "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0",
- (lib["id"],)
- ).fetchone()[0]
- exist_icon = "" if exists else " ⚠ mappe ikke fundet"
- label = f"{path}{exist_icon}\n {count} sange · senest scannet: {last_scan}"
- item = QListWidgetItem(label)
- item.setData(Qt.ItemDataRole.UserRole, dict(lib))
- if not exists:
- from PyQt6.QtGui import QColor
- item.setForeground(QColor("#5a6070"))
- self._list.addItem(item)
- except Exception as e:
- print(f"Library manager load fejl: {e}")
-
- def _scan_all(self):
- mw = self.parent()
- if hasattr(mw, "start_scan"):
- mw.start_scan()
- self._set_status("Scanning startet...")
-
- def _set_status(self, text: str):
- pass # kan udvides med statuslinje i dialogen
-
- def _add_folder(self):
- from PyQt6.QtWidgets import QFileDialog
- folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
- if folder:
- mw = self.parent()
- if hasattr(mw, "add_library_path"):
- mw.add_library_path(folder)
- # Genindlæs listen efter kort pause så DB er opdateret
- from PyQt6.QtCore import QTimer
- QTimer.singleShot(600, self._load)
-
- def _remove_selected(self):
- item = self._list.currentItem()
- if not item:
- return
- lib = item.data(Qt.ItemDataRole.UserRole)
- reply = QMessageBox.question(
- self, "Fjern bibliotek",
- f"Fjern overvågningen af:\n{lib['path']}\n\n"
- "Sange i biblioteket forbliver i databasen men markeres som manglende.",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- )
- if reply == QMessageBox.StandardButton.Yes:
- try:
- mw = self.parent()
- if hasattr(mw, "_watcher") and mw._watcher:
- mw._watcher.remove_library(lib["id"])
- else:
- from local.local_db import remove_library
- remove_library(lib["id"])
- self.library_removed.emit(lib["id"])
- if hasattr(mw, "_reload_library"):
- mw._reload_library()
- self._load()
- except Exception as e:
- QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}")
diff --git a/ui/library_panel.py b/ui/library_panel.py
deleted file mode 100644
index 62f6fa63..00000000
--- a/ui/library_panel.py
+++ /dev/null
@@ -1,274 +0,0 @@
-"""
-library_panel.py — Musikbibliotek med søgning og drag-and-drop til danseliste.
-"""
-
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
- QLineEdit, QLabel, QHBoxLayout, QPushButton, QProgressBar,
- QAbstractItemView,
-)
-from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray
-from PyQt6.QtGui import QColor, QDrag
-
-
-class DraggableLibraryList(QListWidget):
- """QListWidget der understøtter drag-start med sang-data som mime."""
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setDragEnabled(True)
- self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
- self.setDefaultDropAction(Qt.DropAction.CopyAction)
-
- def startDrag(self, supported_actions):
- item = self.currentItem()
- if not item:
- return
- song = item.data(Qt.ItemDataRole.UserRole)
- if not song:
- return
-
- import json
- data = json.dumps(song).encode("utf-8")
-
- mime = QMimeData()
- mime.setData("application/x-linedance-song", QByteArray(data))
- mime.setText(song.get("title", ""))
-
- drag = QDrag(self)
- drag.setMimeData(mime)
- drag.exec(Qt.DropAction.CopyAction)
-
-
-class LibraryPanel(QWidget):
- song_selected = pyqtSignal(dict)
- add_to_playlist = pyqtSignal(dict)
- scan_requested = pyqtSignal()
- edit_tags_requested = pyqtSignal(dict)
- send_mail_requested = pyqtSignal(dict)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._all_songs: list[dict] = []
- self._filtered: list[dict] = []
- self._search_timer = QTimer(self)
- self._search_timer.setSingleShot(True)
- self._search_timer.setInterval(150)
- self._search_timer.timeout.connect(self._do_search)
- self._build_ui()
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(0)
-
- # Header
- header = QHBoxLayout()
- header.setContentsMargins(10, 6, 10, 6)
- lbl = QLabel("BIBLIOTEK")
- lbl.setObjectName("section_title")
- header.addWidget(lbl)
- header.addStretch()
-
- btn_manage = QPushButton("⚙ Mapper")
- btn_manage.setFixedHeight(24)
- btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
- btn_manage.clicked.connect(self._manage_libraries)
- header.addWidget(btn_manage)
- layout.addLayout(header)
-
- # Scan status
- self._scan_bar = QProgressBar()
- self._scan_bar.setObjectName("scan_bar")
- self._scan_bar.setTextVisible(True)
- self._scan_bar.setFormat("Scanner...")
- self._scan_bar.setFixedHeight(16)
- self._scan_bar.setRange(0, 0)
- self._scan_bar.hide()
- layout.addWidget(self._scan_bar)
-
- self._scan_label = QLabel("")
- self._scan_label.setObjectName("result_count")
- self._scan_label.hide()
- layout.addWidget(self._scan_label)
-
- # Søgefelt
- self._search = QLineEdit()
- self._search.setPlaceholderText("Søg i titel, artist, album, dans...")
- self._search.textChanged.connect(self._on_search_changed)
- layout.addWidget(self._search)
-
- # Resultat-tæller + drag-hint
- hint_row = QHBoxLayout()
- hint_row.setContentsMargins(8, 2, 8, 2)
- self._count_label = QLabel("0 sange")
- self._count_label.setObjectName("result_count")
- hint_row.addWidget(self._count_label)
- hint_row.addStretch()
- drag_hint = QLabel("træk til danseliste →")
- drag_hint.setObjectName("result_count")
- hint_row.addWidget(drag_hint)
- layout.addLayout(hint_row)
-
- # Liste — draggable
- self._list = DraggableLibraryList()
- self._list.setObjectName("library_list")
- self._list.itemDoubleClicked.connect(self._on_double_click)
- self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- self._list.customContextMenuRequested.connect(self._show_context_menu)
- layout.addWidget(self._list)
-
- # ── Scanning ──────────────────────────────────────────────────────────────
-
- def _on_scan_clicked(self):
- self.scan_requested.emit()
-
- def set_scanning(self, scanning: bool, status_text: str = ""):
- if scanning:
- self._scan_bar.show()
- self._scan_label.setText(status_text or "Starter...")
- self._scan_label.show()
- else:
- self._scan_bar.hide()
- self._scan_label.hide()
-
- def update_scan_status(self, text: str):
- self._scan_label.setText(text)
-
- # ── Sange ─────────────────────────────────────────────────────────────────
-
- def load_songs(self, songs: list[dict]):
- self._all_songs = songs
- self._do_search()
-
- # ── Søgning ───────────────────────────────────────────────────────────────
-
- def _on_search_changed(self):
- self._search_timer.start()
-
- def _do_search(self):
- q = self._search.text().strip().lower()
- self._filtered = [s for s in self._all_songs if self._matches(s, q)] if q else list(self._all_songs)
- total = len(self._all_songs)
- found = len(self._filtered)
- q_text = self._search.text().strip()
- self._count_label.setText(
- f"{found} resultat{'er' if found != 1 else ''} for \"{q_text}\"" if q_text
- else f"{total} sang{'e' if total != 1 else ''}"
- )
- self._render()
-
- def _matches(self, song: dict, q: str) -> bool:
- return any(q in f.lower() for f in [
- song.get("title", ""), song.get("artist", ""),
- song.get("album", ""), song.get("file_format", ""),
- ] + song.get("dances", []))
-
- def _render(self):
- self._list.clear()
- q = self._search.text().strip().lower()
- for song in self._filtered:
- dances = song.get("dances", [])
- dance_levels = song.get("dance_levels", [])
- missing = song.get("file_missing", False)
-
- # Byg dans-streng med niveau hvis tilgængeligt
- dance_parts = []
- for i, d in enumerate(dances):
- lvl = dance_levels[i] if i < len(dance_levels) else ""
- dance_parts.append(f"{d} / {lvl}" if lvl else d)
- dance_str = " · " + " | ".join(dance_parts) if dance_parts else ""
-
- line1 = ("⚠ " if missing else "") + song.get("title", "—")
- bpm = song.get("bpm", 0)
- bpm_str = f"{bpm} BPM" if bpm else "? BPM"
- line2 = f" {song.get('artist','—')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}"
- item = QListWidgetItem(f"{line1}\n{line2}")
- item.setData(Qt.ItemDataRole.UserRole, song)
- if missing:
- item.setForeground(QColor("#5a6070"))
- elif q and any(q in d.lower() for d in dances):
- item.setForeground(QColor("#e8a020"))
- self._list.addItem(item)
-
- # ── Handlinger ────────────────────────────────────────────────────────────
-
- def _on_double_click(self, item: QListWidgetItem):
- song = item.data(Qt.ItemDataRole.UserRole)
- if song:
- self.song_selected.emit(song)
-
- def _show_context_menu(self, pos):
- from PyQt6.QtWidgets import QMenu
- item = self._list.itemAt(pos)
- if not item:
- return
- song = item.data(Qt.ItemDataRole.UserRole)
- if not song:
- return
- menu = QMenu(self)
- act_add = menu.addAction("Tilføj til danseliste")
- act_play = menu.addAction("Afspil")
- menu.addSeparator()
- act_tags = menu.addAction("✎ Rediger dans-tags...")
- act_bpm = menu.addAction("♩ Analysér BPM")
- menu.addSeparator()
- send_menu = menu.addMenu("Send til")
- act_mail = send_menu.addAction("✉ Send som mail")
- action = menu.exec(self._list.mapToGlobal(pos))
- if action == act_add:
- self.add_to_playlist.emit(song)
- elif action == act_play:
- self.song_selected.emit(song)
- elif action == act_tags:
- self.edit_tags_requested.emit(song)
- elif action == act_bpm:
- self._analyze_bpm(song)
- elif action == act_mail:
- self.send_mail_requested.emit(song)
-
- def _analyze_bpm(self, song: dict):
- """Analysér BPM i baggrundstråd og opdater biblioteket."""
- path = song.get("local_path", "")
- song_id = song.get("id", "")
- if not path or not song_id:
- return
- from PyQt6.QtCore import QThread, pyqtSignal as _sig
-
- class BpmWorker(QThread):
- done = _sig(float)
- def __init__(self, p, sid):
- super().__init__()
- self._p, self._sid = p, sid
- def run(self):
- from local.tag_reader import analyze_and_save_bpm
- bpm = analyze_and_save_bpm(self._p, self._sid)
- if bpm:
- self.done.emit(bpm)
-
- self._bpm_worker = BpmWorker(path, song_id)
-
- def on_bpm_done(bpm):
- # Opdater sangen i _all_songs listen direkte
- for s in self._all_songs:
- if s.get("id") == song_id:
- s["bpm"] = int(round(bpm))
- break
- self._do_search()
-
- self._bpm_worker.done.connect(on_bpm_done)
- self._bpm_worker.start()
-
- def _manage_libraries(self):
- from ui.library_manager import LibraryManagerDialog
- dialog = LibraryManagerDialog(parent=self.window())
- dialog.library_removed.connect(lambda _: self.scan_requested.emit())
- dialog.exec()
-
- def _add_folder(self):
- from PyQt6.QtWidgets import QFileDialog
- folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
- if folder:
- mw = self.window()
- if hasattr(mw, "add_library_path"):
- mw.add_library_path(folder)
diff --git a/ui/login_dialog.py b/ui/login_dialog.py
deleted file mode 100644
index f87847b1..00000000
--- a/ui/login_dialog.py
+++ /dev/null
@@ -1,139 +0,0 @@
-"""
-login_dialog.py — Login-dialog til at gå online.
-Server-URL er hardcodet i config.
-"""
-
-from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel,
- QLineEdit, QPushButton, QFrame, QCheckBox,
-)
-from PyQt6.QtCore import Qt, QSettings
-
-# ── Hardcodet server-URL ──────────────────────────────────────────────────────
-API_URL = "http://din-server:8000"
-# ─────────────────────────────────────────────────────────────────────────────
-
-
-class LoginDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Gå online")
- self.setFixedWidth(340)
- self.setModal(True)
-
- self._token: str | None = None
- self._username: str | None = None
- self._api_url = API_URL
-
- self._build_ui()
- self._load_saved_settings()
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setSpacing(10)
- layout.setContentsMargins(20, 20, 20, 20)
-
- title = QLabel("Log ind på LineDance")
- title.setObjectName("track_title")
- title.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(title)
-
- sub = QLabel("Synkroniser projekter og alternativ-danse med andre brugere")
- sub.setObjectName("track_meta")
- sub.setWordWrap(True)
- sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
- layout.addWidget(sub)
-
- line = QFrame()
- line.setFrameShape(QFrame.Shape.HLine)
- layout.addWidget(line)
-
- layout.addWidget(QLabel("Brugernavn:"))
- self._user_input = QLineEdit()
- self._user_input.setPlaceholderText("dit-brugernavn")
- layout.addWidget(self._user_input)
-
- layout.addWidget(QLabel("Kodeord:"))
- self._pass_input = QLineEdit()
- self._pass_input.setEchoMode(QLineEdit.EchoMode.Password)
- self._pass_input.setPlaceholderText("••••••••")
- self._pass_input.returnPressed.connect(self._on_login)
- layout.addWidget(self._pass_input)
-
- self._remember = QCheckBox("Husk brugernavn")
- self._remember.setChecked(True)
- layout.addWidget(self._remember)
-
- self._status_label = QLabel("")
- self._status_label.setObjectName("track_meta")
- self._status_label.setWordWrap(True)
- layout.addWidget(self._status_label)
-
- btn_row = QHBoxLayout()
- btn_cancel = QPushButton("Annuller")
- btn_cancel.clicked.connect(self.reject)
- btn_row.addWidget(btn_cancel)
-
- self._btn_login = QPushButton("Log ind")
- self._btn_login.setObjectName("btn_play")
- self._btn_login.setDefault(True)
- self._btn_login.clicked.connect(self._on_login)
- btn_row.addWidget(self._btn_login)
-
- layout.addLayout(btn_row)
-
- def _load_saved_settings(self):
- settings = QSettings("LineDance", "Player")
- self._user_input.setText(settings.value("username", ""))
-
- def _save_settings(self):
- if self._remember.isChecked():
- settings = QSettings("LineDance", "Player")
- settings.setValue("username", self._user_input.text().strip())
-
- def _on_login(self):
- username = self._user_input.text().strip()
- password = self._pass_input.text()
-
- if not username or not password:
- self._set_status("Udfyld brugernavn og kodeord", error=True)
- return
-
- self._btn_login.setEnabled(False)
- self._set_status("Forbinder...")
-
- try:
- import urllib.request, urllib.parse, json
-
- data = urllib.parse.urlencode({
- "username": username,
- "password": password,
- }).encode()
-
- req = urllib.request.Request(
- f"{API_URL}/auth/login",
- data=data,
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- method="POST",
- )
- with urllib.request.urlopen(req, timeout=8) as resp:
- body = json.loads(resp.read())
- self._token = body.get("access_token")
- self._username = username
-
- self._save_settings()
- self._set_status("Logget ind!", error=False)
- self.accept()
-
- except Exception as e:
- self._set_status(f"Fejl: {e}", error=True)
- self._btn_login.setEnabled(True)
-
- def _set_status(self, text: str, error: bool = False):
- self._status_label.setText(text)
- color = "#e74c3c" if error else "#2ecc71"
- self._status_label.setStyleSheet(f"color: {color};")
-
- def get_credentials(self) -> tuple[str, str, str]:
- """Returnerer (api_url, username, token) efter succesfuldt login."""
- return self._api_url, self._username, self._token
diff --git a/ui/main_window.py b/ui/main_window.py
deleted file mode 100644
index e5c829cb..00000000
--- a/ui/main_window.py
+++ /dev/null
@@ -1,927 +0,0 @@
-"""
-main_window.py — Linedance afspiller hovedvindue.
-"""
-
-from PyQt6.QtWidgets import (
- QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
- QPushButton, QSlider, QLabel, QFrame, QSplitter,
- QSizePolicy, QMenuBar, QMenu, QStatusBar, QFileDialog,
- QMessageBox,
-)
-from PyQt6.QtCore import Qt, QTimer
-from PyQt6.QtGui import QAction
-
-from ui.vu_meter import VUMeter
-from ui.playlist_panel import PlaylistPanel
-from ui.library_panel import LibraryPanel
-from ui.themes import apply_theme
-from ui.scan_worker import ScanWorker
-from ui.login_dialog import LoginDialog, API_URL
-from ui.playlist_manager import PlaylistManagerDialog
-from ui.settings_dialog import SettingsDialog, load_settings
-from player.player import Player
-
-
-class ProgressBar(QWidget):
- def __init__(self, parent=None):
- super().__init__(parent)
- self._fraction = 0.0
- self._demo_fraction = 0.0
- self.setFixedHeight(10)
- self.setCursor(Qt.CursorShape.PointingHandCursor)
-
- def set_fraction(self, f: float):
- self._fraction = max(0.0, min(1.0, f))
- self.update()
-
- def set_demo_marker(self, f: float):
- self._demo_fraction = max(0.0, min(1.0, f))
- self.update()
-
- def paintEvent(self, event):
- from PyQt6.QtGui import QPainter, QColor
- p = QPainter(self)
- w, h = self.width(), self.height()
- p.fillRect(0, 0, w, h, QColor("#2c3038"))
- fill_w = int(w * self._fraction)
- if fill_w > 0:
- p.fillRect(0, 0, fill_w, h, QColor("#e8a020"))
- if self._demo_fraction > 0:
- mx = int(w * self._demo_fraction)
- p.fillRect(mx - 1, 0, 2, h, QColor("#3b8fd4"))
- p.end()
-
- def mousePressEvent(self, event):
- if event.button() == Qt.MouseButton.LeftButton:
- fraction = event.position().x() / self.width()
- mw = self.window()
- if hasattr(mw, "_on_seek"):
- mw._on_seek(fraction)
-
-
-class MainWindow(QMainWindow):
- def __init__(self):
- super().__init__()
- self.setWindowTitle("LineDance Player")
- self.setMinimumSize(1000, 680)
- self.resize(1600, 820)
-
- self._dark_theme = True
- self._player = Player(self)
- self._current_idx = -1
- self._song_ended = False
- self._demo_active = False
- self._watcher = None
- self._scan_worker = None
- self._api_url: str | None = None
- self._api_token: str | None = None
- self._api_username: str | None = None
-
- # Indlæs indstillinger
- self._settings = load_settings()
- self._dark_theme = self._settings.get("dark_theme", True)
- self._demo_seconds = self._settings.get("demo_seconds", 10)
-
- self._connect_player_signals()
- self._build_menu()
- self._build_ui()
- self._build_statusbar()
- apply_theme(self._app_ref(), dark=self._dark_theme)
- self._theme_btn.setText("☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA")
-
- # Gendan gemt vinduestørrelse og splitter-position
- self._restore_window_state()
-
- # Start DB og scanning ved opstart
- QTimer.singleShot(200, self._init_local_db)
-
- # Auto-login hvis aktiveret i indstillinger
- if self._settings.get("auto_login") and self._settings.get("password"):
- QTimer.singleShot(800, self._auto_login)
-
- def _app_ref(self):
- from PyQt6.QtWidgets import QApplication
- return QApplication.instance()
-
- def _connect_player_signals(self):
- self._player.position_changed.connect(self._on_position)
- self._player.time_changed.connect(self._on_time)
- self._player.levels_changed.connect(self._on_levels)
- self._player.song_ended.connect(self._on_song_ended)
- self._player.state_changed.connect(self._on_state_changed)
-
- # ── Menu ──────────────────────────────────────────────────────────────────
-
- def _build_menu(self):
- menubar = self.menuBar()
-
- # ── Filer ─────────────────────────────────────────────────────────────
- file_menu = menubar.addMenu("Filer")
-
- self._act_go_online = QAction("Gå online...", self)
- self._act_go_online.setShortcut("Ctrl+L")
- self._act_go_online.triggered.connect(self._go_online)
- file_menu.addAction(self._act_go_online)
-
- self._act_go_offline = QAction("Gå offline", self)
- self._act_go_offline.triggered.connect(self._go_offline)
- self._act_go_offline.setEnabled(False)
- file_menu.addAction(self._act_go_offline)
-
- file_menu.addSeparator()
-
- act_settings = QAction("Indstillinger...", self)
- act_settings.setShortcut("Ctrl+,")
- act_settings.triggered.connect(self._open_settings)
- file_menu.addAction(act_settings)
-
- file_menu.addSeparator()
-
- act_quit = QAction("Afslut", self)
- act_quit.setShortcut("Ctrl+Q")
- act_quit.triggered.connect(self.close)
- file_menu.addAction(act_quit)
-
- # ── Ingen Danseliste- eller Visning-menu ──────────────────────────────
- # Ny/Gem/Hent ligger direkte i danseliste-panelet
- # Tema-skift ligger i topbar-knappen
- # Mapper og scan ligger i ⚙ Mapper dialogen
-
- # Gem reference til scan-action (bruges stadig internt)
- self._act_scan = QAction("Scan", self)
- self._act_scan.triggered.connect(self.start_scan)
-
- # ── Statuslinje ───────────────────────────────────────────────────────────
-
- def _build_statusbar(self):
- self._statusbar = QStatusBar()
- self.setStatusBar(self._statusbar)
- self._statusbar.showMessage("Klar")
-
- def _set_status(self, text: str, timeout_ms: int = 0):
- """Vis besked i statuslinjen. timeout_ms=0 = permanent."""
- self._statusbar.showMessage(text, timeout_ms)
-
- # ── UI byggeri ────────────────────────────────────────────────────────────
-
- def _build_ui(self):
- root = QWidget()
- root.setObjectName("root")
- self.setCentralWidget(root)
- main_layout = QVBoxLayout(root)
- main_layout.setContentsMargins(10, 6, 10, 10)
- main_layout.setSpacing(4)
-
- main_layout.addWidget(self._build_topbar())
- main_layout.addWidget(self._build_now_playing())
- main_layout.addWidget(self._build_progress())
- main_layout.addWidget(self._build_transport())
- main_layout.addWidget(self._build_panels(), stretch=1)
-
- def _build_topbar(self) -> QFrame:
- bar = QFrame()
- bar.setObjectName("topbar")
- layout = QHBoxLayout(bar)
- layout.setContentsMargins(12, 6, 12, 6)
-
- logo = QLabel("LINEDANCE PLAYER")
- logo.setObjectName("logo")
- logo.setTextFormat(Qt.TextFormat.RichText)
- layout.addWidget(logo)
- layout.addStretch()
-
- self._conn_label = QLabel("● OFFLINE")
- self._conn_label.setObjectName("conn_label")
- layout.addWidget(self._conn_label)
-
- self._theme_btn = QPushButton("☀ LYS TEMA")
- self._theme_btn.setFixedHeight(26)
- self._theme_btn.clicked.connect(self._toggle_theme)
- layout.addWidget(self._theme_btn)
-
- return bar
-
- def _build_now_playing(self) -> QFrame:
- frame = QFrame()
- frame.setObjectName("now_playing_frame")
- layout = QHBoxLayout(frame)
- layout.setContentsMargins(12, 10, 12, 10)
-
- track_frame = QFrame()
- track_frame.setObjectName("track_display")
- track_layout = QVBoxLayout(track_frame)
- track_layout.setContentsMargins(10, 8, 10, 8)
- track_layout.setSpacing(3)
-
- self._lbl_title = QLabel("—")
- self._lbl_title.setObjectName("track_title")
- track_layout.addWidget(self._lbl_title)
-
- self._lbl_meta = QLabel("—")
- self._lbl_meta.setObjectName("track_meta")
- track_layout.addWidget(self._lbl_meta)
-
- self._lbl_dances = QLabel("")
- self._lbl_dances.setObjectName("track_meta")
- self._lbl_dances.setWordWrap(True)
- track_layout.addWidget(self._lbl_dances)
-
- layout.addWidget(track_frame, stretch=1)
-
- self._vu = VUMeter()
- layout.addWidget(self._vu)
-
- return frame
-
- def _build_progress(self) -> QFrame:
- frame = QFrame()
- frame.setObjectName("progress_frame")
- layout = QHBoxLayout(frame)
- layout.setContentsMargins(12, 6, 12, 6)
- layout.setSpacing(8)
-
- self._lbl_cur = QLabel("0:00")
- self._lbl_cur.setObjectName("track_meta")
- self._lbl_cur.setFixedWidth(36)
- layout.addWidget(self._lbl_cur)
-
- self._progress = ProgressBar(self)
- self._progress.setSizePolicy(
- QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
- )
- layout.addWidget(self._progress, stretch=1)
-
- self._lbl_tot = QLabel("0:00")
- self._lbl_tot.setObjectName("track_meta")
- self._lbl_tot.setFixedWidth(36)
- self._lbl_tot.setAlignment(Qt.AlignmentFlag.AlignRight)
- layout.addWidget(self._lbl_tot)
-
- return frame
-
- def _build_transport(self) -> QFrame:
- frame = QFrame()
- frame.setObjectName("transport_frame")
- layout = QHBoxLayout(frame)
- layout.setContentsMargins(14, 10, 14, 10)
- layout.setSpacing(8)
-
- def btn(text, name=None, size=52, checkable=False):
- b = QPushButton(text)
- if name:
- b.setObjectName(name)
- b.setFixedSize(size, size)
- if checkable:
- b.setCheckable(True)
- return b
-
- self._btn_prev = btn("⏮", size=52)
- self._btn_play = btn("▶", "btn_play", size=72)
- self._btn_stop = btn("⏹", "btn_stop", size=52)
- self._btn_next = btn("⏭", size=52)
- self._btn_demo = btn(f"▶\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True)
-
- self._btn_prev.clicked.connect(self._prev_song)
- self._btn_play.clicked.connect(self._toggle_play)
- self._btn_stop.clicked.connect(self._stop)
- self._btn_next.clicked.connect(self._next_song)
- self._btn_demo.clicked.connect(self._toggle_demo)
-
- layout.addWidget(self._btn_prev)
- layout.addWidget(self._btn_play)
- layout.addWidget(self._btn_stop)
- layout.addWidget(self._btn_next)
-
- sep1 = QFrame()
- sep1.setFrameShape(QFrame.Shape.VLine)
- sep1.setFixedWidth(1)
- layout.addWidget(sep1)
-
- layout.addWidget(self._btn_demo)
- layout.addStretch()
-
- lbl_vol = QLabel("VOL")
- lbl_vol.setObjectName("vol_label")
- layout.addWidget(lbl_vol)
-
- self._vol_slider = QSlider(Qt.Orientation.Horizontal)
- self._vol_slider.setRange(0, 100)
- self._vol_slider.setValue(78)
- self._vol_slider.setFixedWidth(100)
- self._vol_slider.valueChanged.connect(self._on_volume)
- layout.addWidget(self._vol_slider)
-
- self._lbl_vol = QLabel("78")
- self._lbl_vol.setObjectName("vol_val")
- layout.addWidget(self._lbl_vol)
-
- return frame
-
- def _build_panels(self) -> QSplitter:
- self._splitter = QSplitter(Qt.Orientation.Horizontal)
-
- self._playlist_panel = PlaylistPanel()
- self._playlist_panel.song_selected.connect(self._load_song_by_idx)
- self._playlist_panel.song_dropped.connect(self._on_song_dropped)
- self._playlist_panel.event_started.connect(self._on_event_started)
- self._playlist_panel.next_song_ready.connect(self._load_song)
-
- self._library_panel = LibraryPanel()
- self._library_panel.song_selected.connect(self._on_library_song_selected)
- self._library_panel.add_to_playlist.connect(self._add_song_to_playlist)
- self._library_panel.scan_requested.connect(self.start_scan)
- self._library_panel.edit_tags_requested.connect(self._open_tag_editor)
- self._library_panel.send_mail_requested.connect(self._send_mail)
-
- self._splitter.addWidget(self._playlist_panel)
- self._splitter.addWidget(self._library_panel)
- self._splitter.setSizes([700, 900])
-
- return self._splitter
-
- def _restore_window_state(self):
- from PyQt6.QtCore import QSettings, QByteArray
- settings = QSettings("LineDance", "Player")
- geom = settings.value("window/geometry")
- if geom:
- self.restoreGeometry(geom)
- splitter_state = settings.value("window/splitter")
- if splitter_state and hasattr(self, "_splitter"):
- self._splitter.restoreState(splitter_state)
-
- def _save_window_state(self):
- from PyQt6.QtCore import QSettings
- settings = QSettings("LineDance", "Player")
- settings.setValue("window/geometry", self.saveGeometry())
- if hasattr(self, "_splitter"):
- settings.setValue("window/splitter", self._splitter.saveState())
-
- # ── Lokal DB + scanning ───────────────────────────────────────────────────
-
- def _init_local_db(self):
- try:
- import sys, os
- sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
- from local.local_db import init_db
- from local.file_watcher import get_watcher
-
- init_db()
-
- # Brug et Qt signal til thread-safe reload fra watcher-tråden
- from PyQt6.QtCore import QMetaObject, Q_ARG
- def on_file_change(event_type, path, song_id):
- QTimer.singleShot(0, self._reload_library)
-
- self._watcher = get_watcher(on_change=on_file_change)
- self._watcher.start()
-
- # Indlæs hvad vi allerede kender fra SQLite
- self._reload_library()
-
- # Gendan sidst aktive danseliste
- restored = self._playlist_panel.restore_active_playlist()
-
- # Gendan event-fremgang hvis liste blev gendannet
- if restored:
- if self._playlist_panel.restore_event_state():
- # Indlæs den sang vi var nået til
- idx = self._playlist_panel._current_idx
- song = self._playlist_panel.get_song(idx)
- if song:
- self._current_idx = idx
- self._load_song(song)
- self._set_status(
- f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte",
- 6000,
- )
-
- # Kør automatisk scanning ved opstart
- self._set_status("Starter scanning af biblioteker...")
- QTimer.singleShot(100, self.start_scan)
-
- except Exception as e:
- self._set_status(f"DB fejl: {e}")
- print(f"DB init fejl: {e}")
-
- def start_scan(self):
- """Start fuld scanning af alle biblioteker i baggrundstråd."""
- if self._scan_worker and self._scan_worker.isRunning():
- return # Scanning kører allerede
-
- if not self._watcher:
- self._set_status("Ingen biblioteker at scanne — tilføj en mappe først")
- return
-
- self._library_panel.set_scanning(True, "Forbereder scanning...")
- self._act_scan.setEnabled(False)
-
- self._scan_worker = ScanWorker(self._watcher, parent=self)
- self._scan_worker.status_update.connect(self._on_scan_status)
- self._scan_worker.scan_done.connect(self._on_scan_done)
- self._scan_worker.start()
-
- def _on_scan_status(self, text: str):
- self._set_status(text)
- self._library_panel.update_scan_status(text)
-
- def _on_scan_done(self, count: int):
- self._library_panel.set_scanning(False)
- self._act_scan.setEnabled(True)
- msg = f"Scanning færdig — {count} filer gennemgået"
- self._set_status(msg, timeout_ms=5000)
- # Genindlæs biblioteket
- QTimer.singleShot(200, self._reload_library)
-
- def _reload_library(self):
- try:
- from local.local_db import search_songs, get_db
- songs_raw = search_songs("", limit=5000)
- songs = []
- for row in songs_raw:
- with get_db() as conn:
- dances_raw = conn.execute(
- "SELECT sd.dance_name, dl.name as level_name "
- "FROM song_dances sd "
- "LEFT JOIN dance_levels dl ON dl.id = sd.level_id "
- "WHERE sd.song_id=? ORDER BY sd.dance_order",
- (row["id"],)
- ).fetchall()
- songs.append({
- "id": row["id"],
- "title": row["title"],
- "artist": row["artist"],
- "album": row["album"],
- "bpm": row["bpm"],
- "duration_sec": row["duration_sec"],
- "local_path": row["local_path"],
- "file_format": row["file_format"],
- "file_missing": bool(row["file_missing"]),
- "dances": [d["dance_name"] for d in dances_raw],
- "dance_levels": [d["level_name"] or "" for d in dances_raw],
- })
- self._library_panel.load_songs(songs)
- count = len(songs)
- self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000)
- except Exception as e:
- print(f"Bibliotek reload fejl: {e}")
-
- def add_library_path(self, path: str):
- try:
- if not self._watcher:
- self._set_status("Watcher ikke klar endnu — prøv igen om et øjeblik", 3000)
- return
- self._watcher.add_library(path)
- self._set_status(f"Tilføjet: {path} — scanner...")
- # Genindlæs bibliotekslisten og start scan
- QTimer.singleShot(500, self._reload_library)
- QTimer.singleShot(1000, self.start_scan)
- except Exception as e:
- self._set_status(f"Fejl ved tilføjelse: {e}")
-
- def _open_settings(self):
- dialog = SettingsDialog(parent=self)
- if dialog.exec():
- self._settings = dialog.get_values()
- self._demo_seconds = self._settings.get("demo_seconds", 10)
- # Opdater tema hvis ændret
- new_dark = self._settings.get("dark_theme", True)
- if new_dark != self._dark_theme:
- self._dark_theme = new_dark
- apply_theme(self._app_ref(), dark=self._dark_theme)
- self._theme_btn.setText(
- "☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA"
- )
- self._vu.set_dark(self._dark_theme)
- # Opdater demo-knap tekst
- self._btn_demo.setText(f"▶\n{self._demo_seconds} SEK")
- # Opdater demo-markør hvis en sang er indlæst
- if hasattr(self, "_current_song") and self._current_song:
- dur = self._current_song.get("duration_sec", 0)
- if dur > 0:
- self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0))
- self._set_status("Indstillinger gemt", 2000)
-
- def _auto_login(self):
- """Forsøg automatisk login med gemte oplysninger."""
- username = self._settings.get("username", "")
- password = self._settings.get("password", "")
- if not username or not password:
- return
- try:
- import urllib.request, urllib.parse, json
- data = urllib.parse.urlencode({"username": username, "password": password}).encode()
- req = urllib.request.Request(
- f"{API_URL}/auth/login", data=data,
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- method="POST",
- )
- with urllib.request.urlopen(req, timeout=8) as resp:
- body = json.loads(resp.read())
- self._api_token = body.get("access_token")
- self._api_url = API_URL
- self._api_username = username
- self._set_online_state(True)
- self._set_status(f"Automatisk logget ind som {username}", 4000)
- # Synkroniser dans-niveauer og navne
- QTimer.singleShot(500, self._sync_dance_data)
- except Exception:
- self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000)
-
- def _go_online(self):
- dialog = LoginDialog(self)
- if dialog.exec():
- url, username, token = dialog.get_credentials()
- self._api_url = url
- self._api_token = token
- self._api_username = username
- self._set_online_state(True)
- self._set_status(f"Online som {username}", 5000)
- QTimer.singleShot(500, self._sync_dance_data)
-
- def _sync_dance_data(self):
- """Synkroniser dans-niveauer og navne fra API."""
- if not self._api_token:
- return
- try:
- import urllib.request, json
- headers = {"Authorization": f"Bearer {self._api_token}"}
-
- # Hent niveauer
- req = urllib.request.Request(f"{API_URL}/dances/levels", headers=headers)
- with urllib.request.urlopen(req, timeout=8) as resp:
- levels = json.loads(resp.read())
- from local.local_db import sync_dance_levels_from_api
- sync_dance_levels_from_api(levels)
-
- # Hent populære dans-navne
- req = urllib.request.Request(f"{API_URL}/dances/names?limit=500", headers=headers)
- with urllib.request.urlopen(req, timeout=8) as resp:
- names = json.loads(resp.read())
- from local.local_db import sync_dance_names_from_api
- sync_dance_names_from_api(names)
-
- self._set_status(f"Synkroniseret {len(levels)} niveauer og {len(names)} dans-navne", 4000)
- except Exception as e:
- print(f"Dans-sync fejl: {e}")
-
- def _go_offline(self):
- self._api_url = self._api_token = self._api_username = None
- self._set_online_state(False)
- self._set_status("Offline — arbejder lokalt", 3000)
-
- def _set_online_state(self, online: bool):
- self._act_go_online.setEnabled(not online)
- self._act_go_offline.setEnabled(online)
- if online:
- name = self._api_username or "?"
- self._conn_label.setText(f"● ONLINE ({name})")
- self._conn_label.setStyleSheet("color: #2ecc71;")
- else:
- self._conn_label.setText("● OFFLINE")
- self._conn_label.setStyleSheet("color: #5a6070;")
-
- def _new_playlist(self):
- self._stop()
- self._playlist_panel.load_songs([])
- self._playlist_panel.set_playlist_name("Ny liste")
- self._set_status("Ny danseliste oprettet", 2000)
-
- def _open_playlist_manager(self):
- dialog = PlaylistManagerDialog(
- current_songs=self._playlist_panel.get_songs(),
- parent=self,
- )
- dialog.playlist_loaded.connect(self._on_playlist_loaded)
- dialog.exec()
-
- def _on_playlist_loaded(self, name: str, songs: list[dict]):
- self._stop()
- self._playlist_panel.load_songs(songs)
- self._playlist_panel.set_playlist_name(name)
- self._set_status(f"Indlæst: {name} ({len(songs)} sange)", 3000)
-
- def _open_tag_editor(self, song: dict):
- from ui.tag_editor import TagEditorDialog
- dialog = TagEditorDialog(song, parent=self)
- if dialog.exec():
- # Genindlæs biblioteket så ændringer vises
- QTimer.singleShot(200, self._reload_library)
-
- def _send_mail(self, song: dict):
- import subprocess, sys, shutil, urllib.parse
- from pathlib import Path
-
- path = song.get("local_path", "")
- title = song.get("title", "")
- artist = song.get("artist", "")
-
- if not path or not Path(path).exists():
- self._set_status("Filen blev ikke fundet — kan ikke sende mail", 4000)
- return
-
- # ── Auto-detekter mailklient ───────────────────────────────────────────
-
- def try_thunderbird() -> bool:
- """Thunderbird: thunderbird -compose attachment='file:///sti'"""
- candidates = []
- if sys.platform == "win32":
- import winreg
- for base in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
- try:
- key = winreg.OpenKey(base,
- r"SOFTWARE\Mozilla\Mozilla Thunderbird")
- inst, _ = winreg.QueryValueEx(key, "Install Directory")
- candidates.append(str(Path(inst) / "thunderbird.exe"))
- except Exception:
- pass
- candidates += [
- r"C:\Program Files\Mozilla Thunderbird\thunderbird.exe",
- r"C:\Program Files (x86)\Mozilla Thunderbird\thunderbird.exe",
- ]
- elif sys.platform == "darwin":
- candidates = [
- "/Applications/Thunderbird.app/Contents/MacOS/thunderbird",
- ]
- else:
- candidates = [shutil.which("thunderbird") or "",
- "/usr/bin/thunderbird",
- "/usr/local/bin/thunderbird",
- "/snap/bin/thunderbird"]
-
- tb = next((c for c in candidates if c and Path(c).exists()), None)
- if not tb:
- return False
-
- file_uri = Path(path).as_uri()
- subject = f"Linedance sang: {title} — {artist}"
- compose = (
- f"subject='{subject}',"
- f"attachment='{file_uri}'"
- )
- subprocess.Popen([tb, "-compose", compose])
- return True
-
- def try_outlook() -> bool:
- """Outlook: outlook.exe /a 'filsti' (kun Windows)"""
- if sys.platform != "win32":
- return False
- candidates = [
- shutil.which("outlook") or "",
- r"C:\Program Files\Microsoft Office\root\Office16\OUTLOOK.EXE",
- r"C:\Program Files (x86)\Microsoft Office\root\Office16\OUTLOOK.EXE",
- r"C:\Program Files\Microsoft Office\Office16\OUTLOOK.EXE",
- ]
- ol = next((c for c in candidates if c and Path(c).exists()), None)
- if not ol:
- return False
- subprocess.Popen([ol, "/a", path])
- return True
-
- def fallback_mailto():
- """Ingen vedhæftning — åbn standard-mailprogram via mailto:"""
- subject = urllib.parse.quote(f"Linedance sang: {title} — {artist}")
- body = urllib.parse.quote(
- f"Sang: {title}\nArtist: {artist}\nFil: {path}\n\n"
- f"(Vedhæft filen manuelt fra ovenstående sti)"
- )
- mailto = f"mailto:?subject={subject}&body={body}"
- if sys.platform == "win32":
- import os; os.startfile(mailto)
- elif sys.platform == "darwin":
- subprocess.Popen(["open", mailto])
- else:
- subprocess.Popen(["xdg-open", mailto])
-
- # ── Prøv i rækkefølge ─────────────────────────────────────────────────
- if try_thunderbird():
- self._set_status(f"Thunderbird åbnet med {Path(path).name} vedh.", 4000)
- elif try_outlook():
- self._set_status(f"Outlook åbnet med {Path(path).name} vedh.", 4000)
- else:
- fallback_mailto()
- self._set_status(
- f"Ingen kendt mailklient fundet — åbnet mailto: (uden vedhæftning)", 5000
- )
-
- def _on_event_started(self):
- """Start event — indlæs første sang i afspilleren klar til afspilning."""
- first = self._playlist_panel.get_song(0)
- if not first:
- return
- self._stop()
- self._current_idx = 0
- self._song_ended = False
- self._load_song(first)
- self._set_status("Event klar — tryk ▶ for at starte", 5000)
-
- def _on_song_dropped(self, song: dict):
- self._set_status(f"Tilføjet: {song.get('title','')}", 2000)
-
- def _menu_add_folder(self):
- folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
- if folder:
- self.add_library_path(folder)
-
- # ── Afspilning ────────────────────────────────────────────────────────────
-
- def _load_song(self, song: dict):
- self._current_song = song
- self._song_ended = False
- self._demo_active = False
- self._btn_demo.setChecked(False)
-
- dur = song.get("duration_sec", 0)
- self._player.load(song.get("local_path", ""), dur)
-
- self._lbl_title.setText(song.get("title", "—"))
- bpm = song.get("bpm", 0)
- fmt_dur = f"{dur//60}:{dur%60:02d}"
- self._lbl_meta.setText(f"{song.get('artist','')} · {bpm} BPM · {fmt_dur}")
-
- dances = song.get("dances", [])
- self._lbl_dances.setText(
- " · ".join(f"[{d}]" for d in dances) if dances else "ingen danse tagget"
- )
-
- if dur > 0:
- self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0))
-
- self._set_status(f"Indlæst: {song.get('title','—')}", 3000)
-
- def _load_song_by_idx(self, idx: int):
- song = self._playlist_panel.get_song(idx)
- if not song:
- return
- self._current_idx = idx
- self._load_song(song)
- self._playlist_panel.set_current(idx)
-
- def _toggle_play(self):
- if self._demo_active:
- self._player.stop()
- self._demo_active = False
- self._btn_demo.setChecked(False)
- self._btn_play.setText("▶")
- return
- if self._player.is_playing():
- self._player.pause()
- else:
- self._song_ended = False
- self._player.play()
- self._btn_play.setText("⏸")
-
- def _stop(self):
- self._player.stop()
- self._song_ended = False
- self._demo_active = False
- self._btn_demo.setChecked(False)
- self._btn_play.setText("▶")
- self._vu.reset()
-
- def _toggle_demo(self):
- if self._demo_active:
- self._player.stop()
- self._demo_active = False
- self._btn_demo.setChecked(False)
- self._btn_play.setText("▶")
- else:
- self._demo_active = True
- self._btn_demo.setChecked(True)
- self._player.play_demo(stop_at_sec=self._demo_seconds)
- self._btn_play.setText("⏸")
-
- def _prev_song(self):
- if self._current_idx > 0:
- self._stop()
- self._load_song_by_idx(self._current_idx - 1)
-
- def _next_song(self):
- if self._current_idx < self._playlist_panel.count() - 1:
- self._stop()
- self._playlist_panel.mark_played(self._current_idx)
- self._load_song_by_idx(self._current_idx + 1)
-
- def _play_next(self):
- self._song_ended = False
- self._player.play()
- self._btn_play.setText("⏸")
-
- def _on_library_song_selected(self, song: dict):
- self._load_song(song)
- self._player.play()
- self._btn_play.setText("⏸")
-
- def _add_song_to_playlist(self, song: dict):
- songs = [self._playlist_panel.get_song(i)
- for i in range(self._playlist_panel.count())]
- songs = [s for s in songs if s]
- songs.append(song)
- self._playlist_panel.load_songs(songs)
- self._set_status(f"Tilføjet til danseliste: {song.get('title','')}", 2000)
-
- # ── Player signals ────────────────────────────────────────────────────────
-
- def _on_position(self, fraction: float):
- self._progress.set_fraction(fraction)
-
- def _on_time(self, cur: int, tot: int):
- self._lbl_cur.setText(f"{cur//60}:{cur%60:02d}")
- self._lbl_tot.setText(f"{tot//60}:{tot%60:02d}")
-
- def _on_levels(self, left: float, right: float):
- self._vu.set_levels(left, right)
-
- def _on_song_ended(self):
- self._song_ended = True
- self._demo_active = False
- self._btn_demo.setChecked(False)
- self._btn_play.setText("▶")
- self._vu.reset()
-
- # Markér den afspillede sang
- self._playlist_panel.mark_played(self._current_idx)
-
- # Synkroniser event-status til den gemte navngivne liste
- self._sync_event_status_to_playlist()
-
- # Find første ikke-afspillede og ikke-skippede sang fra TOPPEN
- ni = self._playlist_panel.next_playable_idx()
- next_song = self._playlist_panel.get_song(ni) if ni is not None else None
- if next_song:
- self._current_idx = ni
- self._playlist_panel.set_next_ready(ni)
- self._load_song(next_song)
- self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte")
- else:
- self._lbl_title.setText("— Danseliste afsluttet —")
- self._lbl_meta.setText("")
- self._lbl_dances.setText("")
- self._set_status("Danselisten er afsluttet")
-
- def _sync_event_status_to_playlist(self):
- """Gem event-fremgang i den aktive navngivne liste."""
- try:
- from local.local_db import get_db
- songs = self._playlist_panel.get_songs()
- statuses = self._playlist_panel.get_statuses()
- with get_db() as conn:
- # Find den aktive liste (ikke __aktiv__)
- pl = conn.execute(
- "SELECT id FROM playlists WHERE name != '__aktiv__' "
- "ORDER BY created_at DESC LIMIT 1"
- ).fetchone()
- if not pl:
- return
- # Opdater status for hver sang i listen
- for i, (song, status) in enumerate(zip(songs, statuses)):
- conn.execute("""
- UPDATE playlist_songs SET status=?
- WHERE playlist_id=? AND song_id=?
- """, (status, pl["id"], song.get("id")))
- except Exception as e:
- print(f"Event-status sync fejl: {e}")
-
- def _on_state_changed(self, state: str):
- if state == "playing":
- self._btn_play.setText("⏸")
- elif state in ("paused", "stopped"):
- self._btn_play.setText("▶")
- if state == "stopped" and not self._song_ended:
- self._vu.reset()
- elif state == "demo_ended":
- self._demo_active = False
- self._btn_demo.setChecked(False)
- self._btn_play.setText("▶")
- self._vu.reset()
-
- def _on_seek(self, fraction: float):
- self._player.set_position(fraction)
-
- def _on_volume(self, value: int):
- self._lbl_vol.setText(str(value))
- self._player.set_volume(value)
-
- # ── Tema ──────────────────────────────────────────────────────────────────
-
- def _toggle_theme(self):
- self._dark_theme = not self._dark_theme
- apply_theme(self._app_ref(), dark=self._dark_theme)
- self._theme_btn.setText(
- "● MØRKT TEMA" if not self._dark_theme else "☀ LYS TEMA"
- )
- self._vu.set_dark(self._dark_theme)
-
- # ── Luk ───────────────────────────────────────────────────────────────────
-
- def closeEvent(self, event):
- self._save_window_state()
- self._player.stop()
- if self._scan_worker and self._scan_worker.isRunning():
- self._scan_worker.quit()
- self._scan_worker.wait(2000)
- try:
- if self._watcher:
- self._watcher.stop()
- except Exception:
- pass
- event.accept()
diff --git a/ui/next_up_bar.py b/ui/next_up_bar.py
deleted file mode 100644
index 345a7465..00000000
--- a/ui/next_up_bar.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""
-next_up_bar.py — Banner der vises når en sang er færdig.
-"""
-
-from PyQt6.QtWidgets import (
- QFrame, QHBoxLayout, QVBoxLayout, QLabel, QPushButton,
-)
-from PyQt6.QtCore import pyqtSignal
-
-
-class NextUpBar(QFrame):
- play_next_clicked = pyqtSignal()
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setObjectName("next_up_frame")
- self.hide()
- self._build_ui()
-
- def _build_ui(self):
- layout = QHBoxLayout(self)
- layout.setContentsMargins(16, 10, 16, 10)
-
- # Tekst
- text_layout = QVBoxLayout()
- text_layout.setSpacing(2)
-
- self._label = QLabel("NÆSTE SANG KLAR")
- self._label.setObjectName("next_up_label")
- text_layout.addWidget(self._label)
-
- self._title = QLabel("—")
- self._title.setObjectName("next_up_title")
- text_layout.addWidget(self._title)
-
- self._sub = QLabel("—")
- self._sub.setObjectName("next_up_sub")
- text_layout.addWidget(self._sub)
-
- layout.addLayout(text_layout)
- layout.addStretch()
-
- # Knap
- self._btn = QPushButton("▶ AFSPIL NÆSTE")
- self._btn.setObjectName("btn_play_next")
- self._btn.setFixedHeight(44)
- self._btn.setMinimumWidth(160)
- self._btn.clicked.connect(self.play_next_clicked.emit)
- layout.addWidget(self._btn)
-
- def show_next(self, title: str, artist: str, dances: list[str]):
- dance_str = "Dans: " + ", ".join(dances) if dances else ""
- sub = f"{artist}{' · ' + dance_str if dance_str else ''}"
- self._title.setText(title)
- self._sub.setText(sub)
- self.show()
-
- def hide_bar(self):
- self.hide()
diff --git a/ui/playlist_manager.py b/ui/playlist_manager.py
deleted file mode 100644
index bfab4021..00000000
--- a/ui/playlist_manager.py
+++ /dev/null
@@ -1,324 +0,0 @@
-"""
-playlist_manager.py — Dialog til danseliste-administration.
-Ny liste, gem, load og importer M3U/M3U8/tekst.
-"""
-
-import os
-from pathlib import Path
-from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
- QPushButton, QListWidget, QListWidgetItem, QFileDialog,
- QMessageBox, QTabWidget, QWidget, QTextEdit,
-)
-from PyQt6.QtCore import Qt, pyqtSignal
-
-
-class PlaylistManagerDialog(QDialog):
- """
- Fanebaseret dialog med tre faner:
- 1. Gem aktuel liste
- 2. Indlæs gemt liste
- 3. Importer fra fil (M3U / M3U8 / tekst)
- """
- playlist_loaded = pyqtSignal(str, list) # (navn, liste af dict)
-
- def __init__(self, current_songs: list[dict], parent=None):
- super().__init__(parent)
- self.setWindowTitle("Danseliste-administration")
- self.setMinimumWidth(500)
- self.setMinimumHeight(460)
- self._current_songs = current_songs
- self._build_ui()
- self._load_saved_playlists()
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(16, 16, 16, 16)
-
- tabs = QTabWidget()
- tabs.addTab(self._build_save_tab(), "💾 Gem liste")
- tabs.addTab(self._build_load_tab(), "📂 Indlæs liste")
- tabs.addTab(self._build_import_tab(), "📥 Importer")
- layout.addWidget(tabs)
-
- btn_close = QPushButton("Luk")
- btn_close.clicked.connect(self.accept)
- row = QHBoxLayout()
- row.addStretch()
- row.addWidget(btn_close)
- layout.addLayout(row)
-
- # ── Fane 1: Gem ───────────────────────────────────────────────────────────
-
- def _build_save_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(10)
-
- layout.addWidget(QLabel(f"Aktuel liste har {len(self._current_songs)} sange."))
-
- layout.addWidget(QLabel("Navn på danselisten:"))
- self._save_name = QLineEdit()
- self._save_name.setPlaceholderText("f.eks. Sommer Event 2025")
- layout.addWidget(self._save_name)
-
- btn_save = QPushButton("💾 Gem")
- btn_save.clicked.connect(self._save_playlist)
- layout.addWidget(btn_save)
-
- self._save_status = QLabel("")
- self._save_status.setObjectName("result_count")
- layout.addWidget(self._save_status)
- layout.addStretch()
- return tab
-
- def _save_playlist(self):
- name = self._save_name.text().strip()
- if not name:
- self._save_status.setText("Angiv et navn")
- return
- if not self._current_songs:
- self._save_status.setText("Danselisten er tom")
- return
- try:
- from local.local_db import create_playlist, add_song_to_playlist, get_db
- pl_id = create_playlist(name)
- for i, song in enumerate(self._current_songs, start=1):
- add_song_to_playlist(pl_id, song["id"], position=i)
- self._save_status.setText(f"✓ Gemt som \"{name}\"")
- self._load_saved_playlists()
- except Exception as e:
- self._save_status.setText(f"Fejl: {e}")
-
- # ── Fane 2: Indlæs ────────────────────────────────────────────────────────
-
- def _build_load_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
-
- layout.addWidget(QLabel("Gemte danselister:"))
- self._pl_list = QListWidget()
- self._pl_list.itemDoubleClicked.connect(self._load_selected)
- layout.addWidget(self._pl_list)
-
- btn_row = QHBoxLayout()
- btn_load = QPushButton("📂 Indlæs valgte")
- btn_load.clicked.connect(self._load_selected_btn)
- btn_delete = QPushButton("🗑 Slet valgte")
- btn_delete.clicked.connect(self._delete_selected)
- btn_row.addWidget(btn_load)
- btn_row.addWidget(btn_delete)
- layout.addLayout(btn_row)
-
- self._load_status = QLabel("")
- self._load_status.setObjectName("result_count")
- layout.addWidget(self._load_status)
- return tab
-
- def _load_saved_playlists(self):
- if not hasattr(self, "_pl_list"):
- return
- self._pl_list.clear()
- try:
- from local.local_db import get_playlists
- for pl in get_playlists():
- item = QListWidgetItem(pl["name"])
- item.setData(Qt.ItemDataRole.UserRole, dict(pl))
- self._pl_list.addItem(item)
- except Exception:
- pass
-
- def _load_selected_btn(self):
- item = self._pl_list.currentItem()
- if item:
- self._load_selected(item)
-
- def _load_selected(self, item: QListWidgetItem):
- pl = item.data(Qt.ItemDataRole.UserRole)
- if not pl:
- return
- try:
- from local.local_db import get_playlist_with_songs, get_db
- data = get_playlist_with_songs(pl["id"])
- songs = []
- for row in data.get("songs", []):
- with get_db() as conn:
- dances = conn.execute(
- "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
- (row["id"],)
- ).fetchall()
- songs.append({
- "id": row["id"],
- "title": row.get("title", ""),
- "artist": row.get("artist", ""),
- "album": row.get("album", ""),
- "bpm": row.get("bpm", 0),
- "duration_sec": row.get("duration_sec", 0),
- "local_path": row.get("local_path", ""),
- "file_format": row.get("file_format", ""),
- "file_missing": bool(row.get("file_missing", False)),
- "dances": [d["dance_name"] for d in dances],
- })
- self.playlist_loaded.emit(pl["name"], songs)
- self._load_status.setText(f"✓ Indlæst: {pl['name']} ({len(songs)} sange)")
- except Exception as e:
- self._load_status.setText(f"Fejl: {e}")
-
- def _delete_selected(self):
- item = self._pl_list.currentItem()
- if not item:
- return
- pl = item.data(Qt.ItemDataRole.UserRole)
- reply = QMessageBox.question(
- self, "Slet liste",
- f"Slet danselisten \"{pl['name']}\"?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- )
- if reply == QMessageBox.StandardButton.Yes:
- try:
- from local.local_db import get_db
- with get_db() as conn:
- conn.execute("DELETE FROM playlists WHERE id=?", (pl["id"],))
- self._load_saved_playlists()
- except Exception as e:
- self._load_status.setText(f"Fejl: {e}")
-
- # ── Fane 3: Importer ──────────────────────────────────────────────────────
-
- def _build_import_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(8)
-
- lbl = QLabel(
- "Importer fra M3U, M3U8 eller en tekstfil med én filsti per linje.\n"
- "Sange der ikke er i biblioteket forsøges tilføjet automatisk."
- )
- lbl.setWordWrap(True)
- lbl.setObjectName("result_count")
- layout.addWidget(lbl)
-
- btn_browse = QPushButton("📂 Vælg fil...")
- btn_browse.clicked.connect(self._browse_import)
- layout.addWidget(btn_browse)
-
- layout.addWidget(QLabel("Eller indsæt filstier direkte (én per linje):"))
- self._import_text = QTextEdit()
- self._import_text.setPlaceholderText(
- "/sti/til/sang1.mp3\n/sti/til/sang2.flac\n..."
- )
- self._import_text.setMaximumHeight(120)
- layout.addWidget(self._import_text)
-
- layout.addWidget(QLabel("Navn på den importerede liste:"))
- self._import_name = QLineEdit()
- self._import_name.setPlaceholderText("Importeret liste")
- layout.addWidget(self._import_name)
-
- btn_import = QPushButton("📥 Importer")
- btn_import.clicked.connect(self._do_import)
- layout.addWidget(btn_import)
-
- self._import_status = QLabel("")
- self._import_status.setObjectName("result_count")
- self._import_status.setWordWrap(True)
- layout.addWidget(self._import_status)
- layout.addStretch()
- return tab
-
- def _browse_import(self):
- path, _ = QFileDialog.getOpenFileName(
- self, "Vælg afspilningsliste",
- filter="Afspilningslister (*.m3u *.m3u8 *.txt);;Alle filer (*)"
- )
- if path:
- self._import_name.setText(Path(path).stem)
- paths = self._parse_playlist_file(path)
- self._import_text.setPlainText("\n".join(paths))
-
- def _parse_playlist_file(self, path: str) -> list[str]:
- """Parser M3U, M3U8 og tekst — returnerer liste af filstier."""
- paths = []
- base_dir = str(Path(path).parent)
- try:
- enc = "utf-8-sig" if path.lower().endswith(".m3u8") else "latin-1"
- with open(path, encoding=enc, errors="replace") as f:
- for line in f:
- line = line.strip()
- if not line or line.startswith("#"):
- continue
- # Gør relativ sti absolut
- if not os.path.isabs(line):
- line = os.path.join(base_dir, line)
- paths.append(line)
- except Exception as e:
- self._import_status.setText(f"Læsefejl: {e}")
- return paths
-
- def _do_import(self):
- raw = self._import_text.toPlainText().strip()
- if not raw:
- self._import_status.setText("Ingen filstier angivet")
- return
-
- name = self._import_name.text().strip() or "Importeret liste"
- paths = [line.strip() for line in raw.splitlines() if line.strip()]
-
- found = []
- missing = []
-
- try:
- from local.local_db import get_song_by_path, upsert_song, get_db
- from local.tag_reader import read_tags, is_supported
-
- for p in paths:
- row = get_song_by_path(p)
- if row:
- # Hent danse
- with get_db() as conn:
- dances = conn.execute(
- "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
- (row["id"],)
- ).fetchall()
- found.append({
- "id": row["id"],
- "title": row["title"],
- "artist": row["artist"],
- "album": row["album"],
- "bpm": row["bpm"],
- "duration_sec": row["duration_sec"],
- "local_path": row["local_path"],
- "file_format": row["file_format"],
- "file_missing": bool(row["file_missing"]),
- "dances": [d["dance_name"] for d in dances],
- })
- elif os.path.exists(p) and is_supported(p):
- # Filen er ikke scannet endnu — høst tags og tilføj
- tags = read_tags(p)
- song_id = upsert_song(tags)
- found.append({
- "id": song_id,
- "title": tags.get("title", Path(p).stem),
- "artist": tags.get("artist", ""),
- "album": tags.get("album", ""),
- "bpm": tags.get("bpm", 0),
- "duration_sec": tags.get("duration_sec", 0),
- "local_path": p,
- "file_format": tags.get("file_format", ""),
- "file_missing": False,
- "dances": tags.get("dances", []),
- })
- else:
- missing.append(p)
-
- if found:
- self.playlist_loaded.emit(name, found)
- status = f"✓ Importeret {len(found)} sange som \"{name}\""
- if missing:
- status += f"\n⚠ {len(missing)} filer ikke fundet"
- self._import_status.setText(status)
- else:
- self._import_status.setText("Ingen filer fundet — tjek stierne")
-
- except Exception as e:
- self._import_status.setText(f"Importfejl: {e}")
diff --git a/ui/playlist_panel.py b/ui/playlist_panel.py
deleted file mode 100644
index 3e378989..00000000
--- a/ui/playlist_panel.py
+++ /dev/null
@@ -1,523 +0,0 @@
-"""
-playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik.
-"""
-
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
- QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
- QMessageBox, QInputDialog,
-)
-from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray
-from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent
-
-
-ACTIVE_PLAYLIST_NAME = "__aktiv__" # fast navn til autogem-listen
-
-
-class PlaylistPanel(QWidget):
- song_selected = pyqtSignal(int)
- status_changed = pyqtSignal(int, str)
- song_dropped = pyqtSignal(dict)
- playlist_changed = pyqtSignal()
- event_started = pyqtSignal()
- next_song_ready = pyqtSignal(dict) # udsendes når næste sang ændres — main_window indlæser den # udsendes af Start event — main_window indlæser første sang # udsendes ved enhver ændring → trigger autogem
-
- STATUS_ICON = {"pending": " ", "playing": " ▶ ", "played": " ✓ ", "skipped": " — ", "next": " ▷ "}
- STATUS_COLOR = {"pending": "#5a6070", "playing": "#e8a020", "played": "#2ecc71", "skipped": "#e74c3c", "next": "#3b8fd4"}
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self._songs: list[dict] = []
- self._statuses: list[str] = []
- self._current_idx = -1
- self._song_ended = False
- self._active_playlist_id: int | None = None
- self._build_ui()
- self.setAcceptDrops(True)
- # Autogem-timer — venter 800ms efter sidst ændring
- self._autosave_timer = QTimer(self)
- self._autosave_timer.setSingleShot(True)
- self._autosave_timer.setInterval(800)
- self._autosave_timer.timeout.connect(self._autosave)
- # Event-state gem — hurtig, kritisk for genopstart efter strømsvigt
- self._event_state_timer = QTimer(self)
- self._event_state_timer.setSingleShot(True)
- self._event_state_timer.setInterval(300)
- self._event_state_timer.timeout.connect(self._save_event_state)
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(0)
-
- # ── Header med titel ──────────────────────────────────────────────────
- header = QHBoxLayout()
- header.setContentsMargins(10, 6, 10, 6)
- self._title_label = QLabel("DANSELISTE")
- self._title_label.setObjectName("section_title")
- header.addWidget(self._title_label)
- layout.addLayout(header)
-
- # ── Ny / Gem / Hent knapper ───────────────────────────────────────────
- toolbar = QHBoxLayout()
- toolbar.setContentsMargins(8, 2, 8, 4)
- toolbar.setSpacing(4)
-
- btn_new = QPushButton("✚ Ny")
- btn_new.setFixedHeight(26)
- btn_new.setToolTip("Opret en ny tom danseliste")
- btn_new.clicked.connect(self._new_playlist)
- toolbar.addWidget(btn_new)
-
- btn_save = QPushButton("💾 Gem som...")
- btn_save.setFixedHeight(26)
- btn_save.setToolTip("Gem aktuel liste med et navn")
- btn_save.clicked.connect(self._save_as)
- toolbar.addWidget(btn_save)
-
- btn_load = QPushButton("📂 Hent...")
- btn_load.setFixedHeight(26)
- btn_load.setToolTip("Hent en tidligere gemt danseliste")
- btn_load.clicked.connect(self._load_dialog)
- toolbar.addWidget(btn_load)
-
- toolbar.addStretch()
-
- self._lbl_autosave = QLabel("")
- self._lbl_autosave.setObjectName("result_count")
- toolbar.addWidget(self._lbl_autosave)
-
- layout.addLayout(toolbar)
-
- # ── Event-kontrol ─────────────────────────────────────────────────────
- ctrl = QHBoxLayout()
- ctrl.setContentsMargins(8, 2, 8, 4)
- ctrl.setSpacing(6)
-
- self._btn_start = QPushButton("▶ START EVENT")
- self._btn_start.setFixedHeight(28)
- self._btn_start.setToolTip("Nulstil alle statusser og gør klar til event")
- self._btn_start.clicked.connect(self._start_event)
- ctrl.addWidget(self._btn_start)
- ctrl.addStretch()
-
- self._lbl_progress = QLabel("0 / 0")
- self._lbl_progress.setObjectName("result_count")
- ctrl.addWidget(self._lbl_progress)
-
- layout.addLayout(ctrl)
-
- # ── Liste ─────────────────────────────────────────────────────────────
- self._list = QListWidget()
- self._list.setObjectName("playlist_list")
- self._list.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
- self._list.setDefaultDropAction(Qt.DropAction.MoveAction)
- self._list.setAcceptDrops(True)
- self._list.itemDoubleClicked.connect(self._on_double_click)
- self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- self._list.customContextMenuRequested.connect(self._show_context_menu)
- self._list.model().rowsMoved.connect(self._on_rows_moved)
- layout.addWidget(self._list)
-
- # ── Drag & drop ───────────────────────────────────────────────────────────
-
- def dragEnterEvent(self, event: QDragEnterEvent):
- if event.mimeData().hasFormat("application/x-linedance-song"):
- event.acceptProposedAction()
- else:
- event.ignore()
-
- def dropEvent(self, event: QDropEvent):
- mime = event.mimeData()
- if mime.hasFormat("application/x-linedance-song"):
- import json
- song = json.loads(mime.data("application/x-linedance-song").data().decode())
- self._append_song(song)
- self.song_dropped.emit(song)
- event.acceptProposedAction()
-
- def _append_song(self, song: dict):
- self._songs.append(song)
- self._statuses.append("pending")
- self._refresh()
- self._trigger_autosave()
-
- # ── Data API ──────────────────────────────────────────────────────────────
-
- def load_songs(self, songs: list[dict], reset_statuses: bool = True, name: str = ""):
- self._songs = list(songs)
- if reset_statuses:
- self._statuses = ["pending"] * len(songs)
- self._current_idx = -1
- self._song_ended = False
- if name:
- self._title_label.setText(f"DANSELISTE — {name.upper()}")
- self._refresh()
- self._trigger_autosave()
-
- def set_current(self, idx: int, song_ended: bool = False):
- self._current_idx = idx
- self._song_ended = song_ended
- if 0 <= idx < len(self._statuses) and not song_ended:
- self._statuses[idx] = "playing"
- self._refresh()
- self._scroll_to(idx)
-
- def mark_played(self, idx: int):
- if 0 <= idx < len(self._statuses):
- self._statuses[idx] = "played"
- self._refresh()
- self._trigger_autosave()
- self._trigger_event_state_save()
-
- def set_next_ready(self, idx: int):
- """Sæt næste sang klar — uden at overskrive skipped/played statusser."""
- self._current_idx = idx
- self._song_ended = False
- # Ændr KUN status hvis den er pending — rør ikke skipped/played
- if 0 <= idx < len(self._statuses):
- if self._statuses[idx] not in ("skipped", "played"):
- self._statuses[idx] = "pending"
- self._refresh()
- self._scroll_to(idx)
-
- def get_song(self, idx: int) -> dict | None:
- return self._songs[idx] if 0 <= idx < len(self._songs) else None
-
- def get_songs(self) -> list[dict]:
- return list(self._songs)
-
- def get_statuses(self) -> list[str]:
- return list(self._statuses)
-
- def count(self) -> int:
- return len(self._songs)
-
- def set_playlist_name(self, name: str):
- self._title_label.setText(f"DANSELISTE — {name.upper()}")
-
- # ── Drag-flytning ─────────────────────────────────────────────────────────
-
- def _on_rows_moved(self, parent, start, end, dest, dest_row):
- """Opdater _songs og _statuses når en sang flyttes via drag."""
- new_songs = []
- new_statuses = []
- for i in range(self._list.count()):
- old_idx = self._list.item(i).data(Qt.ItemDataRole.UserRole)
- if old_idx is not None and 0 <= old_idx < len(self._songs):
- new_songs.append(self._songs[old_idx])
- new_statuses.append(self._statuses[old_idx])
- self._songs = new_songs
- self._statuses = new_statuses
- self._current_idx = -1
- self._song_ended = False
- self._refresh()
- self._trigger_autosave()
-
- # Find første afspilbare sang og udsend signal så afspilleren opdateres
- ni = self.next_playable_idx()
- if ni is not None:
- self._current_idx = ni
- self._refresh()
- self.next_song_ready.emit(self._songs[ni])
-
- # ── Event-state ───────────────────────────────────────────────────────────
-
- def _save_event_state(self):
- """Gem current_idx og statuses — overlever strømsvigt."""
- try:
- from local.local_db import save_event_state
- save_event_state(self._current_idx, self._statuses)
- except Exception as e:
- print(f"Event-state gem fejl: {e}")
-
- def _trigger_event_state_save(self):
- self._event_state_timer.start()
-
- def restore_event_state(self) -> bool:
- """Gendan gemt event-fremgang. Returnerer True hvis gendannet."""
- try:
- from local.local_db import load_event_state
- result = load_event_state()
- if not result:
- return False
- idx, statuses = result
- if len(statuses) != len(self._songs):
- return False # listen er ændret siden sidst
- self._statuses = statuses
- self._current_idx = idx
- self._song_ended = False
- self._refresh()
- return True
- except Exception as e:
- print(f"Event-state gendan fejl: {e}")
- return False
-
- def next_playable_idx(self) -> int | None:
- """Find første sang fra toppen der ikke er 'skipped' eller 'played'."""
- for i in range(len(self._songs)):
- if self._statuses[i] not in ("skipped", "played"):
- return i
- return None
-
- # ── Autogem ───────────────────────────────────────────────────────────────
-
- def _trigger_autosave(self):
- """Start/nulstil debounce-timer — gemmer 800ms efter sidst ændring."""
- self._autosave_timer.start()
- self._lbl_autosave.setText("● ikke gemt")
-
- def _autosave(self):
- """Gem til den faste 'Aktiv liste' i SQLite."""
- try:
- from local.local_db import get_db, create_playlist, add_song_to_playlist
- with get_db() as conn:
- # Slet den gamle aktive liste
- conn.execute(
- "DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
- )
- # Opret ny
- pl_id = create_playlist(ACTIVE_PLAYLIST_NAME)
- self._active_playlist_id = pl_id
- for i, song in enumerate(self._songs, start=1):
- if song.get("id"):
- add_song_to_playlist(pl_id, song["id"], position=i)
- self._lbl_autosave.setText("✓ gemt")
- self.playlist_changed.emit()
- except Exception as e:
- self._lbl_autosave.setText(f"⚠ gemfejl")
- print(f"Autogem fejl: {e}")
-
- def restore_active_playlist(self):
- """Indlæs den sidst aktive liste ved opstart."""
- try:
- from local.local_db import get_db
- with get_db() as conn:
- pl = conn.execute(
- "SELECT id FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
- ).fetchone()
- if not pl:
- return False
- songs_raw = conn.execute("""
- SELECT s.*, ps.position FROM playlist_songs ps
- JOIN songs s ON s.id = ps.song_id
- WHERE ps.playlist_id=? ORDER BY ps.position
- """, (pl["id"],)).fetchall()
- songs = []
- for row in songs_raw:
- dances = conn.execute(
- "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
- (row["id"],)
- ).fetchall()
- songs.append({
- "id": row["id"], "title": row["title"],
- "artist": row["artist"], "album": row["album"],
- "bpm": row["bpm"], "duration_sec": row["duration_sec"],
- "local_path": row["local_path"], "file_format": row["file_format"],
- "file_missing": bool(row["file_missing"]),
- "dances": [d["dance_name"] for d in dances],
- })
- if songs:
- self._songs = songs
- self._statuses = ["pending"] * len(songs)
- self._refresh()
- self._lbl_autosave.setText("✓ gendannet")
- return True
- except Exception as e:
- print(f"Gendan aktiv liste fejl: {e}")
- return False
-
- # ── Ny / Gem som / Hent ───────────────────────────────────────────────────
-
- def _new_playlist(self):
- if self._songs:
- reply = QMessageBox.question(
- self, "Ny danseliste",
- "Ryd den aktuelle liste og start forfra?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- )
- if reply != QMessageBox.StandardButton.Yes:
- return
- self._songs = []
- self._statuses = []
- self._current_idx = -1
- self._song_ended = False
- self._title_label.setText("DANSELISTE — NY")
- self._refresh()
- self._trigger_autosave()
-
- def _save_as(self):
- if not self._songs:
- QMessageBox.information(self, "Gem", "Danselisten er tom.")
- return
- name, ok = QInputDialog.getText(
- self, "Gem danseliste", "Navn på danselisten:",
- )
- if not ok or not name.strip():
- return
- name = name.strip()
- try:
- from local.local_db import create_playlist, add_song_to_playlist
- pl_id = create_playlist(name)
- for i, song in enumerate(self._songs, start=1):
- if song.get("id"):
- add_song_to_playlist(pl_id, song["id"], position=i)
- self._title_label.setText(f"DANSELISTE — {name.upper()}")
- self._lbl_autosave.setText(f"✓ gemt som \"{name}\"")
- except Exception as e:
- QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
-
- def _load_dialog(self):
- """Vis liste af gemte danselister og lad brugeren vælge."""
- try:
- from local.local_db import get_db
- with get_db() as conn:
- lists = conn.execute(
- "SELECT id, name, created_at FROM playlists "
- "WHERE name != ? ORDER BY created_at DESC",
- (ACTIVE_PLAYLIST_NAME,)
- ).fetchall()
- except Exception as e:
- QMessageBox.warning(self, "Fejl", f"Kunne ikke hente lister: {e}")
- return
-
- if not lists:
- QMessageBox.information(self, "Hent liste", "Ingen gemte danselister fundet.")
- return
-
- names = [f"{row['name']} ({row['created_at'][:10]})" for row in lists]
- choice, ok = QInputDialog.getItem(
- self, "Hent danseliste", "Vælg en liste:", names, editable=False
- )
- if not ok:
- return
-
- idx = names.index(choice)
- pl_id = lists[idx]["id"]
- pl_name = lists[idx]["name"]
-
- try:
- from local.local_db import get_db
- with get_db() as conn:
- songs_raw = conn.execute("""
- SELECT s.*, ps.position FROM playlist_songs ps
- JOIN songs s ON s.id = ps.song_id
- WHERE ps.playlist_id=? ORDER BY ps.position
- """, (pl_id,)).fetchall()
- songs = []
- for row in songs_raw:
- dances = conn.execute(
- "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
- (row["id"],)
- ).fetchall()
- songs.append({
- "id": row["id"], "title": row["title"],
- "artist": row["artist"], "album": row["album"],
- "bpm": row["bpm"], "duration_sec": row["duration_sec"],
- "local_path": row["local_path"], "file_format": row["file_format"],
- "file_missing": bool(row["file_missing"]),
- "dances": [d["dance_name"] for d in dances],
- })
- self.load_songs(songs, name=pl_name)
- except Exception as e:
- QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}")
-
- # ── Start event ───────────────────────────────────────────────────────────
-
- def _start_event(self):
- if not self._songs:
- return
- reply = QMessageBox.question(
- self, "Start event",
- "Dette nulstiller alle statusser i danselisten.\nFortsæt?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- )
- if reply == QMessageBox.StandardButton.Yes:
- self._statuses = ["pending"] * len(self._songs)
- self._current_idx = -1
- self._song_ended = True
- try:
- from local.local_db import clear_event_state
- clear_event_state()
- except Exception:
- pass
- self._refresh()
- self._scroll_to(0)
- self.event_started.emit()
-
- # ── Højreklik ─────────────────────────────────────────────────────────────
-
- def _show_context_menu(self, pos):
- item = self._list.itemAt(pos)
- if not item:
- return
- idx = item.data(Qt.ItemDataRole.UserRole)
- if idx is None:
- return
- menu = QMenu(self)
- act_play = menu.addAction("▶ Afspil denne")
- menu.addSeparator()
- act_skip = menu.addAction("— Spring over")
- act_unplay = menu.addAction("↺ Sæt til ikke afspillet")
- act_played = menu.addAction("✓ Sæt til afspillet")
- menu.addSeparator()
- act_remove = menu.addAction("✕ Fjern fra liste")
- action = menu.exec(self._list.mapToGlobal(pos))
- if action == act_play:
- self.song_selected.emit(idx)
- elif action == act_skip:
- self._statuses[idx] = "skipped"
- self.status_changed.emit(idx, "skipped")
- self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
- elif action == act_unplay:
- self._statuses[idx] = "pending"
- self.status_changed.emit(idx, "pending")
- self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
- elif action == act_played:
- self._statuses[idx] = "played"
- self.status_changed.emit(idx, "played")
- self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
- elif action == act_remove:
- self._songs.pop(idx)
- self._statuses.pop(idx)
- if self._current_idx >= idx:
- self._current_idx = max(-1, self._current_idx - 1)
- self._refresh(); self._trigger_autosave()
-
- # ── Render ────────────────────────────────────────────────────────────────
-
- def _refresh(self):
- self._list.clear()
- played = sum(1 for s in self._statuses if s == "played")
- self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet")
- for i, song in enumerate(self._songs):
- is_current = (i == self._current_idx and not self._song_ended)
- is_next = (self._song_ended and i == self._current_idx + 1) or \
- (self._current_idx == -1 and self._song_ended and i == 0)
- status = "playing" if is_current else "next" if is_next else self._statuses[i]
- icon = self.STATUS_ICON.get(status, " ")
- dances = " / ".join(song.get("dances", [])) or "ingen dans tagget"
- text = f"{i+1:>2}. {song.get('title','—')}\n {song.get('artist','')} · {dances}"
- item = QListWidgetItem(f"{icon} {text}")
- item.setData(Qt.ItemDataRole.UserRole, i)
- color = self.STATUS_COLOR.get(status, "#5a6070")
- if status in ("playing", "next"):
- item.setForeground(QColor(color))
- f = item.font(); f.setBold(True); item.setFont(f)
- elif status == "played":
- item.setForeground(QColor("#2ecc71"))
- elif status == "skipped":
- item.setForeground(QColor("#e74c3c"))
- else:
- item.setForeground(QColor("#9aa0b0"))
- self._list.addItem(item)
-
- def _scroll_to(self, idx: int):
- if 0 <= idx < self._list.count():
- self._list.scrollToItem(
- self._list.item(idx), QListWidget.ScrollHint.PositionAtCenter)
-
- def _on_double_click(self, item: QListWidgetItem):
- idx = item.data(Qt.ItemDataRole.UserRole)
- if idx is not None:
- self.song_selected.emit(idx)
diff --git a/ui/scan_worker.py b/ui/scan_worker.py
deleted file mode 100644
index 13ae61ba..00000000
--- a/ui/scan_worker.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""
-scan_worker.py — Kører fuld biblioteks-scanning i en baggrundstråd
-så GUI ikke fryser.
-"""
-
-from PyQt6.QtCore import QThread, pyqtSignal
-
-
-class ScanWorker(QThread):
- """
- Kører _full_scan_all() i en baggrundstråd.
- Sender status-opdateringer undervejs.
- """
- status_update = pyqtSignal(str) # løbende statusbeskeder
- scan_done = pyqtSignal(int) # antal behandlede filer
-
- def __init__(self, watcher, parent=None):
- super().__init__(parent)
- self._watcher = watcher
- self._total = 0
-
- def run(self):
- try:
- from local.local_db import get_libraries
- from local.tag_reader import is_supported
- import os
- libraries = get_libraries(active_only=True)
-
- if not libraries:
- self.status_update.emit("Ingen biblioteker konfigureret")
- self.scan_done.emit(0)
- return
-
- total_processed = 0
- for lib in libraries:
- from pathlib import Path
- path = Path(lib["path"])
- name = path.name
-
- if not path.exists():
- self.status_update.emit(f"⚠ Mappe ikke fundet: {path}")
- continue
-
- self.status_update.emit(f"Scanner: {name}...")
-
- # Tæl filer med os.walk — håndterer permission-fejl sikkert
- count = 0
- for dirpath, _, filenames in os.walk(str(path), followlinks=False):
- for f in filenames:
- if is_supported(f):
- count += 1
-
- self.status_update.emit(f"Scanner: {name} ({count} filer)...")
-
- # Kør scanning
- self._watcher._full_scan_library(lib["id"], str(path))
- total_processed += count
-
- self.status_update.emit(f"Scan færdig — {total_processed} filer gennemgået")
- self.scan_done.emit(total_processed)
-
- except Exception as e:
- self.status_update.emit(f"Scan fejl: {e}")
- self.scan_done.emit(0)
diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py
deleted file mode 100644
index dcd7a3dc..00000000
--- a/ui/settings_dialog.py
+++ /dev/null
@@ -1,262 +0,0 @@
-"""
-settings_dialog.py — Indstillinger for LineDance Player.
-Gemmes via QSettings og læses ved opstart.
-"""
-
-from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
- QPushButton, QComboBox, QSpinBox, QCheckBox, QFrame,
- QTabWidget, QWidget, QFileDialog, QGroupBox, QFormLayout,
-)
-from PyQt6.QtCore import Qt, QSettings
-
-
-SETTINGS_KEY_THEME = "appearance/dark_theme"
-SETTINGS_KEY_DEMO_SEC = "playback/demo_seconds"
-SETTINGS_KEY_MAIL_CLIENT = "mail/client" # "auto"|"thunderbird"|"outlook"|"mailto"
-SETTINGS_KEY_MAIL_PATH = "mail/custom_path"
-SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
-SETTINGS_KEY_USERNAME = "online/username"
-SETTINGS_KEY_PASSWORD = "online/password" # gemt i klartekst — ikke ideelt, men funktionelt
-
-
-def load_settings() -> dict:
- """Indlæs alle indstillinger med fornuftige standardværdier."""
- s = QSettings("LineDance", "Player")
- return {
- "dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool),
- "demo_seconds": s.value(SETTINGS_KEY_DEMO_SEC, 10, type=int),
- "mail_client": s.value(SETTINGS_KEY_MAIL_CLIENT, "auto"),
- "mail_path": s.value(SETTINGS_KEY_MAIL_PATH, ""),
- "auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool),
- "username": s.value(SETTINGS_KEY_USERNAME, ""),
- "password": s.value(SETTINGS_KEY_PASSWORD, ""),
- }
-
-
-def save_settings(values: dict):
- s = QSettings("LineDance", "Player")
- s.setValue(SETTINGS_KEY_THEME, values.get("dark_theme", True))
- s.setValue(SETTINGS_KEY_DEMO_SEC, values.get("demo_seconds", 10))
- s.setValue(SETTINGS_KEY_MAIL_CLIENT, values.get("mail_client", "auto"))
- s.setValue(SETTINGS_KEY_MAIL_PATH, values.get("mail_path", ""))
- s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False))
- s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
- s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", ""))
-
-
-class SettingsDialog(QDialog):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Indstillinger")
- self.setMinimumWidth(480)
- self.setModal(True)
- self._values = load_settings()
- self._build_ui()
- self._populate()
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(16, 16, 16, 16)
- layout.setSpacing(12)
-
- tabs = QTabWidget()
- tabs.addTab(self._build_appearance_tab(), "🎨 Udseende")
- tabs.addTab(self._build_playback_tab(), "▶ Afspilning")
- tabs.addTab(self._build_mail_tab(), "✉ Mail")
- tabs.addTab(self._build_online_tab(), "🌐 Online")
- layout.addWidget(tabs)
-
- # Knapper
- btn_row = QHBoxLayout()
- btn_row.addStretch()
- btn_cancel = QPushButton("Annuller")
- btn_cancel.clicked.connect(self.reject)
- btn_row.addWidget(btn_cancel)
- btn_save = QPushButton("💾 Gem indstillinger")
- btn_save.setObjectName("btn_play")
- btn_save.setDefault(True)
- btn_save.clicked.connect(self._save_and_close)
- btn_row.addWidget(btn_save)
- layout.addLayout(btn_row)
-
- # ── Fane: Udseende ────────────────────────────────────────────────────────
-
- def _build_appearance_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(12)
-
- grp = QGroupBox("Standard tema")
- grp_layout = QVBoxLayout(grp)
-
- self._chk_dark = QCheckBox("Start med mørkt tema")
- grp_layout.addWidget(self._chk_dark)
-
- note = QLabel("Du kan altid skifte tema mens programmet kører via topbar-knappen.")
- note.setObjectName("result_count")
- note.setWordWrap(True)
- grp_layout.addWidget(note)
- layout.addWidget(grp)
- layout.addStretch()
- return tab
-
- # ── Fane: Afspilning ──────────────────────────────────────────────────────
-
- def _build_playback_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(12)
-
- grp = QGroupBox("Forspil (▶ N SEK knappen)")
- grp_layout = QFormLayout(grp)
-
- self._spin_demo = QSpinBox()
- self._spin_demo.setRange(3, 60)
- self._spin_demo.setSuffix(" sekunder")
- self._spin_demo.setFixedWidth(140)
- grp_layout.addRow("Forspil-længde:", self._spin_demo)
-
- note = QLabel(
- "Forspillet afspiller begyndelsen af sangen så arrangøren kan bekræfte\n"
- "at det er den rigtige sang og dans inden eventet starter."
- )
- note.setObjectName("result_count")
- note.setWordWrap(True)
- grp_layout.addRow(note)
- layout.addWidget(grp)
- layout.addStretch()
- return tab
-
- # ── Fane: Mail ────────────────────────────────────────────────────────────
-
- def _build_mail_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(12)
-
- grp = QGroupBox("Mailklient")
- grp_layout = QFormLayout(grp)
-
- self._mail_combo = QComboBox()
- self._mail_combo.addItem("Auto-detekter (Thunderbird → Outlook → mailto:)", "auto")
- self._mail_combo.addItem("Thunderbird", "thunderbird")
- self._mail_combo.addItem("Outlook (Windows)", "outlook")
- self._mail_combo.addItem("Brugerdefineret sti", "custom")
- self._mail_combo.addItem("Kun mailto: (ingen vedhæftning)", "mailto")
- self._mail_combo.currentIndexChanged.connect(self._on_mail_combo_changed)
- grp_layout.addRow("Klient:", self._mail_combo)
-
- path_row = QHBoxLayout()
- self._mail_path = QLineEdit()
- self._mail_path.setPlaceholderText("/usr/bin/thunderbird eller C:\\...\\thunderbird.exe")
- path_row.addWidget(self._mail_path)
- btn_browse = QPushButton("...")
- btn_browse.setFixedWidth(32)
- btn_browse.clicked.connect(self._browse_mail_path)
- path_row.addWidget(btn_browse)
- self._mail_path_row_widget = QWidget()
- self._mail_path_row_widget.setLayout(path_row)
- grp_layout.addRow("Sti:", self._mail_path_row_widget)
-
- note = QLabel(
- "Med Thunderbird og Outlook åbnes et nyt compose-vindue med filen vedhæftet.\n"
- "mailto: åbner standard-mailprogrammet men uden automatisk vedhæftning."
- )
- note.setObjectName("result_count")
- note.setWordWrap(True)
- grp_layout.addRow(note)
- layout.addWidget(grp)
- layout.addStretch()
- return tab
-
- def _on_mail_combo_changed(self, idx: int):
- is_custom = self._mail_combo.currentData() == "custom"
- self._mail_path_row_widget.setVisible(is_custom)
-
- def _browse_mail_path(self):
- path, _ = QFileDialog.getOpenFileName(self, "Vælg mailklient")
- if path:
- self._mail_path.setText(path)
-
- # ── Fane: Online ──────────────────────────────────────────────────────────
-
- def _build_online_tab(self) -> QWidget:
- tab = QWidget()
- layout = QVBoxLayout(tab)
- layout.setSpacing(12)
-
- grp = QGroupBox("Automatisk login ved opstart")
- grp_layout = QFormLayout(grp)
-
- self._chk_auto_login = QCheckBox("Log automatisk ind når programmet starter")
- self._chk_auto_login.stateChanged.connect(self._on_auto_login_changed)
- grp_layout.addRow(self._chk_auto_login)
-
- self._user_input = QLineEdit()
- self._user_input.setPlaceholderText("dit-brugernavn")
- grp_layout.addRow("Brugernavn:", self._user_input)
-
- self._pass_input = QLineEdit()
- self._pass_input.setEchoMode(QLineEdit.EchoMode.Password)
- self._pass_input.setPlaceholderText("••••••••")
- grp_layout.addRow("Kodeord:", self._pass_input)
-
- note = QLabel(
- "⚠ Kodeordet gemmes lokalt på denne computer.\n"
- "Brug kun dette på en personlig maskine."
- )
- note.setObjectName("result_count")
- note.setWordWrap(True)
- grp_layout.addRow(note)
- layout.addWidget(grp)
- layout.addStretch()
- return tab
-
- def _on_auto_login_changed(self, state: int):
- enabled = state == Qt.CheckState.Checked.value
- self._user_input.setEnabled(enabled)
- self._pass_input.setEnabled(enabled)
-
- # ── Populer fra gemte værdier ─────────────────────────────────────────────
-
- def _populate(self):
- v = self._values
- self._chk_dark.setChecked(v.get("dark_theme", True))
- self._spin_demo.setValue(v.get("demo_seconds", 10))
-
- # Mail
- client = v.get("mail_client", "auto")
- for i in range(self._mail_combo.count()):
- if self._mail_combo.itemData(i) == client:
- self._mail_combo.setCurrentIndex(i)
- break
- self._mail_path.setText(v.get("mail_path", ""))
- self._on_mail_combo_changed(self._mail_combo.currentIndex())
-
- # Online
- auto = v.get("auto_login", False)
- self._chk_auto_login.setChecked(auto)
- self._user_input.setText(v.get("username", ""))
- self._pass_input.setText(v.get("password", ""))
- self._user_input.setEnabled(auto)
- self._pass_input.setEnabled(auto)
-
- # ── Gem ───────────────────────────────────────────────────────────────────
-
- def _save_and_close(self):
- values = {
- "dark_theme": self._chk_dark.isChecked(),
- "demo_seconds": self._spin_demo.value(),
- "mail_client": self._mail_combo.currentData(),
- "mail_path": self._mail_path.text().strip(),
- "auto_login": self._chk_auto_login.isChecked(),
- "username": self._user_input.text().strip(),
- "password": self._pass_input.text(),
- }
- save_settings(values)
- self._values = values
- self.accept()
-
- def get_values(self) -> dict:
- return self._values
diff --git a/ui/tag_editor.py b/ui/tag_editor.py
deleted file mode 100644
index 07e9d88d..00000000
--- a/ui/tag_editor.py
+++ /dev/null
@@ -1,444 +0,0 @@
-"""
-tag_editor.py — Rediger danse og alternativ-danse med niveau og autoudfyld.
-
-Fire sektioner:
- Mine danse | Fællesskabets danse
- Mine alternativer | Fællesskabets alternativer
-"""
-
-from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
- QPushButton, QListWidget, QListWidgetItem, QFrame,
- QSplitter, QWidget, QMessageBox, QComboBox, QCompleter,
- QGridLayout, QGroupBox,
-)
-from PyQt6.QtCore import Qt, QTimer, QStringListModel, pyqtSignal
-from PyQt6.QtGui import QColor
-
-
-class AutoCompleteLineEdit(QLineEdit):
- """QLineEdit med autoudfyld fra dans-navne databasen."""
-
- def __init__(self, placeholder: str = "", parent=None):
- super().__init__(parent)
- self.setPlaceholderText(placeholder)
- self._completer_model = QStringListModel()
- self._completer = QCompleter(self._completer_model, self)
- self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
- self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
- self._completer.setMaxVisibleItems(12)
- self.setCompleter(self._completer)
- self._timer = QTimer(self)
- self._timer.setSingleShot(True)
- self._timer.setInterval(150)
- self._timer.timeout.connect(self._update_suggestions)
- self.textChanged.connect(lambda _: self._timer.start())
-
- def _update_suggestions(self):
- prefix = self.text().strip()
- if len(prefix) < 1:
- return
- try:
- from local.local_db import get_dance_name_suggestions
- names = get_dance_name_suggestions(prefix, limit=20)
- self._completer_model.setStringList(names)
- except Exception:
- pass
-
-
-class DanceRow(QWidget):
- """Én dans med navn og niveau-dropdown."""
- removed = pyqtSignal()
-
- def __init__(self, dance_name: str = "", level_id: int | None = None,
- levels: list = [], readonly: bool = False, parent=None):
- super().__init__(parent)
- layout = QHBoxLayout(self)
- layout.setContentsMargins(0, 2, 0, 2)
- layout.setSpacing(6)
-
- if readonly:
- self._name_lbl = QLabel(dance_name)
- self._name_lbl.setObjectName("track_meta")
- layout.addWidget(self._name_lbl, stretch=1)
- else:
- self._name_edit = AutoCompleteLineEdit("Dansenavn...", self)
- self._name_edit.setText(dance_name)
- layout.addWidget(self._name_edit, stretch=1)
-
- self._level_combo = QComboBox()
- self._level_combo.addItem("— intet niveau —", None)
- self._level_data = [None]
- for lvl in levels:
- self._level_combo.addItem(lvl["name"], lvl["id"])
- self._level_data.append(lvl["id"])
- if level_id is not None:
- for i, lid in enumerate(self._level_data):
- if lid == level_id:
- self._level_combo.setCurrentIndex(i)
- break
- self._level_combo.setFixedWidth(130)
- self._level_combo.setEnabled(not readonly)
- layout.addWidget(self._level_combo)
-
- if not readonly:
- btn_rm = QPushButton("✕")
- btn_rm.setFixedSize(24, 24)
- btn_rm.clicked.connect(self.removed.emit)
- layout.addWidget(btn_rm)
-
- def get_name(self) -> str:
- if hasattr(self, "_name_edit"):
- return self._name_edit.text().strip()
- return self._name_lbl.text()
-
- def get_level_id(self) -> int | None:
- return self._level_combo.currentData()
-
-
-class AltRow(QWidget):
- """Én alternativ-dans med navn, niveau og note."""
- removed = pyqtSignal()
- copy_to_mine = pyqtSignal(str, object, str) # name, level_id, note
-
- def __init__(self, alt_name: str = "", level_id: int | None = None,
- note: str = "", levels: list = [],
- readonly: bool = False, source: str = "local",
- rating: float = 0, rating_count: int = 0, parent=None):
- super().__init__(parent)
- layout = QHBoxLayout(self)
- layout.setContentsMargins(0, 2, 0, 2)
- layout.setSpacing(6)
-
- if readonly:
- lbl = QLabel(f"→ {alt_name}")
- lbl.setObjectName("track_meta")
- layout.addWidget(lbl, stretch=1)
- if rating_count > 0:
- stars = "★" * round(rating) + "☆" * (5 - round(rating))
- lbl_r = QLabel(f"{stars} ({rating_count})")
- lbl_r.setObjectName("result_count")
- layout.addWidget(lbl_r)
- else:
- prefix_lbl = QLabel("→")
- prefix_lbl.setObjectName("track_meta")
- layout.addWidget(prefix_lbl)
- self._name_edit = AutoCompleteLineEdit("Alternativ dansenavn...", self)
- self._name_edit.setText(alt_name)
- layout.addWidget(self._name_edit, stretch=1)
-
- self._level_combo = QComboBox()
- self._level_combo.addItem("— niveau —", None)
- self._level_data = [None]
- for lvl in levels:
- self._level_combo.addItem(lvl["name"], lvl["id"])
- self._level_data.append(lvl["id"])
- if level_id is not None:
- for i, lid in enumerate(self._level_data):
- if lid == level_id:
- self._level_combo.setCurrentIndex(i)
- break
- self._level_combo.setFixedWidth(120)
- self._level_combo.setEnabled(not readonly)
- layout.addWidget(self._level_combo)
-
- if readonly:
- btn_copy = QPushButton("← Kopier")
- btn_copy.setFixedHeight(22)
- btn_copy.clicked.connect(
- lambda: self.copy_to_mine.emit(alt_name, self._level_combo.currentData(), note)
- )
- layout.addWidget(btn_copy)
- else:
- self._note_edit = QLineEdit()
- self._note_edit.setPlaceholderText("note...")
- self._note_edit.setText(note)
- self._note_edit.setFixedWidth(100)
- layout.addWidget(self._note_edit)
- btn_rm = QPushButton("✕")
- btn_rm.setFixedSize(24, 24)
- btn_rm.clicked.connect(self.removed.emit)
- layout.addWidget(btn_rm)
-
- def get_name(self) -> str:
- if hasattr(self, "_name_edit"):
- return self._name_edit.text().strip()
- return ""
-
- def get_level_id(self) -> int | None:
- return self._level_combo.currentData()
-
- def get_note(self) -> str:
- if hasattr(self, "_note_edit"):
- return self._note_edit.text().strip()
- return ""
-
-
-class TagEditorDialog(QDialog):
- def __init__(self, song: dict, parent=None):
- super().__init__(parent)
- self._song = song
- self._levels = []
- self._my_dance_rows: list[DanceRow] = []
- self._my_alt_rows: list[AltRow] = []
- self.setWindowTitle(f"Rediger tags — {song.get('title','')}")
- self.setMinimumSize(860, 620)
- self._load_levels()
- self._build_ui()
- self._load_data()
-
- def _load_levels(self):
- try:
- from local.local_db import get_dance_levels
- self._levels = [dict(r) for r in get_dance_levels()]
- except Exception:
- self._levels = []
-
- def _build_ui(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(16, 16, 16, 16)
- layout.setSpacing(10)
-
- # ── Sang-info ─────────────────────────────────────────────────────────
- info = QFrame()
- info.setObjectName("track_display")
- info_layout = QHBoxLayout(info)
- info_layout.setContentsMargins(10, 8, 10, 8)
- title_col = QVBoxLayout()
- lbl_title = QLabel(self._song.get("title", "—"))
- lbl_title.setObjectName("track_title")
- title_col.addWidget(lbl_title)
- meta = f"{self._song.get('artist','')} · {self._song.get('bpm',0)} BPM · {self._song.get('file_format','').upper()}"
- lbl_meta = QLabel(meta)
- lbl_meta.setObjectName("track_meta")
- title_col.addWidget(lbl_meta)
- can_write = self._song.get("file_format","").lower() in ("mp3","flac","ogg","opus","m4a")
- lbl_write = QLabel("✓ Tags skrives til filen" if can_write else "⚠ Tags gemmes kun i database")
- lbl_write.setObjectName("result_count")
- title_col.addWidget(lbl_write)
- info_layout.addLayout(title_col, stretch=1)
- layout.addWidget(info)
-
- # ── Fire paneler i 2x2 grid ───────────────────────────────────────────
- grid = QWidget()
- grid_layout = QGridLayout(grid)
- grid_layout.setSpacing(8)
-
- grid_layout.addWidget(self._build_my_dances_panel(), 0, 0)
- grid_layout.addWidget(self._build_community_dances_panel(), 0, 1)
- grid_layout.addWidget(self._build_my_alts_panel(), 1, 0)
- grid_layout.addWidget(self._build_community_alts_panel(), 1, 1)
-
- layout.addWidget(grid, stretch=1)
-
- # ── Knapper ───────────────────────────────────────────────────────────
- btn_row = QHBoxLayout()
- btn_row.addStretch()
- btn_cancel = QPushButton("Annuller")
- btn_cancel.clicked.connect(self.reject)
- btn_row.addWidget(btn_cancel)
- btn_save = QPushButton("💾 Gem tags")
- btn_save.setObjectName("btn_play")
- btn_save.clicked.connect(self._save)
- btn_row.addWidget(btn_save)
- layout.addLayout(btn_row)
-
- # ── Mine danse ────────────────────────────────────────────────────────────
-
- def _build_my_dances_panel(self) -> QGroupBox:
- grp = QGroupBox("Mine danse")
- layout = QVBoxLayout(grp)
- layout.setSpacing(4)
-
- self._my_dances_container = QVBoxLayout()
- layout.addLayout(self._my_dances_container)
- layout.addStretch()
-
- add_row = QHBoxLayout()
- self._new_dance_input = AutoCompleteLineEdit("Ny dans...", self)
- self._new_dance_input.returnPressed.connect(self._add_my_dance)
- add_row.addWidget(self._new_dance_input)
- btn_add = QPushButton("+ Tilføj")
- btn_add.clicked.connect(self._add_my_dance)
- add_row.addWidget(btn_add)
- layout.addLayout(add_row)
- return grp
-
- def _add_my_dance(self, name: str = "", level_id=None):
- n = name or self._new_dance_input.text().strip()
- if not n:
- return
- row = DanceRow(n, level_id, self._levels, readonly=False, parent=self)
- row.removed.connect(lambda r=row: self._remove_dance_row(r))
- self._my_dance_rows.append(row)
- self._my_dances_container.addWidget(row)
- self._new_dance_input.clear()
-
- def _remove_dance_row(self, row: DanceRow):
- self._my_dance_rows.remove(row)
- self._my_dances_container.removeWidget(row)
- row.deleteLater()
-
- # ── Fællesskabets danse ───────────────────────────────────────────────────
-
- def _build_community_dances_panel(self) -> QGroupBox:
- grp = QGroupBox("Fællesskabets danse")
- layout = QVBoxLayout(grp)
- self._community_dances_container = QVBoxLayout()
- layout.addLayout(self._community_dances_container)
- layout.addStretch()
- lbl = QLabel("Kræver online forbindelse")
- lbl.setObjectName("result_count")
- layout.addWidget(lbl)
- return grp
-
- # ── Mine alternativer ─────────────────────────────────────────────────────
-
- def _build_my_alts_panel(self) -> QGroupBox:
- grp = QGroupBox("Mine alternativ-danse")
- layout = QVBoxLayout(grp)
- layout.setSpacing(4)
- self._my_alts_container = QVBoxLayout()
- layout.addLayout(self._my_alts_container)
- layout.addStretch()
-
- add_row = QHBoxLayout()
- self._new_alt_input = AutoCompleteLineEdit("Alternativ dansenavn...", self)
- self._new_alt_input.returnPressed.connect(self._add_my_alt)
- add_row.addWidget(self._new_alt_input)
- btn_add = QPushButton("+ Tilføj")
- btn_add.clicked.connect(self._add_my_alt)
- add_row.addWidget(btn_add)
- layout.addLayout(add_row)
- return grp
-
- def _add_my_alt(self, name: str = "", level_id=None, note: str = ""):
- n = name or self._new_alt_input.text().strip()
- if not n:
- return
- row = AltRow(n, level_id, note, self._levels, readonly=False, parent=self)
- row.removed.connect(lambda r=row: self._remove_alt_row(r))
- self._my_alt_rows.append(row)
- self._my_alts_container.addWidget(row)
- self._new_alt_input.clear()
-
- def _remove_alt_row(self, row: AltRow):
- self._my_alt_rows.remove(row)
- self._my_alts_container.removeWidget(row)
- row.deleteLater()
-
- # ── Fællesskabets alternativer ────────────────────────────────────────────
-
- def _build_community_alts_panel(self) -> QGroupBox:
- grp = QGroupBox("Fællesskabets alternativ-danse")
- layout = QVBoxLayout(grp)
- self._community_alts_container = QVBoxLayout()
- layout.addLayout(self._community_alts_container)
- layout.addStretch()
- lbl = QLabel("Kræver online forbindelse")
- lbl.setObjectName("result_count")
- layout.addWidget(lbl)
- return grp
-
- # ── Indlæs eksisterende data ──────────────────────────────────────────────
-
- def _load_data(self):
- try:
- from local.local_db import get_db, get_alternatives_for_dance
- song_id = self._song.get("id")
- with get_db() as conn:
- dances = conn.execute(
- "SELECT id, dance_name, dance_order, level_id FROM song_dances "
- "WHERE song_id=? ORDER BY dance_order",
- (song_id,)
- ).fetchall()
-
- for d in dances:
- self._add_my_dance(d["dance_name"], d["level_id"])
- # Indlæs alternativer for denne dans
- alts = get_alternatives_for_dance(d["id"])
- for alt in alts:
- if alt["source"] == "local":
- self._add_my_alt(
- alt["alt_dance_name"],
- alt["level_id"],
- alt["note"],
- )
- else:
- # Community-alternativ
- row = AltRow(
- alt["alt_dance_name"], alt["level_id"],
- alt["note"], self._levels,
- readonly=True, source="community",
- parent=self,
- )
- row.copy_to_mine.connect(self._add_my_alt)
- self._community_alts_container.addWidget(row)
- except Exception as e:
- print(f"Tag editor load fejl: {e}")
-
- # ── Gem ───────────────────────────────────────────────────────────────────
-
- def _save(self):
- song_id = self._song.get("id")
- local_path = self._song.get("local_path", "")
-
- try:
- from local.local_db import get_db, register_dance_name, add_alternative
- from local.tag_reader import write_dances, can_write_dances
-
- # Saml danse fra UI
- dances = [(r.get_name(), r.get_level_id())
- for r in self._my_dance_rows if r.get_name()]
-
- dance_ids = []
- with get_db() as conn:
- # Slet eksisterende danse og alternativer
- old_dances = conn.execute(
- "SELECT id FROM song_dances WHERE song_id=?", (song_id,)
- ).fetchall()
- for od in old_dances:
- conn.execute("DELETE FROM dance_alternatives WHERE song_dance_id=?", (od["id"],))
- conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
-
- # Indsæt nye danse og hent IDs
- for i, (name, level_id) in enumerate(dances, start=1):
- conn.execute(
- "INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) "
- "VALUES (?,?,?,?)",
- (song_id, name, i, level_id)
- )
- new_id = conn.execute(
- "SELECT id FROM song_dances WHERE song_id=? AND dance_order=?",
- (song_id, i)
- ).fetchone()["id"]
- dance_ids.append(new_id)
- register_dance_name(name)
-
- # Indsæt alternativer knyttet til første dans
- if dance_ids and self._my_alt_rows:
- first_dance_id = dance_ids[0]
- for row in self._my_alt_rows:
- name = row.get_name()
- if name:
- import uuid as _uuid
- conn.execute("""
- INSERT INTO dance_alternatives
- (id, song_dance_id, alt_dance_name, level_id, note, source)
- VALUES (?,?,?,?,?,'local')
- """, (str(_uuid.uuid4()), first_dance_id,
- name, row.get_level_id(), row.get_note()))
- register_dance_name(name)
-
- # Skriv til fil
- if local_path and can_write_dances(local_path):
- dance_names = [n for n, _ in dances]
- ok = write_dances(local_path, dance_names)
- if not ok:
- QMessageBox.warning(self, "Advarsel",
- "Tags gemt i database, men kunne ikke skrives til filen.")
-
- self.accept()
-
- except Exception as e:
- QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme tags: {e}")
diff --git a/ui/themes.py b/ui/themes.py
deleted file mode 100644
index f5dff76a..00000000
--- a/ui/themes.py
+++ /dev/null
@@ -1,334 +0,0 @@
-"""
-themes.py — Lyst og mørkt tema til PyQt6.
-"""
-
-DARK = """
-QWidget {
- background-color: #1a1c1f;
- color: #e8eaf0;
- font-family: 'Barlow', 'Segoe UI', sans-serif;
- font-size: 13px;
-}
-QMainWindow, #root {
- background-color: #111214;
-}
-
-/* Knapper */
-QPushButton {
- background-color: #30343c;
- color: #9aa0b0;
- border: 1px solid #4a5060;
- border-radius: 4px;
- padding: 6px 14px;
-}
-QPushButton:hover {
- background-color: #454a56;
- color: #e8eaf0;
- border-color: #e8a020;
-}
-QPushButton:pressed {
- background-color: #22252a;
-}
-QPushButton:checked {
- background-color: #e8a020;
- color: #111214;
- border-color: #c47a10;
-}
-QPushButton#btn_play {
- background-color: #e8a020;
- color: #111214;
- border-color: #c47a10;
- font-size: 22px;
- font-weight: bold;
-}
-QPushButton#btn_play:hover {
- background-color: #c47a10;
-}
-QPushButton#btn_stop {
- color: #e74c3c;
-}
-QPushButton#btn_stop:hover {
- border-color: #e74c3c;
-}
-QPushButton#btn_demo {
- color: #3b8fd4;
- border-color: #3b8fd4;
- font-size: 11px;
-}
-QPushButton#btn_demo:hover, QPushButton#btn_demo:checked {
- background-color: #3b8fd4;
- color: #111214;
- border-color: #3b8fd4;
-}
-
-/* Slider */
-QSlider::groove:horizontal {
- height: 4px;
- background: #2c3038;
- border-radius: 2px;
-}
-QSlider::sub-page:horizontal {
- background: #e8a020;
- border-radius: 2px;
-}
-QSlider::handle:horizontal {
- background: #e8a020;
- width: 12px;
- height: 12px;
- margin: -4px 0;
- border-radius: 6px;
-}
-
-/* Lister */
-QListWidget {
- background-color: #1a1c1f;
- border: none;
- outline: none;
-}
-QListWidget::item {
- padding: 6px 10px;
- border-bottom: 1px solid #22252a;
-}
-QListWidget::item:selected {
- background-color: #2c3038;
- color: #e8eaf0;
- border-left: 2px solid #e8a020;
-}
-QListWidget::item:hover {
- background-color: #22252a;
-}
-
-/* Søgefelt */
-QLineEdit {
- background-color: #111214;
- border: 1px solid #3a3e46;
- border-radius: 3px;
- padding: 5px 8px;
- color: #e8eaf0;
-}
-QLineEdit:focus {
- border-color: #e8a020;
-}
-
-/* Labels */
-QLabel#track_title {
- font-size: 20px;
- font-weight: bold;
- color: #e8eaf0;
- font-family: 'Rajdhani', 'Segoe UI', sans-serif;
-}
-QLabel#track_meta {
- font-size: 11px;
- color: #9aa0b0;
- font-family: 'Courier New', monospace;
-}
-QLabel#section_title {
- font-size: 11px;
- font-weight: bold;
- color: #5a6070;
- letter-spacing: 2px;
- font-family: 'Courier New', monospace;
- padding: 6px 10px;
- background-color: #22252a;
- border-bottom: 1px solid #3a3e46;
-}
-QLabel#next_up_label {
- color: #e8a020;
- font-family: 'Courier New', monospace;
- font-size: 11px;
- letter-spacing: 2px;
-}
-QLabel#next_up_title {
- font-size: 17px;
- font-weight: bold;
- color: #e8eaf0;
-}
-QLabel#next_up_sub {
- font-size: 11px;
- color: #9aa0b0;
- font-family: 'Courier New', monospace;
-}
-QLabel#vol_label {
- font-size: 10px;
- color: #5a6070;
- font-family: 'Courier New', monospace;
- letter-spacing: 1px;
-}
-QLabel#vol_val {
- font-size: 11px;
- color: #9aa0b0;
- font-family: 'Courier New', monospace;
- min-width: 28px;
-}
-QLabel#result_count {
- font-size: 10px;
- color: #5a6070;
- font-family: 'Courier New', monospace;
- padding: 3px 10px;
-}
-
-/* Frames / paneler */
-QFrame#panel {
- background-color: #1a1c1f;
- border: 1px solid #3a3e46;
- border-radius: 4px;
-}
-QFrame#now_playing_frame {
- background-color: #1a1c1f;
- border: 1px solid #3a3e46;
- border-radius: 4px 4px 0 0;
-}
-QFrame#track_display {
- background-color: #111214;
- border: 1px solid #3a3e46;
- border-radius: 3px;
- padding: 4px;
-}
-QFrame#transport_frame {
- background-color: #1a1c1f;
- border: 1px solid #3a3e46;
- border-top: none;
- border-radius: 0 0 4px 4px;
-}
-QFrame#next_up_frame {
- background-color: #22252a;
- border: 1px solid #e8a020;
- border-top: none;
- border-bottom: none;
-}
-QFrame#progress_frame {
- background-color: #1a1c1f;
- border: 1px solid #3a3e46;
- border-top: none;
- border-bottom: none;
-}
-
-/* Scrollbar */
-QScrollBar:vertical {
- background: #1a1c1f;
- width: 6px;
- border-radius: 3px;
-}
-QScrollBar::handle:vertical {
- background: #4a5060;
- border-radius: 3px;
- min-height: 20px;
-}
-QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
-
-/* Højreklik-menu */
-QMenu {
- background-color: #22252a;
- color: #e8eaf0;
- border: 1px solid #4a5060;
- padding: 4px 0;
- font-size: 14px;
-}
-QMenu::item {
- padding: 8px 24px;
- border-radius: 0;
-}
-QMenu::item:selected {
- background-color: #e8a020;
- color: #111214;
-}
-QMenu::separator {
- height: 1px;
- background: #3a3e46;
- margin: 4px 8px;
-}
-
-/* Topbar */
-QFrame#topbar {
- background-color: #1a1c1f;
- border: 1px solid #3a3e46;
- border-radius: 4px;
-}
-QLabel#logo {
- font-size: 16px;
- font-weight: bold;
- letter-spacing: 3px;
- color: #e8a020;
- font-family: 'Rajdhani', 'Segoe UI', sans-serif;
-}
-QLabel#conn_label {
- font-size: 11px;
- color: #5a6070;
- font-family: 'Courier New', monospace;
- letter-spacing: 1px;
-}
-"""
-
-LIGHT = DARK + """
-QWidget {
- background-color: #d8dae0;
- color: #1a1c22;
-}
-QMainWindow, #root {
- background-color: #c8cad0;
-}
-QPushButton {
- background-color: #b0b4bc;
- color: #1a1c22;
- border-color: #8890a0;
-}
-QPushButton:hover {
- background-color: #c8ccd4;
- color: #1a1c22;
- border-color: #c07010;
-}
-QPushButton#btn_play {
- background-color: #c07010;
- color: #fff;
- border-color: #a05808;
-}
-QListWidget {
- background-color: #d8dae0;
- color: #1a1c22;
-}
-QListWidget::item {
- color: #1a1c22;
-}
-QListWidget::item:selected {
- background-color: #c07010;
- color: #ffffff;
- border-left: 2px solid #a05808;
-}
-QListWidget::item:hover {
- background-color: #c8ccd4;
- color: #1a1c22;
-}
-QLineEdit {
- background-color: #c8cad0;
- border-color: #aab0bc;
- color: #1a1c22;
-}
-QLineEdit:focus { border-color: #c07010; }
-QFrame#panel, QFrame#now_playing_frame,
-QFrame#transport_frame, QFrame#progress_frame {
- background-color: #d8dae0;
- border-color: #aab0bc;
-}
-QFrame#track_display { background-color: #c8cad0; border-color: #aab0bc; }
-QFrame#topbar { background-color: #d8dae0; border-color: #aab0bc; }
-QLabel#section_title { background-color: #e4e6ec; color: #1a1c22; border-color: #aab0bc; }
-QLabel#track_title { color: #1a1c22; }
-QLabel#track_meta { color: #4a5060; }
-QLabel#result_count { color: #5a6070; }
-QSlider::groove:horizontal { background: #b0b4bc; }
-QScrollBar:vertical { background: #d8dae0; }
-QScrollBar::handle:vertical { background: #8890a0; }
-QMenu {
- background-color: #e4e6ec;
- color: #1a1c22;
- border: 1px solid #aab0bc;
-}
-QMenu::item:selected {
- background-color: #c07010;
- color: #ffffff;
-}
-"""
-
-
-def apply_theme(app, dark: bool = True):
- app.setStyleSheet(DARK if dark else LIGHT)
diff --git a/ui/vu_meter.py b/ui/vu_meter.py
deleted file mode 100644
index b85fcadb..00000000
--- a/ui/vu_meter.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""
-vu_meter.py — VU-meter widget der tegner L og R kanaler.
-Opdateres via set_levels(left, right) med værdier 0.0–1.0.
-"""
-
-from PyQt6.QtWidgets import QWidget
-from PyQt6.QtCore import Qt, QTimer
-from PyQt6.QtGui import QPainter, QColor
-import random
-
-
-NUM_BARS = 14
-BAR_W = 14
-BAR_H = 4
-BAR_GAP = 2
-CHAN_GAP = 6
-PADDING = 4
-
-COLOR_OFF = QColor("#1a2218")
-COLOR_GREEN = QColor("#28a050")
-COLOR_YELLOW = QColor("#c8a020")
-COLOR_RED = QColor("#c83020")
-
-# Grænser for farver (bar-indeks fra bunden)
-YELLOW_FROM = NUM_BARS - 4
-RED_FROM = NUM_BARS - 2
-
-
-class VUMeter(QWidget):
- def __init__(self, parent=None):
- super().__init__(parent)
- self._left = 0.0
- self._right = 0.0
- self._peak_l = 0.0
- self._peak_r = 0.0
- self._dark = True
-
- total_h = NUM_BARS * (BAR_H + BAR_GAP) + PADDING * 2 + 16 # +16 til label
- total_w = (BAR_W + CHAN_GAP) * 2 + PADDING * 2
- self.setFixedSize(total_w, total_h)
-
- def set_dark(self, dark: bool):
- self._dark = dark
- self.update()
-
- def set_levels(self, left: float, right: float):
- """Sæt niveauer 0.0–1.0. Kaldes fra afspiller-tråden via signal."""
- self._left = max(0.0, min(1.0, left))
- self._right = max(0.0, min(1.0, right))
- self._peak_l = max(self._peak_l * 0.92, self._left)
- self._peak_r = max(self._peak_r * 0.92, self._right)
- self.update()
-
- def reset(self):
- self._left = self._right = self._peak_l = self._peak_r = 0.0
- self.update()
-
- def paintEvent(self, event):
- painter = QPainter(self)
- painter.setRenderHint(QPainter.RenderHint.Antialiasing)
-
- off_color = QColor("#d0d8cc") if not self._dark else COLOR_OFF
-
- for ch_idx, level in enumerate([self._left, self._right]):
- x = PADDING + ch_idx * (BAR_W + CHAN_GAP)
- active_bars = int(level * NUM_BARS)
-
- for bar_idx in range(NUM_BARS):
- y = PADDING + (NUM_BARS - 1 - bar_idx) * (BAR_H + BAR_GAP)
-
- if bar_idx < active_bars:
- if bar_idx >= RED_FROM:
- color = COLOR_RED
- elif bar_idx >= YELLOW_FROM:
- color = COLOR_YELLOW
- else:
- color = COLOR_GREEN
- else:
- color = off_color
-
- painter.fillRect(x, y, BAR_W, BAR_H,
- QColor(color.red(), color.green(), color.blue(), 220))
-
- # Kanal-labels
- label_y = PADDING + NUM_BARS * (BAR_H + BAR_GAP) + 4
- painter.setPen(QColor("#5a6070"))
- font = painter.font()
- font.setPointSize(8)
- font.setFamily("Courier New")
- painter.setFont(font)
-
- for ch_idx, label in enumerate(["L", "R"]):
- x = PADDING + ch_idx * (BAR_W + CHAN_GAP) + BAR_W // 2
- painter.drawText(x - 4, label_y + 10, label)
-
- painter.end()
diff --git a/venv/lib64 b/venv/lib64
deleted file mode 120000
index 7951405f..00000000
--- a/venv/lib64
+++ /dev/null
@@ -1 +0,0 @@
-lib
\ No newline at end of file
diff --git a/venv/pyvenv.cfg b/venv/pyvenv.cfg
deleted file mode 100644
index 46675874..00000000
--- a/venv/pyvenv.cfg
+++ /dev/null
@@ -1,5 +0,0 @@
-home = /usr/bin
-include-system-site-packages = false
-version = 3.12.3
-executable = /usr/bin/python3.12
-command = /usr/bin/python3 -m venv /home/carsten/Dokumenter/GitClone/LinedanceAfspiller/venv