- PHP 46.2%
- TypeScript 31.5%
- Twig 15%
- CSS 6.8%
- JavaScript 0.4%
| .ddev | ||
| assets | ||
| bin | ||
| config | ||
| media | ||
| migrations | ||
| public | ||
| src | ||
| storage/audio | ||
| templates | ||
| uploads | ||
| .editorconfig | ||
| .env | ||
| .env.dev | ||
| .gitignore | ||
| .npmrc | ||
| composer.json | ||
| composer.lock | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| symfony.lock | ||
| TODO.md | ||
| tsconfig.json | ||
MusicDock
Ein schlankes, selbstgehostetes Musik-Streaming-Grundgerüst im Stil von Spotify/Amazon Music/Soundcloud – Symfony 7+ Backend, minimal-dependencies Frontend (Bootstrap 5.3 nur per CDN + HTMX + Vanilla JS, kein Build-Schritt).
Die Grundidee
Ein Künstler ist ein Ordner. Ein Album ist ein Unterordner. Ein Song ist eine
Datei. Deshalb gibt es genau eine Tabelle (nodes), die einen Baum aus
Ordnern und Dateien abbildet. App\Entity\Node unterscheidet nur zwischen
type (folder/file) und einem freien metadata-JSON-Feld, in dem u. a.
kind (artist, album, category) steckt.
Alle "Ansichten" – Künstler, Kategorie, Dateimanager, Suche – sind reine Interpretationen desselben Baums, keine eigenen Datenmodelle:
| Ansicht | Controller | Interpretation |
|---|---|---|
| Künstler | ArtistController |
Wurzel-Ordner mit kind=artist, deren Kinder = Alben |
| Kategorie | CategoryController |
Wurzel-Ordner mit kind=category, rekursiv über path LIKE |
| Dateimanager | FileManagerController |
roher Baum, keine Interpretation |
| Suche | SearchController |
name LIKE + metadata.artist_name LIKE |
Setup mit DDEV
Vollständige Reihenfolge für einen frischen git clone (jeder Schritt baut
auf dem vorherigen auf, daher wichtig in dieser Reihenfolge):
# 1. Container starten - baut dabei auch das Web-Image inkl. ffmpeg
# (.ddev/web-build/Dockerfile) und die Upload-Limits (.ddev/php/upload.ini)
ddev start
# 2. Font Awesome Pro braucht einen Token, BEVOR npm install funktioniert
# (die @fortawesome-Registry in .npmrc ist authentifiziert) - Token unter
# https://fontawesome.com/account -> "Account Details" -> "Package Token"
cp .ddev/.env.example .ddev/.env
# .ddev/.env öffnen, FONTAWESOME_NPM_AUTH_TOKEN=<dein Token> eintragen
ddev restart # nötig, damit die neue .ddev/.env tatsächlich geladen wird
# 3. PHP-Abhängigkeiten (inkl. symfony/lock für die Cron-Befehle)
ddev composer install
# 4. Datenbankschema - alle Migrationen in einem Rutsch (nodes, playlists,
# uploads-Tabelle, ...)
ddev exec bin/console doctrine:migrations:migrate --no-interaction
# 5. Frontend-Abhängigkeiten + Build - ohne das fehlt public/build/app.js
# und app.css komplett, die Seite wäre unstyled/nicht interaktiv
ddev npm install
ddev npm run build
Danach ist die Seite unter ddev launch erreichbar (leer, bis importiert
wurde - siehe "Musik importieren" unten). media/, storage/audio/,
uploads/ und public/media/covers/ existieren bereits als leere Ordner
(nur .gitkeep versioniert, siehe .gitignore) - kein manuelles Anlegen
nötig.
Optional, nicht für den Betrieb erforderlich:
- Direkter DB-Zugriff (PhpStorm/phpMyAdmin) - siehe eigener Abschnitt unten.
- Cronjobs für
app:generate-pending-waveformsundapp:process-uploads- ohne die funktioniert alles synchron beim Import, nur eben ohne asynchrones Nachholen für--no-waveform-Importe bzw. neue Web-Uploads (letztere werden dann gar nicht automatisch verarbeitet - der Cron dafür ist also nur bei tatsächlicher Upload-Nutzung nötig, siehe "Web-Upload").
Direkter DB-Zugriff (phpMyAdmin, PhpStorm/DBeaver)
.ddev/config.yaml legt einen festen host_db_port (13087) fest - damit
ist die Datenbank auch direkt vom Host aus erreichbar, ohne über
ddev mysql zu gehen:
- PhpStorm/DBeaver/TablePlus o.Ä.: neue MySQL-Datenquelle,
Host
127.0.0.1, Port13087, Nutzerdb, Passwortdb, Datenbankdb. - phpMyAdmin ist seit einiger Zeit kein DDEV-Core-Feature mehr, sondern
ein Add-on - einmalig installieren:
ddev add-on get ddev/ddev-phpmyadmin ddev restart ddev phpmyadmin
host_db_port: "13087" ist projektspezifisch - läuft parallel ein anderes
DDEV-Projekt mit demselben Port, in .ddev/config.yaml einfach auf einen
freien Port ändern und ddev restart.
Musik importieren
Lege deine Bibliothek nach diesem Muster in media/ ab:
media/
Pink Floyd/
The Dark Side of the Moon/
cover.jpg <- optional, wird als Album-Cover übernommen
01 Speak to Me.mp3
02 Breathe.mp3
01 Money.mp3 <- Track ohne Album-Unterordner, landet direkt beim Künstler
Daft Punk/
Discovery/
01 One More Time.mp3
Dann:
ddev exec bin/console app:import-media
Der Import liest ID3-Tags (Titel, Album, Tracknummer, Dauer) über
james-heinrich/getid3 und legt für jede Ebene einen Node an. Künstler-
Ordner werden automatisch mit kind=artist getaggt, Alben mit kind=album.
Zeigt dabei eine Fortschrittsleiste (Symfony Console ProgressBar,
SymfonyStyle::createProgressBar()): Balken, Zähler, Prozent und der
gerade verarbeitete Dateiname. MediaImporter::countPendingFiles() zählt
vorab die Gesamtzahl (rein lesend, dieselbe Traversierung wie
importAll() selbst, nur ohne Seiteneffekte) für die Prozentanzeige;
importAll() ruft nach jedem einzelnen Track einen optionalen
$onTrackImported-Callback mit dem Dateinamen auf, den der Befehl nutzt,
um den Balken weiterzuschieben und die Nachricht zu aktualisieren -
MediaImporter selbst weiß dabei nichts von Symfony Console, reiner
callable-Parameter.
Referenz: Alle Shell-Befehle & Cronjobs
Kurzübersicht, damit man sich das nicht aus den einzelnen Abschnitten zusammensuchen muss. Details/Optionen jeweils beim verlinkten Abschnitt.
Einmalig nach Checkout/Klon (siehe "Setup mit DDEV" oben für die
vollständige Reihenfolge inkl. .env/Font-Awesome-Token):
ddev start
ddev composer install
ddev exec bin/console doctrine:migrations:migrate --no-interaction
ddev npm install
ddev npm run build
Musik importieren (siehe "Musik importieren" unten):
ddev exec bin/console app:import-media # kopiert, media/ bleibt erhalten (Standard)
ddev exec bin/console app:import-media --move # verschiebt, media/ danach leer
ddev exec bin/console app:import-media --no-waveform # überspringt ffmpeg-Wellenformberechnung (schneller)
ddev exec bin/console app:import-media --flush # löscht + importiert komplett neu (fragt nochmal nach)
Wellenformen nachträglich berechnen (siehe "Wellenformen asynchron
nachholen"), z.B. nach --no-waveform oder für Alt-Tracks:
ddev exec bin/console app:generate-pending-waveforms
ddev exec bin/console app:generate-pending-waveforms --time-budget=120 --batch-size=50 # Optionen anpassen
Lyrics nachträglich abrufen (siehe "Lyrics") - lädt Song-Lyrics von lrclib.net für Tracks nach, für die das noch nie versucht wurde:
ddev exec bin/console app:fetch-pending-lyrics
ddev exec bin/console app:fetch-pending-lyrics --time-budget=120 --batch-size=30 --max-duration=1200 # Optionen anpassen
Web-Uploads verarbeiten (siehe "Web-Upload") - nötig, sonst bleiben hochgeladene Dateien dauerhaft im Status "wartend" hängen:
ddev exec bin/console app:process-uploads
Frontend-Build (siehe "Frontend-Build"):
ddev npm run build # einmaliger Produktions-Build (minifiziert)
ddev npm run watch # während der Entwicklung, baut bei jeder Änderung neu
ddev npm run typecheck # nur Typprüfung, kein Build
Cronjobs (Host-Crontab, nicht im Container - ddev exec startet den
Container bei Bedarf mit):
# Wellenformen: alle 5 Minuten während der 4-Uhr-Stunde (ruhige Nachtstunde)
*/5 4 * * * cd /pfad/zu/musicdock && ddev exec bin/console app:generate-pending-waveforms >> var/log/waveform-cron.log 2>&1
# Lyrics: alle 10 Minuten, unkritisch wann - reine externe API-Abfrage ohne CPU-Last
*/10 * * * * cd /pfad/zu/musicdock && ddev exec bin/console app:fetch-pending-lyrics >> var/log/lyrics-cron.log 2>&1
# Uploads: minütlich, damit neue Uploads zeitnah in der Bibliothek landen
* * * * * cd /pfad/zu/musicdock && ddev exec bin/console app:process-uploads >> var/log/upload-cron.log 2>&1
Alle drei Cron-Befehle sind Lock-geschützt (Symfony Lock, FlockStore) und
zeitbudgetiert - ein noch laufender Vorgänger blockt den nächsten Tick
einfach ab, kein Tick läuft je über
sein eigenes Zeitbudget hinaus. Wellenformen/Uploads nutzen dafür die
gemeinsame App\Service\RuntimeStorage-Klasse, app:fetch-pending-lyrics
eine simple lokale time()-Deadline direkt im Befehl selbst - funktional
gleichwertig, nur ohne die (hier nicht gebrauchte) geteilte Utility-Klasse,
die für Deadline-Weitergabe ÜBER Klassengrenzen hinweg gedacht ist
(RuntimeStorage wird z.B. sowohl vom Cron-Befehl selbst als auch von
WaveformGenerator gelesen).
Datenbank direkt erreichen (siehe "Direkter DB-Zugriff"):
ddev add-on get ddev/ddev-phpmyadmin # einmalig
ddev restart
ddev phpmyadmin
Speicher-Architektur: Inbox vs. verwalteter Speicher
media/ ist bewusst nur eine Inbox für neue Musik, kein dauerhafter
Speicherort - und liegt deshalb auch nicht mehr unter public/ (nie über
den Webserver erreichbar, war vorher unnötig offen). Beim Import wird jede
Audiodatei zusätzlich nach storage/audio/ übernommen - genau wie Cover in
256 Hex-Bucket-Ordner (<uuid>.<ext>) einsortiert, obfuskiert und
einheitlich benannt, unabhängig vom ursprünglichen Dateinamen
(MediaImporter::storeAudioFile()). StreamController liefert Audio immer
aus storage/audio/, nie aus media/ direkt.
Kopieren (Standard) vs. Verschieben (--move):
ddev exec bin/console app:import-media # kopiert - media/ bleibt erhalten
ddev exec bin/console app:import-media --move # verschiebt - media/ ist danach leer
Standardmäßig wird kopiert, das Original bleibt in media/ liegen. Das
ist wichtig für --flush: der leert die komplette DB und baut aus media/
neu auf - wären die Originale bereits verschoben, hätte ein Flush nichts
mehr zum Reimportieren. Mit --move werden Dateien tatsächlich
verschoben (spart Plattenplatz, hält media/ als reine Inbox für
"aktuell noch unverarbeitete" Musik sauber) - dafür kann ein späteres
--flush diese Dateien nicht mehr reimportieren, weil die Quelle
fehlt. storage/audio/ ist dann die einzige verbleibende Kopie -
entsprechend wichtig, das separat zu sichern/backupen. Der Befehl warnt
davor explizit und fragt nochmal nach.
Warum liegen Cover trotzdem weiter unter public/media/covers/, Audio
aber nicht mehr unter public/? Cover-Bilder sind unkritisch - kein
Grund, den Overhead eines eigenen Controllers dafür zu tragen. Audio
dagegen soll ausschließlich über StreamController laufen (Range-Requests
fürs Spulen, und später der natürliche Ansatzpunkt für Zugriffskontrolle,
sobald Login existiert) - das funktioniert unabhängig davon, ob die Datei
unter public/ liegt oder nicht, BinaryFileResponse braucht dafür
keinen Webserver-Zugriff auf den Pfad. Es liegt einfach konsequenterweise
dort, wo es hingehört: nicht-öffentlicher Anwendungsspeicher.
public/media/covers/ ist NICHT dasselbe wie das Top-Level media/
(die Inbox für neue Musik) - nur zufällig derselbe Name. media/ ist vom
Nutzer kuratierter Quellinhalt, public/media/covers/ sind rein
generierte, jederzeit per Reimport reproduzierbare Artefakte. Der Name
kam auf ausdrücklichen Wunsch zustande (Ablage weiterhin unter public/
für Zero-Overhead-Auslieferung, aber mit "media" im Pfad statt dem
generischen public/covers/).
--flush räumt jetzt auch storage/audio/ leer (clearAudioStorage()),
nicht mehr nur Cover - sonst blieben bei geänderten/entfernten
Quelldateien alte Kopien liegen.
--flush räumt auch verwaiste Upload-Tracking-Einträge auf
(UploadRepository::deleteImported()) - nur die mit Status "importiert",
deren Node gerade mitgelöscht wurde und deren Datei (dank --move-
Semantik in importUploadedFile()) ohnehin längst nicht mehr in
uploads/ liegt. Bewusst NICHT "wartend"/"in Arbeit"/"fehlgeschlagen"
mitgelöscht - deren Dateien liegen noch echt in uploads/ und warten auf
Verarbeitung, das hat mit einem Bibliotheks-Flush nichts zu tun.
file_path ist relativ, nicht absolut
metadata.file_path auf einem Track-Node speichert seit Kurzem einen
relativen Pfad wie audio/ec/1234-....mp3 - relativ zum Storage-Root
(dem Elternordner von storage/audio/), nicht mehr den vollen absoluten
Pfad inklusive Container-Präfix (früher z.B.
/var/www/html/storage/audio/...). Grund: ein absoluter Pfad ist fest an
genau ein Deployment gebunden - verschiebt sich der Präfix (anderer
Server, anderer Docroot, ein zweiter Container für einen DB-Export/-Import),
stimmen alle gespeicherten Pfade nicht mehr.
src/Service/AudioPathResolver.php übersetzt in beide Richtungen:
toRelative() beim Schreiben (MediaImporter), toAbsolute() beim Lesen
für tatsächliche Dateizugriffe (StreamController,
GenerateWaveformsCommand). Der Präfix selbst kommt zur Laufzeit aus
%kernel.project_dir% - Symfony löst das in jeder Umgebung automatisch
korrekt auf, keine manuell gepflegte Umgebungsvariable dafür nötig.
Kein erzwungener Reimport nötig: toAbsolute() erkennt alte, bereits
gespeicherte absolute Pfade am führenden / und gibt sie unverändert
zurück - bestehende Tracks funktionieren also weiter, auch ohne
--flush. Neu importierte/hochgeladene Tracks bekommen automatisch den
neuen relativen Pfad. Für volle Konsistenz (z.B. bevor man die DB auf
einen anderen Server umzieht) trotzdem empfehlenswert, einmal
app:import-media --flush laufen zu lassen.
Kategorien/Playlists folgen nicht zwangsläufig der Ordnerstruktur einer
Musikbibliothek (ein Song kann in mehreren Playlists sein) und werden daher
separat gepflegt – entweder über die Doctrine-Konsole/eine kleine Fixture,
oder indem du dir später einen app:create-category-Command o. Ä. baust, der
bestehende Track-Nodes per kind=category-Wurzel-Ordner gruppiert und als
zusätzlichen Node-Eintrag mit denselben file_path-Metadaten referenziert.
Frontend-Build (TypeScript, lokale Dependencies)
Kein CDN mehr - htmx.org und bootstrap liegen als npm-Pakete vor und
werden zusammen mit dem TypeScript-Player per esbuild zu
public/build/app.js + public/build/app.css gebündelt.
ddev npm install
ddev npm run build # einmaliger Produktions-Build (minifiziert)
ddev npm run watch # während der Entwicklung, baut bei jeder Änderung neu
ddev npm run typecheck # nur Typprüfung, kein Build
public/build/ wird nicht versioniert (siehe .gitignore) - nach jedem
Checkout einmal ddev npm install && ddev npm run build laufen lassen,
sonst fehlt base.html.twig die referenzierte app.js/app.css.
Quelltext liegt unter assets/ts/ (app.ts = Einstiegspunkt, player.ts =
der eigentliche Player) und assets/css/ (theme.css = Sonora-Theme,
app.css = projekteigene Overrides darüber).
Design: Sonora-Theme
Das komplette Frontend basiert auf einem übernommenen Theme-Entwurf
("Sonora Music Library") - Sidebar-Layout, Dark/Light-Modus + 4
Akzentfarben (Violet/Ocean/Emerald/Rose), Queue-/Lyrics-Seitenpanels,
Kontextmenü pro Track, alles über CSS Custom Properties + Container
Queries. assets/css/theme.css ist die (leicht angepasste)
Theme-Quelldatei, assets/css/app.css enthält nur unsere eigenen
Ergänzungen/Overrides darüber (Importreihenfolge in app.ts: Bootstrap →
Font Awesome → theme.css → app.css - bei gleicher Spezifität gewinnt also
immer die eigene Anpassung).
Font Awesome Pro kommt lokal per npm statt CDN, passend zu
Bootstrap/htmx - @fortawesome/fontawesome-pro, nicht die Free-Variante:
das Theme nutzt u.a. list-music (Warteschlange/Playlist-Icon), das in
der komplett kostenlosen Font-Awesome-Stufe in der Kategorie "Music +
Audio" gar nicht existiert (dort gibt es in Classic Solid keine einzige
Free-Version). package.jsons Build-Skripte haben --loader:.woff=file
(und .woff2) bekommen, damit esbuild die Font-Dateien mit ins Bundle
kopiert.
Nur woff/woff2, nicht ttf/eot/svg: Font Awesomes CSS listet pro
Icon-Stil mehrere Font-Formate als Fallback-Kette (src: url(...woff2), url(...woff), url(...ttf), ...) - jeder halbwegs aktuelle Browser lädt
davon aber ausschließlich woff2 (die erste, bevorzugte Quelle). ttf/eot/svg
sind reine Altlasten für IE11 & uraltes Android/Safari, aber jeweils
1+ MB groß. Da esbuild beim Bundeln blind jede url()-Referenz einer CSS
kopiert, ohne zu wissen, dass der Browser davon eh nur eine lädt, würden
sie sonst mitgebündelt werden, ohne je gebraucht zu werden. Deshalb
--external:*.ttf --external:*.eot --external:*.svg statt eines
file-Loaders dafür - esbuild lässt die url()-Referenz dann einfach
unangetastet stehen, kopiert aber nichts.
Font Awesome Pro liegt in einer authentifizierten npm-Registry - dafür brauchst du einen Node-Auth-Token aus deinem Font-Awesome-Pro-Account (https://fontawesome.com/account → "Account Details" → "Package Token"). Der Token landet nicht im Repo:
cp .ddev/.env.example .ddev/.env
# .ddev/.env öffnen, FONTAWESOME_NPM_AUTH_TOKEN=<dein Token> eintragen
ddev restart
.ddev/.env wird von DDEV automatisch an alle Container durchgereicht und
ist per .gitignore ausgeschlossen. .npmrc im Projekt-Root leitet das
@fortawesome-Scope an npm.fontawesome.com um und liest den Token per
${FONTAWESOME_NPM_AUTH_TOKEN} aus dieser Umgebungsvariable - dort steht
also ebenfalls kein Klartext-Secret. Solltest du (oder jemand anders, der
das Projekt später aufsetzt) keinen Pro-Zugang haben: einfach zurück auf
@fortawesome/fontawesome-free wechseln (in package.json und dem Import
in assets/ts/app.ts) und fa-list-music durch ein Free-Icon ersetzen,
z.B. fa-list-ol.
Theme-Umschaltung (assets/ts/theme.ts): data-mode/data-theme auf
<html> + localStorage, keine Server-Abhängigkeit. Ein kleines Inline-
<script> im <head> von base.html.twig setzt beide synchron VOR dem
ersten Rendern (verhindert einen kurzen Flacker im falschen Theme beim
Laden) - theme.ts übernimmt danach normal weiter.
Deterministische Farbverlauf-Platzhalter statt grauer Boxen: Nodes
ohne echtes Cover zeigen einen der 8 (bzw. 5 für Künstler) Farbverläufe aus
dem Theme, ausgewählt über die UUID (GradientExtension/gradient_class-
Twig-Filter, src/Twig/GradientExtension.php) - deterministisch, damit
dieselbe Kachel bei jedem Aufruf denselben Verlauf zeigt statt bei jedem
Reload zufällig zu wechseln.
Bewusste Anpassungen gegenüber der Theme-Vorlage (die war als Standalone-Demo mit festem JS-Datenarray gebaut, nicht für einen Server-gerenderten Multi-Page-HTMX-Stack):
- Album-/Künstlerdetails sind echte Seiten, kein Bootstrap-Modal. Die
Vorlage öffnet Album-/Künstler-Details in einem JS-befüllten Modal
(
detailAlbum()/detailArtist()in der Original-music-app.js). Bei uns sind das eigene URLs (/album/{id},/kuenstler/{id}) mit HTMX-Boost-Navigation - konsistent mit dem Rest der App, Browser-History- fähig, kein Duplizieren von Serverdaten in JS. Optisch wiederverwendet (.detail-modal/.detail-hero/.artist-detailals "abgerundete Karte", nicht als tatsächliches Modal). - Kontextmenü nur an echten Track-Zeilen, nicht an Album-/
Künstler-Kacheln. Im Original verwies der "..."-Button einer
Album-Kachel auf einen einzelnen Beispiel-Track (
data-track="0") - in echten Daten ist unklar, "welcher" Track eines Albums gemeint wäre. Album- /Künstler-Kacheln sind bei uns reine Navigation zur Detailseite. - Der frühere separate "+"-Playlist-Button ist im Kontextmenü
aufgegangen (Play now/next/Queue/Playlist/Favorit gebündelt) - der
Klick auf das Cover selbst startet jetzt die Wiedergabe (wie im
Original), mit einem kleinen Hover-Overlay als Play-Affordanz
(
.track-cover::afterinapp.css, in der Vorlage nicht vorhanden). - Progress-/Volume-Leiste: klickbare
<div>s wie im Original, aber echte Klick-Logik für die Lautstärke ergänzt (im Original-JS nur für die Fortschrittsanzeige verdrahtet, Lautstärke war rein optisch). - "Recently played" → "Kürzlich hinzugefügt": es gibt keine
Wiedergabe-Historie (dafür bräuchte es ein eigenes Tracking-Feature) -
stattdessen zeigt die Startseite die zuletzt importierten Alben
(
Node::$createdAt, kostenlos vorhanden). - "Favoriten" sind keine eigene Tabelle, sondern eine Playlist mit
festem Slug
favorites(PlaylistRepository::findOrCreateFavorites()) - reine Konvention statt Schema-Änderung, siehe Abschnitt "Playlists". - Lyrics-Panel bleibt UI-Hülle ohne Daten - es gibt keine Lyrics-Quelle, das Original hatte einen fest eingebauten Beispieltext. Zeigt stattdessen ehrlich "Keine Lyrics verfügbar."
- Kein "Downloads"-Menüpunkt - keine Offline-/Download-Funktion vorhanden, wurde nicht als Fake-Feature nachgebaut.
- Genres sind exakt unsere bestehenden Kategorien (
kind=category), nur umbenannt in der Navigation - kein neues Konzept.
Genres
Eigenständige, granulare Entity (App\Entity\Genre) statt eines reinen
Metadata-Strings auf dem Node - bewusste Design-Entscheidung, drei
Gründe:
- Deduplizierung "for free" über den
UNIQUE-Constraint aufname- die case-insensitive Tabellen-Collation (
utf8mb4_unicode_ci, wie im Rest des Projekts) - "Rock" und "rock" landen automatisch auf demselben Datensatz, keine eigene Normalisierungslogik nötig.
- die case-insensitive Tabellen-Collation (
- Viele-zu-viele-Zuordnung (ein Track/Album kann mehrere Genres
haben, ein Genre gehört zu vielen Tracks/Alben) ist mit Doctrine
sauber und ohne Umwege abbildbar -
Node::$genres,ManyToManygegenGenre, Join-Tabellenode_genres. - "Alle Tracks/Alben zu Genre X" ist darüber eine simple, schnelle Abfrage über die Join-Tabelle statt einer teuren JSON-Feld-Suche über alle Nodes hinweg - Grundlage für eine spätere Genre-Übersichtsseite (noch nicht gebaut, aber die Architektur trägt das direkt).
Zuweisung bewusst nur auf Track- und Album-Nodes (UI-seitig
durchgesetzt - _genre_editor.html.twig wird nur in track/show.html.twig
und album/show.html.twig eingebunden) - die Doctrine-Relation selbst
schränkt das nicht ein, ein Genre könnte technisch an jeden Node hängen.
Vererbung vom Album: Node::getEffectiveGenres() - liefert die
eigenen Genres, falls der Track welche hat, sonst (nur relevant für
Tracks unter einem Album) die des Elternalbums. Reine Lesefunktion, keine
Duplizierung der Zuordnung in der Datenbank - ändert sich das
Album-Genre später, zieht jeder Track ohne eigenes Genre das automatisch
nach. Auf der Track-Detailseite wird das geerbte Genre informativ
angezeigt ("Vom Album geerbt: ..."), editierbar ist dort aber immer nur
die eigene Zuordnung (node.genres, nicht die effektive).
GenreController, generisch auf Node-Ebene (nicht im
TrackController/AlbumController) - dieselbe Aktion (Genre
hinzufügen/entfernen, POST/DELETE /node/{id}/genres) gilt für beide
Node-Typen. GenreRepository::findOrCreateByName() sucht zuerst nach
einem bestehenden Genre mit diesem Namen (case-insensitive dank
Collation), legt es nur an, falls es das noch nicht gibt - ein
UniqueConstraintViolationException-Fangnetz deckt den seltenen Fall
ab, dass zwei parallele Anfragen gleichzeitig dasselbe neue Genre
anlegen wollen (die DB lässt nur eine durch, die andere liest den
inzwischen angelegten Datensatz).
_genre_editor.html.twig, wiederverwendbar in Track- UND
Album-Detailseite - Chips mit Entfernen-Button + Eingabefeld, komplett
HTMX-getrieben (hx-post/hx-delete, hx-target="#genre-editor-{id}" hx-swap="outerHTML" ersetzt die ganze Komponente mit der Server-Antwort)
- kein zusätzliches JS nötig, auch das Leeren des Eingabefelds nach dem Hinzufügen passiert automatisch (die Server-Antwort ist ein frisches, leeres Formular).
"Method Not Allowed" bei Reload nach jeder Genre-Interaktion - auch
beim Entfernen-Button, der nie in einem <form> war. Zwei
Zwischen-Fixe (native method/action-Absicherung, hx-boost="false",
später ein komplett formularloser Umbau) halfen alle nicht - der
gemeinsame blinde Fleck: hx-boost="false" schaltet NUR das
Boost-spezifische Link-/Formular-Verhalten ab, nicht die SEPARAT
vererbten hx-select/hx-push-url-Attribute. Die tatsächliche
Ursache (siehe base.html.twig, dort ausführlich dokumentiert - genau
dieses Muster steckt bereits im Playlist-Picker als Präzedenzfall):
hx-target/hx-select/hx-push-url sitzen am <body> (fürs
App-weite #content-Boosting) und werden von JEDEM HTMX-Request
geerbt, sofern nicht EXPLIZIT überschrieben - auch von kleinen
Fragmenten wie diesem hier, unabhängig von hx-boost. Ohne Override
suchte HTMX im (viel kleineren) Fragment-Response ein #content-
Element, fand nichts ("Zeile leert sich"), UND änderte trotzdem die
Browser-URL (hx-push-url="true" vom body geerbt) - ein Reload dieser
URL scheiterte dann als GET an einer POST/DELETE-only-Route.
Behoben, indem jeder hx-post/hx-delete explizit hx-select (auf
sich selbst) und hx-push-url="false" setzt - exakt wie im
Playlist-Picker (playlist/_picker.html.twig).
Bleibt trotzdem formularlos (aus dem vorherigen Zwischenschritt) -
Chips + Eingabefeld + Hinzufügen-Button sind eigenständige Elemente mit
direktem hx-post/hx-delete, hx-trigger="keyup[key=='Enter']" auf
dem Eingabefeld selbst für die Enter-Taste, hx-include für den
Hinzufügen-Button, um den Eingabefeld-Wert mitzuschicken - unabhängig
vom eigentlichen Fund oben eine zusätzliche, saubere Absicherung gegen
jedes denkbare native Formular-Submit-Verhalten.
Noch keine Zugriffsbeschränkung - laut Absprache soll das später an ein User-ACL-System gebunden werden, sobald es Nutzer im Projekt gibt. Bis dahin kann jeder, der die Seite erreicht, Genres pflegen.
Migration Version20260824000000 - erzeugt genres (id, name mit
UNIQUE-Index) und node_genres (Join-Tabelle, node_id+genre_id
als zusammengesetzter Primärschlüssel, beide Fremdschlüssel mit
ON DELETE CASCADE - ein gelöschter Track/Album oder ein gelöschtes
Genre räumt seine Zuordnungen automatisch mit auf).
Genre-Autocomplete
GenreExtension (Twig-Funktion all_genre_names()) liefert alle
bestehenden Genre-Namen für ein natives <datalist> im Genre-Editor
(_genre_editor.html.twig) - vermeidet doppelte/unterschiedlich
geschriebene Einträge ("Hip-Hop" vs "HipHop", die Collation dedupliziert
nur reine Groß-/Kleinschreibung wie "Rock"/"rock", nicht das). Als
Twig-Funktion statt vom jeweiligen Controller durchgereicht, damit nicht
JEDE der vier Einbindungsstellen (Track-/Album-Detailseite,
GenreController::add()/remove()) das explizit mitliefern muss -
für eine private Musikbibliothek unproblematisch (kleine, günstige
Abfrage), sonst wäre ein direkter DB-Zugriff aus Twig heraus ein
Anti-Pattern. Browser übernehmen Filterung/Tastatur-Navigation selbst,
kostenlos - kein eigenes JS-Autocomplete nötig.
Genre-Chips in der Trackliste
_track_row.html.twig zeigt die EFFEKTIVEN Genres (Node::getEffectiveGenres()
- eigene, sonst vom Album geerbt) als kleine, farbige Chips unter
Künstler/Album, verlinkt zur jeweiligen Genre-Seite.
Genre::$color(falls gesetzt) färbt den Chip GitHub-Label-artig: gedämpfter Hintergrund (Hex-Farbe +26-Alpha-Suffix, ~15% Deckkraft) plus volle Farbe für Text/Rand. Ohne gesetzte Farbe fällt der Chip auf die neutrale CSS-Standardoptik zurück.
Performance-Hinweis: löst pro Zeile eine (kleine, lazy-geladene)
Doctrine-Abfrage aus - bei sehr langen Listen (Tracks-Übersicht, 60
Zeilen/Seite) potenziell spürbar, für eine private Musikbibliothek aber
unproblematisch. Bei Bedarf später mit Eager-Loading (JOIN in
NodeRepository) nachbessern.
Genre-Cover-Upload
Genre::$coverUrl (Migration Version20260826000000) - "zweite
visuelle Aufwertung zur Farbe" (beide können gleichzeitig gesetzt sein,
das Cover ersetzt die Farbe nicht), wird ausschließlich auf der
Genre-Detailseite groß gezeigt (genre/show.html.twig), nicht in
Listen. Anzeige-Priorität dort: Cover > Farbe > deterministischer
Gradient, genau wie bei Album-Covern das gleiche Muster.
GenreCoverUploader - deutlich einfacher als der Album-/Track-
Cover-Pfad in MediaImporter (dort thumb+mini, mehrere Quellformate
über ID3-Extraktion): nur EIN direkter Upload, EINE Zielgröße (GD-
Skalierung auf maximal 512×512, Seitenverhältnis erhalten, kein
Zuschnitt/keine Verzerrung), gespeichert als JPEG unter
genre_covers_path (public/media/genre-covers/, eigener Ordner statt
covers_path/ - Genre-Cover sind konzeptuell unabhängig von Album-/
Track-Covern, kein zugehöriger Node). Ein Bild pro Genre, Dateiname =
Genre-ID, ein erneuter Upload überschreibt einfach die bestehende Datei
- Cache-Busting per
?v=timestamp-Suffix in der gespeicherten URL, sonst würde der Browser nach einem erneuten Upload weiterhin das alte Bild aus dem Cache zeigen.
Upload-Feld im "..."-Popup neben der Farbauswahl
(components/_genre_card.html.twig) - Datei-Uploads über HTMX brauchen
hx-encoding="multipart/form-data" auf dem sendenden Element selbst
(baut intern ein temporäres Formular fürs Multipart-Encoding auf), kein
separates <form>-Element nötig, genau wie beim Farbfeld daneben -
inklusive derselben hx-select/hx-push-url="false"-Absicherung.
Track-Cover in MP3 einbetten
Das riskanteste der vier Features - schreibt tatsächlich in die
Audiodatei auf der Festplatte, nicht nur in eine separate Anzeige-Datei.
TrackCoverEmbedder bettet ein hochgeladenes Bild als APIC-Frame
(ID3v2 "attached picture") direkt in die MP3 ein, über
james-heinrich/getid3s Schreib-Modul (getid3_writetags, liegt NICHT
im Composer-PSR-4-Autoloading - getID3s Struktur ist historisch
dateibasiert, kein Namespace - der Pfad zu write.php wird deshalb per
Reflection relativ zur bereits injizierten getID3-Hauptklasse
ermittelt, statt einen Vendor-Pfad hart zu codieren).
Entsprechend defensiv:
- Nur MP3 (
mime_type audio/mpeg) - andere Formate haben andere Tag-Systeme (FLAC: Vorbis-Kommentare, OGG etc.), getID3s ID3v2- Schreib-Modul ist spezifisch für MP3/ID3. Der Upload-Bereich wird auf der Track-Detailseite nur bei MP3-Tracks überhaupt angezeigt. - Bestehende Tags werden vor dem Schreiben vollständig ausgelesen
(
getID3->analyze()) und beim Schreiben explizit mitgegeben - nicht nur aufgetid3_writetags' eigenes Merge-Verhalten verlassen, sonst könnten Titel/Künstler/Album/Tracknummer etc. beim Cover-Update verloren gehen. - Backup vor dem Schreiben (Kopie der Original-Datei,
.cover-backup-Suffix) -getid3_writetagsbietet kein natives atomares Schreiben (kein Temp-Datei+rename()-Muster). Schlägt das Schreiben fehl oder wirft eine Exception, wird die Original-Datei aus dem Backup wiederhergestellt, bevor der Fehler nach oben gereicht wird - eine kaputte Mediendatei ist inakzeptabel. Backup wird in jedem Fall (Erfolg wie Fehler) am Ende wieder gelöscht.
Generiert danach dieselben Anzeige-Varianten (thumb/mini) wie beim
Import, über die wiederverwendeten MediaImporter::finalizeCover()/
bucketFor() (dafür von private auf public umgestellt) - kein
duplizierter Code für die Thumbnail-Erzeugung. Aktualisiert
cover_url/cover_thumb_url/cover_mini_url auf dem Track-Node.
Vorhandenes Cover wird VOR dem Schreiben gelöscht (Dateisystem) -
das ORIGINALE, beim Import extrahierte Cover kann jede Extension haben
(MediaImporter::storeCoverFile() übernimmt die Extension der
Quelldatei, z.B. .png/.webp), während dieser Upload IMMER als
.jpg schreibt. Ohne Bereinigung würde bei abweichender Original-
Extension die alte Datei als Leiche liegen bleiben - cover_url zeigt
zwar korrekt auf die neue .jpg, aber die alte Datei verbraucht
dauerhaft unnötig Speicherplatz. glob($bucket/$uuid.'*') (kein Punkt
im Muster) erfasst {uuid}.EXT UND {uuid}_thumb.jpg/{uuid}_mini.jpg
gleichermaßen, unabhängig von der genauen Extension - alle werden vor
dem Schreiben der neuen Dateien entfernt.
XMLHttpRequest statt natives Formular/HTMX (assets/ts/track-detail.ts,
initTrackCoverUpload()) - dasselbe Muster wie assets/ts/upload.ts für
den Drag&Drop-Upload: kein <form>, kein hx-post. Ein erster Entwurf
nutzte ein natives Formular mit vollständigem Redirect, aus Sorge vor den
hx-select/hx-push-url-Fallstricken, die beim Genre-Editor mehrfach
Probleme gemacht hatten - das war zu vorsichtig gedacht: das eigentliche
Kollisionsrisiko ist nie "HTMX generell", sondern spezifisch, dass
hx-select/hx-push-url vom <body> an JEDES Element mit hx-*-
Attributen vererbt werden. Ein reiner XHR-Aufruf trägt gar keine
hx-*-Attribute, betrifft dieses Problem also überhaupt nicht - der
Drag&Drop-Upload ist der Beweis, dass das schon die ganze Zeit
problemlos funktioniert. Aktualisiert das Cover-Bild nach Erfolg direkt
im DOM (#trackCoverImg), kein Reload nötig - deutlich bessere UX als
der ursprüngliche Redirect-Ansatz. Antwort ist JSON
({status: 'ok', cover_url: ..., ...} oder {error: '...'}), genau wie
UploadController::upload()s Antwortformat.
Cache-Busting (?v=timestamp-Suffix an den gespeicherten URLs,
analog zum Genre-Cover-Upload) - derselbe Dateiname (Track-UUID) wird
bei jedem Upload überschrieben, ohne das Suffix würde der Browser nach
einem erneuten Upload weiterhin das alte Bild aus dem Cache zeigen
(auch bei einem künftigen Seitenaufruf, nicht nur der aktuellen Session).
Erster Live-Test deckte einen echten Bug auf: getID3 als Konstruktor-
Argument autowiren lassen funktioniert nicht. getID3 hat keine
registrierte Symfony-Service-Definition (Autowiring schlug mit "Cannot
autowire service ... references class getID3 but no such service
exists" fehl) - MediaImporter selbst injiziert getID3 deshalb NIE per
Konstruktor-Typisierung, sondern instanziiert es manuell im Konstruktor-
Body ($this->getId3 = new getID3();). TrackCoverEmbedder macht das
jetzt genauso.
Fehlermeldungen jetzt spezifisch statt generisch - embed() warf
vorher bei JEDEM Fehler (falsches Audioformat, fehlende Datei,
ungültiges Bild, Schreibfehler) null zurück, die UI zeigte immer
dieselbe Meldung ("ungültiges Bild") - beim ersten echten Testfall
irreführend, wenn die Ursache in Wahrheit ganz woanders lag. Jede
Fehlerquelle wirft jetzt eine \RuntimeException mit der KONKRETEN
Ursache (falsches Audioformat, GD kann das Bildformat nicht dekodieren,
getid3_writetagss eigene Fehlermeldungen, etc.) - der Controller
reicht die Nachricht direkt an die UI durch, Details landen zusätzlich
im Log.
Weiterhin ohne vollständigen End-to-End-Test ausgeliefert - der
Autowiring-Fehler verhinderte bisher JEDEN Aufruf von
getid3_writetags, das eigentliche Schreiben in eine echte MP3-Datei
ist also noch nicht verifiziert. Vor dem produktiven Einsatz an
wichtigen Dateien: bitte zuerst an einem unwichtigen Test-Track
ausprobieren. Das Backup-Verfahren (Punkt 3 oben) sollte auch bei einem
unerwarteten Fehler die Originaldatei schützen, aber echtes Testen mit
einer echten MP3-Datei ersetzt das nicht.
Genre-Übersichtsseite
/genres (genre_index) - für jedes Genre die EFFEKTIVE Track-Anzahl
(inkl. vom Album geerbter, nicht nur direkt zugewiesener) plus bis zu 5
Beispiel-Tracks als Vorschau (GenreController::PREVIEW_TRACK_COUNT).
Klick auf ein Genre führt zu /genre/{id} (genre_show) mit der
vollständigen Track-Liste (normale _track_row.html.twig-Zeilen, ganz
normal abspielbar wie überall sonst).
Ersetzt ein ALTES, nie genutztes category-Node-System - der
"Genres"-Sidebar-Link zeigte vorher auf category_index
(kind:'category'-Nodes, ein generischer Ordner-Typ aus einer früheren
Session, für den nie tatsächlich Daten angelegt wurden) - deshalb zeigte
die Seite immer "Noch keine Genres angelegt", unabhängig davon, ob
Genres über die neue Entity längst existierten. Sidebar-Link UND die
Genre-Vorschau-Sektion auf der Startseite (home/index.html.twig,
vorher ebenfalls auf categories aufgebaut) zeigen jetzt beide auf die
echte Genre-Entity. Das alte CategoryController/category-System
bleibt unangetastet bestehen (wird noch als Fallback in der Suche
referenziert, search/_results.html.twig/_suggestions.html.twig, für
generische Node-Kinds, falls solche Nodes je entstehen sollten) - kein
aktiver Code-Pfad führt aber mehr dorthin.
GenreRepository::findEffectiveTracksForGenre() - die Umkehrung von
Node::getEffectiveGenres(): sammelt direkt zugewiesene Tracks PLUS
Tracks, deren Album dieses Genre hat und die selbst KEIN eigenes Genre
gesetzt haben (Vererbung). Ein Track mit einem ANDEREN eigenen Genre
taucht bewusst NICHT auf, selbst wenn sein Album dieses Genre hat - das
eigene Genre hat Vorrang, konsistent mit der Anzeige auf der
Track-Detailseite. Reine PHP-Objektgraph-Traversierung statt einer
komplexen DQL-Abfrage - bei der für eine private Musikbibliothek
üblichen Größenordnung unproblematisch, Doctrine lädt die
Kind-Collections betroffener Alben ohnehin lazy nach.
Genre-Farben
Manuell wählbare Kartenfarbe pro Genre (Genre::$color, Hex, z.B.
"#7c3aed") - bewusst kein eigenes Farbpaletten-System, ein freies
Hex-Feld reicht und lässt sich direkt in einem nativen
<input type="color"> bearbeiten. Ohne gewählte Farbe fällt die
Anzeige weiterhin auf den deterministischen Gradient zurück (aus der
Genre-ID abgeleitet), wie bei Alben/Künstlern ohne Cover.
"..."-Menü auf jeder Übersichtskarte (components/_genre_card.html.twig,
eigenständige, wiederverwendbare Komponente - GenreController::index()
UND ::setColor() rendern beide dasselbe Fragment, damit nach einem
Farbwechsel per HTMX NUR diese eine Karte ersetzt wird, nicht die ganze
Übersichtsseite). assets/ts/genre-color-menu.ts regelt das Öffnen/
Schließen des Popups rein per Event-Delegation auf document - läuft
dadurch automatisch auch für Karten, die erst nach einem HTMX-Swap neu
ins DOM kommen, ohne erneuten Init-Aufruf.
Bewusst KEIN verschachteltes <a> um "..."-Trigger + Popup - der
klickbare Kartenbereich (Name + Vorschau) ist ein eigener <a>
(.genre-overview-link), der Farbwähler-Menü-Bereich (.genre-color-menu)
liegt daneben als Geschwister-Element, nicht darin verschachtelt -
interaktive Elemente ineinander verschachtelt sind HTML-technisch
fragwürdig (Screenreader-/Tastatur-Navigation).
<input type="color"> sendet seinen Wert automatisch mit (eigenes
name="color"-Attribut reicht, kein hx-vals nötig - HTMX nimmt jedes
Element mit name-Attribut innerhalb seiner Request-Parameter mit, ganz
ohne <form>, genau wie beim formularlosen Genre-Editor). Ein separater
"Zurücksetzen"-Button (nur sichtbar, wenn aktuell eine Farbe gesetzt
ist) schickt stattdessen reset=1 - <input type="color"> selbst kann
nie "leer" sein (liefert immer einen gültigen 6-stelligen Hex-Wert),
deshalb dieser explizite, separate Weg zurück zum deterministischen
Gradient.
hx-select/hx-push-url wieder explizit überschrieben - dieselbe
Lektion wie beim Genre-Editor (siehe unten): ohne Override sucht HTMX im
Fragment-Response ein #content-Element (findet nichts) und ändert
trotzdem die Browser-URL.
Migration Version20260825000000 - fügt genres.color hinzu
(nullable, VARCHAR(7)).
Noch keine Admin-Seite zum genrelosen Massenpflegen ("Genre setzen,
ohne in jeden Track/jedes Album einzeln reinzugehen") - laut Absprache
für später, sobald es sich lohnt. Die aktuelle Pflege läuft ausschließlich
über _genre_editor.html.twig direkt auf Track-/Album-Detailseiten.
Track-Detailseite
/track/{id} (track_show, TrackController::show(),
templates/track/show.html.twig) - "eine Etage tiefer" als die Album-
Seite: großes Cover, alle Metadaten (die wir tatsächlich gespeichert
haben - kein rohes ID3-Tag-Dump, MediaImporter::importTrack()
extrahiert nur ein festes Feld-Set, keine vollständige Tag-Sammlung),
Songtext. Der Tracktitel in jeder Track-Zeile (components/_track_row.html.twig)
führt jetzt hierher statt zur Album-/Künstlerseite.
Play-Button ohne neues JS - das sichtbare Hero-Markup trägt
dieselben data-track-*-Attribute wie eine normale .track-row in der
Liste, verpackt in zwei display:contents-Wrapper
([data-track-list] außen, .track-row innen). .track-row bringt ihr
eigenes Grid-Layout (6 enge Spalten für die Listenansicht) mit, das mit
dem gewünschten .detail-hero-Layout (260px Cover + Rest) kollidieren
würde - display:contents lässt diese Wrapper selbst KEINE eigene Box
rendern, ihre Kinder erscheinen direkt im umgebenden .detail-hero-Grid,
unbeeinflusst vom .track-row-eigenen Layout. Die DOM-STRUKTUR bleibt
für player.ts aber unverändert intakt - wireTrackDelegation()/
playFromRow() finden die Zeile ganz normal über closest('.track-row'),
der [data-track-list]-Wrapper stellt zusätzlich sicher, dass NUR dieser
eine Track in die Wiedergabe-Warteschlange kommt (ohne einen solchen
Vorfahren würde playFromRow() auf document.body zurückfallen und ALLE
.track-row-Elemente der Seite einsammeln).
Songtext per JS nachgeladen (assets/ts/track-detail.ts,
initTrackDetailLyrics()) - dieselbe loadLyrics()/renderLyrics()-
Infrastruktur wie das Lyrics-Panel des Players, aber OHNE Player-Queue-
Bezug: der hier gezeigte Track ist nicht zwangsläufig der gerade
abgespielte, ein onUpdated-Callback, der den Player-State/localStorage
nachzieht, wäre hier fehl am Platz - der Callback rendert deshalb nur
lokal im selben Container neu (z.B. nach einem erfolgreichen "Lyrics
suchen"-Klick).
Synchronisierte Zeilen sprangen im FALSCHEN (dem gerade laufenden)
Track - renderLyrics() hängte für Klick-zum-Springen bisher
UNBEDINGT einen Handler an, der das globale #player-audio-Element
ansteuert - im Player-Panel/Karaoke-Overlay korrekt (dort ist der
gezeigte Text immer der des laufenden Tracks), auf der Track-Detailseite
aber nicht: ein Klick sprang witzig, aber falsch im aktuell laufenden,
meist komplett ANDEREN Track. renderLyrics() hat dafür jetzt einen
seekable-Parameter (Standard true, bewahrt das bisherige Verhalten
für Panel/Karaoke) - track-detail.ts übergibt explizit false, kein
Klick-Handler wird angehängt, .lyrics-static (CSS-Klasse auf dem
Container) nimmt zusätzlich den Zeiger-Cursor/Hover-Effekt weg, damit es
auch optisch klar nicht-interaktiv wirkt.
Klick auf Künstler-/Album-Namen im Untertitel navigiert weiterhin
gezielt zu Künstler/Album (unverändert) - nur der HAUPTTITEL selbst
wechselt jetzt zur Track-Detailseite. Die Album-/Künstler-Sprungmarken-
Navigation der Player-Leiste (data-track-link, "wo ist dieser Track in
meiner Bibliothek") bleibt davon unberührt, eine bewusst separate
Funktion von "zeig mir Details zu DIESEM Track".
Player-Leiste-Titelklick tat nichts mehr, wenn ein Track von seiner
eigenen Detailseite aus abgespielt wurde - data-track-link war dort
zunächst bewusst leer gesetzt (da man ja schon auf der Detailseite ist),
das führt aber zu titleLink.href = '#' in player.ts - ein Klick
darauf tut buchstäblich nichts. Jetzt trägt auch die Detailseite dieselbe
title_target-Sprungmarke (Album-/Künstlerseite) wie jede normale
Track-Zeile - der Player-Leiste-Klick verhält sich dadurch konsistent,
egal von wo der Track gestartet wurde.
Künstler-/Album-/Track-Links (Player-Leiste, Track-Zeilen, Warteschlange)
Wie auf jeder bekannten Plattform: Klick auf einen Tracktitel/Künstler/
Album navigiert dorthin (mit Sprungmarke + Hervorhebung zum Track auf der
Zielseite), Klick auf Cover/Play-Button spielt ab - zwei getrennte
Aktionen. components/_track_row.html.twig löst dafür pro Track die
strukturellen Eltern auf (track.parent -> Album -> dessen Parent ->
Künstler) und legt sowohl fertige Links im Markup selbst an (Untertitel:
Künstler- und Album-Name je eigener Link) als auch data-track-*-Attribute
(data-track-artist-link, data-track-album-link, data-track-link mit
#track-row-ID-Anker) für JS-Konsumenten.
Sprungmarke landet nicht hinter der sticky Topbar (--topbar, 76px
Desktop/64px Mobile, .topbar{position:sticky;top:0}) -
scroll-margin-top: var(--topbar) sitzt auf .track-row selbst (nicht
nur .track-row:target, siehe unten warum), CSS respektiert das bei
scrollIntoView() unabhängig vom block-Parameter. Eine einzige
CSS-Variable statt zweier unabhängig gepflegter Zahlen (app.css,
ganz oben): treibt sowohl die TATSÄCHLICHE Topbar-Höhe (Override der
theme.css-Vorgabe) als auch jede scroll-margin-top, die die Topbar
berücksichtigen muss - bleibt automatisch synchron, egal welcher
künftige Code zu einem .track-row scrollt (scrollIntoView(),
Browser-eigene #anchor-Navigation, ...), ohne dass man an mehreren
Stellen nachziehen müsste. Zwei Werte, weil die Topbar selbst schon
responsiv ist (theme.css, @media(max-width:700px)) - dieselbe
Bedingung in app.css gespiegelt, damit --topbar in jedem Breakpoint
zur tatsächlichen Höhe passt. scrollToHashTarget() (app.ts) nutzt dafür bewusst
block: 'start' statt 'center' - bei 'center' kann der Browser, wenn
die Seite/Liste zu kurz für echtes Zentrieren ist (wenig Scroll-
Spielraum, z.B. ein kleines Album mit nur 2-3 Tracks), das Ziel trotzdem
ganz oben landen lassen, wo die Marge unzuverlässiger greift. 'start'
- Marge ist das vorhersehbarere Paar. Betrifft NUR diesen Hauptseiten-
Scroll - die Lyrics-/Karaoke-Sync-Zeile (
lyrics.ts,updateActiveLine()) scrollt innerhalb eines eigenen Panel-/Overlay-Containers, dort gibt's keine Topbar-Überlagerung,block: 'center'bleibt dort unverändert richtig.
Der eigentliche Grund, warum das trotz der Marge weiterhin zu Position
0 sprang: scrollToHashTarget() verließ sich komplett auf
location.hash NACH dem HTMX-Swap - ein #fragment wird nie an den
Server gesendet (reine Client-Angelegenheit), und ob HTMX es beim
eigenen history.pushState() nach einem Boosted-Request überhaupt
zuverlässig überträgt, ist nicht garantiert. War das nicht der Fall, war
location.hash leer, scrollToHashTarget() brach sofort ab (if (!target) return), und übrig blieb nur HTMX' eigenes Standardverhalten
nach einer Boosted-Navigation: zum Seitenanfang scrollen - exakt das
gemeldete Symptom, unabhängig von scroll-margin-top/block, weil der
eigene Scroll-Code gar nicht erst lief. Behoben, indem das #fragment
schon BEIM KLICK selbst gemerkt wird (pendingScrollTarget, aus dem
originalen href, per click-Listener im CAPTURE-Modus registriert -
garantiert VOR HTMX' eigenem Klick-Handler, unabhängig von der
Registrierungsreihenfolge) statt sich erst nach dem Swap auf
location.hash zu verlassen. Fällt nur noch auf location.hash zurück,
wenn es gar keinen vorherigen Klick gab (erstes Laden mit Hash in der
URL - Lesezeichen, geteilter Link).
Player-Leiste und Warteschlangen-Panel sind kein Server-Template,
sondern werden von assets/ts/player.ts per JS befüllt - deshalb
spiegelt buildArtistAlbumHtml() dort exakt dieselbe "Künstler · Album,
je eigener Link"-Logik wie der Twig-Untertitel, nur clientseitig.
htmx.process() nach jedem Trackwechsel, sonst bricht die Wiedergabe
beim Klicken ab. HTMX erkennt "boost-fähige" <a>-Tags beim initialen
Scan der Seite - ein zur Ladezeit hrefloser Link (der Titel-Link hat
anfangs kein Ziel, bis der erste Track lädt) oder per innerHTML neu
eingefügte Links (Künstler/Album in #playerArtist) werden dabei nicht
zuverlässig automatisch erkannt. Ohne explizites Nachscannen bliebe ein
Klick eine normale, nicht von HTMX abgefangene Browser-Navigation - ein
kompletter Seiten-Reload, der die laufende Wiedergabe beendet (genau das
gemeldete Symptom). loadTrack() ruft deshalb nach dem Setzen von
titleLink.href und dem Neuaufbau von #playerArtist einmal
window.htmx.process(document.querySelector('.player-track')) auf -
sagt HTMX explizit "scanne diesen Bereich (neu)", deckt damit automatisch
sowohl den Titel-Link als auch die Künstler-/Album-Links ab, ohne für
jeden einzeln gesondert etwas tun zu müssen. Kein eigener
Klick-Handler/history.pushState() nötig - sobald HTMX den Link korrekt
erkennt, übernimmt es Navigation und URL-Pushen ganz normal selbst.
Bewusst STRUKTURELLE Namen (artist.name/album.name) statt der reinen
ID3-Tag-Strings für Linktext UND -Ziel zusammen - sonst könnte der
angezeigte Name (Tag) vom tatsächlichen Link-Ziel (Struktur) abweichen,
v.a. bei Compilation-Tracks unter "Various Artists" (siehe
"Compilations / Various Artists" weiter unten).
Wiedergabe komplett leeren
Papierkorb-Symbol ganz rechts in der Player-Toolleiste
(MusicDockPlayer::clearPlayback()) - anders als playPause()/
onTrackEnded(), die den Player sichtbar und "bereit für den nächsten
Track" lassen, entfernt das JEDE Wiedergabe-Information komplett UND
blendet die Player-Leiste selbst aus (display:none über
.player-bar--hidden), statt sie nur leer stehen zu lassen:
- Audio wird pausiert UND die Quelle wirklich entladen
(
removeAttribute('src')+load()) - nicht nur pausiert, sonst bliebe die zuletzt geladene Datei im Hintergrund geladen. - Warteschlange +
currentIndexzurückgesetzt,localStorage-Einträge für Queue/Index/Position gelöscht. Lautstärke bleibt bewusst erhalten (eigener Key, wird nicht gelöscht) - das ist eine Geräte-/ Nutzereinstellung, keine Wiedergabe-Information. - Alle
.track-row--active-Markierungen verschwinden automatisch (syncActiveRowHighlight()mit leerer Queue), Warteschlangen-Panel zeigt den leeren Zustand.
Kommt automatisch wieder, sobald erneut ein Track gestartet wird -
loadTrack() entfernt .player-bar--hidden als erstes, bevor es
irgendetwas anderes tut. Gilt auch für restoreFromStorage() beim
nächsten Seitenaufruf (falls dann wieder eine gespeicherte Queue
existiert - nach clearPlayback() ist sie das nicht, die Leiste bleibt
dann im normalen, nicht versteckten Leerzustand "Kein Track ausgewählt").
Sidebar reichte nicht bis ganz unten, solange kein Player sichtbar
war - .app-sidebar reserviert per theme.css
(inset:0 auto var(--player) 0) IMMER Platz für die Player-Leiste am
unteren Rand, unabhängig davon, ob sie gerade sichtbar ist. Fehlt sie
(.player-bar--hidden), blieb eine unschöne leere Lücke stehen. Behoben
mit body:has(.player-bar--hidden) .app-sidebar { bottom: 0 !important; }
:has()nutzen wir schon bei den Suchvorschlägen, hier derselbe Ansatz statt einer JS-Lösung.
Durchgehende Wiedergabe bei Navigation
Der <audio>-Tag liegt in templates/player/_bar.html.twig, außerhalb
des Bereichs, den HTMX bei Klicks austauscht:
<body hx-boost="true" hx-target="#content" hx-select="#content" hx-swap="outerHTML">
...
<main id="content" hx-history-elt>{% block content %}{% endblock %}</main>
{% include 'player/_bar.html.twig' %} {# bleibt bei Navigation unberührt #}
hx-boost fängt Klicks auf interne Links ab, lädt die Zielseite per AJAX und
ersetzt nur #content - die Player-Leiste (und damit das laufende
<audio>-Element) wird nie aus dem DOM entfernt.
hx-swap="outerHTML", nicht "innerHTML" - wichtige Lektion, die auch
für jede künftige HTMX-Erweiterung gilt. hx-select wählt zwar ein
Element aus der Server-Antwort aus, aber HTMX swappt dabei dessen
KOMPLETTES outerHTML (inklusive des Elements selbst) gemäß hx-swap in
den Target ein - nicht nur seinen Inhalt. Bei hx-select="#content" +
hx-target="#content" + hx-swap="innerHTML" landet also das GANZE
<div id="content"> aus der Antwort als KIND im bereits bestehenden
#content - zwei verschachtelte #content-Divs, doppeltes CSS-Padding
(genau der Bug, der das mal ausgelöst hat, sichtbar nur beim Vergleich
zwischen einem hart geladenen und einem per HTMX-Boost nachgeladenen
Dokument). outerHTML ersetzt das Ziel-Element stattdessen komplett durch
das ausgewählte - keine Verschachtelung. Gilt für jeden Fall, wo
Ziel- und Auswahl-Selektor dieselbe ID treffen - betraf auch die
Suchvorschau (siehe unten).
Ausnahme, wo innerHTML weiterhin richtig ist: wenn JS eine feste
Referenz auf das Ziel-Element hält (z.B. navigation.ts für die
Suchvorschau, zum Schließen bei Klick außerhalb). Ein outerHTML-Swap
würde diese DOM-Node bei jedem Request ersetzen und die JS-Referenz
veralten lassen. Lösung dort: Ziel- und Auswahl-ID bewusst
UNTERSCHIEDLICH halten (#search-suggestions bleibt stabil, nur sein
Kind #search-suggestions-inner wird per innerHTML ausgetauscht) - dann
ist innerHTML sicher, weil keine Verschachtelung entstehen kann.
Browser Zurück/Vor: HTMX snapshotet für die History standardmäßig den
kompletten <body> und würde bei einem Zurück-Klick alles - inklusive
Player-Leiste - durch die alte Version ersetzen. Das hx-history-elt
auf #content sorgt dafür, dass nur dieser Bereich gesnapshotet und bei
Zurück/Vor wiederhergestellt wird. Der Player ist damit bei jeder Art
von Navigation (Klick, Boost, History) safe.
assets/ts/player.ts bindet außerdem nur einen delegierten
Click-Listener auf document für alle .track-play-btn - der funktioniert
automatisch auch für Zeilen, die HTMX erst nach einem Swap oder einer
History-Restore ins DOM einfügt, ohne dass irgendwo neu gebunden werden
müsste. Nach jedem htmx:afterSettle (Swap und History-Restore) wird
zusätzlich die Markierung der aktuell spielenden Zeile neu gesetzt, damit
die Optik nie hinter dem tatsächlichen Player-State zurückbleibt.
Einzige verbleibende Grenze: ein vollständiger Seiten-Reload (F5) lädt
zwangsläufig auch dieses Skript neu und unterbricht die Wiedergabe - das ist
technisch nicht vermeidbar (jede Web-App verliert dabei ihren State). Die
Warteschlange wird aber in localStorage gespiegelt, sodass nach einem
Reload zumindest Titel/Position/Warteschlange wiederhergestellt werden
(Autoplay bleibt aus, weil Browser automatisches Abspielen ohne
Nutzerinteraktion blockieren).
Streaming & Spulen
StreamController::stream() nutzt Symfonys BinaryFileResponse mit
->prepare($request) – das wertet einen Range-Header automatisch aus und
liefert bei Bedarf 206 Partial Content. Genau das braucht der Browser, um
im <audio>-Element vor- und zurückspulen zu können, ohne die komplette
Datei neu zu laden.
Track-Download
GET /download/{id} (StreamController::download()) - eigener Endpunkt,
nicht einfach ein Query-Parameter an /stream: der <audio>-Tag erwartet
beim Streamen KEIN Content-Disposition: attachment (manche Browser
laden dann gar nicht erst ab, statt abzuspielen), Download und Streaming
brauchen also unterschiedliche Header auf demselben zugrundeliegenden
File-Response. Dateiname wird aus Künstler + Titel gebaut (nicht der
intern genutzte UUID-Dateiname in storage/audio/), Sonderzeichen
entfernt, die auf gängigen Dateisystemen (v.a. Windows) Probleme machen
könnten. Ausgelöst über "Herunterladen" im Kontextmenü (echte Navigation,
window.location.href, kein fetch()/Blob-Handling nötig - der Browser
übernimmt den Download-Dialog selbst korrekt).
Album-Download (ZIP)
GET /album/{id}/download (StreamController::downloadAlbum()) - packt
alle Tracks eines Albums als ZIP, ohne Kompression
(ZipArchive::CM_STORE statt des Standard-Deflate): Audiodateien
(MP3/FLAC/etc.) sind bereits komprimiert, ein erneutes Deflate würde nur
CPU-Zeit kosten, ohne den Platzbedarf nennenswert zu senken. addFile()
statt addFromString(file_get_contents()) liest beim Packen direkt von
der Platte, läuft nicht komplett durch den PHP-Speicher - wichtig bei
großen Alben. Das ZIP wird in eine temporäre Datei geschrieben (nicht
komplett im Speicher aufgebaut) und nach vollständigem Versand automatisch
gelöscht (BinaryFileResponse::deleteFileAfterSend(true)). Fehlt bei
einem einzelnen Track die Datei (z.B. extern gelöscht), wird er
übersprungen statt den ganzen Download abzubrechen. Tracknummer wird dem
Dateinamen im ZIP vorangestellt, damit die Reihenfolge auch außerhalb von
MusicDock erkennbar bleibt.
Braucht ext-zip (in composer.json deklariert) - in den meisten
PHP-Images (auch DDEVs Standard-Web-Image) bereits enthalten, falls nicht:
.ddev/config.yaml -> webimage_extra_packages bzw. die passende
PHP-Extension nachinstallieren.
hx-boost="false" auf dem Download-Button ist Pflicht, kein Stilfehler.
Ein normaler <a href> würde sonst von HTMX' globalem hx-boost (siehe
<body> in base.html.twig) abgefangen und als AJAX-Request statt einer
echten Browser-Navigation behandelt - der Browser zeigt den nativen
Download-Dialog nur bei echter Navigation, nicht bei einem
XHR/fetch-Response. Jeder künftige Download-Link (egal welche Route)
braucht dasselbe Attribut, sonst passiert beim Klick sichtbar nichts
(der Download lädt unsichtbar per AJAX und versucht dann, seinen
Binärinhalt sinnlos in #content zu swappen). Der Track-Download im
Kontextmenü ist davon NICHT betroffen - der läuft über
window.location.href in JS, nicht über ein echtes <a>-Tag im DOM,
und umgeht HTMX' Klick-Interception dadurch automatisch.
"Läuft gerade"-Anzeige in der Track-Zeile
Die aktuell spielende Zeile bekam schon vorher .track-row--active
(Hintergrund-Tönung + Akzentfarbe im Titel) - auf den ersten Blick war das
aber nicht unbedingt als "läuft gerade" statt nur "das ist der aktuelle
Track" erkennbar. Deshalb ersetzt die Tracknummer-Spalte bei der aktiven
Zeile ihre Zahl durch drei kleine animierte Balken (.playing-bars,
CSS-Keyframes, kein JS-getriebenes Neuzeichnen nötig) - dasselbe visuelle
Muster wie z.B. bei Spotify.
Animiert NUR, wenn tatsächlich Ton läuft (body.is-playing, per JS
bei den play/pause-Events des <audio>-Elements gesetzt) - pausiert
zeigt sich derselbe Balkensatz ruhig stehend statt fälschlich "läuft
gerade" zu signalisieren. Der CSS-Selektor kombiniert beide Zustände
(body.is-playing .track-row--active .playing-bars i), kein
zusätzliches Nachziehen pro Zeile nötig - funktioniert automatisch auch
für Zeilen, die erst nach einem HTMX-Swap ins DOM kommen, weil
.track-row--active von syncActiveRowHighlight() ohnehin schon nach
jedem Swap neu gesetzt wird.
Überarbeitet, weil kaum sichtbar (erste Version: 13px hoch, 2px
breit, alle drei Balken exakt dieselbe Basis-Höhe UND dieselben
Animations-Keyframes) - dadurch sahen sie eher wie drei winzige, gleich
hohe Punkte aus statt wie ein Equalizer, selbst mit laufender Animation
kaum als solche wahrnehmbar. Jetzt: 20px hoch, 3px breit (.track-number
ist laut theme.css 38px breit, die Zeile mindestens 52px hoch - reichlich
Platz), unterschiedliche Basis-Höhen pro Balken (45/90/65%, sieht auch im
pausierten/statischen Zustand nach einem "eingefrorenen Equalizer" aus,
nicht nach drei gleichen Strichen).
Animierte nur einmal kurz, dann Stillstand - reine CSS-Lösung
letztlich aufgegeben. Erster Versuch: eine gemeinsame @keyframes-
Animation für alle drei Balken. Zweiter Versuch: drei getrennte
@keyframes mit konsolidierten animation-Shorthand-Deklarationen
(statt separater animation/animation-delay-Properties, die
theoretisch dieselbe Override-Reihenfolge-Problematik hätten haben
können). Beides zeigte in der Praxis dasselbe Symptom - die genaue
Ursache ließ sich trotz mehrfacher Korrektur nicht zweifelsfrei isoliert
reproduzieren. Statt weiter an reinem CSS zu suchen: komplett auf
JS-gesteuerte Animation umgestellt
(MusicDockPlayer::animatePlayingBars(), requestAnimationFrame) -
dieselbe Grundidee wie beim WebGL-Visualizer, hier aber einfach nur drei
<i>-Höhen, die per style.height gesetzt werden (transition: height 0.12s linear in CSS glättet zwischen den Frames). Drei leicht
unterschiedliche Sinus-Frequenzen/Phasen statt CSS-Keyframes - eliminiert
jede Abhängigkeit von CSS-Animation-Zuverlässigkeit komplett, läuft
dauerhaft im Hintergrund (wie der Visualizer), tut aber nichts (kein
nennenswerter Overhead), solange weder body.is-playing gesetzt ist noch
eine .track-row--active .playing-bars im DOM existiert.
Struktur der Warteschlange (Queue)
Jede Track-Zeile (templates/components/_track_row.html.twig) trägt ihre
Daten als data-*-Attribute und einen Play-Button. Klick auf ▶ sammelt
player.js alle .track-row-Geschwister im selben
[data-track-list]-Container ein und baut daraus die Warteschlange – die
Album-, Kategorie-, Dateimanager- und Suchansicht müssen dafür nichts
Player-spezifisches wissen, sie liefern nur Markup mit den richtigen
Attributen.
Cover
MediaImporter löst pro Album in dieser Reihenfolge auf:
cover.jpg/cover.png/folder.jpg/folder.pngdirekt im Album-Ordner- eingebettetes ID3-Bild des ersten Tracks im Album
- kein Cover -> Frontend zeigt einen Platzhalter (
components/_cover.html.twig)
Jeder Track bekommt zusätzlich sein EIGENES eingebettetes Cover
(importTrack()), nicht nur Album/Künstler. Ursprünglich extrahierte NUR
der allererste Track eines Parents ohne Cover ein Bild, und zwar
ausschließlich für den PARENT - alle Tracks eines Albums zeigten dadurch
zwangsläufig dasselbe (Parent-)Bild, selbst wenn einzelne Tracks (Live-
Mitschnitte, Compilations, Podcast-artige Sammlungen) ein eigenes,
abweichendes Cover eingebettet hatten. Jetzt versucht jeder einzelne
Track sein eigenes Bild zu extrahieren und speichert es auf sich selbst;
der Parent bekommt weiterhin (wie bisher) ein Fallback-Cover vom ersten
Track mit Bild, falls er noch keins hat. components/_track_row.html.twig
prüft dafür ohnehin schon zuerst das Track-eigene Cover, bevor es auf den
Parent zurückfällt (track.getMetadataValue('cover_thumb_url') ?: ...)
- die Anzeigenlogik war also schon vorbereitet, nur die Datenerzeugung fehlte. Kostet bei "normalen" Alben (identisches Cover in jeder Datei eingebettet) etwas mehr Speicherplatz/Rechenzeit (pro Track eigene Thumb+Mini-Dateien statt nur einmal auf Album-Ebene) - keine Deduplizierung über den Bildinhalt, bewusst einfach gehalten.
Künstler-Kacheln zeigen eine Collage aus bis zu 4 Album-Covern statt nur
eines einzelnen Bilds: MediaImporter::addAlbumCoverToArtistCollage()
sammelt beim Import die Cover der ersten (bis zu) 4 Alben mit einem Cover in
metadata.cover_urls auf dem Künstler-Node, dedupliziert dabei über die
URL. Das Frontend (components/_cover.html.twig + .cover-collage-* in
assets/css/app.css) rendert je nach Anzahl ein passendes CSS-Grid-Layout:
2 nebeneinander, 3 (eines groß links, zwei gestapelt rechts), 4 im
klassischen 2×2-Raster. Bei nur einem Album-Cover oder keinem zeigt die
Kachel wie bisher ein einzelnes Bild bzw. den Platzhalter. Tracks
ohne Album-Unterordner (direkt beim Künstler) versuchen nur ihr eigenes
eingebettetes Bild.
War zwischenzeitlich komplett unsichtbar, reiner Frontend-Bug, kein
Backend-Problem. MediaImporter::addAlbumCoverToArtistCollage() hat
metadata.cover_urls die ganze Zeit über korrekt befüllt - der Twig-Code
in _cover.html.twig, der diese Daten tatsächlich ANZEIGT, ist bei einem
der Lazy-Loading-/Mini-Thumbnail-Refactors versehentlich verloren
gegangen (das Template wurde mehrfach komplett neu geschrieben, dabei
blieb nur der beschreibende Kommentar übrig, nicht der Code selbst) -
Künstler-Kacheln zeigten seitdem nur noch den Gradient-Platzhalter, auch
wenn Album-Cover längst vorhanden waren. Wiederhergestellt, mit
Lazy-Loading-Unterstützung (jedes Collage-Bild einzeln per data-src).
Zweiter, unabhängiger Grund, warum sie trotzdem nicht auftauchte:
JEDE Stelle im Frontend, die einen Künstler-Node rendert (Home-Seite,
Künstler-Übersicht), übergibt shape: 'round' an _cover.html.twig - die
erste Wiederherstellung hatte (dem alten Kommentar folgend "eine
2x2-Collage sieht in einem Kreis nicht gut aus") Collagen für shape == 'round' bewusst ausgeschlossen. Da es aktuell KEINE eckigen
Künstler-Kacheln im UI gibt, war die Collage dadurch technisch tot,
unabhängig vom ersten Fix. Diese Einschränkung ist jetzt aufgehoben -
eine runde Collage funktioniert optisch problemlos: overflow:hidden auf
der äußeren Kachel rundet die 2x2-Aufteilung automatisch mit ab, keine
gesonderte CSS-Änderung nötig.
Dritter, ebenfalls unabhängiger Grund: die Künstler-DETAILSEITE
(artist/show.html.twig, der große runde Avatar oben auf der eigenen
Künstlerseite) band _cover.html.twig gar nicht erst ein - eigenes,
separates Cover-Handling, das nur cover_url (das einzelne, vom ersten
Album übernommene Cover) kannte, cover_urls (die Collage-Daten) nie
berücksichtigte. Jetzt durch dasselbe gemeinsame Include ersetzt wie auf
den Grid-Seiten ({% include 'components/_cover.html.twig' with {node: artist, shape: 'round', extra_class: 'artist-large'} %}) - kein
duplizierter Code mehr für dieselbe Anzeigenlogik an drei Stellen.
extra_class ist dafür neu in _cover.html.twig: die Detailseite braucht
eine feste 220px-Größe (.artist-large), die shape: 'round' allein
nicht mitbringt (.artist-photo hat KEINE feste Größe, die kommt normalerweise
vom umgebenden Grid) - wird einfach zusätzlich zur shape_class gesetzt.
Vierter Grund, diesmal ein ECHTES Datenproblem statt nur Anzeige:
addAlbumCoverToArtistCollage() lief bisher AUSSCHLIESSLICH im
media/-Hauptimport (importAll()), nie im Upload-Workflow
(importUploadedFile()). Künstler, die ausschließlich über den Uploader
importiert wurden, bekamen dadurch nie eine Künstler-Collage UND nicht
mal das einfache Einzelbild-Fallback (metadata.cover_url/
cover_thumb_url auf dem Künstler-Node selbst) - selbst wenn ihre Alben
längst eigene Cover hatten. importUploadedFile() ruft
addAlbumCoverToArtistCollage() jetzt ebenfalls auf (nach importTrack(),
nur wenn der Track tatsächlich in einem Album landet - lose Tracks direkt
beim Künstler haben kein Album, dessen Cover in die Collage aufgenommen
werden könnte, die bekommen ihr Cover bereits über importTrack()s
eigenen generischen Fallback). Hier ist ein Reimport (--flush oder
erneutes Hochladen über den Uploader) tatsächlich nötig - anders als
bei den ersten drei Gründen waren hier wirklich Daten nie geschrieben
worden, nicht nur die Anzeige kaputt.
Dateinamen sind UUIDs, keine Datenbank-IDs. Jeder Node bekommt eine
UUID (Node::$uuid), die deterministisch (UUID v5) aus dem relativen
Datei-/Ordnerpfad berechnet wird (MediaImporter::uuidFor()) - nicht
zufällig. Cover landen entsprechend als <uuid>.<ext> (Vollbild, Format
wie importiert - JPEG/PNG/WebP), <uuid>_thumb.jpg (max. 300px) und
<uuid>_mini.jpg (max. 80px, siehe unten) - Thumb/Mini sind IMMER JPEG,
unabhängig vom Format des Vollbilds (siehe unten). Alle per GD erzeugt
(ext-gd muss aktiv sein, in DDEVs PHP-Images standardmäßig der Fall)
und werden direkt vom Webserver ausgeliefert, kein eigener Controller
nötig. Grid-Kacheln, die Player-Leiste und die Warteschlange verwenden
bevorzugt die Thumbnail-Variante (metadata.cover_thumb_url),
Track-Zeilen die Mini-Variante (siehe unten) - beide fallen automatisch
aufs jeweils nächstgrößere Bild zurück, falls aus irgendeinem Grund keine
erzeugt werden konnte.
Thumb/Mini sind grundsätzlich JPEG, auch bei einer PNG- oder
WebP-Quelle (generateThumbnail()) - deutlich kleinere Dateien als PNG
bei praktisch identischer wahrgenommener Qualität für fotografisches
Cover-Material (PNG lohnt sich nur bei Flächen/Text/Transparenz, nicht
bei Albumcovern, die so gut wie nie transparent sind). Weißer Hintergrund
vor dem Kopieren, für den in der Praxis kaum vorkommenden, aber defensiv
behandelten Fall von Transparenz in der Quelle - JPEG kennt keinen
Alphakanal. Das Vollbild (cover_url) bleibt davon unberührt - nur
die generierten Ableitungen (Thumb/Mini) werden komprimiert, nie die als
Master gedachte Originaldatei; wer die auch komprimiert haben möchte,
müsste das gesondert einrichten.
Mini-Thumbnails für Track-Zeilen (metadata.cover_mini_url,
MediaImporter::MINI_THUMBNAIL_MAX_DIMENSION = 80,
MINI_THUMBNAIL_JPEG_QUALITY = 55) - eine dritte, deutlich kleinere und
stärker komprimierte Variante NUR für components/_track_row.html.twig.
Track-Zeilen zeigen das Cover typischerweise bei ~38-42px CSS-Größe,
selbst bei High-DPI-Displays (2x) reichen dafür ~80px tatsächliche Pixel
völlig - bei langen Tracklisten (z.B. 60 Tracks/Seite) summiert sich der
Unterschied zur 300px-Variante spürbar. generateThumbnail() ist dafür
parametrisiert (Zielgröße + Qualität als Parameter) statt die
GD-Skalierlogik zu duplizieren - finalizeCover() ruft sie einmal für
jede der beiden Größen auf (THUMBNAIL_JPEG_QUALITY = 85 für die
"normale" Größe, 55 für die Mini-Variante - bei der winzigen
Anzeigegröße fallen Kompressionsartefakte praktisch nicht auf).
Player-Leiste/Warteschlange bleiben bewusst bei der 300px-Variante
(data-track-cover in _track_row.html.twig nutzt weiterhin
cover_thumb_url) - die zeigen das Cover größer, würden mit der
Mini-Variante sichtbar unscharf.
Sharding in 256 Bucket-Ordner (00-ff). Cover liegen nicht flach in
public/media/covers/, sondern in public/media/covers/<bucket>/<uuid>.jpg -
<bucket> sind die ersten zwei Hex-Zeichen der UUID
(MediaImporter::coverBucket()). Bei einer großen Bibliothek (mehrere
Tausend Cover + Thumbnails) verteilt das die Dateien gleichmäßig über 256
Ordner, statt alle in einem einzigen Verzeichnis zu sammeln - viele
Dateisysteme werden bei zehntausenden Einträgen in einem Ordner spürbar
langsamer (Verzeichnis-Listing, Lookups). Da UUIDs (v5-Hashes) bereits
gleichverteilt aussehen, reicht "erste zwei Hex-Ziffern nehmen" für eine
gleichmäßige Verteilung, ganz ohne eine separate Hash-Funktion. Alle 256
Ordner werden einmal zu Beginn von importAll() angelegt
(ensureCoverBuckets()), damit nicht bei jeder einzelnen Datei ein
is_dir()/mkdir()-Check nötig ist.
Zwei Gründe für UUIDs statt nodes.id im Dateinamen:
- Keine fortlaufenden, erratbaren IDs in öffentlichen URLs.
- Stabil über
--flush-Neuaufbauten hinweg: weil die UUID aus dem Pfad berechnet wird, ergibt derselbe Ordner/dieselbe Datei nach einemapp:import-media --flushwieder exakt dieselbe UUID und denselben Cover-Dateinamen - Browser-Caches bleiben gültig, keine verwaisten Cover-Dateien.
Lazy-Loading für Cover-Bilder
assets/ts/lazy-images.ts, IntersectionObserver-basiert - Bilder in
Grids/Listen (Alben, Künstler, Track-Zeilen) laden erst kurz BEVOR sie in
den sichtbaren Bereich scrollen (rootMargin: '200px' als Vorlauf, meist
schon fertig geladen, bevor man tatsächlich hinscrollt), nicht alle
sofort beim Seitenaufbau - bei vielen Einträgen auf einer Seite (z.B. 60
Tracks/Seite) spart das spürbar Bandbreite und Ladezeit.
Arbeitet mit dem bestehenden Gradient-Platzhalter-Muster zusammen
(gradient_class-Filter) statt einer separaten Lösung: der Container
trägt jetzt IMMER die deterministische Verlaufsfarbe als Hintergrund -
vorher nur im "kein Cover"-Fall, jetzt zusätzlich als Ladezustand, bis
das per data-src verzögert geladene <img> tatsächlich da ist. Das
<img> liegt mit opacity:0 darüber (.lazy-cover) und blendet erst
sanft ein (.lazy-loaded, nach dem load-Event per JS gesetzt) - bis
dahin bleibt der Verlauf sichtbar statt eines leeren/kaputten
Bild-Icons, kein zusätzlicher Zwischenzustand nötig.
initLazyImages() ist sicher mehrfach aufrufbar (nach jedem HTMX-Swap,
in app.ts sowohl initial als auch bei htmx:afterSettle) - bereits
verarbeitete <img>s (Klasse .lazy-observed) werden übersprungen, nur
neu ins DOM gekommene beobachtet. Kein IntersectionObserver im Browser
(sehr alt)? Bilder laden dann einfach sofort, kein Lazy-Loading, aber
auch kein Fehlerzustand.
Bewusst NICHT für die Warteschlange (Queue-Panel) oder die großen
Detail-Hero-Bilder (Album-/Künstlerseite) - die Queue ist typischerweise
kurz und wird per JS-innerHTML gerendert (zusätzlicher Aufwand, erneut
initLazyImages() nach jedem renderQueue()-Aufruf zu rufen, für wenig
Nutzen bei wenigen Einträgen), Hero-Bilder sind ohnehin fast immer "above
the fold" beim Seitenaufruf - Lazy-Loading brächte dort keinen
nennenswerten Vorteil.
Mehrfach-CDs (Disc-Nummer)
MediaImporter liest zusätzlich zur Tracknummer (ID3v2 TRCK) auch die
Disc-Nummer (ID3v2 TPOS, bei getID3 als part_of_a_set verfügbar) und
speichert sie als metadata.disc_number. Beide werden zu einer einzigen
Sortierzahl kombiniert (Node::sortOrder): Disc 1 Track 3 → 1003, Disc 2
Track 1 → 2001 - eine reine Tracknummern-Sortierung hätte sonst "Disc 2,
Track 1" vor "Disc 1, Track 12" einsortiert. Fehlt der Disc-Tag, wird Disc 1
angenommen (Normalfall Einzel-CD).
In der Track-Zeile wird die Disc-Nummer nur als Präfix angezeigt, wenn sie
tatsächlich > 1 ist (z.B. 2-05) - bei normalen Einzel-CDs bleibt es beim
schlichten 05, um nicht unnötig zu drängen.
(Cover nicht versioniert, siehe .gitignore - reproduzierbar durch
erneuten app:import-media-Lauf.)
Import zurücksetzen (--flush)
ddev exec bin/console app:import-media --flush
Löscht vor dem eigentlichen Import alle bestehenden Nodes (Künstler,
Alben, Tracks), alle PlaylistItems (per FK-Cascade automatisch, weil sie
auf gelöschte Track-Nodes zeigen würden - die Playlists selbst bleiben
als leere Container erhalten), alle generierten Cover-Dateien
(MediaImporter::clearCovers()) und alle Dateien in storage/audio/
(MediaImporter::clearAudioStorage()). Fragt interaktiv nach Bestätigung,
außer --no-interaction ist gesetzt. Ohne --flush legt ein erneuter Lauf
schlicht doppelte Nodes an - es gibt (noch) keinen Abgleich "nur neue/
geänderte Dateien importieren".
Nur sicher, solange bisher ohne --move importiert wurde - siehe
"Speicher-Architektur" weiter oben. Nach einem --move-Import ist media/
leer, und --flush hätte nichts mehr zum Reimportieren.
Sinnvoll immer dann, wenn sich an der Bibliotheksstruktur grundlegend was
geändert hat, oder direkt nach einer Migration wie
Version20260822000000 (führt selbst schon ein TRUNCATE durch, siehe deren
Beschreibung) - danach ist die DB leer und ein --flush-Import baut alles
sauber neu auf.
Tag-basierter Import (_dump-Ordner)
Für Dateien, bei denen sich eine saubere Ordnerstruktur nicht lohnt oder
nicht vorhanden ist - einfach in media/_dump/ werfen, beliebig
flach oder verschachtelt:
media/
Pink Floyd/ <- normal ordnerbasiert, wie gehabt
The Dark Side of the Moon/
01 Speak to Me.mp3
_dump/ <- Sonderordner, wird NICHT als Künstler interpretiert
irgendwas/x/y/song.mp3 <- Ordnerlage hier komplett egal
noch_ein_song.mp3
Alles unterhalb von _dump/ wird rekursiv gescannt und ausschließlich
anhand der ID3-Tags (Artist, Album, Title) einsortiert -
MediaImporter::importDumpFolder(). Künstler/Album werden dabei per
Namensabgleich wiederverwendet statt dupliziert
(NodeRepository::findChildByNameAndKind()): trifft der Tag-Künstlername
"Pink Floyd" auf den bereits ordnerbasiert vorhandenen Künstler "Pink
Floyd", landet der Track dort - unabhängig davon, ob der Zielknoten
ursprünglich aus einem echten Ordner oder aus Tags entstanden ist. Fehlen
Artist-/Album-Tags komplett, landen die Tracks unter "Unbekannter
Künstler"/"Unbekanntes Album".
Weil ein tag-erzeugter Künstler/Album-Node ein ganz normaler Node mit
metadata.kind ist wie jeder ordnerbasierte auch, braucht der Rest der App
(Künstler-/Kategorie-/Dateimanager-Ansicht, Suche, Player, Playlists) dafür
keine einzige Sonderfall-Zeile - das ist genau der Punkt der
Ordner-als-Baum-Abstraktion.
Künstler-Erkennung bei losen Tracks ("Künstler - Album" als ein Ordner)
Nicht jede Bibliothek ist sauber Künstler/Album/Track.mp3 verschachtelt -
ein gängiges reales Muster (v.a. bei Einzel-Downloads/EPs) ist ein
einziger Ordner wie The Koi Boys - Meant to be/ direkt unter media/,
ohne eigenen Album-Unterordner. Ohne Gegenmaßnahme interpretiert der
Import so einen Ordner als KÜNSTLER (Name = kompletter Ordnername
inklusive Album-Teil) und die Tracks darin als Singles ohne Album -
sichtbar falsch, sowohl beim Künstlernamen als auch bei der fehlenden
Album-Gruppierung.
Deshalb: für Tracks, die direkt in einem "Künstlerordner" liegen (kein
Album-Unterordner), liest MediaImporter::importAll() pro Track zuerst
die ID3-Tags. Findet sich ein Album-Tag, werden Künstler UND Album daraus
abgeleitet - genau dieselbe findOrCreateTaggedFolder()-Logik wie beim
_dump-Import, hier aber als Fallback für "eigentlich verschachtelt
gedachte" Ordner, deren Struktur es nicht ganz ist. Nur Tracks GANZ OHNE
Album-Tag bleiben echte Singles direkt beim Ordner-Künstler.
Kann einen jetzt leeren Künstlerordner hinterlassen (der ursprüngliche
"The Koi Boys - Meant to be"-Ordner-Node bleibt bestehen, aber ohne
Kinder, wenn alle seine Tracks zum tag-basierten "The Koi Boys"-Künstler
umgezogen sind) - NodeRepository::deleteChildlessFolders() räumt das am
Ende von importAll() automatisch auf, in mehreren Durchgängen (falls das
Löschen eines leeren Albums seinen Elternkünstler dadurch ebenfalls leer
werden lässt).
Derselbe Bug steckte separat auch im Web-Upload-Pfad
(MediaImporter::importUploadedFile(), siehe "Web-Upload" weiter unten) -
eine ANDERE Methode als importAll(), die beim ersten Fix übersehen
wurde, und beim zweiten Anlauf noch nicht vollständig: zuerst wurde der
Album-Tag bei einem einzelnen Ordner-Level (oder gar keinem Ordnerkontext)
komplett ignoriert - nur der Artist-Tag zählte, nie das Album. Der nächste
Fix ergänzte die Album-Gruppierung, korrigierte dabei aber nur das Album,
NICHT den Künstlernamen selbst - Ordner "The Koi Boys - Meant to Be" blieb
als Künstlername stehen, bekam nur zusätzlich ein (korrektes) Album "Meant
to Be" drin. Jetzt wird bei EINEM Ordner-Level auch der Artist-Tag geprüft
und bei Abweichung vom Ordnernamen bevorzugt - genau wie bei importAll()
schon der Fall. ProcessUploadsCommand räumt danach ebenfalls per
NodeRepository::deleteChildlessFolders() auf, falls der
ursprüngliche Ordner-Künstler dadurch leer zurückbleibt.
Dritter Anlauf, diesmal auch bei ZWEI Ordner-Ebenen: selbst
Künstler/Album/Track.mp3 ist nicht zwingend sicher - der äußere Ordner
kann trotzdem selbst "Künstler - Album" heißen (z.B. eine versehentlich
doppelt verschachtelte Struktur wie
Gregorian - Pure Chants/Pure Chants/track.mp3). folderNameCombinesArtistTag()
prüft deshalb IMMER den ersten Ordner-Level, unabhängig von der
Verschachtelungstiefe - aber bewusst NICHT "weicht der Artist-Tag
irgendwie vom Ordnernamen ab" (das würde bei Compilation-Ordnern wie
"Various Artists" falsch auslösen, wo einzelne Track-Artists ABSICHTLICH
vom Ordnernamen abweichen), sondern gezielt nur das Muster "Ordnername
beginnt mit <Artist-Tag> gefolgt von einem Trenner" (Bindestrich,
Halbgeviertstrich, Doppelpunkt). Derselbe Helfer wird sowohl in
importAll() (lose Tracks direkt im Künstlerordner) als auch in
importUploadedFile() (Upload-Workflow) genutzt - eine Prüfung, ein Ort.
Im Upload-Workflow entsteht dabei kein "Hilfsknoten" mehr.
folderNameCombinesArtistTag() arbeitet rein mit dem Ordnername-STRING,
nicht mit einem bereits angelegten Node - importUploadedFile() prüft
deshalb VOR dem ersten findOrCreateTaggedFolder()-Aufruf, welcher Name
der richtige ist, statt erst mit dem (evtl. falschen) Ordnernamen einen
echten Node anzulegen und ihn bei Bedarf hinterher zu korrigieren. Kein
Zwischenschritt, der später wieder verworfen werden müsste, keine
"verbrauchte" Auto-Increment-ID.
importAll() (der ordner-basierte media/-Import) lässt sich das dagegen
nicht ganz so einfach ersparen: der äußere $artist-Node wird dort auch
für echte Album-Unterordner gebraucht (die vertrauen dem Ordnernamen
direkt, keine Tag-Prüfung nötig) und muss deshalb so oder so früh angelegt
werden, bevor klar ist, ob lose Tracks ihn am Ende umleiten.
deleteChildlessFolders() bleibt dort als Sicherheitsnetz bestehen.
Grundsatz seitdem konsistent an allen Stellen: erst ab ZWEI Ordner-Ebenen (Künstler/Album/Track.mp3) gilt die Ordnerstruktur als eindeutig genug, um ohne Tag-Rückfrage übernommen zu werden - darunter entscheiden immer die Tags mit (Künstler UND Album), und selbst bei zwei Ebenen wird der äußere Ordnername gegen das "Künstler - X"-Muster geprüft.
Compilations / Various Artists
Ein Album, an dem mehrere Künstler beteiligt sind, braucht besondere Behandlung - sonst würde jeder beitragende Künstler sein eigenes Album-Fragment mit nur seinen eigenen Tracks bekommen, und man könnte die Compilation nirgends als Ganzes browsen.
Ordnerbasierter Import: leg die Compilation unter einem eigenen
Künstler-Ordner ab, z.B. media/Various Artists/Jetzt! 2024/. Die
Ordnerlage entscheidet über den Baum, ID3-Tags sind hier nur
Anzeige-Metadata - jede Track-Zeile zeigt trotzdem den tatsächlichen
Interpreten aus dem Artist-Tag an, auch wenn der Track unter "Various
Artists" hängt. Das ist die präzisere Variante, weil sie sich nicht auf
Tag-Heuristik verlassen muss.
Tag-basierter Dump-Import: MediaImporter::importDumpFolder() erkennt
Compilations automatisch - taucht unter demselben Albumnamen mehr als ein
Artist-Tag auf, landet das komplette Album unter einem gemeinsam genutzten
"Various Artists"-Künstler statt in einzelne Artist-Fragmente zerrissen zu
werden. Bewusster Kompromiss: die Erkennung hängt einzig am "mehrere
Artists, gleicher Albumname"-Muster, nicht an einem expliziten
Compilation-Tag (der ist zu uneinheitlich gesetzt, um sich darauf zu
verlassen). Zwei echte, unabhängige Alben mit zufällig identischem Titel
von unterschiedlichen Künstlern würden dadurch fälschlich zusammengelegt -
selten, aber möglich. Wo das wichtig ist: ordnerbasiert importieren statt
über _dump/.
Album-Übersicht (/alben, AlbumController::index() +
NodeRepository::findAllFoldersByKind()): zeigt alle Alben aus der
gesamten Bibliothek auf einen Blick, unabhängig davon unter welchem
Künstler sie hängen - genau dafür gedacht, eine Compilation zu finden, ohne
vorher wissen zu müssen, dass sie unter "Various Artists" liegt. Jede
Kachel zeigt zusätzlich den zugehörigen Künstler-Namen darunter.
Playlists
Bewusst außerhalb des Node-Baums modelliert (Playlist +
PlaylistItem), weil ein Track in mehreren Playlists gleichzeitig stecken
kann, im Baum aber nur einen einzigen tatsächlichen Ort hat (sein
Album/Künstler-Ordner). PlaylistItem ist die Zuordnungstabelle mit
expliziter position fürs Sortieren.
Aktuell sind Playlists global (kein Login vorhanden). Sobald die Login-
Funktion kommt, braucht Playlist ein owner-Feld (ManyToOne auf User,
nullable für die bis dahin angelegten globalen Playlists) und
PlaylistRepository::findAllOrdered() einen Owner-Filter - der Rest der
Struktur bleibt unverändert.
Jede Track-Zeile hat einen "+"-Button, der per HTMX ein kleines Dropdown
(playlist/_picker.html.twig) direkt an die Zeile hängt - Playlist
auswählen oder per Schnellformular gleich eine neue anlegen. Entfernen aus
einer Playlist läuft über hx-delete auf der Playlist-Detailseite.
In-App-Navigation (eigene Zurück/Vor-Buttons)
Wie bei Spotify/Amazon Music/iTunes gibt es in der Navbar eigene ‹/›-Buttons
(#nav-back/#nav-forward, verdrahtet in assets/ts/navigation.ts), die
einfach history.back()/history.forward() aufrufen. Technisch ist das
kein eigener History-Stack - HTMX pflegt durch hx-push-url ohnehin schon
die normale Browser-Session-History, die Buttons machen sie nur Teil der
eigenen App-Oberfläche, ohne dass Nutzer auf den Browser-eigenen Button
angewiesen sind. Der hx-history-elt-Fix (siehe unten) sorgt dafür, dass
auch das den Player nie unterbricht.
WebGL-Ambient-Hintergrund
assets/ts/ambient-fx.ts, #ambientFxCanvas (templates/base.html.twig,
ganz vorne im Body - liegt dadurch von Natur aus hinter allem später
Deklarierten, kein z-index-Wettrüsten nötig). Ein Partikelfeld im
3D-Tiefenraum ("Demoscene-Starfield"-Prinzip) mit Verbindungslinien
zwischen nahen Punkten ("Konstellations"-Look) - bewusst KEINE
zusätzliche Hintergrundebene (Nebel/Aurora) mehr, siehe unten warum.
Drei Anläufe für eine zusätzliche Hintergrundebene, keiner hat
überzeugt - erst eine fraktale Rauschtextur (Domain-Warping,
Zoom-Tiefenschichten für einen "durch den Nebel fliegen"-Eindruck), dann
Aurora-Borealis-Lichtvorhänge (Sinuswellen, später 1D-Rauschen für
organischere Bahnen). Trotz mehrfacher Kalibrierung überzeugte keine
Version durchgehend (mal "wabernder Matschsumpf", mal zu flächig -
80-90% der Fläche eingefärbt trotz mehrfacher Nachjustierung des
Falloffs, mal die Balance zwischen Light-/Dark-Theme-Sichtbarkeit nicht
getroffen). "Weniger ist mehr": das Sternenfeld allein, mit
spürbar mehr Punkten als zuvor (POINT_COUNT: 55 → 110, jetzt wo es den
kompletten visuellen Effekt allein trägt), ersetzt die separate
Hintergrundebene komplett.
Starfield-Bewegung: jeder Punkt hat eine Tiefe (z), die pro Frame
abnimmt (p.z -= speed) - bei der Projektion (screenX = x/z,
screenY = y/z) wandern Punkte dadurch von der Bildmitte (weit weg,
klein, unauffällig) nach außen (nah, groß, hell), bis sie die Nah-Ebene
passieren und mit neuer zufälliger Position zurück auf die Fern-Ebene
gesetzt werden (randomizePoint()) - der klassische "durch Sterne
fliegen"-Effekt, bewusst LANGSAM (BASE_SPEED, kalibriert für eine
komplette Durchquerung in ~17s bei Normal-Tempo).
Verbindungslinien: pro Frame prüft JS für jedes Punktpaar den
Abstand in der projizierten 2D-Ebene (CONNECT_DISTANCE = 0.22) - liegen
zwei Punkte nah genug beieinander, wird ein Liniensegment mit Deckkraft
proportional zur Nähe gezeichnet. MAX_CONNECTIONS_PER_POINT = 4
begrenzt die Verbindungen pro Punkt - sonst würden dichte Cluster
schnell unübersichtlich vernetzt aussehen.
Kompletter Ansatzwechsel gegenüber der allerersten Version (reiner
Vollbild-Fragment-Shader mit Rauschen bzw. Distanzfeldern): JS übernimmt
die eigentliche Partikelsimulation (Positionen, Tiefe, welche Punkte
gerade verbunden sind) und baut daraus PRO FRAME echte WebGL-Vertex-
Buffer (Punkte via gl.POINTS, Linien via gl.LINES) - der
GPU-Rasterizer zeichnet nur die tatsächlich sichtbaren Formen, statt dass
ein Fragment-Shader für jeden Bildschirm-Pixel alle Punkte/Paare
durchrechnen müsste.
Canvas wird jeden Frame per glClear() auf --bg zurückgesetzt -
übernahm vorher die (jetzt entfallene) Nebel-/Aurora-Hintergrundebene
implizit mit, da sie als undurchsichtiges Vollbild-Quad jeden Pixel
überschrieb. body{background:transparent} (app.css) bleibt trotzdem
nötig, damit das Canvas (das den kompletten Bildschirm mit --bg füllt,
Punkte/Linien werden darüber gezeichnet) überhaupt sichtbar wird.
Interne Auflösung deutlich höher als bei reinen Rausch-Shadern nötig
wäre (Math.min(1800, innerWidth * min(2, dpr))) - Punkte/Linien sind
geometrisch scharfe Formen (kein Rauschen), für die GPU sehr billig zu
rasterisieren, eine niedrige Auflösung ließ sie nur unnötig "blurry"
wirken. Punktgrößen sind mit einem Skalierungsfaktor
(resScale = canvas.width / 640) nachgezogen.
Audio-reaktives Wabbeln - jeder Punkt bekommt eine winzige, phasenversetzte Sinus-Oszillation auf seine projizierte Position, deren Amplitude mit der geglätteten Lautstärke wächst - bei Stille praktisch unsichtbar, bei Musik ein spürbares Wackeln/Wabern der Punkte.
Echte Beat-Erkennung statt reiner (träger) RMS-Glättung - wertet nur
den BASS-Frequenzbereich aus (unterste ~8% der Frequenzbänder), Beats
sind dort am deutlichsten erkennbar. Vergleicht den aktuellen Bass-Pegel
mit einem gleitenden Durchschnitt der letzten ~0.65s - liegt er deutlich
darüber (und über einer Mindestlautstärke, gegen falsche Treffer bei
generell leiser Musik), gilt das als Beat: beatPulse springt auf 1.0
und klingt danach schnell wieder ab - wirkt wie ein kurzer "Blitz" im
Rhythmus der Musik, moduliert Fluggeschwindigkeit, Punktgröße und
-Helligkeit.
Zwei getrennte WebGL-Programme (pointsProgram/linesProgram) -
Punkte nutzen gl_PointSize im Vertex-Shader (pro Punkt unterschiedlich
groß, je nach Tiefe) und einen weichen kreisförmigen Alpha-Falloff im
Fragment-Shader (gl_PointCoord-basiert), Linien sind einfache
gl.LINES-Segmente mit interpolierter Deckkraft pro Vertex.
Im Light-Theme spürbar zurückhaltender (alphaScale: 0.5 Light /
0.9 Dark) - ein bewegter Hintergrund wirkt auf hellem Grund schneller
unruhig/unleserlich als auf dunklem. Respektiert die gewählte
Akzentfarbe (--accent-rgb, alle 4 Themes) automatisch, da bei jedem
Frame neu ausgelesen.
Kein WebGL im Browser? Der Canvas selbst trägt background:var(--bg)
als CSS-Fallback (identisch zur bisherigen Optik) - initAmbientFx()
bricht sauber ab, kein Fehlerzustand, sieht einfach aus wie vorher, nur
ohne Bewegung.
Dekorativer Verlaufshintergrund (body::before)
Reines CSS, eigenständig von #ambientFxCanvas (das WebGL-Sternenfeld,
siehe assets/ts/ambient-fx.ts) - liegt als zusätzliche, statische
atmosphärische Ebene DAVOR: ein sanfter linearer Verlauf von oben
(Akzentfarbe, abnehmende Deckkraft) plus ein radialer Verlauf
(Akzentfarbe im Zentrum, zu --bg ausblendend). Getrennte Werte pro
Theme-Modus (html[data-mode=light]), analog zu .detail-modal.
Explizites z-index: 0 ergänzt (im ursprünglichen Entwurf nicht
gesetzt) - der Ambient-Canvas hat z-index: -1, das Canvas selbst
rendert trotz alpha: false im WebGL-Context transparent (lokal
verifiziert), verdeckt diesen Verlauf also ohnehin nicht. Die explizite
Stacking-Reihenfolge (VOR dem Canvas, aber weiterhin HINTER dem
eigentlichen Seiteninhalt - Topbar/Sidebar/Content liegen normal im
Fluss, kein eigenes niedriges z-index) ist trotzdem sauberer als sich
auf implizite Regeln zu verlassen. pointer-events: none, rein
dekorativ, soll nie Klicks abfangen.
Genre-Karten: glasiger Look + Hover-Verfeinerung
.detail-modal und .genre-overview-card teilen sich jetzt EINE
konsolidierte Hintergrund-Regel (vorher zwei separate, identische
Werte-Paare) - ein Wert für beide vermeidet künftiges Auseinanderlaufen,
falls die Deckkraft/Blur nochmal angepasst wird.
Neuer, deutlicherer Hover-Zustand - die Karte wird beim Hover
DURCHSICHTIGER (rgb(from var(--surface) r g b / 25%)) statt nur die
Randfarbe zu wechseln, passend zum glasigen Grundlook. Der innere Link
(.genre-overview-link) bleibt dabei IMMER transparent (vorher bekam er
beim Hover einen eigenen var(--surface-2)-Hintergrund) - sonst würden
sich zwei unterschiedlich deckende Ebenen übereinanderlegen und die
Karte fleckig wirken.
Verzögertes Positions-Restore (weniger Requests bei Reload)
Ein Reload/Seitenaufruf ruft restoreFromStorage() auf, die den
gespeicherten Track lädt UND (bei resumeAt > 0) an die zuletzt
gehörte Position springen will. Eine frühere Version tat das SOFORT
(per loadedmetadata-Listener direkt in loadTrack()), UNABHÄNGIG
davon, ob der Nutzer die Wiedergabe überhaupt fortsetzen wollte.
Bei einer MP3 ohne exakten Zeit-zu-Byte-Index (kein Xing/VBRI-
Header - bei vielen "einfach" gerippten Dateien der Fall) muss der
Browser für einen Sprung an eine bestimmte Zeitposition SELBST durch
mehrere Range-Requests "sondieren", um den passenden Byte-Offset zu
finden - reines, dokumentiertes Browser-Verhalten beim HTML5-<audio>-
Seeking, kein Fehler in unserer eigenen Range-Behandlung
(StreamController::serve() nutzt Symfonys BinaryFileResponse::prepare(),
Standard-konform). Bei langen Tracks (Podcasts) mit weit
fortgeschrittener gespeicherter Position waren das teils ~20 Requests -
DIREKT bei jedem Seitenaufruf, auch wenn der Nutzer die Seite nur kurz
öffnete, ohne die Wiedergabe fortzusetzen. "Je nach Streaming-
Fortschritt" (die Beobachtung, die zur Diagnose führte) passt genau ins
Bild: je weiter/ungenauer die Zielposition geschätzt werden muss, desto
mehr Sondierungs-Requests.
Jetzt nur gemerkt (MusicDockPlayer::pendingResumeTime), nicht
sofort angewendet - applyPendingResumeTime() wird erst von JEDER
Stelle aufgerufen, die tatsächlich audio.play() auslöst (playPause(),
sowie loadTrack()s eigener autoplay-Zweig, dort zur Konsistenz, auch
wenn pendingResumeTime dort praktisch immer 0 ist - nur beim
Storage-Restore gesetzt, wo autoplay immer false ist). Die teuren
Seek-Requests passieren dadurch erst beim tatsächlichen Play-Wunsch,
nicht mehr blind bei jedem bloßen Laden der Seite.
UX-Kompromiss: die Zeit-/Fortschrittsanzeige zeigt bis zum ersten Play-Klick "0:00" statt der gespeicherten Position, springt aber sofort korrekt, sobald tatsächlich abgespielt wird - vertretbarer Trade-off gegen die vielen unnötigen Requests bei jedem bloßen Seitenaufruf.
Nachtrag: die verzögerte Restore-Position allein reichte nicht - der
Netzwerk-Tab zeigte weiterhin 11 Requests bei einem reinen Reload, ALLE
mit Initiator: Other statt eines unserer eigenen Skripte. Das war der
entscheidende Hinweis: Other bedeutet, der BROWSER selbst löst das
aus, nicht unser JS. Ursache: <audio preload="metadata"> - der Browser
beginnt SOFORT, sobald src gesetzt wird, mit dem Laden (mindestens die
ersten Bytes für Header/Metadaten). Bei VBR-MP3s ohne exakten Zeit-Index
(Xing/VBRI-Header) muss der Browser dafür teils MEHRERE Range-Requests
machen, nur um die genaue Dauer zu ermitteln - komplett unabhängig vom
verzögerten Resume, direkt bei jedem Trackwechsel/Seitenaufruf.
Jetzt preload="none" (templates/player/_bar.html.twig) - wir
brauchen den Browser dafür gar nicht: die Dauer steht bereits aus
unseren eigenen Metadaten bereit (track.duration, aus der Datenbank,
ursprünglich per getID3 beim Import ermittelt). loadTrack() setzt
die Zeit-/Dauer-Anzeige jetzt SOFORT aus track.duration, statt auf das
Browser-loadedmetadata-Event zu warten - der loadedmetadata-Handler
bleibt als Fallback bestehen (aktualisiert den Wert, falls
audio.duration nach dem tatsächlichen Laden abweicht), ist aber nicht
mehr der alleinige Auslöser. preload="none" verhindert JEDES
automatische Laden, bis der Nutzer tatsächlich auf Play klickt.
Play-Button in der Trackliste startete den laufenden Track neu
Klick auf das Track-Cover (.track-play-btn) in einer Liste löste immer
playFromRow() aus, egal ob der Track schon lief - playFromRow() ruft
loadTrack(), das audio.src neu setzt und die Wiedergabe damit
IMMER von vorne startet, auch bei einem bereits laufenden/pausierten
Track. Der kleine Equalizer (.playing-bars) neben der Track-Nummer
zeigte zwar korrekt an, dass der Track lief, aber ein Klick auf das
Cover daneben pausierte nicht, sondern setzte zurück.
Klick auf den BEREITS aktiven Track togglet jetzt nur noch Play/Pause
(this.playPause()), ohne loadTrack() erneut aufzurufen - ein Klick
auf einen ANDEREN Track lädt/startet ihn weiterhin wie bisher. Dieselbe
Anpassung auch beim Mikrofon-Button (Lyrics öffnen) - läuft der Track
schon, wird er beim Klick nicht mehr neu gestartet, nur das Panel
geöffnet.
Play-Icon wechselt jetzt zu Pause, solange der aktive Track spielt
(body.is-playing .track-row--active .track-cover::after, dasselbe
Selektor-Muster, das bereits für den Equalizer genutzt wird) - zeigt
korrekt an, was ein Klick als Nächstes tut, weiterhin nur bei :hover
sichtbar (unverändertes Grundverhalten).
Cover-Upload optisch über das Track-Cover statt separatem Dateifeld
Das sichtbare <input type="file"> unterhalb des "Abspielen"-Buttons
wirkte deplatziert. Jetzt ein Hover-Overlay direkt auf dem großen Cover
(.track-cover-upload-zone/.track-cover-upload-overlay) - bekanntes
Muster (Profilbild-Upload bei Spotify/Discord u.ä.). Das eigentliche
<input type="file"> bleibt im DOM, aber visuell versteckt (Standard-
"visually-hidden"-Technik: 1×1px, clip, nicht display:none - bleibt
dadurch für Tastatur-/Screenreader-Fokus technisch erreichbar) -
track-detail.ts öffnet es per input.click(), sobald auf das Overlay
geklickt wird, ein programmatischer Klick auf ein <input type="file">
öffnet denselben nativen Datei-Dialog wie ein direkter Klick.
Statistiken: Auswertung (Meistgehört, zuletzt gehört)
Zweite Stufe der Statistik-Funktion, vorgezogen - die Datenerhebung
(play_count/last_played_at, TrackController::playEvent()) lief
schon länger, jetzt gibt es endlich eine Ansicht dafür (/statistiken,
neuer Sidebar-Link unter "Deine Musik"). Rein lesend, keine neue
Datenerhebung.
Drei neue NodeRepository-Methoden, native SQL statt DQL für die
Sortierung - JSON_VALUE()/JSON_EXTRACT() liefern einen JSON-
Skalarwert zurück, dessen STRING-Repräsentation für numerischen
Vergleich ungeeignet wäre (z.B. "9" > "10" alphabetisch) -
CAST(... AS UNSIGNED) erzwingt echten numerischen Vergleich. Andere
Repository-Methoden nutzen JSON_EXTRACT() zwar bereits in WHERE-
Bedingungen per DQL (findRecentlyAddedAlbums() etc.), aber ein Cast
speziell in ORDER BY wäre darüber unzuverlässiger gewesen als eine
direkte, gezielt geschriebene SQL-Formulierung:
findMostPlayedTracks()- absteigend nachplay_count.findRecentlyPlayedTracks()- absteigend nachlast_played_at(ISO- 8601-Strings sortieren korrekt lexikografisch, kein Cast nötig).getPlayStatsSummary()- Gesamtsumme aller Plays + Anzahl verschiedener gehörter Tracks, eine gemeinsame Abfrage statt zwei getrennter JSON-Extraktionen.
findOrderedByIds()-Hilfsmethode - ein SQL WHERE id IN (...)
garantiert die Reihenfolge NICHT, wird aber gebraucht, um die bereits
korrekt sortierten IDs (aus dem nativen SQL) in vollständige Node-
Entities über die normale Doctrine-Hydration umzuwandeln, ohne die
mühsam erarbeitete Sortierung zu verlieren.
_track_row.html.twig um optionalen extra_label-Parameter
erweitert - zeigt einen Zusatztext unter der Dauer (Play-Count/
Zeitstempel), Standard bleibt unverändert (keine Auswirkung auf
bestehende Includes, die den Parameter nicht setzen).
Kapitel-Buttons umpositioniert: immer sichtbar, neben dem Play-Button
Zwei Nachbesserungen an den gerade eingeführten Kapitel-Sprung-Buttons.
Neue Position - jetzt direkt in der Hauptsteuerungsreihe, neben dem
Play-Button, statt separat an der Progress-Leiste:
[Track zurück] [Kapitel zurück] [PLAY] [Kapitel vor] [Track vor].
Teilen sich die .player-icon-Basisoptik (38px, gleicher Hover-Effekt
wie die übrigen Controls), mit kleinerem Icon als die übrigen Symbole -
"nächstes Kapitel" ist konzeptuell nebensächlicher als "nächster
Track", die kleinere Größe spiegelt das.
Immer sichtbar statt hidden - ein Button, der je nach Track auf-
und wieder verschwindet, hätte die Nachbar-Buttons (allen voran den
Play-Button selbst) bei jedem Trackwechsel leicht verschoben. Jetzt
per .chapter-nav-btn--disabled-Klasse nur noch halbtransparent +
pointer-events: none, wenn der aktuelle Track keine Kapitel hat -
renderChapterMarkers() togglet diese Klasse statt vorher das
hidden-Attribut. Startet im Template bereits als --disabled
(statt erst per JS beim ersten loadTrack() gesetzt zu werden) - sonst
wären die Buttons für einen kurzen Moment beim allerersten Laden der
Seite voll aktiv sichtbar gewesen, bevor überhaupt ein Track geladen
ist.
Sichtbare Vor-/Zurück-Buttons für Kapitel-Sprünge
Bisher nur über die Tasten ]/[ erreichbar (jumpToChapter()) - jetzt
zusätzlich zwei kleine, sichtbare Buttons direkt neben der Progress-
Leiste (#chapterPrevBtn/#chapterNextBtn), für alle, die die
Tastenkürzel nicht kennen. Rufen dieselbe jumpToChapter()-Logik auf.
Eigene Chevron-Icons statt der Step-Symbole der großen Track-Skip- Buttons weiter oben in der Player-Leiste - vermeidet Verwechslung zwischen "nächster Track" und "nächstes Kapitel", die sich sonst optisch zu ähnlich gewesen wären, direkt übereinander sitzend.
Nur sichtbar, wenn der aktuelle Track tatsächlich Kapitel hat -
renderChapterMarkers() blendet beide Buttons per hidden-Attribut
ein/aus, zusammen mit den Wellenform-Markern selbst (dieselbe Prüfung:
Kapitel vorhanden UND Laufzeit bekannt). Einmalig in wireControls()
gebunden (die Buttons sind Teil der statischen Player-Leiste, werden nie
per HTMX ausgetauscht).
Kapitel-Marker blieben komplett unsichtbar, auch nach Reload
Der vorherige Fix (updateTrackChapters() nach dem Speichern) deckte
NICHT den Fall ab, dass der Track schon lief, BEVOR seine Kapitel zum
allerersten Mal angelegt wurden - genau das war beim Testen der Fall.
Ursache: War der Track schon vor dem Bearbeiten in der
Warteschlange (mit chapters: [] zum Ladezeitpunkt), blieb diese in
localStorage gespeicherte Kopie dauerhaft auf dem alten (leeren)
Stand eingefroren - restoreFromStorage() lädt beim nächsten
Seitenaufruf ja explizit AUS localStorage, nicht vom Server. Selbst
nach einem Reload zeigte der Player deshalb weiterhin die veralteten,
leeren Kapitel, obwohl track/show.html.twig auf der Detailseite
selbst längst die frisch gespeicherten Kapitel korrekt anzeigte -
zwei unterschiedliche Datenquellen (Server-gerendertes DOM vs.
eingefrorener localStorage-Snapshot), die auseinanderliefen.
syncChaptersFromCurrentPage() (player.ts, läuft in
syncActiveRowHighlight() mit - dadurch automatisch bei jedem
Trackwechsel UND jeder HTMX-Navigation, nicht nur bei einem vollen
Reload) - prüft, ob die Zeile/Detailseite des AKTUELL SPIELENDEN Tracks
gerade sichtbar ist (data-track-chapters steht dort immer aktuell,
frisch aus der DB gerendert) und gleicht die Warteschlangen-Kopie bei
Bedarf über updateTrackChapters() ab. Durchsucht bewusst alle
.track-row-Elemente statt eines interpolierten Attribut-Selektors -
Track-IDs landen so nie ungeprüft in einem CSS-Selector-String.
Kapitel-Marker aktualisierten sich nicht sofort nach dem Speichern
Nach dem Speichern neuer Kapitel blieb die Wellenform-Anzeige auf dem
alten Stand, falls genau dieser Track gerade lief - die Marker werden
nur bei loadTrack() neu gerendert, nicht bei jeder Änderung am
zugrundeliegenden Queue-Objekt. Der Nutzer hätte den Track pausieren/
neu starten müssen, nur um seine gerade gespeicherten Kapitel überhaupt
zu sehen.
Neue updateTrackChapters()-Methode (player.ts) - dasselbe
State-Sync-Muster wie updateTrackCover() (für den Cover-Upload):
aktualisiert alle passenden Queue-Einträge, rendert die Marker sofort
neu, falls es der aktuell laufende Track ist, und persistiert. Rein
clientseitig, kein eigener Request - der Editor hat den Server bereits
aktualisiert.
chapters.ts bekommt die Player-Instanz als Parameter (dasselbe
Muster wie initTrackCoverUpload(player) in track-detail.ts) und
ruft player.updateTrackChapters() nach erfolgreichem Speichern auf -
importiert Chapter direkt aus player.ts statt einer eigenen,
duplizierten Interface-Definition, um Typ-Drift zwischen beiden Modulen
zu vermeiden.
Wellenform verschwand komplett nach dem Kapitel-Feature
Echter Bug, direkt im Anschluss ans Kapitel-Feature aufgefallen: die komplette Wellenform-Anzeige war weg, nicht nur die neuen Kapitel- Marker.
Ursache: restoreFromStorage() lädt die Warteschlange aus
localStorage mit JSON.parse(...) as Track[] - dieses as Track[]
ist nur eine TypeScript-Typannahme, KEINE Laufzeitprüfung. Eine bereits
gespeicherte Warteschlange von VOR Einführung des chapters-Felds
enthält dieses Feld schlicht nicht - zur Laufzeit ist track.chapters
dann undefined, obwohl TypeScript es als garantiertes Array
behandelt. renderChapterMarkers() griff direkt auf
track.chapters.length zu, das warf bei undefined eine Exception -
und die brach den GESAMTEN Rest von loadTrack() ab, BEVOR
loadWaveform() überhaupt lief (renderChapterMarkers() steht davor
in der Aufrufreihenfolge). Nicht nur die Kapitel-Marker blieben
dadurch aus, sondern die komplette Wellenform-Anzeige.
Fix an der Quelle: restoreFromStorage() normalisiert jetzt jeden
aus localStorage geladenen Track (chapters: t.chapters ?? []) -
zusätzlich renderChapterMarkers()/jumpToChapter() selbst ebenfalls
defensiv (track.chapters ?? [] statt direktem Zugriff) als zweites
Sicherheitsnetz, falls chapters aus einem anderen Weg als
localStorage einmal fehlen sollte.
Nebenbei entdeckt und ebenfalls behoben: .progress-track verließ
sich bisher auf flex: 1 aus der Theme-Basisklasse, das nur wirkt,
wenn der DIREKTE Elternteil display: flex hat - durch den neuen
.progress-track-wrapper (siehe oben) ist das nicht mehr der Fall, der
Wrapper selbst hat nur flex: 1 (als Empfänger vom äußeren
Flex-Container), aber kein eigenes display: flex. Jetzt mit
explizitem width: 100% auf .progress-track selbst abgesichert,
unabhängig vom Flex-Kontext.
Tastenkürzel, Wiedergabegeschwindigkeit, Kapitel/Sprungmarken
Drei neue Player-Funktionen in einem Rutsch.
Tastenkürzel
wireKeyboardShortcuts() (player.ts) - Leertaste (Play/Pause),
Pfeiltasten (±10s spulen, ±5% Lautstärke), Umschalt+Pfeil (nächster/
vorheriger Track), ]/[ (nächstes/vorheriges Kapitel, siehe unten).
Feuert NICHT, während der Fokus in einem Eingabefeld liegt (Input/
Textarea/Select/contenteditable) - sonst würde z.B. die Leertaste in
einem Suchfeld die Wiedergabe togglen statt ein Leerzeichen einzufügen.
Ohne geladenen Track passiert nichts, Standardverhalten der Tasten
(z.B. Scrollen bei Pfeiltasten) bleibt dann unangetastet.
Wiedergabegeschwindigkeit
Neuer #speedButton in der Player-Leiste (Text statt Icon, zeigt die
aktuelle Rate wie "1.5x") - zykelt bei Klick durch feste Werte
(0.75x-2x). Gilt GLOBAL, nicht pro Track, und wird in localStorage
persistiert - wie bei Podcast-Playern üblich, eine gewählte
Geschwindigkeit soll für den nächsten Track/die nächste Folge erhalten
bleiben, nicht bei jedem Wechsel auf 1x zurückspringen.
this.playbackRate wird bei JEDEM loadTrack() erneut explizit
gesetzt (nicht nur einmal) - manche Browser setzen playbackRate bei
einem neuen audio.src nicht zuverlässig zurück, andere schon
(uneinheitliches Verhalten je nach Engine), die erneute Zuweisung ist
ein günstiges Sicherheitsnetz.
Kapitel/Sprungmarken
Für lange Tracks (Podcasts etc.) - Sprungmarken mit Zeit + Titel, erscheinen als kleine, klickbare Striche mit Tooltip auf der Wellenform/Progress-Leiste in der Player-Leiste.
Anders als Lyrics (lrclib.net) gibt es KEINEN externen Kapitel-
Datendienst - Kapitel werden ausschließlich manuell gepflegt, über
einen neuen Editor auf der Track-Detailseite (assets/ts/chapters.ts):
Zeit im MM:SS-Format (oder reine Sekundenzahl) + Titel, mit einem
"aktuelle Position übernehmen"-Button (liest #player-audio direkt
aus, kein Umweg über eine öffentliche Player-API nötig). Hinzufügen/
Entfernen passiert rein clientseitig in einer lokalen Liste,
"Speichern" schickt die VOLLSTÄNDIGE Liste an den neuen Endpunkt
POST /track/{id}/chapters (TrackController::setChapters()) -
ersetzt den bisherigen Stand komplett, kein Anhängen einzelner
Einträge. Serverseitig zusätzlich validiert und nach Zeit sortiert
(Client-seitige Prüfung ist nur UX, kein Sicherheitsnetz).
Speicherung: chapters-Node-Metadaten als JSON-Array
({time, title}), dasselbe etablierte Muster wie bio/play_count
etc. Landet als data-track-chapters-Attribut im DOM
(_track_row.html.twig, track/show.html.twig) UND im Album-JSON-
Endpunkt (AlbumController::tracksJson(), für "Album abspielen" von
einer Kachel aus) - bewusst DIREKT im DOM statt bei Bedarf nachgeladen
(anders als Lyrics-Text, der absichtlich NICHT im DOM steht): Kapitel-
Listen sind typischerweise winzig, ein Nachladen wäre unnötiger
Aufwand für so wenig Daten.
Wellenform-Marker brauchten einen zusätzlichen Wrapper um
.progress-track (templates/player/_bar.html.twig) - der hat selbst
overflow: hidden und keine eigene Positionierung (theme.css), Marker
DARIN wären am 4px hohen Balken abgeschnitten worden. Der neue
.progress-track-wrapper trägt position: relative, die Marker
liegen als Geschwister-Ebene NEBEN .progress-track (#chapterMarkers)
statt darin - können dadurch über dessen schmale Höhe hinausragen.
Container selbst hat pointer-events: none (normales Seeken durch die
restliche Leiste bleibt unangetastet), nur die einzelnen
.chapter-marker-Striche sind klickbar und holen sich das per
pointer-events: auto zurück.
]/[-Tasten springen zum nächsten/vorherigen Kapitel
(jumpToChapter(), player.ts) - sucht das nächste Kapitel VOR bzw.
NACH der aktuellen Position, nicht einfach "Index ± 1" (sonst würde ein
Sprung MITTEN in einem Kapitel beim "zurück" das eigentlich aktuelle
Kapitel überspringen). 2-Sekunden-Puffer beim Rückwärtssprung -
"zurück" innerhalb der ersten 2s eines Kapitels springt zum
VORHERIGEN, nicht zum eigenen Anfang (dasselbe Verhalten wie der
"vorheriger Track"-Button bei den meisten Playern: erst zurück zum
Anfang, ein zweites Drücken erst zum vorherigen).
Statistiken: Datenerhebung (Play-Count)
Erste Stufe der geplanten Statistik-Funktion - reine Datenerhebung, noch KEINE eigene Auswertungsseite/-ansicht. Sammelt jetzt schon Play-Zahlen, damit eine spätere Auswertung nicht bei null anfängt, sondern auf bereits vorhandenen Daten aufbauen kann.
play_count/last_played_at als Node-Metadaten auf dem Track -
dasselbe etablierte Muster wie bio/cover_url etc., keine neue
Tabelle nötig für zwei einfache Zähler-/Zeitstempel-Felder. Neuer
Endpunkt POST /track/{id}/play-event
(TrackController::playEvent()).
Nur "gültige" Plays zählen - der Client meldet sich erst, sobald der
aktuelle Track entweder 30 Sekunden erreicht hat ODER 50% seiner
Laufzeit (was zuerst eintritt, maybeReportPlayEvent() in player.ts)
- derselbe Schwellenwert, den auch große Streaming-Dienste nutzen, um
echtes Hören von einem bloßen Antippen/sofortigen Überspringen zu
unterscheiden. Nur EINMAL pro Track-Ladevorgang (
playEventReported- Flag, inloadTrack()bei jedem neuen Track zurückgesetzt) - ein erneutes Abspielen desselben Tracks (z.B. nach Ende/Neustart) zählt wieder neu.
Positions-basiert, nicht Hörzeit-akkumuliert - prüft
audio.currentTime gegen die Schwellenwerte, akkumuliert NICHT die
tatsächlich gehörte Zeit über Pausen/Sprünge hinweg. Bewusst einfach für
eine erste Datenerhebung, keine hochpräzise Hörzeit-Messung. Der
Duration-Check (50%) greift nur bei einer gültigen, positiven Laufzeit
- bei unbekannter/0 Laufzeit (Metadaten noch nicht geladen) würde "50% von 0" sonst sofort bei Sekunde 0 auslösen.
Minimale Verifikations-Anzeige auf der Track-Detailseite
("Abspielungen: N" in den Detail-Metadaten, nur sichtbar sobald
play_count > 0) - kein Ersatz für eine richtige Auswertung, nur ein
einfacher Nachweis, dass die Zählung tatsächlich ankommt.
Kachel-Play-Icon: dezenter statt Akzentfarbe + Dropshadow
Der runde Icon-Kreis auf den Album-Kachel-Covern (.album-card-play-icon i)
nutzte bisher var(--accent) mit Dropshadow, wie die anderen Hero-
Play-Buttons. Jetzt halbtransparentes Schwarz mit weißem Icon, kein
Schatten - fügt sich in jede Cover-Farbe ein, statt sie zu überstrahlen.
Das umschließende Abdunkel-Overlay (deckt die gesamte Cover-Fläche
leicht ab, damit der Kreis besser sichtbar ist) blieb unverändert -
betroffen war nur der Icon-Kreis selbst.
Album-Detailseite: Cover-Play-Icon entfernt, Buttons enger zusammen
Zwei Nachbesserungen am gerade zurückgebrachten Layout.
Play/Pause-Icon direkt auf dem Cover (#albumCoverPlayBtn) ist
wieder komplett entfallen - mit dem großen, eigenständigen
#albumHeroPlayBtn direkt daneben war es tatsächlich redundant, zwei
Auslöser für dieselbe Aktion nebeneinander. "Cover ändern" ist damit
wieder die einzige Interaktion auf dem Cover selbst - auf der Kachel-
Übersicht bleibt das Cover dagegen weiterhin der Play/Pause-Auslöser
(dort gibt es kein "Cover ändern", das um denselben Klickbereich
konkurrieren könnte).
Play/Bearbeiten/ZIP-Download stehen jetzt direkt nebeneinander
statt mit .col/.col-auto auseinandergezogen zu sein - eine schlichte
d-flex gap-2-Reihe, alle drei Buttons dicht beieinander statt
Bearbeiten/ZIP an den rechten Rand geschoben.
syncAlbumCoverPlayButton() und der delegierte Klick-Handler behandeln
jetzt wieder nur zwei Stellen (#albumHeroPlayBtn,
.album-card-cover-btn) statt drei.
Separater Play-Button auf der Album-Detailseite zurückgebracht
Der große, eigenständige Play-Button war beim Umbau auf reines Cover- Play/Pause versehentlich als "jetzt redundant" komplett entfernt worden
- sollte aber weiterhin da sein, nur zusammen mit Bearbeiten/ZIP- Download in einer Reihe (Amazon-Music-artiges Layout: alle Aktionen nebeneinander statt verteilt).
Neuer #albumHeroPlayBtn - der große Button ist zurück (.col in
der Bootstrap-Grid-Reihe, Bearbeiten/ZIP-Download als .col-auto
daneben), läuft aber jetzt SYNCHRON mit dem kleinen Cover-Icon
(#albumCoverPlayBtn) statt unabhängig immer stur von vorne zu
starten - beide zeigen zu jedem Zeitpunkt denselben Play-/Pause-
Zustand. syncAlbumCoverPlayButton() und der delegierte Klick-Handler
behandeln jetzt DREI mögliche Stellen (Cover-Icon, Hero-Button,
Kachel-Buttons) in derselben Schleife bzw. demselben Fall - alle tragen
data-album-id, kein separater Code-Pfad pro Stelle nötig. Der
"gehört das zur Album-Detailseite oder zu einer Kachel"-Unterschied
(wichtig für den [data-track-list]-Shortcut, siehe oben) wird jetzt
über !classList.contains('album-card-cover-btn') geprüft statt einer
festen ID - deckt beide Detailseiten-Buttons gleichermaßen ab.
Kachel-Klick spielte immer denselben, falschen Container ab
Echter Bug: Klick auf IRGENDEINE Album-Kachel spielte immer denselben, falschen Inhalt - unabhängig davon, welches Album tatsächlich angeklickt wurde.
Ursache: Der Klick-Handler prüfte zuerst, ob bereits eine
[data-track-list] im DOM existiert (als Abkürzung, um bei bereits
vorhandener Trackliste keinen unnötigen Extra-Request zu machen) - diese
Prüfung war aber viel zu grob: document.querySelector('[data-track-list]')
findet einfach die ERSTE passende Stelle auf der GESAMTEN Seite, ganz
unabhängig davon, ob sie überhaupt zum angeklickten Album gehört. Auf
der Künstlerseite gibt es z.B. eine [data-track-list] für lose Singles
(Tracks ohne Album-Zwischenebene, artist/show.html.twig) - die hat
mit keinem der angezeigten Alben etwas zu tun, wurde aber trotzdem
gefunden und blind abgespielt, komplett unabhängig davon, welche Kachel
tatsächlich angeklickt wurde.
Fix: Die Abkürzung greift jetzt NUR noch, wenn der Klick vom
einzelnen Button auf der Album-DETAILSEITE selbst kam
(#albumCoverPlayBtn, erkennbar an der ID) - dort ist garantiert, dass
die einzige vorhandene [data-track-list] tatsächlich zum angezeigten
Album gehört. Ein Klick auf eine Kachel (.album-card-cover-btn, auf
Künstlerseite/Album-Übersicht/Startseite) läuft jetzt IMMER über
playAlbumById() (den JSON-Endpunkt) - kein blinder DOM-Shortcut mehr,
der versehentlich einen unabhängigen Container erwischen könnte.
Album-Kachel: Klick aufs Cover ist jetzt IMMER Play/Pause
Zwei Nachbesserungen aus dem Praxistest der Kachel-Play-Buttons.
Klick auf Play navigierte trotzdem ins Album
Der Play-Button lag bisher innerhalb des Kachel-<a>-Links, player.ts
rief event.preventDefault() auf, um die Navigation zu unterdrücken -
funktionierte aber nicht zuverlässig. Ursache: HTMX' eigener Klick-
Handler für den geboosteten Link feuerte VOR dem eigenen Handler und
leitete die Navigation bereits ein, ganz unabhängig vom später
aufgerufenen preventDefault().
Der <a>-Wrapper ums Cover ist komplett entfallen - die ganze
Cover-Fläche ist jetzt selbst ein <button class="album-card-cover-btn">,
kein Navigations-Ziel mehr, an dem HTMX überhaupt ansetzen könnte. Ein
Klick IRGENDWO auf dem Cover löst jetzt zuverlässig Play/Pause aus, wie
bei Spotify/Apple Music - ins Album kommt man nur noch über den Titel-
Link darunter.
Play-Icon zentriert statt in der Ecke
Auf der Kachel-Übersicht (nicht auf der Album-Detailseite - dort bleibt die kleine Ecken-Variante bestehen, da dort zusätzlich das konkurrierende "Cover ändern"-Hover-Overlay sitzt, ein zentriertes vollflächiges Icon wäre dort in die Quere gekommen) zeigt ein halbtransparentes Overlay jetzt das Play-/Pause-Icon mittig auf dem Cover, statt eines kleinen Kreises in der Ecke - erscheint beim Hover oder permanent, wenn gerade ein Track dieses Albums spielt.
Nachtrag: direkter Album-ID-Abgleich statt URL-String-Vergleich
Die "gehört dieser Track zu Album X"-Prüfung verglich bisher track.albumLink
(eine URL-String, z.B. /album/123) gegen window.location.pathname
(Detailseite) bzw. eine selbst zusammengebaute /album/{id}-URL
(Kachel) - funktioniert, aber indirekter als nötig und an die konkrete
URL-Struktur gebunden.
Track-Interface um albumId erweitert (die rohe Node-ID, nicht
die URL) - befüllt aus einem neuen data-track-album-id-Attribut
(_track_row.html.twig, track/show.html.twig) bzw. direkt im
JSON-Endpunkt (AlbumController::tracksJson()). Der Vergleich läuft
jetzt einfach currentTrack.albumId === albumId - robuster
(unabhängig von URL-Formatierung) und leichter zu lesen.
Beide Fälle (Detailseite + Kacheln) laufen jetzt über dieselbe
Schleife in syncAlbumCoverPlayButton() sowie denselben Klick-Fall in
der delegierten Event-Behandlung - da beide Buttons jetzt data-album-id
tragen, gibt es keinen Grund mehr für zwei fast identische Code-Pfade.
Der Klick-Handler probiert zuerst, ob bereits eine [data-track-list]
im DOM existiert (Detailseite - Tracks stehen dort schon bereit, kein
Extra-Request nötig) und fällt erst sonst (Kachel-Übersicht) auf
playAlbumById() zurück.
Album abspielen direkt aus der Kachel-Übersicht
Album-Kacheln (Album-Übersicht, Künstlerseite, Startseite) bekamen dasselbe Play/Pause-Icon wie die Album-Detailseite - man muss nicht mehr erst ins Album hinein, um es zu starten.
Neuer JSON-Endpunkt GET /album/{id}/tracks.json - liefert die
Tracks eines Albums in EXAKT derselben Struktur wie player.ts'
Track-Interface (dieselben Felder, die trackFromElement() normalerweise
aus den data-track-*-Attributen einer .track-row liest). Nötig, weil
eine Kachel-Übersicht - anders als die Album-Detailseite, wo
#albumCoverPlayBtn/.play-container-btn einfach die nächste
[data-track-list] im DOM finden - KEINE Tracks im DOM stehen hat, nur
Cover + Name pro Album.
player.ts bekam playAlbumById() - ruft den JSON-Endpunkt ab,
setzt die Warteschlange direkt aus der Antwort und startet den ersten
Track. Der neue Klick-Fall .album-card-play-btn (mehrere pro Seite
möglich, data-album-id für die Zuordnung) läuft über dieselbe
delegierte Event-Behandlung wie alle anderen Play-Buttons -
funktioniert dadurch auch nach HTMX-Navigation ohne erneutes Binden.
preventDefault() verhindert dabei die Navigation zur Albumseite (der
Button liegt innerhalb des Kachel-<a>-Links, ein Klick würde sonst
zusätzlich navigieren).
syncAlbumCoverPlayButton() erweitert - synchronisiert jetzt sowohl
den einzelnen Button auf der Detailseite als auch beliebig viele
Kachel-Buttons auf einer Übersicht. Kachel-Variante baut sich die
erwartete albumLink-URL aus data-album-id zusammen (/album/{id}),
da mehrere Kacheln auf derselben Seite stehen können - anders als die
Detailseite, die einfach window.location.pathname vergleichen kann.
Der separate .hero-play-btn/.play-container-btn auf der Album-
Detailseite selbst wurde entfernt - jetzt, wo das Cover direkt Play/
Pause übernimmt, wäre ein zweiter Play-Button daneben redundant
gewesen. Bearbeiten/ZIP-Download bleiben als einfache Button-Reihe
stehen (kein .col/.col-auto-Split mehr nötig, ohne Play-Button gibt
es nichts mehr, wovon sie getrennt werden müssten) - dieselbe schlichte
Anordnung wie Bearbeiten + "Metadaten abrufen" auf der Künstlerseite.
Edit-Button umpositioniert + Album-Cover-Play/Pause
Edit-Button saß deplatziert direkt neben dem Titel
Wanderte in dieselbe Zeile wie der ZIP-Download-Button (Album/Track) bzw. neben "Metadaten abrufen" (Künstler, wo es keinen Play-Button gibt, der ihn davon trennen müsste).
Bootstrap-Grid (row/col/col-auto) statt eigener Spacer-Klasse
für Album/Track (beide brauchen einen echten Links-/Rechts-Split: Play-
Button links, Bearbeiten/ZIP-Download rechts) - margin-left: auto
direkt in .hero-download-btn verankert hätte sich bei MEHREREN
solchen Buttons in einer Reihe falsch verhalten (der freie Platz hätte
sich auf jeden Auto-Margin einzeln aufgeteilt, statt alle rechten
Buttons bündig nebeneinander zu halten) - eine erste Zwischenlösung mit
einem eigenen .hero-actions-spacer-Element funktionierte zwar, war
aber unnötig, da Bootstrap (ohnehin Projektabhängigkeit) genau dafür
gebaute Utilities mitbringt: .col (Play-Button) nimmt den gesamten
verfügbaren Platz, .col-auto (Bearbeiten/ZIP-Download) bleiben auf
ihre Inhaltsbreite begrenzt und rutschen dadurch automatisch an den
rechten Rand. Fehlt die .col (z.B. leeres Album ohne Tracks), sitzen
die .col-auto-Spalten einfach am Zeilenanfang - kein Sonderfall nötig.
Genre/Künstler (kein Split nötig, nur eine einfache Button-Reihe) bleiben
bei der schlichten .hero-actions-Flex-Klasse.
Album-Hero-Actions-Zeile ist jetzt IMMER sichtbar, nicht nur wenn Tracks vorhanden sind - der Edit-Button muss auch bei einem leeren Album erreichbar bleiben, nur Play/ZIP-Download brauchen echte Tracks und bleiben bedingt.
displayContainerId korrigiert - zeigte vorher auf einen Wrapper,
der Name UND Stift-Button (bzw. bei Album/Track sogar die ganze
Play/ZIP-Zeile) umfasste. Fürs Bearbeiten muss aber wirklich nur der
NAME-Text selbst versteckt werden - sitzt jetzt direkt auf dem <h2>.
Album-Cover: Play/Pause-Icon
Klick auf das Album-Cover spielt jetzt das Album ab (kleiner, separater
Kreis in der unteren rechten Ecke - bewusst NICHT vollflächig wie das
"Cover ändern"-Overlay, sonst würden beide beim Hover denselben Bereich
beanspruchen wollen). Läuft bereits ein Track dieses Albums, zeigt der
Kreis ein Pause- statt Play-Icon und bleibt PERMANENT sichtbar (nicht
nur beim Hover) - dasselbe Play/Pause-Toggle-Prinzip wie
.track-play-btn in Tracklisten, nur auf Albumebene übertragen.
syncAlbumCoverPlayButton() (player.ts) - vergleicht
track.albumLink direkt gegen window.location.pathname (beide sind
identisch, wenn man sich gerade auf der Albumseite befindet, deren
Track spielt) statt eine zusätzliche data-album-id-Zuordnung zu
brauchen. Läuft an drei Stellen mit: syncActiveRowHighlight() (jeder
Trackwechsel), den play/pause-Audio-Events (reines Play/Pause-
Toggle desselben Tracks, OHNE Trackwechsel - würde sonst nicht
mitgezogen), und darüber auch bei jeder HTMX-Navigation sowie beim
initialen Laden (restoreFromStorage() → loadTrack() →
syncActiveRowHighlight()).
Eigener, per closest() erkannter Klick-Fall in der bestehenden
delegierten wireTrackDelegation() (nicht .play-container-btn
wiederverwendet) - der Button braucht ein echtes Toggle (läuft der
Track schon, nur pausieren/fortsetzen), .play-container-btn selbst
startet immer stur von vorne, ohne Toggle-Möglichkeit. Als eigenes
click-Handling delegiert statt separat gebunden - ein direkt am
Element gebundener Listener hätte nach jeder HTMX-Navigation zur
Albumseite ins Leere gezeigt (Element existierte beim einmaligen
Konstruktor-Aufruf noch nicht).
Metadaten editierbar: Künstler, Album, Track
Größer gedacht als nur die Bio - Stift-Button auf allen drei Detailseiten (Künstler/Album/Track), öffnet ein Inline-Formular (ersetzt die normale Anzeige temporär), speichert per XHR.
Was editierbar ist:
- Künstler: Name + Bio, in einem gemeinsamen Formular.
- Album: nur der Name.
- Track: Titel + Track-/Disc-Nummer.
Reine DB-Metadaten, KEIN Rückschreiben in Mediendateien - bewusste Entscheidung, mit unterschiedlicher Begründung je Ebene:
- Künstler/Album haben ohnehin keine eigene Mediendatei (Künstler nie, ein Album ist nur ein Ordner mit mehreren Tracks darin) - ein Album-Name-Edit MÜSSTE, um ihn zurückzuspiegeln, JEDEN einzelnen Track-MP3 im Album neu schreiben - für eine reine Namensänderung unverhältnismäßig.
- Bei Track wäre es technisch machbar (EIN Track = EINE Datei, dieselbe
getid3_writetags-Infrastruktur wieTrackCoverEmbedderfürs Cover- Einbetten) - bewusst als mögliche SPÄTERE Erweiterung offengelassen, nicht Teil dieser ersten Version (ID3-Schreibzugriff bräuchte eigene Sorgfalt: Backup/Restore bei Fehlschlag, welche Tag-Version schreiben etc. - der DB-Edit an sich ist schon der wertvolle Teil).
Name-Edits sind gefahrlos - Node::setName() ändert nur den
Anzeigenamen in der DB, rührt den tatsächlichen Ordner/die Dateien auf
der Festplatte nicht an. Verifiziert: Streaming (StreamController)
nutzt an keiner Stelle den Namen zur Pfadberechnung, nur die beim
Import gespeicherte metadata.file_path bzw. eine feste, deterministische
UUID (Cover-Speicherorte) - eine Umbenennung hat darauf keinen Einfluss.
Ein gemeinsames, parametrisierbares JS-Modul (assets/ts/node-edit.ts,
initNodeEdit()) statt drei fast identischer Kopien - die drei Seiten
unterscheiden sich nur in der Feldmenge und dem Ziel-Endpunkt.
initArtistEdit()/initAlbumEdit()/initTrackEdit() sind dünne,
seitenspezifische Wrapper darum, lesen die jeweilige Node-ID selbst aus
einem data-*-Attribut am Stift-Button (konsistent mit
initArtistMetadataFetch()), kein Parameter-Durchreichen in app.ts
nötig.
Künstler-Sonderfall: onSuccess-Callback blendet die Bio-Sektion
ein, falls sie vorher versteckt war (noch keine Bio vorhanden), und
setzt die frische Bio wieder auf "eingeklappt" (.artist-bio-clamped) -
analog zu applyResult() in artist-metadata.ts nach einem
MusicBrainz-Abruf, sonst bliebe ein zuvor ausgeklappter Zustand nach
dem Speichern stehen.
Biografie: begrenzt auf 3 Zeilen mit Klick-Toggle
#artistBioText bekam bisher unbegrenzt viel Platz - ein langer
Wikipedia-Extract sprengte die Hero-Karte spürbar. Jetzt standardmäßig
auf 3 Zeilen begrenzt (.artist-bio-clamped, CSS -webkit-line-clamp -
trotz des Namens de-facto-Standard für mehrzeilige Text-Trunkierung,
von allen gängigen Browsern unterstützt), mit "..."-Trunkierung. Klick
auf den Text selbst togglet die Begrenzung (initBioToggle(),
assets/ts/artist-metadata.ts) - kein separater Auf-/Zuklappen-Button
nötig, der ausgeklappte Zustand braucht kein eigenes CSS (die Klasse
wird einfach entfernt, dann gilt normaler, unbegrenzter Fließtext).
Nach einem erneuten "Metadaten abrufen" startet die Anzeige immer wieder
eingeklappt (applyResult() fügt die Klasse explizit wieder hinzu) -
unabhängig vom vorherigen Auf-/Zu-Zustand, konsistent mit dem
Anfangszustand beim ersten Laden der Seite.
Drei Nachträge aus dem Praxistest
Genre-Gruppierung zeigt jetzt Album-Kacheln statt sofortiger Track-Listen
Die "nach Album gruppieren"-Ansicht dumpte bisher unter jeder Album-
Überschrift sofort die komplette Track-Liste. Jetzt eine Kachel-Übersicht
(wiederverwendet components/_album_card.html.twig, inkl. Cover) - Klick
auf ein Album führt zur normalen Albumseite (alle Tracks des Albums,
nicht nur die mit diesem Genre - einfacher und konsistenter mit dem
Rest der App als eine eigene gefilterte Zwischenansicht extra dafür zu
bauen). Singles (Tracks ohne Album-Zwischenebene) haben kein natürliches
Cover - bleiben als flache Liste unter einer Künstler-Überschrift, wie
bisher.
GenreController::show() trennt die Gruppen jetzt bereits selbst in
albumGroups/singleGroups (statt im Twig zu filtern) - das Template
rendert beide unterschiedlich.
"Metadaten aktualisiert", aber kein Bild - Wikidata-Fallback nachgerüstet
Bei Gregorian (und vermutlich vielen anderen Künstlern) hat MusicBrainz
NUR eine "wikidata"-Relation, keine direkte "wikipedia"-Relation -
findWikipediaTitle() suchte bisher AUSSCHLIESSLICH nach der direkten
Variante. Das war eine bewusste, aber falsche Einschätzung beim
ursprünglichen Bau ("zusätzlicher Schritt für einen bei bekannten
Künstlern ohnehin seltenen Fall") - Tags/Genres kamen trotzdem korrekt
(die stammen direkt aus MusicBrainz, unabhängig von Wikipedia), aber
Bio/Bild blieben leer, ohne dass ein Fehler sichtbar wurde.
Wikidata-Fallback jetzt nachgerüstet - findet findWikipediaTitle()
keine direkte Wikipedia-Relation, aber eine Wikidata-Relation, wird die
Wikidata-Entität abgefragt (Special:EntityData/{id}.json) und daraus
der passende Sitelink für die konfigurierte Sprache (dewiki) gezogen.
Eigener try/catch - ein fehlgeschlagener Wikidata-Abruf bricht nicht die
gesamte Metadaten-Abfrage ab, nur Bio/Bild bleiben dann eben leer.
Durchgängiges Logging ergänzt (findWikipediaTitle(),
fetchWikipediaSummary(), ArtistController::tryDownloadAndSetCover())
- vorher blieben mehrere Fehlerpfade (keine Relation gefunden, Wikipedia- Abruf schlug fehl, kein Vorschaubild im Artikel, Bild-Download/- Verarbeitung schlug fehl) komplett spurlos, "Metadaten aktualisiert" erschien trotzdem als Erfolg (Bio/Genres kamen ja tatsächlich an), ohne dass irgendwo sichtbar wurde, warum das Bild fehlte.
Genre-Vorschläge blieben dauerhaft (und veraltet) bestehen
Vorher wurden Vorschläge in metadata.suggested_genres auf dem Künstler
gespeichert und erschienen dadurch bei JEDEM künftigen Seitenaufruf
erneut - auch für Genres, die längst übernommen waren, oder Wochen nach
dem eigentlichen Abruf. Verwirrend statt hilfreich.
Nicht mehr persistiert - Vorschläge existieren jetzt ausschließlich
als Teil der fetch-metadata-Antwort, rein im Browser-Speicher dieser
Sitzung. Verlässt man die Seite oder lädt neu, sind sie weg;
artist/show.html.twig rendert den Vorschläge-Bereich beim normalen
Laden nicht mehr serverseitig, nur noch als leerer, versteckter
Platzhalter, den assets/ts/artist-metadata.ts nach einem frischen
Abruf befüllt.
Bereits vorhandene Genres werden zusätzlich rausgefiltert - bevor
die Vorschläge in der Antwort landen, vergleicht ArtistController::fetchMetadata()
sie (case-insensitive) gegen $artist->getGenres() - ein Vorschlag für
ein Genre, das der Künstler schon hat, wäre ohnehin nutzlos und wird
gar nicht erst gezeigt.
MusicBrainz-Fehler nach anfänglichem Erfolg + Genre-Seite ohne Struktur
Zwei weitere Nachträge aus dem praktischen Testen.
"Keine externen Daten gefunden" für JEDEN Künstler
Trat erst nach anfänglich erfolgreichen Abrufen auf - ein starkes Indiz
für eine TECHNISCHE Ursache (MusicBrainz-Rate-Limit erreicht,
abgelehnter User-Agent, Netzwerkproblem), nicht für "kein Treffer" bei
jedem einzelnen Künstler. Das Problem: jeder HTTP-Fehlschlag (429
Rate-Limit, 503 überlastet, 403 abgelehnt, echtes "kein Treffer") führte
bisher zu genau derselben generischen null-Rückgabe und Meldung -
technischer Fehler und "nichts gefunden" waren dadurch nicht zu
unterscheiden.
ArtistMetadataFetcher wirft jetzt eine \RuntimeException mit der
konkreten Ursache (analog zu TrackCoverEmbedder/NodeCoverUploader)
statt still null zurückzugeben - der tatsächliche HTTP-Status-Code
UND der Response-Body werden zusätzlich geloggt (vorher: gar nicht).
Bei einem 503 zeigt die Meldung jetzt explizit den Hinweis auf Rate-
Limit/Überlastung. ArtistController::fetchMetadata() reicht die
konkrete Meldung direkt an die UI durch.
User-Agent MusicBrainz-konformer gemacht - MusicBrainz verlangt laut
eigenen Richtlinien eine ECHTE Kontakt-URL/E-Mail im Format
AppName/Version ( contact ), ein rein beschreibender String ohne
Kontakt (vorherige Version) wird teils strenger gedrosselt oder ganz
abgelehnt. Der aktuelle Platzhalter (https://example.invalid/contact)
ist NICHT echt - bitte durch eine eigene Kontakt-URL/E-Mail in
ArtistMetadataFetcher::requestHeaders() ersetzen.
Bitte nach dem Update die Logs prüfen (var/log), falls der Fehler weiterhin auftritt - die neue Meldung sollte jetzt zeigen, ob es tatsächlich ein Rate-Limit ist (dann hilft nur warten) oder etwas anderes.
Genre-Seite ohne Struktur bei vielen Tracks
Nachdem die Genre-Vererbung bis zum Künstler jetzt korrekt funktioniert, zeigte sich: bei einem breiten Genre kamen schnell hunderte Tracks in einer einzigen, unpaginierten, ungruppierten Liste zusammen.
Zwei Ansichten jetzt (Umschalter oben in .detail-body):
- Flache Liste MIT Paginierung (Standard,
?page=N) - dieselbe Seitengröße (60) und dasselbe Muster wieTrackController::index(). - Nach Album gruppiert (
?group_by_album=1) - KEINE Paginierung dort, die Gruppierung selbst schafft schon genug Struktur, auch bei vielen Tracks. Tracks ohne Album (lose Singles direkt unter einem Künstler) landen in einer eigenen Gruppe PRO Künstler, nicht in einer gemeinsamen "ohne Album"-Sammelgruppe - sonst würden Singles komplett unterschiedlicher Künstler vermischt.
Paginierung/Gruppierung passiert in PHP, nicht per DB-Query -
GenreRepository::findEffectiveTracksForGenre() bleibt reine
Objektgraph-Traversierung (die "effektive" Zugehörigkeit inkl. Album-/
Künstler-Vererbung steht erst danach fest, kein einfaches SQL-LIMIT/
OFFSET möglich) - für eine private Musikbibliothek unproblematisch.
"Alle abspielen" nur in der flachen Ansicht - bei der Gruppierung
gibt es mehrere [data-track-list]-Container (einer pro Album),
player.tss play-container-btn nimmt aber immer nur den ERSTEN auf
der Seite - hätte dort nur die erste Gruppe abgespielt, nicht das
gesamte Genre. Lieber ausgeblendet als ein verwirrend halbes Verhalten
anbieten.
Genre-Vererbung bis zum Künstler + Bild-Konflikt-Auflösung
Zwei Nachträge, nachdem die MusicBrainz-Genre-Vorschläge in der Praxis getestet wurden.
Genre-Vererbung endete vorher beim Album
Node::getEffectiveGenres() kannte bisher nur Track→Album -
ein Genre AUF DEM KÜNSTLER selbst (z.B. gerade über die neuen
MusicBrainz-Vorschläge gesetzt) hatte dadurch praktisch KEINE Wirkung:
kein Track/Album darunter zeigte es an, die Genre-Übersicht zählte 0
effektive Tracks - "leer", bis man es manuell auf jeden Track/jedes
Album einzeln wiederholt hätte. Genau das widersprach dem Sinn der neu
vorgeschlagenen Künstler-Genres.
Jetzt die vollständige Kette: Track → eigenes Genre, sonst Album →
eigenes Genre, sonst Künstler. Zusätzlich: Tracks OHNE Album-
Zwischenebene (lose Singles direkt unter einem Künstler) erben jetzt
auch direkt vom Künstler. Rekursiv gelöst (Album::getEffectiveGenres()
climbt bei Bedarf selbst zum Künstler, ein Track darüber übernimmt
einfach das Ergebnis) statt die "zum Künstler erben"-Logik doppelt zu
schreiben.
GenreRepository::findEffectiveTracksForGenre() (für Genre-Übersicht/
-Detailseite, arbeitet unabhängig auf der Genre→Node-Zuordnung, nicht
über getEffectiveGenres()) bekam denselben Nachtrag: ein Genre direkt
auf einem Künstler-Node zählt jetzt dessen Alben ohne eigenes Genre
(deren Tracks ohne eigenes Genre erben mit) und Tracks direkt unter dem
Künstler ohne eigenes Genre.
Gefundenes Bild konnte ein bestehendes Cover nicht ersetzen
Vorher: hatte der Künstler bereits ein Cover, wurde ein bei "Metadaten abrufen" gefundenes Bild einfach stillschweigend verworfen (die "nur wenn leer"-Regel) - der Nutzer bekam nie die Wahl, es trotzdem zu übernehmen.
Jetzt eine echte Entscheidung: Hat der Künstler noch KEIN Cover,
wird das gefundene Bild weiterhin sofort übernommen (nichts zu
verlieren, keine Rückfrage nötig). Hat er bereits eins, liefert die
Antwort nur die externe Bild-URL als Vorschau (suggestedImageUrl) -
der Browser zeigt sie direkt vom Wikipedia-Server, kein eigener Download
für die reine Vorschau. Zwei neue Buttons ("Übernehmen"/"Verwerfen"):
"Verwerfen" blendet die Vorschau einfach aus, kein Server-Aufruf nötig.
"Übernehmen" ruft den neuen Endpunkt POST /kuenstler/{id}/apply-image
auf, der das Bild jetzt TATSÄCHLICH herunterlädt und das bestehende
Cover ERSETZT - der Nutzer hat das durch den expliziten Klick bereits
bestätigt.
Domain-Whitelist beim Übernehmen - der neue Endpunkt nimmt die
Bild-URL als Parameter entgegen (kein erneuter MusicBrainz/Wikipedia-
Roundtrip nötig, die URL wurde schon beim ursprünglichen Abruf
ermittelt), akzeptiert aber nur wikipedia.org/wikimedia.org-Domains
- die URL kommt zwar vom eigenen Frontend, aber ein manipulierter Request könnte theoretisch eine beliebige URL einschleusen; der Server soll nicht als offener Bild-Downloader für irgendeine URL missbraucht werden können.
Externe Metadaten: MusicBrainz + Wikipedia
Neuer "Metadaten abrufen"-Button auf der Künstlerseite - reichert einen Künstler mit Bio-Text, Genre-Vorschlägen und optional einem Bild an. Bewusst NUR für Künstler gebaut (nicht Alben) - Bio/Bild/Tags machen auf Künstler-Ebene am meisten Sinn, album-spezifische Metadaten (Release- Datum etc.) wären ein separates, größeres Thema für sich.
Zwei kostenlose Dienste ohne API-Key - MusicBrainz (strukturierte
Daten: eindeutige MBID, Tags/Genres) und Wikipedia (Prosa-Bio-Text,
Vorschaubild). Bewusst NICHT Last.fm, obwohl ursprünglich mit
vorgeschlagen - würde einen persönlichen API-Key verlangen, den wir
hier nicht fest hinterlegen könnten, und Last.fm hat seine eigene
Bildunterstützung ohnehin weitgehend abgebaut. MusicBrainz selbst hostet
weder Prosa-Biografien noch Bilder - ArtistMetadataFetcher folgt dafür
EINER direkten Wikipedia-Relation aus den MusicBrainz-Daten, sofern
vorhanden (bewusst kein Umweg über Wikidata, falls nur eine Wikidata-
statt einer direkten Wikipedia-Relation existiert - zusätzlicher,
fehleranfälligerer Schritt für einen bei bekannten Künstlern seltenen
Fall).
MusicBrainz-Rate-Limit beachtet (1 Anfrage/Sekunde pro IP, sonst temporäre Sperre) - der Service macht pro Aufruf zwei MusicBrainz- Anfragen (Suche, dann Lookup mit Tags+URL-Relationen), dazwischen liegt eine kurze Pause. Für einen manuellen, vom Nutzer ausgelösten Klick (kein Bulk-Job über die ganze Bibliothek) ist die daraus resultierende Wartezeit von 1-2 Sekunden akzeptabel.
Genres werden NIE automatisch übernommen - nur als Vorschläge
gespeichert (metadata.suggested_genres), jeder Chip fügt sein Genre
erst bei Klick hinzu (bestehender node_genre_add-Endpunkt).
Automatisches Anwenden ungeprüfter externer Tags wäre riskant (falsche
Zuordnungen bei Namensgleichheit verschiedener Künstler, unpassende
MusicBrainz-Tags). Ein erneuter Klick auf einen bereits hinzugefügten
Vorschlag ist harmlos/idempotent - der Chip bleibt deshalb bewusst
einfach stehen, statt sich selbst zu entfernen.
Bild nur als Cover gesetzt, wenn der Künstler noch KEIN eigenes hat
- dieselbe "nur wenn leer"-Regel wie bei der automatischen Track→Album→
Künstler-Vererbung (
TrackCoverEmbedder::propagateToAlbumAndArtist()), ein extern gefundenes Bild soll ein bereits gesetztes/hochgeladenes Cover nie stillschweigend überschreiben. Download läuft über den bestehendenHttpClientInterfacein eine Temp-Datei, dann dieselbe Skalierungs-/Speicherlogik wie beim manuellen Upload (NodeCoverUploader) - ein fehlgeschlagener Bild-Download bringt die restlichen, bereits erfolgreich geholten Metadaten (Bio, Genre- Vorschläge) nicht mit zu Fall.
XHR statt HTMX (assets/ts/artist-metadata.ts) - ein einzelnes
HTML-Fragment hätte für drei unabhängig ein-/auszublendende Bereiche
(Bio, Genre-Vorschläge, Cover) nicht gereicht. Für den Künstler-Cover-
Fall (Collage-Rendering, kein einfaches <img id="nodeCoverImg">)
dieselbe Update-Logik wie initCoverUploadOverlay()
(assets/ts/cover-upload.ts) - der Inhalt des äußeren .cover/
.artist-photo-Divs wird durch ein frisches <img> ersetzt. Neu
eingefügte Genre-Vorschlags-Chips brauchen ein explizites
window.htmx.process() - anders als bei <a>-Tags (die HTMX per
MutationObserver automatisch erkennt) gilt das nicht automatisch für
neu eingefügte Elemente mit hx-post u.ä.
Warteschlange per Drag&Drop umsortierbar
Dieselbe Sortable.js-Technik wie bei Playlists (assets/ts/playlist-reorder.ts),
aber deutlich einfacher: kein Server-Sync, kein Debounce, keine Offline-
Queue nötig - die Warteschlange ist rein lokaler localStorage-Zustand
dieses Browsers (persist()), kein Server-Roundtrip erforderlich.
Drag-Handle (.drag-handle, fa-grip-vertical) - dasselbe inline
gestylte Markup wie bei Playlists, kein neues CSS für den Handle selbst
nötig. Sortable.create() mit handle: '.drag-handle' sorgt dafür, dass
nur ein Ziehen AM Handle einen Drag startet, nicht die ganze Zeile - ein
Klick zum Springen zu einem Track bleibt dadurch unverändert möglich.
data-queue-index bleibt fest am jeweiligen DOM-Element (Index zum
Zeitpunkt des Renderns) - Sortable.js ändert beim Ziehen NUR die DOM-
Reihenfolge, nie die Attribute selbst. Nach einem Drop
(handleQueueReorder()) lässt sich daraus die neue Anordnung als
Permutation der ursprünglichen Indizes auslesen - robuster als über
Track-IDs, die bei doppelt vorkommenden Tracks in der Warteschlange
nicht eindeutig wären. this.currentIndex wird passend mitgeführt (der
zuvor aktuelle Track landet dort, wo die Permutation seinen alten Index
enthält) - die tatsächliche Audio-Wiedergabe bleibt dabei komplett
unberührt (kein loadTrack()-Aufruf), nur seine Position in der
Warteschlangen-Datenstruktur ändert sich.
.queue-item brauchte eine vierte Grid-Spalte (theme.css hatte nur
drei: 42px 1fr auto für Cover/Text/Zeit) - der neue Drag-Handle als
zusätzliches erstes Kind-Element hätte das bestehende Grid sonst
durcheinandergebracht.
Einmalig im Konstruktor gebunden, nicht bei jedem renderQueue()-
Aufruf - queueList wird nie durch einen HTMX-Swap ersetzt, nur sein
innerHTML per renderQueue(). Sortable.js überwacht den Container
selbst, nicht die ursprünglichen Kind-Elemente, bleibt also auch nach
wiederholten renderQueue()-Aufrufen gültig.
PWA: "Als App installieren"
MusicDock lässt sich jetzt als eigenständige App installieren (Chrome/ Edge/Safari - bekannt von Netflix, Amazon Music). Drei Bestandteile:
public/manifest.json - App-Metadaten (Name, Icons, Farben,
display: standalone für ein fensterloses App-Erscheinungsbild ohne
Browser-Chrome). theme_color/background_color (#0b0c10) passen
zum Standard-Dunkelmodus - bleiben statisch, auch wenn der Nutzer
später zu Hell wechselt (ein Meta-Tag/Manifest kann nicht dynamisch
je nach gewähltem Theme reagieren, ohne deutlich mehr Aufwand für einen
kleinen kosmetischen Vorteil).
public/sw.js (Service Worker) - eine der beiden technischen
Voraussetzungen für den "Installieren"-Button (Chrome verlangt
Manifest + einen Service Worker mit Fetch-Handler). Bewusst KEIN
vollständiges Offline-Erlebnis: gecacht wird ausschließlich die
statische App-Shell (/build/app.js, /build/app.css, die beiden
großen Icons) - Audio-Streams, /stream/*, JSON-Endpunkte, HTMX-
Navigationen etc. gehen immer direkt ans Netzwerk, nie in den Cache
(würde sonst schnell riesige/veraltete Daten anhäufen).
Stale-while-revalidate statt reinem Cache-First - esbuild erzeugt
keinen Content-Hash im Dateinamen (app.js/app.css bleiben nach
einem Rebuild unter demselben Pfad), eine reine Cache-First-Strategie
hätte einen neuen Build sonst dauerhaft verdeckt. Stattdessen: die
gecachte Version sofort liefern (falls vorhanden), gleichzeitig im
Hintergrund neu laden und den Cache für den nächsten Aufruf
aktualisieren.
assets/ts/pwa.ts - registriert den Service Worker (initPwa(),
einmalig in app.ts, wie initEqualizer()/initFx()). Reiner
Feature-Check ('serviceWorker' in navigator) statt blind
draufzuschreiben - ältere/exotische Browser bleiben dadurch
funktionsfähig, nur ohne Installierbarkeit.
Eigenes Icon generiert (public/img/icons/, Quelle
icon.svg) - eine selbst gezeichnete Doppel-Achtelnote auf dem
bestehenden Marken-Farbverlauf (--accent → --accent-2, dieselben
Farben wie .brand-mark in der Sidebar), bewusst kein kopiertes
Font-Awesome-Glyph (Lizenzfrage). Drei Varianten: icon-192.png/
icon-512.png (normale App-Icons), icon-512-maskable.png (eigene
zweite SVG-Quelle mit ~20% Sicherheitsabstand zum Rand - Android
beschneidet maskable Icons kreisförmig/eckig je nach Launcher, ein
Icon ohne diese Reserve würde dabei Teile verlieren), sowie
apple-touch-icon.png (180×180, undurchsichtiger Hintergrund - iOS
kommt mit transparenten Home-Screen-Icons nicht gut zurecht).
Track-Play-Button und Startseiten-Genre-Kacheln nachgezogen
Track-Detailseite hatte noch den alten, rechteckigen Play-Button -
beim Vereinheitlichen von Album/Genre auf runde Icon-Buttons
(.hero-play-btn) übersehen. Jetzt gleich, funktionale
.track-play-btn-Klasse (für player.tss Klick-Handler) bleibt
unverändert erhalten, nur die visuellen btn btn-primary-Klassen
weichen hero-play-btn.
Genre-Kacheln auf der Startseite ignorierten genre.coverUrl
komplett - zeigten nur Farbe/Gradient, selbst wenn ein Cover
hochgeladen war (im Gegensatz zur Genre-Übersichts- und -Detailseite,
die die Priorität Cover > Farbe > Gradient bereits korrekt hatten - die
Startseite hat ein eigenes, einfacheres Markup statt
components/_genre_card.html.twig wiederzuverwenden, wurde beim
ursprünglichen Bauen des Cover-Features übersehen). Jetzt dieselbe
Priorität wie überall sonst - .genre-card span hatte bereits
position: relative; z-index: 1 (theme.css), das neue <img> braucht
kein eigenes z-index, es steht im DOM vor dem ::after-Overlay-
Gradient und dem <span>, landet dadurch automatisch dahinter.
ZIP-Download-Button transparent bei Hover + Hero-Buttons vereinheitlicht
.btn-soft (theme.css) hatte keine eigene :hover-Regel - Bootstrap
(im Projekt eingebunden) definiert für die generische .btn-
Basisklasse ohne eigene Farbvariante einen transparenten Hover-Fallback
über CSS-Variablen, der ohne Override durchschlug. Betraf ALLE
.btn-soft-Stellen im Projekt (Playlist, Startseite, Track-Seiten-
Pagination), nicht nur den ZIP-Download-Button, der den Bug ursprünglich
auffällig machte - global mit einer expliziten .btn-soft:hover-Regel
behoben.
Album-/Genre-Hero-Buttons vereinheitlicht: "Album abspielen"/"Alle
abspielen" sind jetzt runde, reine Icon-Buttons (.hero-play-btn,
52px) statt rechteckiger Text-Buttons - dieselbe Optik wie der Play-
Button unten im Player (.play-button, 40px), nur etwas größer für die
Hero-Prominenz. ZIP-Download (nur Album) ist jetzt ein kleinerer,
weniger prominenter Icon-Button (.hero-download-btn, 38px, fa-file-zipper
statt fa-download) daneben, per margin-left: auto an den rechten
Rand der Zeile geschoben - die Zeile selbst nimmt die volle Breite der
zweiten .detail-hero-Grid-Spalte ein (1fr), der Button landet dadurch
am rechten Rand des gesamten Hero-Bereichs, ohne dass position: absolute
nötig wäre. Beide Button-Farben nutzen halbtransparentes Weiß statt der
Theme-Variablen (var(--surface-3) etc.) - der Hero-Hintergrund ist
immer ein dunkler, farbiger Gradient, unabhängig vom Dark-/Light-Modus
der restlichen App, die Theme-Variablen wären dort nicht zuverlässig
sichtbar gewesen.
Große Cover-Bilder wirkten verzerrt statt beschnitten
.detail-cover (theme.css) hat feste width/height (260px bzw. 190px
im kleineren Breakpoint), aber KEIN object-fit gesetzt - Browser-
Standard dafür ist object-fit: fill, das Bild wird proportionslos
gestreckt statt beschnitten, sobald sein Seitenverhältnis nicht exakt
quadratisch ist (der Normalfall - kaum ein hochgeladenes Bild ist exakt
1:1). Betraf alle vier Upload-Detailseiten (Track/Album/Künstler/Genre),
die .detail-cover für ihr großes Hero-Cover nutzen. Andere Cover-
Stellen im Projekt (.cover, .track-cover, .genre-overview-cover)
hatten object-fit: cover bereits korrekt gesetzt - nur diese eine
Klasse nicht.
object-fit: cover ergänzt - füllt den Bereich vollständig aus und
beschneidet dafür bei Bedarf die längere Achse, statt zu verzerren,
konsistent mit den anderen Cover-Stellen im Projekt. Wirkungslos auf
<div>-Platzhalter (Gradient-Fall ohne echtes Bild) - object-fit
betrifft nur ersetzte Elemente wie <img>.
Künstlername erschien doppelt bei Tracks ohne Album
Tracks, die direkt unter einem Künstler-Ordner liegen (keine Album- Zwischenebene, z.B. lose Singles), zeigten den Künstlernamen zweimal - einmal korrekt verlinkt, ein zweites Mal unverlinkt genau dort, wo normalerweise das Album stünde.
Ursache im Import: MediaImporter::importTrack() setzte
album_name bisher mit $tags['album'][0] ?? $parent->getName() - ohne
echten Album-ID3-Tag fiel das auf $parent->getName() zurück. $parent
ist bei einem Track OHNE Album-Zwischenebene aber der KÜNSTLER-Ordner
selbst - album_name bekam dadurch fälschlich den Künstlernamen.
_track_row.html.twig/track/show.html.twig zeigten diesen (falschen)
Fallback-Wert dann als vermeintliches Album an, sobald keine strukturelle
Album-Beziehung vorlag.
Fix an zwei Stellen: Der Import setzt album_name jetzt nur noch,
wenn entweder ein echter Album-Tag vorhanden ist ODER $parent
TATSÄCHLICH ein Album-Ordner ist ($parent->getKind() === 'album') -
betrifft nur künftige Importe. Für bereits bestehende, VOR dem Fix
importierte Tracks (ohne Reimport) wurde zusätzlich der Template-
Fallback eingeschränkt: album_name wird nur noch angezeigt, wenn wir
NICHT bereits strukturell wissen, dass der Track direkt unter einem
Künstler liegt (artist gesetzt, album null) - in diesem Fall zeigt
die Zeile jetzt einfach nichts an der Album-Stelle, wie gewünscht.
Dieselbe Einschränkung auch für data-track-album (Player-Leiste/
Warteschlange, player.tss buildArtistAlbumHtml() überspringt einen
leeren Wert ohnehin automatisch, kein Fehlerzustand).
Künstler-Cover-Upload unterbrach die laufende Wiedergabe
Der Upload-Erfolgspfad für Künstler fiel bisher bewusst auf einen
vollständigen window.location.reload() zurück - die Collage-
Darstellung (Grid aus bis zu 4 Album-Covern) wurde als "zu komplex, um
sie 1:1 in JS nachzubilden" eingestuft. Ein voller Reload unterbricht
aber IMMER die laufende Wiedergabe (verzögertes Positions-Restore
verhindert zwar unnötige Requests, siehe oben, aber die Musik startet
nach einem Reload nie automatisch neu, das ist so gewollt) - bei Album/
Genre/Track fiel das nie auf, da dort das direkte DOM-Update ohne
Reload lief.
Reload durch echtes DOM-Update ersetzt, ohne die Collage-Logik
nachzubilden. Der Schlüssel: NodeCoverUploader::upload() leert bei
JEDEM manuellen Upload die Collage-Array (cover_urls, siehe der
Cache-Busting-Fix weiter unten in dieser Historie) - der Zielzustand
nach einem erfolgreichen Upload ist deshalb IMMER "Einzelbild", nie
Collage, unabhängig vom Ausgangszustand. components/_cover.html.twig
bekam dafür einen stabilen Wrapper id="{zoneId}Display" um alle drei
Fälle (Collage/Einzelbild/Gradient) - deren äußeres .cover/
.artist-photo-Div ist in allen drei Fällen strukturell identisch
(class="{{ shape_class }} {{ gradient_class }}"), nur der INHALT
unterscheidet sich. initCoverUploadOverlay() muss bei Erfolg deshalb
nur diesen Inhalt durch ein frisches <img> ersetzen (outerCoverDiv.innerHTML = ...)
shape_class/gradient_classbleiben unverändert auf dem bereits vorhandenen äußeren Div, kein erneutes Berechnen in JS nötig. Ein Reload bleibt nur noch als reines Sicherheitsnetz bestehen, falls{zoneId}Displayaus irgendeinem Grund fehlen sollte (sollte praktisch nie eintreten).
Album-Hero zeigte das Cover doppelt (gestreckt als Hintergrund + sauber als Thumbnail)
Album war die einzige der vier Detailseiten, die das Cover-Bild SELBST
gestreckt (background-size: cover) als Hero-Hintergrund zeigte -
Track, Genre und Künstler nutzen alle nur einen Gradient. Zwei Probleme
damit: der Hintergrund aktualisierte sich nach einem Cover-Upload nie
(initCoverUploadOverlay(), assets/ts/cover-upload.ts, kennt nur das
saubere Vordergrund-Thumbnail, nicht diesen separaten Hintergrund-Style
- blieb also auf dem alten Bild stehen, während das Thumbnail davor korrekt aktualisiert wurde), UND das gestreckte Cover direkt neben dem sauberen Cover-Thumbnail wirkte redundant und optisch unschön.
Jetzt immer nur der Gradient, unabhängig davon, ob ein Cover vorhanden ist - konsistent mit den anderen drei Detailseiten. Behebt beides gleichzeitig: kein Sync-Bug mehr möglich (der Gradient hängt nie vom Cover ab), und "nicht gestreckt" ist der Gradient von Natur aus, kein Bild, das verzerrt werden könnte.
Cover-Upload jetzt auch für Album, Künstler und Genre
Dasselbe Hover-Overlay-Muster wie beim Track-Cover-Upload, jetzt auch auf Album-, Künstler- und Genre-Detailseite - ohne MP3-Bezug (Album/ Künstler/Genre haben keine eigene Mediendatei, in die man ein Cover einbetten könnte), rein eine eigene Anzeige-Datei.
Bug gefunden und behoben, bevor er relevant werden konnte:
MediaImporter::addAlbumCoverToArtistCollage() prüfte bisher nur
count($collage) === 1 (das allererste Element in der Collage-Array),
um zu entscheiden, ob es das singuläre cover_url auf dem Künstler
setzen soll - ein MANUELL hochgeladenes Künstler-Cover rührt diese
Collage-Array aber gar nicht an. Ohne Fix hätte das nächste Album-Cover-
Ereignis (Import oder nachträglicher Track-/Album-Cover-Upload) ein
manuell gesetztes Künstler-Cover stillschweigend überschrieben. Prüft
jetzt zusätzlich !$artist->getMetadataValue('cover_url').
NodeCoverUploader (neuer Service, für Album UND Künstler - beide
Node-Entities) - skaliert auf maximal 512×512, dieselbe Speicher-
struktur wie beim automatischen Import (public/media/covers/{bucket}/{uuid}.*,
über die wiederverwendeten MediaImporter::finalizeCover()/bucketFor()).
Anders als die automatische "nur wenn leer"-Vererbung (Track → Album →
Künstler, siehe TrackCoverEmbedder::propagateToAlbumAndArtist())
ERSETZT ein manueller Upload ein bestehendes Cover bewusst - expliziter
Nutzerwunsch, kein automatisches Ableiten. POST /album/{id}/cover
zieht bei Erfolg trotzdem denselben Künstler-Collage-Mechanismus nach
(addAlbumCoverToArtistCollage(), "nur wenn der Künstler noch keins
hat") - konsistent mit dem Track-Pfad. POST /kuenstler/{id}/cover für
den Künstler direkt.
Genre nutzt einen ZWEITEN, separaten Endpunkt (POST /genre/{id}/cover-json,
GenreController::setCoverJson()) statt des bestehenden
genre_set_cover - der bestehende Endpunkt liefert ein HTML-Fragment
für das "..."-Popup der Genre-Übersichtsseite (hx-swap="outerHTML"),
der neue liefert JSON im selben Format wie Track/Album/Künstler. Ein
einheitlicher Endpunkt hätte eines der beiden Frontends gebrochen -
setCover() (HTML-Fragment) bleibt für die Übersichtsseite unverändert,
setCoverJson() ist neu für die Detailseite.
_cover.html.twig um optionalen upload_zone_id/upload_url-Wrapper
erweitert - NUR gesetzt auf der Künstler-Detailseite (Grid-Kacheln
bleiben unverändert), legt einen zusätzlichen Wrapper MIT Hover-Upload-
Overlay um das bestehende Collage-/Einzelbild-/Gradient-Markup, ohne
diese Fälle selbst zu verändern oder zu duplizieren. album/show.html.twig
bekam dasselbe Overlay-Markup direkt eingebaut (bindet _cover.html.twig
gar nicht ein, hatte schon vorher eigenes, einfacheres Markup).
Neues, gemeinsames JS-Modul assets/ts/cover-upload.ts - wieder-
verwendet für Album/Künstler/Genre. Track bleibt bewusst bei seiner
eigenen, bereits getesteten Logik in track-detail.ts (kein Umbau-
Risiko für den funktionierenden Track-Pfad). Die Ziel-URL kommt über ein
data-upload-url-Attribut am Zone-Element (Twig kennt die URL bereits
über path(), kein JS-seitiges URL-Bauen nötig).
Künstler fällt bei Erfolg auf einen vollständigen Reload zurück -
die Collage-Darstellung (Grid aus bis zu 4 Album-Covern) ist zu komplex,
um sie 1:1 in JS nachzubilden; ein einfaches <img id="nodeCoverImg">
existiert dort deshalb bewusst nicht. initCoverUploadOverlay() prüft
das automatisch (fehlt das Element, wird stattdessen neu geladen) - für
Album/Genre (genau ein Bild) läuft es weiterhin ohne Reload direkt im
DOM.
CSS konsolidiert statt dupliziert - .track-cover-upload-* (Track)
und die neuen .cover-upload-* (Album/Künstler/Genre) teilen sich
dieselben Regeln (.track-cover-upload-zone, .cover-upload-zone { ... }
usw.). Künstler-Cover ist rund (.artist-photo/.artist-large,
border-radius: 50%) - #artistCoverUploadZone bekommt dieselbe
Rundung + overflow: hidden, sonst hätte das eckige Overlay über die
runde Form hinausgeragt oder deren Ecken freigelassen.
Nachtrag: manuell hochgeladenes Künstler-Cover erschien in Tracks/
Alben ohne eigenes Cover, aber NICHT auf der Künstlerseite selbst - dort
blieb weiterhin die alte Collage sichtbar. components/_cover.html.twig
prüft cover_urls|length > 1 (die Collage-Array, bis zu 4 Album-Cover)
VOR dem einzelnen cover_url - NodeCoverUploader::upload() hatte diese
Collage-Array beim manuellen Upload nie angerührt, eine bestehende
Collage gewann deshalb weiterhin gegen das gerade hochgeladene, neue
Cover. Jetzt wird cover_urls bei jedem manuellen Upload explizit
geleert (bei Album ist die Property ohnehin immer leer, dort ein No-Op).
Tracks, die das Künstler-Cover erben, sind kein Bug - Tracks ohne
eigenes/Album-Cover erben vom Eltern-Ordner
(components/_track_row.html.twig, track.parent); bei Singles ohne
Album-Zwischenebene ist der Eltern-Ordner der Künstler selbst. Bereits
bestehendes Verhalten, nicht neu durch diese Funktion eingeführt.
Nachtrag 2: ein erneuter Cover-Upload für Album/Künstler zeigte
weiterhin das alte Bild. NodeCoverUploader hatte - anders als Track-
und Genre-Cover-Upload - schlicht KEIN Cache-Busting. Da der Dateiname
(Node-UUID) bei jedem Upload gleich bleibt, blieb auch die gespeicherte
URL bei einem erneuten Upload BUCHSTÄBLICH identisch - der Browser hatte
also gar keinen Grund, das Bild neu vom Server zu laden, und zeigte
weiterhin das alte aus dem eigenen Cache, obwohl die Datei auf der
Festplatte längst ersetzt war. Jetzt mit demselben ?v=timestamp-Suffix
wie bei Track/Genre.
Sound-Effekte: Reverb, Echo, Kompressor
Neues, eigenständiges FX-Panel (Trigger #fxButton in der Player-
Leiste, neben dem Equalizer-Button) - inspiriert von einem Community-
Skript, das der Nutzer gefunden hat. Nutzt denselben geteilten Audio-
Graphen wie der Equalizer (assets/ts/audio-graph.ts,
createMediaElementSource() darf pro <audio>-Element nur einmal im
Seitenleben aufgerufen werden - beide Module teilen sich deshalb das
bestehende Singleton-Muster, kein zweiter, konkurrierender Graph-Aufbau).
Erweiterte Signalkette:
audio -> [5 EQ-Filter] -> Compressor -> Delay(dry+wet) -> Reverb(dry+wet) -> Analyser -> destination
Der Analyser sitzt weiterhin ganz am Ende (nach EQ UND FX) - der Visualizer zeigt dadurch weiterhin das tatsächlich hörbare Signal, inklusive aller eigenen Effekte, nicht das unveränderte Original.
Delay/Echo und Reverb haben je einen eigenen Dry/Wet-Split - Signal
läuft parallel unverändert UND durch den Effekt, beide werden an einem
gemeinsamen Ausgangs-GainNode wieder gemischt (Web Audio summiert
mehrere eingehende connect()-Aufrufe an einem Node automatisch, kein
eigener "Merge"-Node-Typ nötig). Erlaubt sauberes, klickfreies Ein-/
Ausblenden über setTargetAtTime() (statt den Signalpfad komplett
umzuschalten) - RAMP_SECONDS = 0.05 glättet jede Wertänderung.
Reverb nutzt einen synthetischen Impulse-Response-Buffer (Zufalls-
rauschen mit exponentiell abklingender Lautstärke pro Kanal, 2 Sekunden
lang) statt eine echte Impulse-Response-Audiodatei laden zu müssen -
derselbe Trick wie im Ausgangsskript. Ein ConvolverNode faltet jedes
Eingangssample mit dieser "Antwort" - zufällige, abklingende Reflexionen
sind genau das, was einen plausiblen Raum-/Plattenhall ausmacht.
Delay/Echo mit Feedback-Loop (delayNode.connect(feedback); feedback.connect(delayNode);) für wiederholte, leiser werdende Echos -
klassisches Tape-Delay-Muster, drei Regler (Zeit, Wiederholungsstärke,
Mix).
Kompressor ohne eigenen Dry/Wet-Split - ein DynamicsCompressorNode
mit threshold: 0dB komprimiert praktisch nichts (kein Signal geht über
0dB), das entspricht funktional "aus"; der Threshold-Regler (-60 bis 0dB)
steuert direkt, ab wann komprimiert wird.
Zwei Effekte aus dem Ausgangsskript bewusst NICHT übernommen:
- Pitch-Shift über
ScriptProcessorNode- die Web-Audio-API stuftScriptProcessorNodeals deprecated ein (blockiert den Hauptthread, ersetzt durchAudioWorkletNode), UND der verwendete Algorithmus ist eigentlich nur ein Resampling-Trick, der Tonhöhe UND Wiedergabegeschwindigkeit gleichzeitig ändert (kein echter, geschwindigkeitsneutraler Pitch-Shift - der bräuchte einen Phase-Vocoder o.ä., deutlich aufwändiger). - "Phone"-Bandpassfilter - würde sich mit dem bestehenden 5-Band-EQ überschneiden (deckt denselben Frequenzbereich bereits ab).
Eigene Persistierung (localStorage, Key musicdock.fx, analog zu
musicdock.eq) - alle Regler-/Toggle-Zustände bleiben über einen Reload
hinweg erhalten, unabhängig vom Equalizer.
Genre-Cover-Upload vereinheitlicht (nur noch über die Detailseite)
Genre war die einzige Ausnahme - Cover-Upload lief dort über ein
sichtbares Datei-Feld im "..."-Popup der Übersichtsseite, nicht wie bei
Track/Album/Künstler über ein Hover-Overlay auf der großen Grafik.
Jetzt einheitlich: das Popup pflegt nur noch die Farbe, Cover-Upload
läuft ausschließlich über genre/show.html.twigs .cover-upload-zone
(genau wie bei den anderen drei).
GenreController::setCover() (der frühere HTML-Fragment-Endpunkt für
das Popup) entfernt - setCoverJson() (JSON-Antwort, ursprünglich nur
für die Detailseite gebaut) übernimmt jetzt allein diese Rolle und wurde
zurück auf den saubereren Namen setCover()/Route /genre/{id}/cover
umbenannt, da er jetzt der einzige Cover-Endpunkt ist. Tote CSS-Klassen
(.genre-cover-upload-label/-input) mit entfernt.
Übersichtskarten zeigen jetzt ein Cover-Thumbnail (.genre-overview-cover,
48×48px, border-radius: 10px) - dasselbe Muster wie eine Track-Zeile
(.track-cover), Priorität Cover > Farbe > Gradient, konsistent mit der
Detailseite. Der frühere Farbakzent-Rand (border-left auf
.genre-overview-link) ist entfallen - das Thumbnail übernimmt jetzt
die Farb-/Cover-Anzeige, ein zusätzlicher Rand wäre redundant gewesen.
Beim Umbau fiel eine versehentlich doppelte .genre-overview-link-Regel
auf (display: block vs. das neue display: flex) - zu einer
konsolidiert.
Player-Leiste zeigte nach Cover-Upload weiterhin das alte Bild
Der Cover-Upload auf der Track-Detailseite (initTrackCoverUpload())
aktualisierte bisher nur das große Bild AUF der Detailseite selbst -
nicht die Player-Leiste, falls genau dieser Track gerade lief, nicht das
Queue-Panel (falls der Track dort auftaucht), und nicht den in
localStorage persistierten Zustand (ein Reload hätte danach wieder das
alte Cover gezeigt, bis zum nächsten Trackwechsel). Kein Browser-Cache-
Problem (Cache-Busting per ?v=timestamp war bereits vorhanden) - die
betroffenen Anzeigen wurden schlicht nie angefasst.
Neue Methode MusicDockPlayer.updateTrackCover(trackId, coverUrl)
(public) - aktualisiert den passenden Queue-Eintrag, die Player-Leiste
(falls der Track aktuell läuft) und ruft persist() auf. Rein
clientseitiger State-Sync, kein eigener Request - der Upload selbst hat
den Server bereits aktualisiert.
player musste dafür in den Modul-Scope von app.ts gehoben werden
- war bisher nur lokal innerhalb der
DOMContentLoaded-Callback deklariert, derhtmx:afterSettle-Handler (derinitTrackCoverUpload()nach jeder Navigation erneut aufruft) hatte darauf keinen Zugriff. Die Instanz selbst wird weiterhin nur EINMAL erzeugt, nur der Verweis wird jetzt außerhalb beider Handler gehalten.
Album-/Künstler-Cover erbten ein nachträgliches Track-Cover nicht
Album- und Künstler-Cover werden ausschließlich beim Import gesetzt -
NUR wenn zu diesem Zeitpunkt noch keins vorhanden war (MediaImporter::importAll(),
if (!$album->getMetadataValue('cover_url'))). Hatte ein Album beim
Import keinen einzigen Track/Ordner-Cover (z.B. ein Album mit nur einem,
damals coverlosen Track), blieb es dauerhaft beim deterministischen
Gradient - auch wenn dieser eine Track später per TrackCoverEmbedder
doch ein Cover bekam. Es gab schlicht keinen Code-Pfad, der "Track hat
jetzt ein Cover" mit "Album/Künstler nachziehen" verband.
Neue Methode TrackCoverEmbedder::propagateToAlbumAndArtist() -
läuft nach jedem erfolgreichen Embed, prüft dieselbe "nur wenn leer"-
Regel wie beim Import fürs Album, und ruft danach
MediaImporter::addAlbumCoverToArtistCollage() auf (dafür von private
auf public umgestellt - kein duplizierter Code, dieselbe Dedupe-/
Collage-Logik wie beim Import). Wird IMMER aufgerufen, nicht nur bei
gerade neu gesetztem Album-Cover - addAlbumCoverToArtistCollage()
prüft selbst, ob die URL schon in der Künstler-Collage steckt, ein
wiederholter Aufruf mit demselben Cover ist harmlos.
Progress ging nach zwei Reloads ohne Play verloren
Ein weiterer Nachtrag zum verzögerten Positions-Restore oben:
loadTrack() ruft an seinem Ende immer persist() auf - persist()
speicherte bisher unbedingt audio.currentTime, das beim Restore aber
BEWUSST noch 0 ist (Seek verzögert bis zum tatsächlichen Play-Klick,
siehe oben). Das überschrieb die echte gespeicherte Position in
localStorage schon beim ERSTEN Reload mit 0 - sichtbar wurde das
erst beim ZWEITEN Reload, weil pendingResumeTime als reine
JS-Variable für die erste Seitenansicht noch den korrekten Wert im
Speicher hielt, aber der zweite restoreFromStorage()-Aufruf bereits
die überschriebene 0 aus localStorage las.
persist() bevorzugt jetzt pendingResumeTime, falls eine
Restore-Position noch aussteht, statt bedingungslos audio.currentTime
zu speichern - alle anderen Persistierungen (Queue, Index, Shuffle/
Repeat) waren nie betroffen, nur die Position.
Wellenform zeigte nach dem Reload-Fix keinen Fortschritt mehr
Nebenwirkung des preload="none"-Fixes (oben): audio.currentTime
bleibt jetzt bis zum ersten Play-Klick bei 0, aber
loadWaveform()s initialer drawWaveform()-Aufruf (ohne Argument)
berechnete den Fortschritt weiterhin aus genau diesem Wert - die
Wellenform selbst war sichtbar, zeigte aber immer "0% gehört", egal wie
weit man vorher tatsächlich war. Dieselbe Ursache betraf auch die
einfache Balken-Progressbar (Fallback ohne Wellenform, hängt über
timeupdate ebenfalls an audio.currentTime).
Neue Methode previewPendingResumePosition() - zeichnet den
visuellen Fortschritt direkt aus pendingResumeTime (der gemerkten,
noch nicht angewendeten Restore-Position), OHNE audio.currentTime
anzufassen - kein Request, rein zeichnerisch. Aufgerufen an ZWEI
Stellen (deckt beide Reihenfolgen ab): einmal direkt in loadTrack()
(Fall ohne Wellenform, reagiert sofort), einmal in loadWaveform()s
Promise-Callback (Fall MIT Wellenform, die erst asynchron nachgeladen
wird - trifft später ein als loadTrack()s eigener Aufruf, der zu dem
Zeitpunkt noch keine Wellenform hatte).
Progressbar-Drag löste kontinuierlich Requests aus
Ein zweiter, unabhängiger Fund: Ziehen an der Progressbar (nicht nur ein
einzelner Klick) feuerte WÄHREND des gesamten Drags laufend Requests ab,
statt erst beim Loslassen - wireSeekScrubbing()s document.mousemove-
Handler setzte bei JEDER Mausbewegung audio.currentTime direkt, und
jede solche Zuweisung löst beim <audio>-Element einen eigenen Seek/
Range-Request aus.
Getrennt in "visuelle Vorschau während des Drags" und "tatsächlicher
Seek bei Drop" - previewPosition(ratio) aktualisiert während des
Ziehens NUR das Sichtbare (Tooltip, Wellenform-Vorschau über einen
neuen optionalen previewRatio-Parameter auf drawWaveform(), oder die
einfache Balken-Progressbar als Fallback), OHNE audio.currentTime
anzufassen - kein Request. Der eigentliche Seek (seekTo(), setzt
audio.currentTime) passiert nur noch: einmalig bei einem reinen Klick
(sofort, wie gewünscht), und einmalig bei mouseup/touchend (dem
Drop) nach einem Drag - nicht mehr bei jedem Zwischenschritt. Gilt
gleichermaßen für Maus- und Touch-Bedienung.
Nachtrag 1: Wellenform flackerte während des Drags zwischen echter und
Drag-Position. Der timeupdate-Handler (läuft weiter, solange die
Wiedergabe im Hintergrund tatsächlich läuft) zeichnete unabhängig davon
weiterhin basierend auf audio.currentTime - konkurrierte damit direkt
mit previewPosition()s Zeichnen der Drag-Position ums selbe Canvas.
Neue Klassen-Eigenschaft isScrubbing (der timeupdate-Handler sitzt
in einer anderen Methode als wireSeekScrubbing(), eine lokale Variable
hätte dort nicht gereicht) - der visuelle Teil des timeupdate-Handlers
(Wellenform/Progressbar, Zeit-Anzeige, aktive Lyrics-Zeile) wird
komplett unterdrückt, solange isScrubbing true ist. Das periodische
persist() läuft unabhängig davon weiter, unbetroffen.
Nachtrag 2: neues Debounce-Verhalten beim Drag - "Verharren ohne
Drop" löst nach STOP_WITHOUT_DROP_MS (600ms) trotzdem den echten Seek
aus. Bleibt die Maus/der Finger beim Ziehen für diese Zeitspanne an
derselben Stelle stehen, ohne dass losgelassen wird, fühlt es sich für
den Nutzer an wie ein kurzes Drop - das Dragging selbst endet dabei
NICHT (Weiterziehen bleibt jederzeit möglich, klassisches Debounce: der
Timer wird bei jeder Mausbewegung neu gestartet und läuft nur ab, wenn
wirklich nichts mehr passiert). Nach einem ausgelösten Stop-Seek wird
KEIN neuer Timer gestartet, bis sich wieder etwas bewegt - kein
Wiederholungs-Risiko bei stillstehender Maus.
Shuffle/Repeat-Persistenz
persist()/restoreFromStorage() speicherten bisher NUR Warteschlange,
Index und Wiedergabeposition - Shuffle/Repeat wurden nie mitgesichert,
standen nach jedem Reload also immer wieder aus, unabhängig vom Zustand
davor. Jetzt zwei zusätzliche localStorage-Keys
(musicdock.shuffle/musicdock.repeat), gesetzt bei jedem Klick auf den
jeweiligen Button (nicht erst beim nächsten Trackwechsel) und beim Start
VOR dem eigentlichen Queue-Restore gelesen - wichtig in dieser
Reihenfolge, da playPrev()/playNext() (z.B. per Tastenkürzel direkt
nach dem Laden) this.shuffle/this.repeat sofort auswerten, nicht erst
nach einem Klick.
Lautstärke-Regler
MusicDockPlayer::wireVolumeScrubbing() - Klick UND Ziehen, dasselbe
Grundmuster wie wireSeekScrubbing() bei der Progress-/Wellenform-Leiste
(mousedown startet, mousemove/mouseup laufen auf document, nicht
nur auf der oft nur wenige Pixel hohen Leiste selbst - sonst reißt das
Ziehen ab, sobald die Maus die Leiste verlässt). Vorher war die Leiste
nur klickbar, kein Ziehen möglich.
Lautstärke-Icon schaltet stumm/laut um (volumeIconBtn) - vorher
hatte der Button gar keine id, ein Klick tat also buchstäblich nichts.
lastNonZeroVolume merkt sich den Pegel VOR dem Stummschalten, ein
erneuter Klick springt dorthin zurück statt immer auf einen festen
Standardwert. Das Icon selbst wechselt je nach Pegel
(fa-volume-xmark/fa-volume-low/fa-volume-high).
Wellenform-Progressbar
Vorab berechnete Peak-Daten statt Audio-Decoding im Browser - genau der Grund, warum das nicht anders sinnvoll geht: würde der Browser die Wellenform selbst berechnen, müsste er dafür jede MP3 komplett laden und per Web Audio API decodieren, nur um eine Kurve zu zeichnen. Für eine reine Progressbar-Optik unnötig teuer, gerade bei vielen Tracks in einer Liste.
Backend, läuft nur beim Import: src/Service/WaveformGenerator.php
dekodiert die Audiodatei per ffmpeg zu rohen PCM-Samples (mono, 4 kHz -
für eine reine Amplitudenkurve über 300 Balken reicht das, spart
ffmpeg-Rechenzeit ggü. der Originalqualität), fasst sie zu 300 Balken
zusammen (lautester Sample-Wert pro Zeitabschnitt, normiert auf 0..1) und
speichert das Ergebnis in metadata.waveform_peaks auf dem Track-Node -
passt ohne Schema-Änderung ins bestehende JSON-Metadata-Feld.
MediaImporter::importTrack() ruft das nach dem Speichern der Audiodatei
auf; app:import-media --no-waveform überspringt es (schneller Import,
z.B. bei sehr großen Bibliotheken oder wenn ffmpeg fehlt).
Performance-Falle, auf die man leicht reinfällt: die Samples dürfen
NIE komplett in ein PHP-Array entpackt werden. Ein 4-Minuten-Track hat bei
4 kHz ~960.000 Samples - PHP-Arrays haben pro Element erheblichen Overhead,
das Entpacken aller Samples auf einmal (frühe Version dieser Klasse) hat
den Import massiv ausgebremst, spürbar bei jedem einzelnen Track.
WaveformGenerator arbeitet deshalb direkt auf dem rohen Byte-String:
pro Balken wird nur der jeweils betroffene Ausschnitt per substr()
entpackt (ein paar Tausend statt hunderttausende Samples auf einmal). Der
ffmpeg-Aufruf läuft außerdem unter timeout 30 (GNU coreutils, in DDEVs
Debian-basiertem Web-Image standardmäßig vorhanden) - falls ffmpeg an
einer kaputten/exotischen Datei hängen bleibt, bricht das nach 30s ab
statt den ganzen Import zu blockieren.
-nostdin ist Pflicht, kein optionaler Zusatz. Ohne dieses Flag
erwartet ffmpeg in bestimmten Situationen Eingaben von stdin und
blockiert dann komplett, wenn keine kommen - ein bekannter
Stolperstein beim Aufruf aus PHP/Skripten/Cron heraus (nicht-interaktiver
Kontext), sichtbar als kompletter Stillstand ohne jede Fehlermeldung.
Zusätzlich < /dev/null als zweite Absicherung, damit ffmpeg stdin gar
nicht erst offen erreichen kann.
-vn (no video) spart unnötige Arbeit bei Cover-tragenden Dateien.
Ein eingebettetes Cover in einer MP3 (siehe
MediaImporter::extractEmbeddedCover()) wird von ffmpeg intern als
eigener Video-Stream behandelt (ein einzelnes MJPEG-Bild) - ohne -vn
verarbeitet ffmpeg diesen ggf. mit, obwohl nur der Audio-Teil gebraucht
wird. Spürbar bei einer Bibliothek, in der die meisten Dateien ein
eingebettetes Cover haben.
Neue Systemvoraussetzung: ffmpeg muss im DDEV-Webcontainer verfügbar
sein - .ddev/web-build/Dockerfile installiert es automatisch als
zusätzlicher Layer auf dem Standard-Web-Image. Nach dem Auspacken/Ändern
einmal ddev restart, damit das Image neu gebaut wird. Ohne ffmpeg
liefert WaveformGenerator::generate() einfach null (kein Hard-Fail des
Imports) - betroffene Tracks fallen dann auf die schlichte
Balken-Progressbar zurück, genau wie Tracks, die vor Einführung dieses
Features importiert wurden.
Frontend: StreamController::waveform() liefert die Peak-Werte als
winzige JSON-Antwort (GET /stream/{id}/waveform) - der Player lädt sie
beim Trackwechsel, nie die Audiodatei selbst zu diesem Zweck.
assets/ts/player.ts zeichnet sie per <canvas> (#waveformCanvas,
liegt über der bestehenden #progressValue-Füllung): gespielter Teil in
Akzentfarbe, Rest in gedämpfter Oberflächenfarbe, bei jedem timeupdate
und Klick-Seek neu gezeichnet. Ein Zähler (waveformRequestId) verwirft
verspätet eintreffende Antworten, falls zwischenzeitlich schon zum
nächsten Track gesprungen wurde - sonst könnte kurzzeitig die Wellenform
des falschen Tracks aufblitzen. Tracks ohne Peak-Daten zeigen unverändert
die schlichte Balken-Progressbar von vorher (#progressValue) - reiner
Fallback, kein Fehlerzustand.
Wellenformen asynchron nachholen (Cronjob)
Für große Bibliotheken (viele Tracks, oder lange Dateien wie 1-Stunden-
Podcasts) läuft app:generate-pending-waveforms als eigener,
Cron-tauglicher Befehl - holt die ffmpeg-Arbeit nach, statt den
eigentlichen app:import-media zu blockieren.
Kein eigenes "pending"-Tag nötig: NodeRepository::findTracksWithoutWaveform()
nutzt einfach die Abwesenheit von metadata.waveform_peaks als Kriterium -
deckt automatisch alle drei Fälle ab (--no-waveform beim Import,
ffmpeg-Fehler, oder ein Track von vor Einführung des Features), ganz ohne
dass beim Import zusätzlich etwas markiert werden müsste.
ddev exec bin/console app:generate-pending-waveforms
Zwei Sicherungen für den Cron-Betrieb:
- Lock (Symfony Lock,
FlockStore- reine Dateisystem-Sperre, braucht keine zusätzliche Konfiguration): läuft ein Aufruf länger als der Cron-Takt, überspringt sich der nächste Tick selbst statt parallel zum noch laufenden Vorgänger zu arbeiten. - Zeitbudget (
--time-budget, Default 240s): bricht NACH dem gerade laufenden Track sauber ab, sobald das Budget aufgebraucht ist, statt über den nächsten Cron-Tick hinweg zu laufen. Ein einzelner sehr langer Track kann das Budget überziehen, aber nie mehr als um die Dauer dieses einen Tracks.
Beispiel-Crontab (auf dem Host, nicht im Container - ddev exec startet
den Container bei Bedarf mit):
# Alle 5 Minuten während der 4-Uhr-Stunde - eine ruhige Nachtstunde mit
# wenig Konkurrenz um CPU-Zeit. Zeitfenster/Takt frei anpassbar.
*/5 4 * * * cd /pfad/zu/musicdock && ddev exec bin/console app:generate-pending-waveforms >> var/log/waveform-cron.log 2>&1
Bis ein Track dran war, zeigt der Player einfach weiter die schlichte Balken-Progressbar - kein Fehlerzustand.
Lyrics
Song-Lyrics von lrclib.net, einer kostenlosen, öffentlichen API ohne Auth-Zwang. Zwei Wege, wie sie ins System kommen - bewusst beide, nicht nur einer:
- Automatisch im Hintergrund -
app:fetch-pending-lyrics(Cronjob, siehe "Referenz: Alle Shell-Befehle & Cronjobs" oben) fragt für Tracks, bei denen das noch nie versucht wurde, bei lrclib.net an. - Manuell auf Zuruf - "Lyrics suchen"-Button im Lyrics-Panel, wenn für einen Track (noch) nichts gefunden wurde. Anders als der automatische Cron-Nachlauf fragt der Button IMMER neu an, auch wenn schon einmal (erfolglos) versucht wurde - ein gezielter Klick ist ein bewusster Neuversuch, keine automatische Wiederholung.
Lyrics-Text steht NICHT im initialen HTML jeder Track-Zeile - nur ein
schlankes data-track-has-lyrics="1" (Boolean, für das Mikrofon-Icon in
der Zeile + die sofortige Hervorhebung des Lyrics-Buttons in der
Player-Toolleiste, ganz ohne den eigentlichen Text zu kennen). Bei z.B.
100 Tracks in einer Liste würden sonst 100 vollständige Songtexte im
initialen HTML landen, obwohl zu jedem Zeitpunkt nur der gerade laufende
Track überhaupt gebraucht wird - unnötig aufgeblähte Seitengröße für
praktisch nie genutzte Daten. Stattdessen holt player.ts (loadLyrics()
in lyrics.ts) den Text erst bei tatsächlichem Bedarf nach: bei jedem
Trackwechsel GET /track/{id}/lyrics (TrackController::getLyrics(),
reiner DB-Read, KEIN neuer lrclib.net-Aufruf - dafür ist der separate
POST .../lyrics/fetch-Endpunkt da, siehe unten). Kein Fetch, wenn
track.hasLyrics bereits false ist (aus dem schlanken Attribut
bekannt) oder der Text für diesen Track in der laufenden Session schon
einmal geladen wurde (im Queue-Eintrag gemerkt, onLyricsUpdated()
zieht localStorage nach). Panel und Karaoke-Overlay teilen sich EINEN
gemeinsamen Ladevorgang statt je einem eigenen - sonst würden zwei
unabhängige Aufrufe denselben Fetch unnötig doppelt auslösen. Ein kurzer
"Lyrics werden geladen…"-Hinweis überbrückt die (in der Praxis sehr
kurze) Wartezeit.
Warum LRC (zeitsynchronisiert) statt reinem Klartext als bevorzugtes
Format: MusicDock hat mit der Wellenform-Progressbar bereits exakte
audio.currentTime-Verfolgung - synchronisierte Lyrics (mitlaufend
hervorgehoben, Klick auf eine Zeile springt zu deren Zeitstempel, wie bei
Spotify/Apple Music) sind damit fast geschenkt. lrclib.net liefert
syncedLyrics im LRC-Format ([mm:ss.xx]Zeile...) UND plainLyrics als
Klartext-Fallback zurück - MusicDock speichert beides
(metadata.lyrics_synced/metadata.lyrics_plain) und zeigt bevorzugt die
synchronisierte Variante, fällt auf Klartext zurück, falls für einen Track
nur der vorliegt.
Architektur, bewusst analog zu Wellenformen: LyricsFetcher
(src/Service/LyricsFetcher.php) macht den eigentlichen HTTP-Request an
GET https://lrclib.net/api/search?track_name=...&artist_name=...&album_name=...
/api/search, nicht/api/get. Beobachtung beim Testen:/api/getmatcht offenbar recht strikt/nah am exakten Titel-String - "Miserere Mei, Deus" (mit Komma) lieferte über/getnur unsynced Lyrics, während/searchmit denselben Parametern mehrere Kandidaten fand, darunter einen mit vollständigen synced+plain Lyrics./searchist damit robuster gegenüber Schreibweise-Abweichungen (Kommas, Klammern, ID3-Tag-Eigenheiten) - dafür liefert es eine LISTE von Kandidaten statt eines einzelnen Treffers,LyricsFetcher::pickBestCandidate()wählt daraus: bevorzugt Kandidaten mit synced Lyrics, darunter (falls die Zieldauer bekannt ist) den mit der geringsten Abweichung davon (unterscheidet z.B. Album- von Live-Versionen desselben Songtitels), sonst dieselbe Logik für plain Lyrics, sonst einfach der erste Treffer./api/searchunterstütztdurationnicht als Filter-Parameter (anders als/api/get) - die Dauer-Abwägung passiert deshalb clientseitig in der Kandidatenauswahl statt als Suchparameter. Netzwerkfehler/Timeouts (8s) werfen NIE eine Exception nach außen - werden geloggt, liefernnullzurück, brechen nie einen Cron-Lauf oder Button-Klick ab.
BEWUSST NICHT synchron während app:import-media abgefragt - das
würde bei einer großen Bibliothek hunderte blockierende HTTP-Requests
bedeuten, den Import massiv verlangsamen und von einer stabilen
Internetverbindung abhängig machen. Stattdessen exakt dasselbe Muster wie
bei Wellenformen: NodeRepository::findTracksWithoutLyricsAttempt()
nutzt die Abwesenheit von metadata.lyrics_fetched_at als Kriterium -
wichtig, das ist "wurde versucht", nicht "hat Lyrics gefunden": ein
Track ohne Treffer bei lrclib.net wird trotzdem als "erledigt" markiert
(lyrics_fetched_at wird auch bei einem Fehltreffer gesetzt), sonst würde
jeder Cron-Lauf erneut erfolglos anfragen.
Frontend (assets/ts/lyrics.ts): parst LRC clientseitig (Regex pro
Zeile, [mm:ss.xx] extrahieren, Rest ist der Text), rendert bei
synchronisierten Lyrics eine Liste von <p data-time="...">-Zeilen -
updateActiveLine() läuft bei jedem audio-timeupdate (in player.ts
verdrahtet) und hebt die aktuell passende Zeile hervor (die im Theme
bereits vorbereitete .lyrics p.active-Klasse) sowie scrollt sie ins
Bild. Klick auf eine Zeile springt zu deren Zeitstempel im Track. Kein
Treffer + noch nie versucht -> "Lyrics suchen"-Button, kein Treffer +
schon versucht -> "Erneut versuchen" (derselbe Endpunkt, nur anderer
Button-Text).
Manueller Retry (POST /track/{id}/lyrics/fetch,
TrackController::fetchLyrics()) antwortet mit JSON statt einem
HTML-Fragment - lyrics.ts baut die Anzeige (inkl. LRC-Parsing) ohnehin
schon clientseitig auf, kein zweiter serverseitiger Render-Pfad für
denselben Anzeigezustand nötig. Nach einem erfolgreichen manuellen Abruf
aktualisiert player.ts auch seinen eigenen Queue-Eintrag (und damit
localStorage) auf denselben Stand - sonst wären neu gefundene Lyrics
beim nächsten Trackwechsel oder Seiten-Reload wieder weg.
Braucht symfony/http-client (composer.json) - kein eigenes Bundle
wie bei Monolog nötig, HttpClientInterface steht automatisch über
Symfonys Standard-Autowiring bereit, sobald das Paket installiert ist.
Lange Tracks (Podcasts, Hörbuch-Kapitel) werden automatisch
übersprungen - --max-duration (Standard 900s = 15min) auf
app:fetch-pending-lyrics, blendet sie direkt aus der Datenbank-Abfrage
aus (NodeRepository::findTracksWithoutLyricsAttempt()). Dafür gibt es
bei lrclib.net ohnehin nie Treffer, unnötige Anfragen. Tracks ohne
bekannte Dauer werden trotzdem einbezogen (können nicht sinnvoll
gefiltert werden). Gilt NUR für den automatischen Cron - der manuelle
"Lyrics suchen"-Button fragt immer an, unabhängig von der Dauer, das ist
eine bewusste Einzelentscheidung. 0 deaktiviert den Filter komplett.
Visuelle Hinweise, welche Tracks Lyrics haben:
- Mikrofon-BUTTON in der Track-Zeile (
.track-lyrics-btn,components/_track_row.html.twig), sichtbar sobaldlyrics_syncedoderlyrics_plainvorliegt - auf einen Blick erkennbar, ohne erst abspielen zu müssen. NORMALES Grid-Kind in einer eigenen Spalte (nutzt die im Grid schon vorhandene, bisher ungenutzte 55px-Spalte:grid-template-columns: 38px 42px minmax(0,1fr) 90px 55px 38px, siehetheme.css), direkt VOR.more-btn- keine absolute Positionierung mehr. Ein früherer Versuch (ein gemeinsamer, absolut positionierter Flex-Wrapper um Mikrofon- + More-Button) brach dabei etwas auf, das schon lange funktionierte:.more-btnhat seit einer viel früheren Session bereits eine App-css-Override-Regel (.more-btn{position:static;width/height:38px}), extra damit es sauber als normales Grid-Kind in die letzte Spalte passt - der absolut positionierte Wrapper riss BEIDE Buttons aus dem Grid-Fluss, unabhängig davon, was die Kinder selbst fürpositionhatten (Ursache für das Überstehen über die schmale Spalte hinaus UND die falsche vertikale Position selbst ohne Lyrics-Icon). Jetzt zentriert.track-row{align-items:center}(theme.css) den Button ganz normal, wie jeden anderen Grid-Inhalt auch. Der Slot wird IMMER gerendert (auch leer ohne Lyrics) - sonst würde die Spaltenbreite je nach Zeile springen. Auf Mobile blendet.track-lyrics-btn-slotgenau wie.more-btnkomplett aus (eigene@media(max-width:700px)-Regel inapp.css, datheme.cssnicht direkt angefasst wird). Echter klickbarer Button, keine reine Deko-Anzeige - Klick spielt den Track ab UND öffnet gleich das Lyrics-Panel (wireTrackDelegation()inplayer.ts, per Event-Delegation wie.track-play-btn). Nutzt dafüropenPanel(), jetzt auspanels.tsexportiert - bewusst nichttogglePanel(): ein Toggle könnte das Panel versehentlich SCHLIESSEN, falls gerade schon ein anderes offen war. - Lyrics-Button in der Player-Toolleiste hervorgehoben
(
#lyricsButton.has-lyrics, dieselbe Akzentfarbe wie.player-icon.selectedbei Shuffle/Repeat, aber eigene Klasse - ist ein reiner Info-Zustand, keine aktivierbare Einstellung), sobald der AKTUELLE Track Lyrics hat.MusicDockPlayer::updateLyricsButtonHighlight()läuft bei jedem Trackwechsel UND nach einem erfolgreichen manuellen Abruf (derselbeonLyricsUpdated-Callback, der auch Panel und Karaoke-Overlay synchron hält).
Lyrics werden nur abgefragt, wenn sowohl Titel als auch Künstler bekannt sind - ohne Künstlername ist eine lrclib.net-Anfrage praktisch nutzlos (matcht dort nur über den Titel, viel zu unpräzise, bei häufigen Songtiteln quasi garantiert der falsche Treffer). Dreifach abgesichert:
NodeRepository::findTracksWithoutLyricsAttempt()schließt Tracks ohnemetadata.artist_name(oder - defensiv, praktisch nie der Fall - ohne Titel) direkt aus der Datenbank-Abfrage aus, der automatische Cron sieht sie also nie.TrackController::fetchLyrics()(der manuelle Endpunkt) lehnt eine Anfrage ohne Titel/Künstler ebenfalls ab - serverseitige Absicherung, falls die Route direkt aufgerufen wird, unabhängig vom Frontend.lyrics.ts(appendRetryButton()) hängt den "Lyrics suchen"/"Erneut versuchen"-Button gar nicht erst an, wenntrack.title/track.artistfehlen - würde ohnehin serverseitig abgelehnt, also gar nicht erst anbieten.
lrclib.net-Eigenheit: lyricsFile statt plainLyrics/syncedLyrics.
Manche Antworten enthalten ein zusätzliches Feld lyricsFile (rohes
YAML) - laut Beobachtung werden plainLyrics/syncedLyrics dann
IGNORIERT, die eigentlichen Lyrics stecken im YAML. Da das genaue Schema
nicht zuverlässig dokumentiert ist, wird es bewusst nicht blind
geparst - falsch geparste Lyrics wären schlimmer als gar keine.
LyricsFetcher erkennt den Fall (hasLyricsFile) und liefert dann kein
synced/plain, das Frontend zeigt stattdessen einen ehrlichen Hinweis
("Lyrics gefunden, aber in einem noch nicht unterstützten Format.") statt
fälschlich "Keine Lyrics verfügbar" zu behaupten. Für spätere Analyse/
einen echten Parser, sobald ein reales lyricsFile-Beispiel vorliegt,
wird zusätzlich Folgendes mitgespeichert:
metadata.lyrics_id- die numerische lrclib.net-ID des Treffers.metadata.lyrics_has_lyrics_file-true, wenn dieser Fall vorlag.metadata.lyrics_raw_response- die KOMPLETTE Rohantwort von lrclib.net als JSON-String, unverändert. Reines Debug-/Analyse-Feld, wird vom Frontend nirgends angezeigt oder ausgewertet.
Karaoke-Ansicht (#karaokeOverlay, Trigger: Vollbild-Icon im
Lyrics-Panel-Header) - vollflächiges, halbtransparentes Overlay mit
Backdrop-Blur, wie bei Amazon Music: aktuelle Zeile groß und hell
hervorgehoben, Nachbarzeilen kleiner und gedämpft. Zeigt exakt dieselben
Daten wie das normale Panel, in einem zweiten Container -
renderLyrics()/updateActiveLine() arbeiten generisch auf einem
beliebigen HTMLElement, kein separates Render-System für das Overlay
nötig. Wird bei jedem Trackwechsel automatisch mitgepflegt (nicht nur
wenn offen) - beim Öffnen sofort aktuell statt erst beim nächsten
Trackwechsel; updateActiveLine() für das Overlay läuft aber nur, wenn
es tatsächlich offen ist (unnötige scrollIntoView()-Aufrufe auf einem
unsichtbaren Container vermeiden). Schließbar per X-Button, Klick auf den
Hintergrund (event.target === currentTarget-Prüfung, nicht auf die
Zeilen selbst) oder Esc. Bewusst NICHT über das generische Panel-System
(panels.ts, openPanel()/closeAllPanels()) gelöst - strukturell
anders (vollflächig statt rechts andockend) und soll parallel zum
normalen Lyrics-Panel bedienbar bleiben, nicht mit ihm um "nur eins
gleichzeitig" konkurrieren.
Klick auf einen manuellen "Lyrics suchen"-Erfolg aktualisiert BEIDE
Container, nicht nur den, in dem geklickt wurde - der zentrale
onLyricsUpdated-Callback in loadTrack() rendert nach einem
erfolgreichen Abruf sowohl Panel als auch Karaoke-Overlay neu, sonst
bliebe der jeweils andere bis zum nächsten Trackwechsel im alten "keine
Lyrics"-Zustand stehen.
Der Neuversuch-Button fehlte, sobald schon (irgendwelche) Lyrics
vorlagen. Ursprünglich erschien "Lyrics suchen"/"Erneut versuchen" NUR
im komplett leeren Zustand - lagen bereits unsynced Lyrics vor, gab es
gar keinen Weg mehr, gezielt auf eine synced Version upzugraden (obwohl
/api/search, siehe oben, durchaus einen ANDEREN Kandidaten mit synced
Lyrics finden kann, selbst wenn der zuerst gespeicherte nur plain hatte).
Jetzt bietet JEDER Zustand außer dem bestmöglichen (synced bereits
vorhanden) einen Neuversuch-Button an - appendRetryButton() in
lyrics.ts ist dafür in eine wiederverwendbare Funktion ausgelagert,
mit passendem Beschriftungstext je Zustand ("Synchronisierte Lyrics
suchen" bei bereits vorhandenem Klartext, "Erneut versuchen"/"Lyrics
suchen" sonst). Ein erneuter Abruf kann dabei nur VERBESSERN, nie etwas
bereits Vorhandenes verlieren - data.synced || track.lyricsSynced statt
??, falls ein neuer Versuch aus irgendeinem Grund weniger liefert als
vorher schon gespeichert war.
Web-Upload (Drag & Drop, ganze Ordner)
Wichtige, mit Datenverlust verbundene Lektion, behoben in
MediaImporter::storeAudioFile()/ensureParentDirectory(): Alle
schreibenden Stellen (Audio, Cover, Thumbnails) legen ihren Ziel-Bucket-
Ordner jetzt selbst an, statt sich darauf zu verlassen, dass vorher
ensureBucketDirectories() gelaufen ist - das ruft NUR importAll()
auf. importUploadedFile() (der komplette Upload-Workflow über
ProcessUploadsCommand) durchläuft importAll() nie und schrieb dadurch
auf einen nicht existierenden Ordner. Schlimmer als nur ein Fehler: beim
--move-Verhalten (das importUploadedFile() immer nutzt) lief
unlink($sourcePath) vorher UNBEDINGT direkt nach copy(), ohne dessen
Rückgabewert zu prüfen - schlug copy() fehl (weil der Zielordner
fehlte), wurde die Quelldatei trotzdem gelöscht: keine Kopie am Ziel,
keine Quelle mehr, Datei komplett weg. unlink() läuft jetzt
ausschließlich nach nachweislich erfolgreichem copy(), sonst wird eine
Exception geworfen (die ProcessUploadsCommand bereits pro Datei abfängt
und als failed markiert, statt den ganzen Cron-Lauf abzubrechen).
Musik lässt sich direkt im Browser hochladen (/upload, Nav-Link
"Upload") - einzelne Dateien oder komplette Ordner per Drag & Drop, wie
man es von Dropzone.js kennt. Läuft in drei Schritten, bewusst
asynchron statt alles in einem Request zu erledigen:
- Frontend sammelt rekursiv (
assets/ts/upload.ts): beim Drop wirdDataTransferItem.webkitGetAsEntry()genutzt, um bei einem Ordner rekursiv durchFileSystemDirectoryReaderzu laufen und alle enthaltenen Audiodateien mit ihrem vollen relativen Pfad einzusammeln (z.B.Pink Floyd/The Wall/01 In The Flesh.mp3- die Ordnerstruktur bleibt erhalten). Fallback: Klick öffnet den normalen Datei-/Ordner- Dialog (<input type="file" webkitdirectory>) für Browser/Systeme, bei denen Ordner-Drag&Drop nicht rund läuft. - Jede Datei wird EINZELN hochgeladen, nicht der ganze Ordner in
einem Request. Dadurch gibt es kein "Upload-Größen"-Problem im Sinne
eines Gesamtlimits - es greift nur
upload_max_filesize/post_max_sizepro Datei (siehe.ddev/php/upload.ini, auf 200M/210M gesetzt - großzügig genug für FLACs oder lange Podcast-Aufnahmen).UploadControllerspeichert jede Datei unteruploads/<relativePath>und legt eineUpload-Entity mit Statuspendingan. app:process-uploadsimportiert asynchron per Cron (genau dasselbe Lock+Zeitbudget-Muster wieapp:generate-pending-waveforms, siehe unten) - holt wartende Uploads, ruftMediaImporter::importUploadedFile()auf (Ordnerstruktur des Uploads wird 1:1 als Künstler/Album interpretiert - dieselbefindOrCreateTaggedFolder()-Logik wie beim_dump-Import, hier aber aus den Ordnernamen statt aus ID3-Tags; eine lose Datei ganz ohne Ordnerkontext fällt auf Tag-basierte Platzierung zurück) und räumtuploads/danach auf (Datei wandert nachstorage/audio/,--move- Verhalten fest einprogrammiert, nicht konfigurierbar - ein Upload ist "verbraucht", sobald er verarbeitet wurde).
Sichtbarer Status statt stiller Blackbox: Upload ist bewusst eine
eigene Entity, unabhängig vom Node-Baum (heißt Upload, nicht
UploadedFile - kollidiert sonst mit
Symfony\Component\HttpFoundation\File\UploadedFile). Solange eine Datei
nur hochgeladen, aber noch nicht verarbeitet ist, taucht sie NICHT in der
Bibliothek auf (kein Node existiert dafür) - stattdessen zeigt /upload
den Status (wartend/in Arbeit/importiert/fehlgeschlagen) mit Fehlermeldung
bei fehlgeschlagenen Imports.
Cronjob (analoges Muster zu den Wellenformen, hier aber minütlich statt alle 5 Minuten - Uploads sollen zeitnah verarbeitet werden, nicht erst nachts):
* * * * * cd /pfad/zu/musicdock && ddev exec bin/console app:process-uploads >> var/log/upload-cron.log 2>&1
App\Service\RuntimeStorage ist das wiederverwendbare Zeitbudget-
Muster dahinter (und hinter app:generate-pending-waveforms, das beim
Bauen dieses Features gleich mit umgestellt wurde): ein Command setzt
einmal RuntimeStorage::setDeadline($sekunden), jede
Verarbeitungsschleife - auch in ganz anderen Klassen, ohne dass ein
$deadline-Parameter durchgereicht werden müsste - prüft einfach
RuntimeStorage::deadlineReached() und bricht sauber ab. Kombiniert mit
dem Lock (Symfony Lock, FlockStore) verhindert das zuverlässig, dass ein
nachfolgender Cron-Tick in einen noch gehaltenen Lock läuft: kein Tick
läuft je länger als sein eigenes Zeitbudget, und ein bereits laufender
Vorgänger blockt den nächsten Tick einfach per Lock ab, statt parallel zu
arbeiten.
Equalizer (5-Band, Web Audio API) & WebGL-Visualizer
Beide clientseitig, keine Server-Beteiligung - und beide hängen am selben
gemeinsamen Web-Audio-Graphen (assets/ts/audio-graph.ts), aus genau
einem Grund: AudioContext.createMediaElementSource() darf pro
<audio>-Element nur EINMAL im gesamten Seitenleben aufgerufen werden
(der Browser wirft sonst eine Exception). Equalizer und Visualizer
bräuchten beide diesen Aufruf - getAudioGraph() baut ihn deshalb lazy
und garantiert nur beim allerersten Aufruf tatsächlich auf, jeder weitere
Aufruf (aus egal welchem Modul) bekommt denselben bereits bestehenden
Graphen zurück:
audio -> Lowshelf(60Hz) -> Peak(250Hz) -> Peak(1kHz) -> Peak(4kHz) -> Highshelf(12kHz) -> Analyser -> Lautsprecher
(Equalizer, 5 Bänder) (Visualizer liest hier)
Der AnalyserNode sitzt bewusst NACH den EQ-Filtern, nicht direkt an der
Quelle - der Visualizer zeigt so das tatsächlich hörbare Signal inklusive
eigener EQ-Einstellungen, nicht das unveränderte Original.
initEqualizer()/initVisualizer() werden beide bewusst nur einmal beim
App-Start aufgerufen, nicht nach jedem HTMX-Swap (anders als z.B.
initPlaylistReordering()) - unproblematisch, weil das <audio>-Element
über die ganze Session hinweg dasselbe bleibt, derselbe Grund, aus dem
auch die Wiedergabe selbst nie unterbrochen wird.
AudioContext startet in den meisten Browsern "suspended" bis zu einer
Nutzer-Geste - ohne resume() bliebe die Wiedergabe stumm. Zwei
Gelegenheiten zum Aufwecken: der erste Klick irgendwo im Dokument, und
(als Netz) jedes Mal, wenn die Wiedergabe tatsächlich startet.
Equalizer
Fünf BiquadFilterNodes (60 Hz Lowshelf, 250 Hz/1 kHz/4 kHz Peaking,
12 kHz Highshelf), Werte bei ±12 dB pro Band, persistiert in
localStorage (musicdock.eq) wie die Lautstärke. Vertikale Regler über
writing-mode: vertical-lr (moderne, browserübergreifend einheitliche
Alternative zum alten -webkit-appearance: slider-vertical).
Genre-Presets (Flach/Rock/Pop/Dance/Jazz/Klassik/Bass-Boost/
Höhen-Boost) sitzen als Record<string, number[]> in PRESETS
(assets/ts/equalizer.ts) - Gains in derselben Reihenfolge wie die Bänder
(60/250/1k/4k/12k Hz). Werte orientieren sich an den Kurven klassischer
Consumer-Equalizer (Winamp, foobar2000, Android-System-EQ), kein Anspruch
auf akustische Exaktheit. Das <select id="eqPreset"> im Panel wird
bewusst NICHT im Twig-Template befüllt, sondern aus PRESETS generiert -
ein Preset existiert nur an einer Stelle im Code. Manuelles Verstellen
eines Reglers springt automatisch zurück auf "Eigene Einstellung", damit
die Auswahl nie fälschlich einen Preset-Namen zeigt, dessen Werte gar nicht
mehr aktiv sind.
WebGL-Visualizer
assets/ts/visualizer.ts, EINE Instanz in der linken Sidebar (kein
separates rechtes Panel - das machte in der Praxis eh niemand auf, siehe
Entscheidung unten). Klick auf den Streifen schaltet die drei Ansichten
durch (Balken -> Linie -> Nadel -> Balken -> ...), Auswahl in
localStorage (musicdock.vizmode) gemerkt, Tastatur-bedienbar
(role="button", Enter/Leertaste):
- Balken - klassischer Frequenz-Equalizer, 20 Balken (bewusst
weniger als in einer früheren Panel-Version mit 48 - bei der schmalen
Sidebar-Breite wären mehr optisch kaum unterscheidbar) aus
analyser.getByteFrequencyData(). Logarithmische statt linearer Frequenzband-Zuordnung - bei linearer Aufteilung (festerdata.length/BAR_COUNT-Schritt) bekam jeder Balken einen gleich GROSSEN Frequenzbereich; da sich die akustische Energie bei praktisch jeder Musik auf die unteren ~10-20% des Spektrums konzentriert (Bass/Mitten), waren die meisten (oberen) Balken dadurch fast immer bei Null - wirkte wie ein fehlendes Frequenzspektrum, war aber schlicht die Natur der linearen Aufteilung. Jetzt exponentiell verteilt: die unteren Bins (wo die meiste Energie/musikalische Information sitzt) bekommen mehr, schmalere Balken, die oberen (Höhen, wenig Energie) weniger, dafür breiter zusammengefasste - entspricht auch der menschlichen Wahrnehmung (Oktaven sind Frequenz-VERDOPPLUNGEN, kein fester Hz-Abstand). Oberer Cutoff bewusst NICHT bis zur vollen Nyquist-Frequenz (sampleRate/2, bei üblichen 44100Hz ~22kHz) - beifftSize=2048deckte der GANZ RECHTE Balken sonst grob den Bereich ~15.5-22kHz ab, wo bei praktisch jeder Musik (besonders komprimierten Formaten wie MP3, die oft schon bei 16-19kHz abschneiden) kaum noch Energie vorhanden ist - akustisch korrekt, der Balken bewegte sich dadurch aber faktisch nie.MAX_FREQ_HZ = 14000kappt den Bereich bei einem musikalisch noch aktiven Wert (Becken, Hi-Hats, Zischlaute liegen typischerweise im 5-14kHz-Bereich) - verschiebt die gesamte logarithmische Verteilung nach unten, auch der letzte Balken bleibt dadurch in einem Bereich, in dem üblicherweise noch etwas passiert. - Linie - "Rubber-Line"-Oszilloskop aus
getByteTimeDomainData()(Zeitsignal, nicht Frequenzspektrum). - Nadel - klassisches VU-Meter, schwenkt basierend auf dem RMS (quadratisches Mittel, nicht Peak - zeigt wahrgenommene Lautstärke statt nervös auf jede kurze Spitze zu reagieren) zwischen zwei Anschlägen hin und her, mit Zifferblatt-Bogen als Hintergrund.
Ein einziges, sehr schlankes Shader-Programm für alle drei Modi
(reine Flächenfarbe per Uniform, aus --accent-rgb gelesen - liegt in
theme.css schon als "155,123,255" vor, kein Hex-Parsing nötig) - jeder
Modus erzeugt nur unterschiedliche Dreiecks-Geometrie in JS, gezeichnet
wird immer gleich (gl.TRIANGLES). Balken und Linie sind als echte
Flächen gezeichnet, nicht gl.LINE_STRIP: WebGLs Linienbreite steht auf
vielen Plattformen (u.a. den meisten Desktop-GPU-Treibern) fest auf 1px,
unabhängig von lineWidth() - eine sichtbar dicke, glatte Linie braucht
ein selbst gezeichnetes Rechteck-Band entlang des Pfads
(buildRibbon(), mit fester Breite senkrecht zur Pfadrichtung pro
Segment).
Läuft bewusst IMMER, kein "nur wenn geöffnet"-Vorbehalt - das ist ja der Sinn: ein ambienter Indikator direkt in der Sidebar, ohne irgendwas öffnen zu müssen.
Design-Entscheidung, ein Umbau während der Entwicklung: ursprünglich
gab es zwei gleichzeitige Instanzen - ein großes, umschaltbares Panel
(Player-Leiste -> eigener Button) UND eine kleine Sidebar-Variante
parallel dazu. In der Praxis wurde das Panel nie geöffnet, die Sidebar-
Variante war der eigentlich genutzte Teil - deshalb radikal vereinfacht
auf EINE Instanz, Klick-zum-Durchschalten statt separater Umschalt-Buttons
in einem eigenen Panel. Weniger Code, ein Rendering-Ziel statt zwei, kein
VizInstance-Parameter mehr nötig (die frühere Zwei-Instanzen-Version
kapselte GL-Kontext/Programm/Buffer pro Canvas in einer solchen Struktur -
mit nur noch einem Canvas unnötige Komplexität).
Kein WebGL im Browser? canvas.getContext('webgl') liefert dann
null, initVisualizer() bricht sauber ab - der Streifen bleibt einfach
leer (Equalizer, Player etc. bleiben komplett unbeeinflusst), kein
Absturz.
Playlist-Reordering (Drag & Drop, offline-tolerant)
Der einzige Ort in der App, an dem bewusst "richtiges" Client-JS statt HTMX
zum Einsatz kommt (assets/ts/playlist-reorder.ts) - für sehr interaktives
Drag&Drop ist das der pragmatischere Weg als der Umweg über Server-Requests
pro Drop.
- Sortable.js übernimmt das reine Drag&Drop im DOM, rein optimistisch, kein Server-Roundtrip für die Optik selbst.
- Nach jedem Drop läuft ein Debounce (800ms) - erst wenn kurz nichts mehr passiert, wird synchronisiert. Mehrere schnelle Drags hintereinander erzeugen also einen Request, nicht einen pro Drop.
- Der Sync schickt die komplette aktuelle Reihenfolge (Liste von
PlaylistItem-IDs) perPUT /playlist/{id}/reorder- der Server nummeriertpositiondaraus neu durch (PlaylistController::reorder()). - Schlägt der Request fehl (offline, Server nicht erreichbar), landet die
Reihenfolge in IndexedDB (
assets/ts/offline-queue.ts, ein Object-Storepending-reorders, ein Eintrag pro Playlist - ein neuerer Reorder überschreibt einfach den alten, kein Merge-Konflikt möglich) und wird beim nächstenonline-Browser-Event oder beim nächsten App-Start automatisch nachgeholt (retryPendingReorders()inapp.ts). - Bewusst kein Service-Worker/Background-Sync - deutlich mehr Umfang (und in Safari nur lückenhaft unterstützt) für einen Anwendungsfall, bei dem "beim nächsten Start nachholen" völlig ausreicht. Falls die App mal komplett offline nutzbar sein soll, ist das ein eigenes, größeres Thema.
Nach jedem HTMX-Swap (htmx:afterSettle) wird initPlaylistReordering()
neu aufgerufen, weil #content inkl. der DOM-Referenzen komplett ersetzt
wurde - alte Sortable-Instanzen werden dabei sauber zerstört, bevor eine
neue entsteht.
Nächste sinnvolle Schritte
- Login/User: Playlists (Owner-Feld, siehe oben), evtl. private
Kategorien, "Zuletzt gehört" - alles baut auf einer
User-Entity auf, die noch fehlt. - Warteschlangen-Anzeige im Player: aktuell ist die Queue nur intern in
player.ts- eine sichtbare "Als Nächstes"-Liste (z.B. als Slide-in-Panel) ist der naheliegende nächste Schritt, sobald Playlists als Datenquelle dafür stehen. - Waveform statt schlichtem Seek-Balken (Idee, noch nicht umgesetzt -
am ehesten zusammen mit dem visuellen Durchgang sinnvoll, weil es sowieso
eine Canvas/SVG-Zeichnung braucht): Peaks beim Import berechnen (z.B. via
ffmpeg/audiowaveform-CLI-Tool aus einem Subprocess inMediaImporter, oder einer PHP-Audio-Decoding-Lib) und als Array inmetadata['waveform_peaks']auf dem Track-Node speichern - passt ohne Schema-Änderung ins bestehende JSON-Metadata-Feld. Frontend zeichnet die Kurve dann per<canvas>statt des aktuellen<input type="range">und färbt den bereits gehörten Teil beimtimeupdateein. - Admin-Oberfläche zum Verschieben/Umbenennen von Nodes per Drag & Drop wäre mit EasyAdminBundle oder einer schlanken eigenen HTMX-Oberfläche machbar – der Dateimanager-Controller liefert dafür schon die Baum-Basis.
- gedmo/doctrine-extensions (Tree-Behavior) als Alternative zum
handgepflegten
path-Feld, falls der Baum später komplexer wird (Verschieben ganzer Teilbäume, Nested-Set-Abfragen).