Compare commits

...
73 Commits
Author SHA1 Message Date
marcandClaude Opus 5 b6e98463ee feat(web+android): Lyrics-Mitlaufen umschaltbar, echtes Mischen, Wiederholung am Queue-Ende
Lyrics: Schalter "Mitlaufen" im Lyrics-Reiter, Vorgabe AUS. Hervorgehoben wird die
laufende Zeile immer, gescrollt nur bei eingeschaltetem Mitlaufen. Nur bei synchronen
Texten sichtbar. Android speichert den Schluessel einzeln, damit das Speichern im
Einstellungsbildschirm ihn nicht auf die Vorgabe zuruecksetzt.

Mischen (Web): sort(() => Math.random() - 0.5) ist keine Mischung. Gemessen blieb der
erste Titel drei- bis fuenfmal zu oft vorn (18,8 % statt 10 % bei n=10, 0,23 % statt
0,05 % bei n=2000). Ersetzt durch Fisher-Yates in queueMath.mischeListe.

Queue-Ende (Web): ein beendetes <audio> meldet sich als pausiert, die Wiederaufnahme
nach unfreiwilligem Hintergrund-Stop rief play() und spielte den letzten Titel erneut.
Jetzt Abbruch bei audio.ended.

Shuffle (Android): das Mischen macht jetzt der Player (playSongs(liste, -1)), damit die
ungemischte Liste erhalten bleibt — vorher leuchtete das Symbol nicht und "Shuffle aus"
konnte die Albumreihenfolge nicht wiederherstellen.

Radio-Modus (beide): der laufende Titel ist aus der letzten Rueckfallebene ausgenommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 17:13:38 +02:00
marcandClaude Opus 5 ae357b0eb9 change: Konzerte ohne Zeitfenster — Chips nur noch bei den Veroeffentlichungen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 10:46:05 +02:00
marcandClaude Opus 5 17a677a5a5 feat(web+android+backend): Konzerte gefolgter Kuenstler als zweiter Reiter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 19:23:00 +02:00
marcandClaude Opus 5 1366ec3832 docs: Entwurf fuer die Konzert-Meldungen im Backlog festhalten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 17:56:17 +02:00
marcandClaude Opus 5 7879e37a7b test(backend): Filterprobe fragt auch YT Music ab — erste Fassung meldete erfundene Fehler
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 17:03:14 +02:00
marcandClaude Opus 5 2f87a12f08 fix(backend): Kuenstler vor Dauer in der Suchkaskade, mit Regressionsprobe
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 09:48:35 +02:00
marcandClaude Opus 5 748b1446a2 fix(backend): Umschrift-Muster toleranter, Kanji-Kuenstler ueber Romaji erkennbar
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 00:09:01 +02:00
marcandClaude Opus 5 06b13caea9 fix(backend): Download-Filter konnte ohne Titel- oder Kuenstlermerkmal zugreifen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 23:31:29 +02:00
marcandClaude Opus 5 1432a960fb style: Honigtopf auf Feldbreite aufgezogen, wirkt jetzt so gross wie das Zahnrad
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 23:11:07 +02:00
marcandClaude Opus 5 37f8f8acac feat(web+android): Gesamtdauer mit Stunden, in Android neu
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 22:38:40 +02:00
marcandClaude Opus 5 c7fed82736 style(web): Honigtopf in der Kopfzeile groesser
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 18:25:22 +02:00
marcandClaude Opus 5 e8582381ec feat(web+android): Honigtopf mit Brief statt Glocke fuer Benachrichtigungen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:53:06 +02:00
marcandClaude Opus 5 899c98977a feat: Wischgeste wechselt Titel, Designs Violet und Crimson, Mischen-Anzeige gefixt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:04:37 +02:00
marcandClaude Opus 5 549bec85ce feat(web): Rollbalken nur ueber dem Inhalt, X-Knopf in der Warteschlange
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:50:23 +02:00
marcandClaude Opus 5 2258e5465f fix(android): Songzeilen halten rechts Platz fuer den Rollbalken
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:47:37 +02:00
marcandClaude Opus 5 2641a6df44 fix(android): Rollbalken verschluckte das Kontextmenue der Songzeilen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:45:20 +02:00
marcandClaude Opus 5 d5b5c8cf84 feat(web+android): Warteschlange bearbeiten — ziehen und entfernen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:31:08 +02:00
marcandClaude Opus 5 77a3bb33f0 fix(android): Rollbalken-Geste brach ab, weil pointerInput auf die Geometrie geschluesselt war
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:24:37 +02:00
marcandClaude Opus 5 d420123a44 fix(web): Rollbalken lief ueber die Player-Leiste
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:21:37 +02:00
marcandClaude Opus 5 f083fad986 fix(android): Rollbalken liess sich nicht greifen — Greifflaeche und Gestenvorrang
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:20:08 +02:00
marcandClaude Opus 5 363eb2b1aa docs: Rollbalken in Android im Changelog (3.1, APK 1.16)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:13:37 +02:00
marcandClaude Opus 5 1aa218f3e3 feat(android): Rollbalken zum Ziehen in den langen Listen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:13:10 +02:00
marcandClaude Opus 5 6652fb4d2b feat(web): greifbarer Rollbalken, Zeitleiste in die Favoriten verlegt (3.1)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 19:41:09 +02:00
marcandClaude Opus 5 073593d963 change(backend): nur noch der erste Kuenstler im Dateinamen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:57:04 +02:00
marcandClaude Opus 5 8d25bf0404 feat(web+backend): Dubletten im Editor aufloesen, Favoriten ueberleben das Umbenennen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 21:41:42 +02:00
marcandClaude Opus 5 24558497da fix(backend): Schraegstrich im Namen und zu lockere Titelsuche fuehrten auf die falsche Datei
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:35:36 +02:00
marcandClaude Opus 5 eb72e5c27d feat(android): Album-Knopfleiste wie im Playlist-Detail; Changelog-Backlog aufgeraeumt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:06:30 +02:00
marcandClaude Opus 5 66fcdbba05 docs: Release als Version 3.0 festhalten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 12:39:22 +02:00
marcandClaude Opus 5 86810b6a51 feat(web+android): gefolgte Künstler begrenzt anzeigen, mit "Alle anzeigen"
Vorher landeten alle gefolgten Künstler in der waagerechten Reihe der
Favoriten — bei 200 Gefolgten 200 Karten samt Bildern, von denen man vier
sieht. Am Ende steht jetzt "Alle anzeigen (+n weitere)" und springt in die
Künstlerliste mit aktivem Gefolgt-Filter.

Im Web richtet sich die Anzahl nach der Fensterbreite statt nach einer
festen Zahl (passende Karten minus eine, damit der Knopf nicht
angeschnitten klebt; Untergrenze zwei), und bei Größenänderung wird neu
aufgeteilt. Android zeigt zehn; die LazyRow baut ohnehin nur Sichtbares,
dort ging es allein um Durchsehbarkeit.

LibraryIntent reicht die Absicht einmalig durch, weil das
LibraryViewModel am Navigationseintrag hängt und einen Zielwechsel nicht
überlebt.

Dazu: die Zeitfenster-Chips der Android-Zeitleiste beginnen mit 90 Tagen,
der Vorgabe — Web war schon so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:37:47 +02:00
marcandClaude Opus 5 cb43c3375e feat(android): Gefolgt-Filter im Künstler-Reiter und Zeitleiste "Neu von deinen Künstlern"
Filter statt eigenem Reiter: die Bibliotheksleiste trägt schon Playlists,
Alben, Künstler und Offline. Die markierten IDs kommen bei jedem
Einschalten frisch, damit ein eben entfolgter Künstler nicht dasteht.

Die Zeitleiste ist über "✨ Neu" in den Favoriten erreichbar — dort stehen
die gefolgten Künstler, also liegt die Frage nach Neuem dort nahe.
Gleiche Darstellung wie im Web: nach Monat gruppiert, neueste zuerst,
Vorhandenes mit Haken statt Download-Knopf, Künstlername anklickbar.
Download läuft über den bekannten Weg samt Fortschrittsmeldung.

Neu: api/ReleasesClient.kt, api/model/ReleaseModels.kt,
viewmodel/ArtistNewsViewModel.kt, ui/screen/ArtistNewsScreen.kt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:10:37 +02:00
marcandClaude Opus 5 ea7167f851 perf(web): Künstlerbilder ohne einen API-Aufruf pro Künstler
Das Künstlerraster feuerte pro Künstler ein getArtistInfo2 nur für die
Bildadresse: bei 200 Künstlern 200 gleichzeitige Aufrufe, die der Browser
zu sechst abarbeitet, und jeder konnte Navidrome zu einer externen
Last.fm-Abfrage zwingen. Fehlende Bilder kamen aus derselben Quelle —
Last.fm liefert für viele Künstler nur noch einen Platzhalter.

getArtists liefert das Bild längst mit (toArtistID3 setzt coverArt, und
Navidrome löst Künstlerbilder selbst über ArtistArtPriority auf und cacht
sie). Jetzt geht nur noch das Bild über die Leitung; ohne coverArt sparen
wir uns den Versuch ganz. Im Test 0 statt 120 Zusatzaufrufe.

Gleiches für die Künstlerkarten in der lokalen Suche und die gefolgten
Künstler in den Favoriten. Android war nie betroffen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:05:11 +02:00
marcandClaude Opus 5 f3d54158d3 feat(web): Filter "❤ Gefolgt" plus Suche auf der Künstlerseite
Die Liste, wem man folgt, gehört dorthin, wo man Künstler ohnehin sucht —
bisher gab es sie nur als waagerechte Reihe unter den Favoriten.
getArtists liefert kein starred mit, die Markierungen kommen deshalb aus
getStarred2 und werden 30 s gehalten, damit das Umschalten flott bleibt.

Ein Herz-Klick verwirft diesen Zwischenspeicher sofort — sonst zeigte der
Filter nach dem Entfolgen noch eine halbe Minute den alten Stand. Beim
Testen aufgefallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:58:09 +02:00
marcandClaude Opus 5 908488a337 feat: Zeitleiste "Neu von deinen Künstlern" (Web) + Backend-Endpunkt
Eigener Punkt in der Seitenleiste: jüngste Veröffentlichungen aller
gefolgten Künstler, nach Monat gruppiert, neueste zuerst, umschaltbar auf
30/90/365 Tage. Was schon in der Bibliothek liegt, ist markiert (Vergleich
über Künstler + Albumname, akzent- und zeichenunabhängig); alles andere
bekommt einen Laden-Knopf zum vertrauten Download-Dialog.

Neuer Endpunkt POST /api/artist-releases (backend/lib/releases.js): die
Deezer-Zuordnung passiert serverseitig, weil die Künstler-Pins dort
ohnehin liegen. Gepinnte nutzen die feste ID, ungepinnte werden über den
Namen gesucht und nach Fan-Zahl sortiert; ohne Treffer kommt der Name als
ohneZuordnung zurück und die Oberfläche rät zum Pinnen.

Cache im Backend statt im Client — bei 30 gefolgten Künstlern wären das
sonst 30 Deezer-Aufrufe pro Gerät und App-Start. 6 h für Alben, 24 h für
die Namenszuordnung, max. 4 Anfragen parallel, bei Deezer-Fehlern wird
der abgelaufene Stand ausgeliefert statt einer leeren Liste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:50:33 +02:00
marcandClaude Opus 5 19f241e493 feat(web+android): Folgen-Herz ins Künstler-Banner, Favoriten zeigen Künstler und Alben
Das Herz sitzt jetzt im Banner neben dem Pin-Zahnrad statt in der
Aktionsleiste: die Leiste ist "was ich jetzt tue" (abspielen, mischen,
Konzerte), Folgen ist ein Zustand und gehört an den Künstler. Auf beiden
Plattformen gleich. Beim Album bleibt das Herz in der Aktionsleiste, dort
gibt es kein Banner.

Dazu die Übersicht, wem man folgt: die Favoritenseite bekommt Abschnitte
Künstler / Alben / Songs. Ohne zusätzlichen Aufruf — getStarred2 liefert
Künstler und Alben im selben Paket wie die Songs, wir haben sie bisher
verworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:11:18 +02:00
marcandClaude Opus 5 5f043beb5d feat(web+android): Künstlern folgen, Alben merken, Top Songs per Künstler-ID
Folgen ist kein eigener Speicher, sondern Navidromes Stern auf dem
Künstler (star&id=<ndArtistId>) — Navidrome wirft id, albumId und
artistId beim Markieren in eine Liste und behandelt sie gleich, der
vorhandene Aufruf trug also ohne Änderung. Zustand aus `starred` in
getArtist/getAlbum, beide Plattformen schalten optimistisch um.

Android nutzt den vorhandenen StarState, der ohnehin über IDs arbeitet.

Dazu: getTopSongs bekommt seit Navidrome 0.64 zusätzlich `id`. Der
Namensweg sucht mit LIKE und Max:1, bei gleichnamigen Künstlern gewann
der erste Treffer. Jetzt id + artist zusammen; in Erkunden bleibt es beim
Namen, weil die artistId dort Deezers ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:51:53 +02:00
marcandClaude Opus 5 de674dbe3f fix(android): Offline-Downloads vollständig löschen, Fortschritt selbst begradigen
Löschen ließ Dateien zurück: Cover wurden nie entfernt (nur die
Audiodatei), unfertige Titel behielten ihre Datei, weil der Pfad aus der
Karte der FERTIGEN Titel kam, und laufende Downloadarbeit lief nach dem
Löschen noch minutenlang ins Leere. Jetzt kommt der Pfad aus der
Datenbankzeile, .tmp wird mitgenommen, Cover gehen nur weg wenn kein
anderer Titel sie noch benutzt, und die Arbeit der Gruppe wird
abgebrochen.

Dazu: ein zu hoch gezählter Fortschritt aus der Zeit vor 1.10 konnte sich
nie mehr korrigieren — die Gruppe galt als vollständig, der Knopf war
unantastbar und das Fortsetzen sucht nur Gruppen unter dem Sollwert. Beim
App-Start wird der Fortschritt aller Gruppen einmal aus dem echten
Zustand neu berechnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:00:08 +02:00
marcandClaude Opus 5 ffedc7532b feat(android): Schalter "Nur über WLAN herunterladen" (Standard an)
Die Downloadarbeit lief mit NetworkType.CONNECTED, also über jede
Verbindung. Das war selbstbegrenzend, solange nach zehn Minuten Schluss
war — mit der automatischen Wiederholung aus 1.9 hätte sich eine große
Playlist unterwegs ununterbrochen weitergeladen (1900 Titel ≈ 9-10 GB).

Neuer Schalter unter "Offline-Downloads", standardmäßig an: dann gilt
UNMETERED, der Download pausiert ohne WLAN und läuft von selbst weiter.
Abschalten nur über eine Rückfrage, die das Datenvolumen benennt.

Beim Speichern werden laufende Gruppen mit REPLACE neu eingereiht —
WorkManager ändert die Bedingungen eingereihter Arbeit nicht mehr, der
Schalter hätte sonst erst beim nächsten App-Start gewirkt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:34:44 +02:00
marcandClaude Opus 5 e2e7b2490f fix(android): Zählfehler im Downloader — halb geladene Gruppen galten als vollständig
Der Worker startete done bei group.downloadedTracks und zählte dieselben
fertigen Titel beim Überspringen nochmal: 412 fertige wurden zu 824.
Übersteigt der Wert totalTracks, gilt die Gruppe als vollständig, der
Fortschrittsbalken verschwindet und der Herunterladen-Knopf wird
unantastbar, obwohl Titel fehlen.

Der Fehler ist älter, fiel aber kaum auf, solange Fortsetzen nur von Hand
passierte — mit der automatischen Wiederholung aus 1.9 hätte er bei jedem
Lauf zugeschlagen. Zähler beginnt jetzt bei 0.

Dazu: beim Überspringen wurde der unveränderte Fortschritt geschrieben,
1900 überflüssige DB-Writes je Lauf. Jetzt nur nach echtem Versuch und
einmal beim Abbruch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:22:18 +02:00
marcandClaude Opus 5 8bc03da88e fix(android): große Offline-Downloads laufen jetzt bis zum Ende
WorkManager stoppt einen Hintergrund-Worker nach ~10 Minuten (ein
Vordergrunddienst scheidet aus, FOREGROUND_SERVICE_TYPE_DATA_SYNC stürzt
unter Android 15 ab). Der Worker prüfte nie isStopped, lief blind weiter
und meldete Result.failure() ohne Backoff — WorkManager startete nichts
neu, bei 1900 Titeln musste man wiederholt von Hand antippen.

Jetzt: sauberer Ausstieg bei isStopped mit Result.retry() und linearem
Backoff, Selbst-Fortsetzung solange Titel offen sind (max. 20 Versuche,
damit dauerhaft kaputte Titel keine Endlosschleife auslösen), Retry statt
Failure im äußeren catch, und beim App-Start werden unvollständige
Gruppen weitergeführt.

Dazu: die Zahl offener Titel kam aus einer Abfrage pro Titel am Ende
jedes Laufs — jetzt ein einzelnes COUNT mit JOIN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:17:50 +02:00
marcandClaude Opus 5 35e36067af fix(android): große Offline-Playlists ließen sich nicht abspielen
Die Warteschlange wurde aus allen Titeln einer Gruppe gebaut, ohne den
Download-Zustand zu prüfen. Die Map für lokale Dateien kennt aber nur
state = 'DONE' — für wartende oder fehlgeschlagene Titel lieferte
streamUrl() die Netz-Adresse, die offline nichts tut. Bei 100 Titeln ist
der Download durch, bei 1900 nicht; steht so ein Titel vorn, scheitert
die Wiedergabe sofort.

Play und Shuffle nehmen jetzt nur fertige Titel, ein Toast nennt die Zahl
der übersprungenen. Die Liste zeigt weiter alle, damit sichtbar bleibt,
was fehlt; Tippen auf einen unfertigen sagt es ebenfalls.

Nicht die Ursache: Media3 reicht große Listen als BundleListRetriever
durch, die 1-MB-Binder-Grenze greift dort nicht.

Dazu Backlog-Notiz: Künstler/Alben liken als Basis für "Künstler folgen".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:50:27 +02:00
marcandClaude Opus 5 cb5ea26c77 fix: Maus-Zurück im PWA-Fenster, sprechende Player-Namen, Pfad-Etikett
Im installierten Fenster ("display": standalone) gibt es keine Zurück-/
Vorwärts-Knöpfe, und die Maus-Seitentasten werden nicht auf den Verlauf
umgelenkt — im Tab ging es, im Fenster nicht. Die App übernimmt X1/X2
jetzt selbst, aber nur im Standalone-Fenster (im Tab würde das doppelt
navigieren; beide Fälle im Test abgedeckt). Zurück läuft über _navGoBack,
schließt also erst ein offenes Now-Playing.

Backend meldet sich jetzt als NovaSonic-Backend, der Scrobble-Dienst als
NovaSonic-Scrobble. Vorher novasonic vs. NovaSonic — in Navidromes
Player-Liste (Name = "client [User-Agent]") zwei Zeilen "[node]", die
sich nur an einem Großbuchstaben unterschieden.

Pfad-Etikett wieder ohne "(Navidrome)": der Wert kommt ohnehin aus der
Subsonic-API, die Erklärung gehört in den Code und nicht ins Fenster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:34:17 +02:00
marcandClaude Opus 5 568bb5002c fix(web): Zurück-Knopf tat oft nichts + Besitzer geteilter Playlists (2.6)
navigate() ließ den Stack-Eintrag weg, wenn sich die Ansicht nicht
geändert hatte, rief history.pushState aber trotzdem. Zweimal auf
denselben Tab geklickt = zwei Browser-Einträge auf dieselbe Ansicht,
jeder davon ein Zurück-Druck ohne sichtbare Wirkung. Im Test: 3x "Alben"
-> 3 Browser-Einträge bei 1 Stack-Eintrag, danach bewegten 4 Zurück-
Drücke die Ansicht einmal. Jetzt pusht nur eine wirklich neue Ansicht,
sonst replaceState. Vorwärts mitgetestet, unverändert.

Playlists: Navidrome liefert owner mit, das Web nutzte es nie. Übersicht
und Detail zeigen "· von <Name>", nur bei fremden Listen. Android zeigte
ihn bisher bei jeder Liste und unbeschriftet; beide Plattformen jetzt
gleich, Bibliotheks-Übersicht ebenfalls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:12:07 +02:00
marcandClaude Opus 5 ea448e109f docs(changelog): Android-Versionen je Release in einer Zeile bündeln
Die APK-Versionen standen als eigene Einträge zwischen den Features und
wiederholten deren Inhalt: "Android 1.6: Warteschlange ist sofort da"
neben "Zuletzt Gehörtes überlebt den Neustart" — die beiden
widersprachen sich sogar (nur Song-Ids vs. volles JSON), weil 1.6 den
Ansatz aus 1.4 abgelöst hatte.

Jede Release-Überschrift trägt jetzt eine "Android:"-Zeile mit Spanne und
je einem Halbsatz; die Einträge darunter beschreiben nur noch die
Änderung. Die Warteschlangen-Einträge sind zu einem zusammengezogen, mit
dem Weg 1.4 -> 1.6 als Unterpunkt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:58:46 +02:00
marcandClaude Opus 5 4ee20de359 fix(docs+web): "NN-NN im Dateinamen" war kein Bug — Navidromes fakePath
Der Pfad Rising Insane/DEATHRACE/01-02 - DEATHRACE.mp3 existiert so nicht
auf der Platte. Navidrome gibt über Subsonic standardmäßig nicht den
echten Pfad heraus, sondern baut ihn per fakePath() aus den Tags:
{AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{ext} — 01 ist die Disc, 02 die
Tracknummer. An der Künstlerstelle steht also kein kaputter Wert, dort
steht gar kein Künstler.

Known-Bug-Eintrag entsprechend widerlegt statt gelöscht, samt Hinweis auf
"Report Real Path" am Player novasonic — das ist die Wurzel der
Datei-Ratearbeit im Backend und damit auch des Fehlers "Metadaten springen
zurück". Das Backend nimmt einen absoluten Pfad bereits ohne Änderung.

Info-Dialog beschriftet das Feld jetzt als "Pfad (Navidrome)".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:56:15 +02:00
marcandClaude Opus 5 ac69d259d5 fix(web): Künstler in den Albumansichten sichtbar machen
Der Name stand in --text2 (#8888aa) und 14px unter einem 28px starken
Albumtitel — er ging darin unter, und dass er anklickbar ist, war nicht
erkennbar. Jetzt --text, 15px, halbfett; wo ein Ziel dahintersteht,
zusätzlich dezent unterstrichen (Hover in Akzentfarbe).

Gilt für lokale Albumseite, Deezer-Albumseite und die Inline-Ansicht in
der globalen Suche. Die Teil-Album-Seite bleibt ohne Link, dorthin kommt
man nur von der Künstlerseite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:43:17 +02:00
marcandClaude Opus 5 ec75fdef7b feat(web+android): vom Album in der globalen Suche zum Künstler springen (2.5 / Android 1.8)
Der Künstlername unter einem Deezer-Album war reiner Text — von dort zum
Künstler ging nur über Zurück und neu suchen. Er ist jetzt anklickbar und
öffnet dieselbe Deezer-Künstlerseite wie eine Künstler-Kachel.

Kostet keinen zusätzlichen API-Aufruf: Deezer legt /search/album und
/album/{id} das artist-Objekt samt id bei (120/120 Treffer über zwölf
Suchen geprüft), beide Mappings übernahmen die id längst.

/artist/{id}/albums lässt das artist-Objekt weg — Alben von dort bleiben
bewusst reiner Text, statt ins Leere zu zeigen.

Android zeigte in der Album-Detailansicht bisher gar keinen Künstler und
lädt Bild + Fanzahl nach, wenn der Künstler aus einem Album stammt.

.album-artist-name trug cursor:pointer auch ohne Ziel — jetzt nur noch,
wo wirklich etwas dahintersteht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:22:37 +02:00
marcandClaude Opus 5 fca7cc79c1 feat(android): Cache nach einem Update einmalig leeren (1.7)
Im cacheDir liegen Coils Bildspeicher und heruntergeladene APK-Updates, beides
jederzeit neu beschaffbar. Blieb dort nach einer neuen Version etwas Altes
liegen, war "Cache leeren" von Hand in den Systemeinstellungen die einzige
Abhilfe — nichts, das sich einem normalen Nutzer erklaeren laesst.

Geleert wird jetzt beim ersten Start nach einem Versionswechsel, verglichen wird
der gespeicherte versionCode. Im laufenden Betrieb passiert nichts, sonst waeren
die Cover bei jedem Start neu zu laden. Beim allerersten Start nach der
Installation ebenfalls nicht, da gibt es nichts aufzuraeumen.

Offline-Downloads bleiben unberuehrt: die liegen unter filesDir/offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:42:27 +02:00
marcandClaude Opus 5 651f979401 perf(android): Warteschlange sofort wiederherstellen, SW-Cache automatisch versionieren
Android speicherte nur die Song-Ids und lud beim Start jeden Titel EINZELN und
nacheinander ueber das Netz nach. Das dauerte Sekunden, war unzuverlaessig und
ohne Verbindung unmoeglich. Gespeichert werden jetzt die vollstaendigen Songs als
JSON — Wiederherstellen ist ein lokaler Lesevorgang, wie im Web. Der Preis ist
ein veralteter Titel oder Cover bis zum naechsten Abspielen, was die deutlich
kleinere Unschoenheit ist.

Dazu sichert MusicService.onTaskRemoved die Warteschlange beim Wegwischen der
App: dort feuert onIsPlayingChanged nicht zuverlaessig, genau dieser Fall ging
vorher verloren.

Service Worker: die Cache-Version stand handgepflegt als ns-v78 in sw.js und
wurde beim Ausliefern vergessen — die Aktivierung loescht nur Caches mit
abweichendem Namen, also blieb der alte bestehen und Nutzer mussten den
Browsercache von Hand leeren. Ein Vite-Plugin setzt sie jetzt aus der Bauzeit,
die Quelldatei bleibt unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:01:30 +02:00
marcandClaude Opus 5 0b23a07039 fix(android): Warteschlangen-Wiederherstellung robuster und nachvollziehbar (1.5)
Das Wiederherstellen schlug still fehl. Mehrere runCatching verschluckten jeden
Grund, sodass von aussen nur "passiert nichts" zu sehen war.

Zwei wahrscheinliche Ursachen adressiert:
- connect() laeuft in Application.onCreate(), wo der DataStore mit den
  Navidrome-Zugangsdaten unter Umstaenden noch nicht gelesen ist. Dann scheitert
  das Aufloesen ALLER Song-Ids und die Warteschlange bleibt leer. Es wird jetzt
  bis zu fuenfmal mit wachsendem Abstand versucht.
- Wischt man die App aus den letzten Apps, wurde nichts gesichert:
  onIsPlayingChanged feuert dabei nicht zuverlaessig. MusicService.onTaskRemoved
  sichert jetzt vor dem Beenden.

Dazu Protokollausgaben unter dem Tag NsQueue an jeder Stufe (gesichert,
gefunden, Id nicht aufloesbar, wiederhergestellt, aufgegeben), damit ein
weiterer Fehlschlag per logcat zu klaeren ist statt per Vermutung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:26:17 +02:00
marcandClaude Opus 5 8da67e0975 chore(android): versionCode 7, versionName 1.4
Erste Android-Version mit Warteschlangen-Wiederherstellung und Streitpunkten.
Das zuvor hochgeladene 1.3 enthielt restoreQueue noch nicht — daher blieb die
Now-Playing-Leiste nach dem Oeffnen leer. Release-Beschreibung mit angehoben,
sonst meldet der In-App-Updater kein Update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:44:25 +02:00
marcandClaude Opus 5 e8108b0a7a docs(changelog): Dateinamen-Fehler richtig beschrieben (NN-NN im Kuenstlerfeld)
Die erste Fassung beschrieb einen anderen Mechanismus (doppelte Tracknummer aus
dem Titel-Tag). Marcs Beispiel zeigt etwas anderes: 01-02 steht an der Stelle des
Kuenstlers, die Tracknummer fehlt. trackPrefix kann diese Form nicht erzeugen,
die Quelle muss also das Kuenstlerfeld so geliefert haben — dieselbe Form ist in
metadata.test.js bereits als "garbage artist prefix" festgehalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:46:47 +02:00
marcandClaude Opus 5 871ecdd72e docs(changelog): doppelte Tracknummern im Dateinamen als bekannten Fehler notiert
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:44:59 +02:00
marcandClaude Opus 5 a2211a62d2 refactor(web+android): nur Warteschlange und Titel sichern, keine Sekunden
Das Mitschreiben der Abspielposition war zu viel des Guten: es bedeutete
staendiges Schreiben waehrend der Wiedergabe fuer einen Gewinn, den man kaum
bemerkt. Gesichert werden jetzt nur Warteschlange und aktueller Titel, der dann
von vorn beginnt.

Der eigentliche Gewinn bleibt und war der Ausloeser: unter Android war die
Now-Playing-Leiste nach dem Oeffnen leer, bis man einmal auf Play drueckte —
gesichert war nur der letzte Song, nicht die Warteschlange. Das Wiederherstellen
fuellt den Zustand jetzt ueber syncState(), also ohne Abspielen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:25:06 +02:00
marcandClaude Opus 5 65fa950043 feat(web+android): Wiedergabe fortsetzen und Streitpunkte im Duell (2.4)
Wiedergabe fortsetzen:
Im Web ueberlebten Warteschlange und letzter Titel bereits, die POSITION im
Titel aber nicht — beim Neuladen begann der Song von vorn. Android sicherte nur
den letzten Song ohne Warteschlange. Beide merken sich jetzt Warteschlange,
Listenposition und Sekunde.

Web schreibt gedrosselt (alle 5 s) waehrend der Wiedergabe und sicher beim
Pausieren, Wegwischen und Tabwechsel — pagehide/visibilitychange statt
beforeunload, das am Handy unzuverlaessig feuert. Android sichert bei jedem
Titelwechsel und beim Pausieren und stellt nach dem Verbinden wieder her:
vorbereitet, aber NICHT abgespielt, wer die App oeffnet will nicht ueberfallen
werden. Laeuft schon etwas (Android Auto), passiert nichts.

Zwei Feinheiten: lag die Position im letzten Rest des Titels, faengt er von vorn
an, statt zwei Sekunden Abspann zu spielen; und gespeichert werden nur Song-Ids,
weil Titel und Cover sich durch Metadaten-Bearbeitung aendern koennen —
verschwundene Titel werden beim Wiederherstellen uebersprungen.

Streitpunkte im Duell:
Gemeinsame Kuenstler nach groesstem Ungleichgewicht statt nach Summe, mit
Kraefteverhaeltnis-Balken. Mindestens vier gemeinsame Plays und 50 % Unwucht,
sonst gilt ein einzelner Play gegen null schon als Streit. Web und Android mit
identischen Schwellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:20:40 +02:00
marcandClaude Opus 5 92c3d96936 docs(changelog): Dublettenprüfung nachgezogen, Unterstrich-Lücke im Variantenfilter notiert
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:11:42 +02:00
marcandClaude Opus 5 dfae34c706 feat(android): Navidrome-Adresse als Vorgabe, Login vorbelegt (1.3)
Ergaenzt Backend und Scrobble um die Navidrome-Adresse: alle drei kommen als
buildConfigField aus dem Build und greifen, wenn im Geraetespeicher nichts steht.
Der Anmeldebildschirm ist damit vorbelegt — beim ersten Start bleiben nur noch
Benutzer und Passwort. Die Felder in den Einstellungen bleiben sichtbar und
ueberschreibbar, ein Umzug ist also ohne neues APK zu korrigieren.

Zugangsdaten bleiben aussen vor. Das Publish-Skript reicht die Werte aus
scripts/.env.release durch, damit kuenftige Builds sie nicht verlieren.
Am erzeugten BuildConfig geprueft, alle vier Werte stehen darin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:37:13 +02:00
marcandClaude Opus 5 334aa51a09 feat(android): Backend- und Scrobble-Adresse als Vorgabe aus dem Build
Beide Adressen stehen ohnehin in der docker-compose.yml — sie zusaetzlich auf
jedem Geraet einzutippen ist Arbeit ohne Nutzen. Sie kommen jetzt als
buildConfigField aus -PnovasonicBackend= / -PnovasonicScrobble= und greifen,
wenn im Geraetespeicher nichts steht.

Die Felder in den Einstellungen bleiben sichtbar und ueberschreibbar: zieht ein
Dienst um oder aendert sich ein Port, laesst sich das am Geraet korrigieren,
statt ein APK bauen, hochladen und installieren zu muessen. Ohne gesetzte
Eigenschaften verhaelt sich die App exakt wie bisher.

Zugangsdaten bleiben bewusst aussen vor — die gehoeren pro Person, nicht in ein
APK, das im Release liegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:35:37 +02:00
marcandClaude Opus 5 83570b22b5 refactor(android): Web-Adresse aus BuildConfig statt als vierte Einstellung
Der Host stand ohnehin schon beim Bauen fest, weil die App-Links ihn im Manifest
brauchen. Ihn zusaetzlich als Einstellung zu fuehren hiess: eine vierte Adresse
pflegen, die vom Manifest abweichen kann, ohne dass es jemand merkt — und der
Teilen-Eintrag versteckte sich, solange sie leer war, was beim Suchen schlicht
wie ein fehlendes Feature aussah.

Jetzt kommt beides aus -PnovasonicHost=…: das Manifest fuer eingehende Links,
BuildConfig.SHARE_HOST fuer ausgehende. Die Einstellung samt Speicherfeld ist
entfernt, Teilen ist immer sichtbar. versionCode 5 / 1.2, APK hochgeladen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:27:27 +02:00
marcandClaude Opus 5 dea0485a54 docs(changelog): Android 1.1 und die Sichtbarkeit des Teilen-Eintrags
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:03:57 +02:00
marcandClaude Opus 5 4323d68edc chore(android): versionCode 4, versionName 1.1
Der In-App-Updater vergleicht versionCode aus der Gitea-Release-Beschreibung mit
BuildConfig.VERSION_CODE. Beide standen auf 3, das hochgeladene APK meldete sich
also korrekt als "kein Update" — trotz Teilen-per-Link, Songloeschen,
Playlist-aus-Import und Duell-Anzeigetafel. Version angehoben und die
Release-Beschreibung auf versionCode=4 gesetzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:03:17 +02:00
marcandClaude Opus 5 91d0d5fd0b feat(web+android): Musik-Match wird zum Duell mit Anzeigetafel
Statt eines Prozentrings ("wie aehnlich seid ihr") zeigt die Ansicht jetzt, wer
worin gewinnt: fuenf Kategorien mit je einem Sieger und ein Gesamtstand.
Hoerzeit, Vielfalt, Tiefe (Plays je Kuenstler), Nachteule (Anteil vor 5 Uhr) und
Entdeckerdrang (Anteil exklusiver Kuenstler) — bewusst Kennzahlen zur Hoerweise
statt zur Menge, sonst gewinnt immer, wer laenger dabei ist.

Am laufenden Dienst mit zwei gegensaetzlichen Nutzern geprueft: 60 Plays a 300 s
ergeben 5,0 h und Tiefe 12,0, jeder dritte Play nachts ergibt 33 %, 60 von 62
exklusiven Kuenstlern ergeben 97 % — Endstand 3:2. Das JSON-Feldschema wurde
gegen das Kotlin-Modell abgeglichen.

Dazu drei Fixes im selben Bereich:
- Die Web-Ansicht lebte nur von Inline-Stilen mit festen Flex-Verhaeltnissen und
  zerfiel am Desktop. Jetzt ein Raster mit eigenen Klassen, eine Spalte am Handy,
  zwei ab 780 px; das Fenster musste dafuer von 720 auf 940 px, sonst haette die
  zweite Spalte nie ausgeloest.
- Gemeinsame Songs wurden ueber song_id abgeglichen und fielen auseinander,
  sobald eine Datei umbenannt wurde. Jetzt normalisierter Titel+Kuenstler.
- Media-Session: die Metadaten wurden feldweise mutiert (gegen Kachel-Flackern
  unter Windows), ueber MPRIS kam davon nichts an — Plasma-Widgets zeigten
  dauerhaft den zuerst gespielten Titel. Bei echtem Titelwechsel jetzt ein neues
  MediaMetadata, sonst weiter in-place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:55:16 +02:00
marcandClaude Opus 5 5a6cff8105 chore(share): echten Signatur-Fingerabdruck in assetlinks.json
Die veroeffentlichten APKs sind Debug-signiert (kein signingConfig im
Release-Buildtype), der Fingerabdruck ist deshalb der des Debug-Keys:
1039A00E… — geprueft mit apksigner am gebauten APK und identisch mit dem
Schluessel des bisher veroeffentlichten Release, Updates laufen also in-place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:42:37 +02:00
marcandClaude Opus 5 5f1650b82a build(apk): Publish-Skript reicht den App-Link-Host an Gradle durch
Der Host steht fest im Manifest und muss beim Bauen mitgegeben werden. Das
Publish-Skript rief Gradle bisher ohne ihn auf, ein so veroeffentlichtes APK
haette also den Platzhalter aus build.gradle.kts getragen und geteilte Links
waeren im Browser gelandet statt in der App. NOVASONIC_HOST (und optional
NOVASONIC_HOST2) kommen aus scripts/.env.release; fehlt der Wert, warnt das
Skript deutlich, statt still das Falsche zu bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:25:14 +02:00
marcandClaude Opus 5 94fdc3274e docs(share): Ablauf eines Domainwechsels als Reihenfolge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:23:05 +02:00
marcandClaude Opus 5 a89e2974bc feat(android): zweiter App-Link-Host fuer einen Domainwechsel
Der Host eingehender Links steht fest im Manifest, ein Domainwechsel brauchte
also zwingend ein neues APK und liess alte Links sofort ins Leere laufen. Mit
-PnovasonicHost2= laesst sich waehrend des Umzugs eine zweite Adresse angeben;
ohne Angabe wird der erste Host wiederholt. Bewusst kein Platzhalter-Host als
Vorgabe: auf Android 11 und aelter verifiziert das System nur, wenn ALLE Hosts
eine gueltige assetlinks.json liefern — ein toter Eintrag wuerde alle blockieren.
Am zusammengefuehrten Manifest geprueft, beide Hosts landen darin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 03:47:35 +02:00
marcandClaude Opus 5 430ecdc737 feat(web+android): Teilen per Link
Ein Song oder Album laesst sich per https://<host>/?song=<id> an jemanden auf
derselben Instanz schicken. Der Link traegt nur die Navidrome-Id, keine
Zugangsdaten — der Empfaenger sieht den Inhalt mit seinem eigenen Konto.

Web: Teilen ueber navigator.share() mit Rueckfall auf die Zwischenablage. Beim
Start wird ?song= / ?album= aufgeloest (nach der Anmeldung, vorher fehlen die
Zugangsdaten), das Album geoeffnet, der Song gestartet und der Parameter aus der
Adresszeile entfernt, damit ein Neuladen nicht erneut losspielt. Das Manifest
bekommt launch_handler=navigate-existing, damit der Link in einem bereits
offenen PWA-Fenster landet statt in einem neuen.

Android: Teilen ueber ACTION_SEND, Empfang ueber einen App-Link-Intent-Filter.
Der Host ist eine Build-Eigenschaft (-PnovasonicHost=…, verifiziert am
zusammengefuehrten Manifest), die Web-Adresse fuer ausgehende Links eine neue
Einstellung. Weil der NavController in der Compose-Hierarchie lebt und die
Activity nicht selbst navigieren kann, reicht DeepLinkState den Link durch;
onNewIntent ist mitbedacht, sonst passierte beim zweiten Link nichts.

assetlinks.json liegt als Vorlage in public/ und wird vom Frontend-Image
ausgeliefert (im Build verifiziert); ohne den Release-Fingerabdruck oeffnet der
Link die Web-App im Browser statt die App. Anleitung: docs/share-links.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 03:30:32 +02:00
marcandClaude Opus 5 3c99778567 fix(backend): Dublettenpruefung erkennt Akzente und Soundtrack-Ordner
Drei Luecken derselben Familie, die dazu fuehrten, dass dieselben Songs ein
zweites Mal geladen wurden:

- Kuenstlernamen wurden zeichengenau verglichen: "Cécile Corbel" und "Cecile
  Corbel" galten als zwei Personen. Welche Schreibweise ankommt, haengt an der
  Quelle (Deezer vs. Spotify). Der Vergleich normalisiert jetzt Akzente weg —
  nur der Vergleich, Tags und Dateinamen behalten das Original.
- Typografische Apostrophe: "Arrietty’s Song" (U+2019) und "Arrietty's Song"
  fielen auseinander. Fiel beim Testen auf, betrifft viele Soundtracks.
- findExistingTrack durchsucht nur den Ordner des uebergebenen Kuenstlers. Bei
  Soundtracks weicht der Album- vom Titel-Kuenstler ab, der Song landet je nach
  Ladeweg unter dem einen oder anderen — geprueft wurde jeweils der falsche.
  Weicht der Titel-Kuenstler ab, wird nun auch dessen Ordner geprueft (~0,6 ms
  bei 28.801 Dateien, gegenueber 3-9 s pro Download).

Sieben neue Tests zielen gezielt auf Fehlalarme: aehnliche Namen (Cecil Taylor
vs. Cécile Corbel), gleicher Titel bei verschiedenen Kuenstlern, Remix gegen
Basistitel, fremder Titel im Soundtrack-Ordner — alle bleiben getrennt.
Gesamte Suite: 42 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:40:02 +02:00
marcandClaude Opus 5 f62b248bb6 fix(backend): Metadaten-Edit trifft nicht mehr die falsche Datei
Findet das Backend die von Navidrome gemeldete Datei nicht am angegebenen Pfad
(Subsonic liefert oft einen aus Tags gebauten virtuellen Pfad), sucht es ueber
Dateiname bzw. Kuenstler+Titel weiter. Liegt derselbe Song mehrfach auf der
Platte, konnte das die falsche Kopie treffen: die wurde getaggt, der bearbeitete
Song blieb unveraendert — die Aenderung "sprang zurueck". Das erklaert, warum es
bei Songs ohne Dublette immer ging und bei anderen nie.

Der ueber Aehnlichkeit gefundene Kandidat wird jetzt per ffprobe gegen die Tags
geprueft, die Navidrome fuer diesen Song meldet, und bei Widerspruch mit 409 und
erklaerender Meldung abgebrochen statt geraten. Zusaetzlich melden uebersprungene
Umbenennungen und Verschiebungen sich als Warnung, statt stumm zu bleiben; die
Antwort nennt den tatsaechlich bearbeiteten Pfad, der Editor zeigt beides an.

Ausserdem: -dontwarn org.slf4j.** — der Release-APK-Build brach seit laengerem an
R8 ab (Ktors Logging referenziert den slf4j-1.x-Binder, den es auf Android nicht
gibt). Vorbestehend, mit HEAD~1 verifiziert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:46:44 +02:00
marcandClaude Opus 5 aae358c4c7 feat(android): Songs loeschen und Playlist aus dem Import, plus Web-Scrollfix
Android holt zwei Sachen nach, die es im Web laengst gibt:

- Song von der Festplatte loeschen, im Kontextmenue unten abgesetzt und rot, mit
  derselben Sicherheitsabfrage wie im Web. Laeuft ueber /api/delete-song und
  erscheint nur mit konfiguriertem Backend — Navidrome allein loescht nichts.
- Playlist direkt aus dem Import anlegen: Haekchen plus Namensfeld ab zwei
  Titeln, danach Scan anstossen, Titel suchen, Playlist aus den gefundenen.
  Nicht gefundene werden gezaehlt statt die Erstellung scheitern zu lassen.

Dazu der Web-Fix zur Album-Position: die gespeicherte Scrollposition wurde in
genau einem requestAnimationFrame gesetzt. Solange Cover und Eintraege noch
nachluden, war scrollHeight kleiner als das Ziel und der Browser kappte den Wert
stillschweigend — man landete wieder oben. Jetzt wird er ueber bis zu ~0,5 s
erneut gesetzt, bis er sitzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:40:57 +02:00
marcandClaude Opus 5 a05c15df93 docs(changelog): YTM-Wächter, Album-Zurück und Scrobble-Gruppierung
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:52:03 +02:00
marcandClaude Opus 5 da32d2911a fix(scrobble): Metadaten-Bearbeitung zerlegt die Statistik nicht mehr
Top-Titel gruppierte nach song_id. Wird eine Datei beim Bearbeiten umbenannt,
vergibt Navidrome eine neue id — der Titel erschien danach zweimal mit geteilten
Zaehlern. Kuenstler/Alben/Genres gruppierten nach lower(), sodass schon ein
doppeltes Leerzeichen zwei Eintraege ergab.

Schluessel ist jetzt durchgehend normalisiert (lower + trim + Leerzeichen
gefaltet), Top-Titel gruppiert nach Titel+Kuenstler statt nach song_id. Die
Anzeigefelder kommen aus dem JUENGSTEN Play statt aus MAX(), sonst zeigte die
Statistik nach einer Korrektur weiter die alte Schreibweise. Am laufenden Dienst
geprueft: 40 Plays vor und 10 nach einer Umbenennung erscheinen als ein Eintrag
mit 50 Plays, neuer Schreibweise und neuer songId.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:47 +02:00
marcandClaude Opus 5 39d47b78d4 fix(web): Zurueck im Album fuehrt dorthin zurueck, wo man geklickt hat
album-back-btn war die einzige Detailseite, die per navigate() VORWAERTS auf die
Albenliste sprang statt einen Schritt im Verlauf zurueck. Dadurch landete man
nicht dort, wo man das Album angeklickt hatte (z. B. Home), und es kam ein
zusaetzlicher History-Eintrag auf den Stapel, durch den sich der Browser-Zurueck-
Knopf erst arbeiten musste. Jetzt _navGoBack() wie bei Artist, Genre, Deezer- und
Partial-Album; die Albenliste bleibt nur das Ziel, wenn es keinen Verlauf gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:47 +02:00
marcandClaude Opus 5 eee98d193f fix(backend): Kanaltreffer in der YTM-Suche verwirft nicht mehr die Trefferliste
getYtmVideoUrls wertete jedes /browse/UC in der Ausgabe als "yt-dlp hat einen
Artist-Channel aufgeklappt" und gab [] zurueck — samt der bereits gesammelten
Song-URLs. Bei "DAT ADAM UFO" stand der Kanal auf Platz 9 und riss sieben gute
Treffer mit, darunter den offiziellen Art Track auf Platz 1; uebrig blieb der
YouTube-Fallback mit einem Live-Mitschnitt. An 8 echten Suchen gemessen feuerte
das in 7 Faellen, Schritt 1 der Kaskade war also praktisch tot.

Mit --flat-playlist kann gar nichts aufgeklappt werden, ein Kanal ist EINE Zeile.
pickYtmVideoUrls() behaelt daher alle watch?v=-Zeilen und ueberspringt browse/UC
(Kanal) wie browse/MPREb_ (Album). Ueber 20k Zufallsausgaben geprueft: die neue
Funktion ist eine Obermenge der alten, kann also nur Kandidaten hinzufuegen.
Drei echte Downloads liefen end-to-end sauber durch (Dauer sekundengenau auf
Deezer, korrekte Tags), alle aus Schritt 1 statt aus dem Fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:32 +02:00
94 changed files with 7613 additions and 890 deletions

No files matched your search

+820 -24
View File
@@ -3,6 +3,9 @@
All notable changes to NovaSonic are documented here. All notable changes to NovaSonic are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features and fixes together). Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features and fixes together).
Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche das waren, steht in einer
**Android:**-Zeile direkt unter der Release-Überschrift — die Einträge darunter beschreiben nur noch,
*was* sich geändert hat, nicht in welchem APK.
--- ---
@@ -13,13 +16,14 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| Music playback (Navidrome) | ✅ | ✅ | | Music playback (Navidrome) | ✅ | ✅ |
| Seek bar / scrubbing | ✅ | ✅ | | Seek bar / scrubbing | ✅ | ✅ |
| Queue management | ✅ | ✅ | | Queue management | ✅ | ✅ |
| Warteschlange bearbeiten (ziehen/löschen) | ✅ | ✅ |
| Shuffle / Repeat | ✅ | ✅ | | Shuffle / Repeat | ✅ | ✅ |
| Radio mode (similar songs) | ✅ | ✅ | | Radio mode (similar songs) | ✅ | ✅ |
| Now Playing overlay | ✅ | ✅ | | Now Playing overlay | ✅ | ✅ |
| Dominant color background | ✅ | ✅ | | Dominant color background | ✅ | ✅ |
| Cover art scale animation | ❌ | ✅ | | Cover art scale animation | ❌ | ✅ |
| Synced lyrics (LRCLIB) | ✅ | ✅ | | Synced lyrics (LRCLIB) | ✅ | ✅ |
| Lyrics autoscroll | ✅ | ✅ | | Lyrics-Mitlaufen (umschaltbar) | ✅ | ✅ |
| Star / favorite song | ✅ | ✅ | | Star / favorite song | ✅ | ✅ |
| Multi-artist navigation | ✅ | ✅ | | Multi-artist navigation | ✅ | ✅ |
| Album detail page | ✅ | ✅ | | Album detail page | ✅ | ✅ |
@@ -35,7 +39,17 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| Playlist browsing | ✅ | ✅ | | Playlist browsing | ✅ | ✅ |
| Playlist create/edit | ✅ | ✅ | | Playlist create/edit | ✅ | ✅ |
| Playlist cover upload | ✅ | ✅ | | Playlist cover upload | ✅ | ✅ |
| Besitzer geteilter Playlists | ✅ | ✅ |
| Künstler folgen / Album merken | ✅ | ✅ |
| Neu von deinen Künstlern | ✅ | ✅ |
| Konzerte deiner Künstler | ✅ | ✅ |
| Spotify / YouTube importer | ✅ | ✅ | | Spotify / YouTube importer | ✅ | ✅ |
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
| Wiedergabe fortsetzen | ✅ | ✅ |
| Teilen per Link | ✅ | ✅ |
| Album → Künstler (Deezer-Suche) | ✅ | ✅ |
| Playlist aus Import anlegen | ✅ | ✅ |
| Song von Festplatte löschen | ✅ | ✅ |
| Metadata editor (tags + cover) | ✅ | ❌ | | Metadata editor (tags + cover) | ✅ | ❌ |
| Song delete (from disk) | ✅ | ❌ | | Song delete (from disk) | ✅ | ❌ |
| Import notification | ✅ | ✅ | | Import notification | ✅ | ✅ |
@@ -50,6 +64,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| ABS notification (modern style) | ❌ | ✅ | | ABS notification (modern style) | ❌ | ✅ |
| Android Auto | — | ✅ | | Android Auto | — | ✅ |
| Offline download (albums/playlists) | ❌ | ✅ | | Offline download (albums/playlists) | ❌ | ✅ |
| Downloads nur über WLAN | — | ✅ |
| Offline download (single songs) | ❌ | ✅ | | Offline download (single songs) | ❌ | ✅ |
| Offline playback | ❌ | ✅ | | Offline playback | ❌ | ✅ |
| Offline detail / manage downloads | ❌ | ✅ | | Offline detail / manage downloads | ❌ | ✅ |
@@ -64,9 +79,678 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
--- ---
## [Unreleased] ## [3.2] — 2026-10-01
**Android:** 1.25 (versionCode 28) → 1.26 (versionCode 29).
### Added
- **Lyrics: Mitlaufen ein- und ausschalten (Web + Android)** — im Lyrics-Reiter sitzt jetzt ein
kleiner Schalter *Mitlaufen / Folgt*. Aus heißt: der Text bleibt stehen, man scrollt selbst.
An heißt: der Text springt mit und hält die laufende Zeile bei 40 % Höhe.
- **Vorgabe ist AUS.** Wer mitliest, blättert auch mal zurück oder vor — und genau dann sprang
der Text bisher bei jeder neuen Zeile unter dem Finger weg.
- **Nur bei synchronen Texten sichtbar.** Ohne Zeitstempel gibt es nichts mitzuführen; bei
reinem Text erscheint der Schalter nicht.
- Hervorgehoben wird die laufende Zeile **immer**, auch bei ausgeschaltetem Mitlaufen — man will
ja sehen, wo man ist. Einschalten richtet sofort aus, statt bis zur nächsten Zeile zu warten.
- Gespeichert wird pro Gerät (`ns_lyrics_folgen` im Web, `lyrics_autoscroll` in den Android-
Einstellungen). Der Android-Schlüssel wird **einzeln** geschrieben: würde ihn das Speichern im
Einstellungsbildschirm mitschreiben, setzte es ihn jedes Mal auf die Vorgabe zurück, weil
dieser Bildschirm das Feld gar nicht führt.
### Fixed ### Fixed
- **Mischen war nicht zufällig (Web)** — `liste.sort(() => Math.random() - 0.5)` ist keine
Mischung: ein Vergleicher, der bei derselben Paarung mal so und mal anders antwortet, ist für
eine Sortierung ungültig, und das Ergebnis ist messbar verzerrt. Gemessen (V8, 3000 Durchläufe)
blieb der erste Titel drei- bis fünfmal zu oft vorn — bei 10 Titeln in 18,8 % der Fälle statt
10 %, bei 100 in 3,1 % statt 1 %, bei 2000 in 0,23 % statt 0,05 %. Die Grobordnung sah dabei
gemischt aus, weshalb es lange nicht auffiel; bevorzugt wurden trotzdem immer dieselben Titel.
Ersetzt durch Fisher-Yates in `src/player/queueMath.js` (`mischeListe`), verwendet an allen vier
Stellen: Warteschlange starten, Mischen umschalten und die beiden Genre-Knöpfe.
- **Der letzte Titel lief mehrfach wieder los (Web/PWA)** — am Ende der Warteschlange ohne Repeat
steht nichts mehr an, trotzdem spielte der letzte Titel bei jedem Zurückholen der Seite erneut.
Ursache: ein beendetes `<audio>` meldet sich als *pausiert*, und die Wiederaufnahme nach einem
unfreiwilligen Hintergrund-Stop rief `play()` — das spult dann auf Anfang zurück. Mit Mischen
fiel es besonders auf, weil der letzte Titel dort ein beliebiger ist. Jetzt bricht die
Wiederaufnahme bei `audio.ended` ab.
- **Shuffle-Knopf und tatsächliche Reihenfolge gingen auseinander (Android)** — jeder
Shuffle-Knopf mischte selbst (`liste.shuffled()`) und übergab die fertige Reihenfolge. Damit war
die „ursprüngliche" Liste schon die gemischte: das Shuffle-Symbol leuchtete nicht, obwohl
gemischt gespielt wurde, und *Shuffle aus* konnte die Albumreihenfolge nie wiederherstellen.
Das Mischen macht jetzt der Player (`playSongs(liste, -1)`), der die ungemischte Liste behält.
- **Radio-Modus konnte den laufenden Titel direkt hinter sich selbst hängen (Web + Android)** — in
der letzten Rückfallebene sind Wiederholungen erlaubt, damit Musik weiterläuft; der gerade
laufende Titel war davon nicht ausgenommen und spielte dann zweimal in Folge. Jetzt ist er es.
## [3.1] — 2026-09-22
**Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf und die Gesamtdauer, 1.24 die Konzerte, 1.25 sie ohne Zeitfenster.
### Added
- **Konzerte deiner Künstler (Web + Android)** — die Zeitleiste hat einen zweiten Reiter: neben
*Neu erschienen* steht jetzt *Konzerte* und zeigt, wann die Künstler spielen, denen du folgst.
Neuer Endpunkt `POST /api/artist-concerts`, Gegenstück zu `/api/artist-releases`.
- **Zwei Reiter statt zweier Knöpfe** in den Favoriten. Beides ist dasselbe Thema; zwei Knöpfe
drängeln sich, und man müsste sich merken, welcher was zeigt.
- **Bewusst nicht in eine gemeinsame Liste gemischt.** Veröffentlichungen liegen hinter dir,
Konzerte vor dir. Chronologisch gemischt stünde „heute" mittendrin und man wüsste nie, ob man
vorwärts oder rückwärts liest. Deshalb läuft die Konzertliste **nach vorn**: das nächste
Konzert zuerst, nach Monat gruppiert wie bei den Veröffentlichungen.
- **Kein Zeitfenster bei den Konzerten.** Die Chips 90/30/365 waren dort zuerst mit dabei und
ergaben keinen Sinn: Ticketmaster liefert ohnehin nur Anstehendes, und bei Terminen will man
alles Angekündigte sehen — eine Tagesgrenze schneidet nur willkürlich ab. Die Chip-Reihe
erscheint jetzt nur noch beim Reiter *Neu erschienen*; der Endpunkt beachtet ein
mitgeschicktes Fenster weiterhin, verlangt es aber nicht mehr.
- **Gegenprobe gegen die Stichwortsuche.** Ticketmaster wird über den Namen abgefragt
(`keyword=`), nicht über `attractionId` — dieselbe Unschärfe wie bei der Deezer-Zuordnung.
Jeder Treffer wird deshalb gegengeprüft: der Künstlername muss im Veranstaltungs- **oder**
Attraktionsnamen vorkommen. Ohne das liefert „MORE" oder „TPR" fremde Veranstaltungen.
- **Ortszeit, nicht UTC.** Ein Konzert um 20:00 stünde sonst als 08:00 da. Die Zeit wird als
zonenlose Ortszeit durchgereicht und unverändert angezeigt.
- **Kein Ortsfilter** — die Suche läuft weltweit. Eine Einschränkung per `.env` ist im Backlog
notiert; fürs Erste reicht, was die API liefert.
- Der Cache liegt im Backend (6 h, höchstens vier Anfragen gleichzeitig), nicht im Client: bei
50 gefolgten Künstlern wären das sonst 50 Abfragen pro Gerät und App-Start. Bei einem Fehler
kommt der abgelaufene Stand zurück statt einer leeren Liste.
- **Neun Prüfungen** in `backend/test/konzerte.test.js` — mit eingeschobener
Ticketmaster-Antwort, also ohne Schlüssel und ohne Netz: Ortszeit, Gegenprobe, Fenster,
Sortierung nach vorn, Cache-Treffer, abgesagte Termine.
- **Zwei neue Designs: Violet und Crimson (Web + Android)** — ein helles Lila auf tiefem
Violettschwarz und ein gedämpftes Rot auf Weinrot-Schwarz. Beide mit demselben Variablensatz wie
die vorhandenen, geprüft: kein Design vermisst eine Variable.
- Crimson trägt bewusst **kein reines Rot** als Akzent — das läge zu nah an der Warnfarbe, und
Löschknöpfe sähen dann aus wie gewöhnliche Akzente. Stattdessen ein gedämpftes Rosé-Rot, und
die Warnfarbe wurde dort heller gesetzt, damit sie weiterhin absticht.
- Beide Plattformen lesen ihre Designliste durch, die Auswahl in den Einstellungen füllt sich
also von selbst.
- **Wischen wechselt den Titel (Android)** — seitwärts wischen blätterte im Now-Playing zwischen
Player, Warteschlange und Text; den Titel wechselte nur ein Wisch **genau über dem Cover**. Das
ist verdreht: der Titelwechsel ist die Geste, die man dort ständig braucht. Jetzt nimmt die ganze
Player-Seite den Wisch entgegen und springt zum nächsten oder vorherigen Titel.
- **Umstellbar** in den Einstellungen unter *Wiedergabe* → „Wischen wechselt den Titel". Aus
heißt: wie bisher. Die Reiter oben schalten in beiden Fällen weiter, es geht also nie eine
Ansicht verloren.
- Auf Warteschlange und Text liegt die Geste **nicht** — dort würde sie mit dem Ziehen in der
Liste streiten.
- **Die Warteschlange lässt sich bearbeiten (Web + Android)** — bisher war sie eine reine Anzeige:
reinlegen ging, umsortieren oder wieder herausnehmen nicht. Jetzt trägt jede Zeile einen Griff
zum Ziehen und ein ✕ zum Entfernen, auf beiden Plattformen und in beiden Web-Listen (Seitenleiste
und Now-Playing).
- **Der Zeiger auf den laufenden Titel wandert mit.** Das ist der Teil, bei dem ein Fehler nicht
auffällt, sondern später den falschen Song spielt: fällt ein Titel **vor** dem laufenden weg,
rutscht dieser eine Stelle vor; trifft es den laufenden selbst, rückt der nächste nach (am
Ende der Liste der davor); beim Verschieben wird der Zeiger über die **Position** des laufenden
Titels neu bestimmt statt gerechnet — das stimmt auch, wenn er selbst gezogen wurde.
- Diese Rechnung liegt im Web als reines Modul ohne Importe in `src/player/queueMath.js`, damit
sie ohne DOM und ohne Wiedergabe prüfbar ist: **12 Fälle, alle grün.**
- **Web: Zeiger-Ereignisse statt HTML5-Drag-and-Drop.** Dessen `dragstart` gibt es auf
Touch-Geräten gar nicht — die Warteschlange wäre am Handy unbearbeitbar geblieben.
`setPointerCapture` bedient Maus und Finger über denselben Weg. Die Zeilen zwischen Start und
Ziel weichen aus, damit die Lücke dorthin wandert, wo man loslässt.
- **Android: getauscht wird laufend**, sobald der Finger eine Zeilenhöhe zurückgelegt hat — so
braucht es kein Geisterbild, die Liste zeigt die neue Reihenfolge sofort. Gezogen wird am
Griff, nicht an der Zeile, sonst ließen sich Ziehen und Scrollen nicht auseinanderhalten; und
der erste Fingerkontakt wird verbraucht, sonst beansprucht die `LazyColumn` die Geste (dieselbe
Falle wie beim Rollbalken).
- Im Player laufen die lokale Liste und Media3 im Gleichschritt (`moveMediaItem`,
`removeMediaItem`). Liefen sie auseinander, zeigte die Oberfläche den falschen Song an, weil
der laufende Index vom Controller kommt, der Titel dazu aber aus der lokalen Liste.
### Changed
- **Gesamtdauer mit Stunden (Web + Android)** — im Web stand bei Playlists und Alben nur eine
Minutenzahl; „134 Min." muss man selbst umrechnen, und bei einer Playlist mit 2000 Titeln steht
dort eine vierstellige Zahl. Ab einer Stunde heißt es jetzt „2 Std. 14 Min.", volle Stunden ohne
Anhängsel („2 Std."). **In Android fehlte die Dauer ganz** — Playlist und Album zeigen sie jetzt
ebenfalls.
- Kostet keinen zusätzlichen Aufruf: Subsonic liefert `duration` beim Playlist- und Album-Objekt
mit, in Android wird genau die genommen; im Web wird weiterhin über die geladenen Titel
summiert, weil die dort ohnehin schon vorliegen.
- Dieselbe Regel und dieselbe Schreibweise auf beiden Plattformen, damit dieselbe Playlist nicht
zwei verschiedene Angaben trägt. Neun Fälle geprüft (0, 59 Min., genau 1 Std., 100 Std., leer).
- **Benachrichtigungen tragen einen Honigtopf mit Brief statt der Glocke (Web + Android)** —
reine Strichzeichnung wie die übrigen Symbole, damit sie die Akzentfarbe annimmt, wenn etwas
Ungelesenes anliegt. Im Web als Inline-SVG, in Android als Vektor-Drawable mit denselben
Konturen, an beiden Stellen (Kopfzeile und Kopf des Benachrichtigungsfensters).
- Bei kleiner Größe zählt jeder Strich: die erste Fassung hatte einen zu großen Brief und einen
geraden Topf, das verlief zu einer Fläche. Der Topf hat jetzt einen gewölbten Bauch und der
Brief sitzt kleiner in der Mitte — gerendert und in Originalgröße gegengesehen.
- **Nennmaß ist nicht gleich Wirkung.** Neben dem Zahnrad wirkte der Topf kleiner, obwohl er
mit 23 px nominal *größer* war. Nachgemessen: das Zahnrad füllt sein 24er-Feld vollständig aus,
der Topf nur 18,4 × 15,9 — also etwa zwei Drittel. Die Zeichnung wird jetzt auf Feldbreite
aufgezogen (Faktor 1,38) und steht bei 19 px; damit stimmen die sichtbaren Höhen überein
(17,3 gegen 18,0). Die Strichstärke ist entsprechend zurückgenommen, weil das Aufziehen sie
mitvergrößert. Dieselbe Korrektur im Android-Vektor, dort war die Schieflage dieselbe.
- **Der Rollbalken läuft nur noch über den Inhalt, nicht über die Suchleiste (Web)** — die
Kopfzeile mit Suche, Glocke und Zahnrad lag **im** Rollbereich und blieb per `sticky` oben
kleben. Der Balken gehört zum Rollbereich und reichte damit bis ganz nach oben, neben eine
Leiste, in der gar nichts rollt. Die Kopfzeile sitzt jetzt außerhalb: eine Spalte rechts der
Seitenleiste trägt oben die feste Kopfzeile und darunter den Rollbereich. Das Suchfeld samt
Ergebnisfenster ist dabei unverändert geblieben — mit jsdom gegengeprüft, **14 Fälle grün**.
- **Die Warteschlange hat einen Schließen-Knopf (Web)** — sie ließ sich nur über denselben Knopf
im Player wieder zumachen, mit dem man sie geöffnet hatte. Jetzt steht ein ✕ in ihrer Kopfzeile.
- **Der Rollbalken ist greifbar (Web)** — er war 4 px breit. Sichtbar reichte das, mit der Maus war
er kaum zu treffen: in einer langen Playlist kam man nicht ans Ende, ohne zu scrollen. Jetzt 12 px
**Greiffläche** mit einem schmalen sichtbaren Balken darin (durchsichtiger Rand plus
`background-clip:content-box`), heller beim Überfahren und beim Ziehen — wie in Spotify.
- `min-height:44px` hält ihn auch bei sehr langen Listen greifbar; bei 1900 Titeln schrumpfte er
sonst auf wenige Pixel, also genau dort, wo man ihn am nötigsten hat.
- **Firefox bekommt nur die Farbe.** Dessen Standardbalken ist ohnehin breit genug; ein
`scrollbar-width:thin` hätte ihn wieder verschmälert. Die Ausnahmen, die bewusst keinen Balken
zeigen (waagerechte Kartenreihen, Lyrics, Chips), bleiben unangetastet.
- **Rollbalken zum Ziehen (Android)** — Compose bringt keinen mit: eine `LazyColumn` zeigt nichts
an, egal wie lang sie ist. Bei einer Playlist mit 1900 Titeln bleibt nur Wischen. Jetzt steht am
rechten Rand ein Balken, der sich greifen und ziehen lässt — in Playlist, Album, Favoriten,
Genre-Titeln und der Offline-Ansicht.
- **Gerechnet wird in Einträgen, nicht in Pixeln.** Die Gesamthöhe einer `LazyColumn` ist
unbekannt, solange nicht alles gebaut wurde, und bei unterschiedlich hohen Zeilen auch nicht
ausrechenbar. Über den Anteil sichtbarer an allen Einträgen stimmt der Balken bei gleich hohen
Zeilen — und genau die haben Song- und Albumlisten.
- **Er bleibt dauerhaft sichtbar,** nur gedämpft (0,28), und wird beim Scrollen und Ziehen
kräftiger. Ein Balken, der erst beim Scrollen erscheint, lässt sich nicht greifen — und Greifen
ist hier der ganze Zweck. Wie im Web gibt es eine Mindesthöhe von 44 dp.
- Beim Ziehen läuft die Position als Kommazahl mit. Würde jeder Zeigerschritt aus
`firstVisibleItemIndex` neu rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal
verloren und der Balken bliebe bei langsamem Ziehen stehen.
- **Nachtrag 1.17: greifen ging zuerst gar nicht.** Zwei Fehler auf einmal. Die Greiffläche war
so schmal wie der sichtbare Balken (8 dp) — am Bildschirmrand trifft das mit dem Finger niemand,
Androids eigene Vorgabe für Bedienelemente liegt bei 48 dp. Und `detectVerticalDragGestures`
wartet die Wischschwelle ab, bevor es die Geste beansprucht; die `LazyColumn` startet ihr
eigenes Scrollen in genau diesem Moment und gewinnt jedes Mal. Jetzt sind sichtbar 6 dp und
anfassbar 28 dp, und der erste Fingerkontakt wird sofort verbraucht, bevor die Liste ihn sieht.
- Verbraucht wird nur, wenn der Finger **auf dem Daumen** aufsetzt (16 dp Spiel). Daneben bleibt
das Wischen am rechten Rand normales Scrollen — sonst käme dort plötzlich die umgekehrte
Richtung heraus, weil Balken ziehen und Inhalt wischen entgegengesetzt laufen.
- **Nachtrag 1.18: das Ziehen brach sofort wieder ab.** `pointerInput` war auf die Geometrie
geschlüsselt — unter anderem auf die Daumenposition, und die steht in jedem Bild anders da. Ein
geänderter Schlüssel setzt die Gestenerkennung neu auf: der Finger lag noch auf dem Schirm, die
Erkennung fing wieder bei null an. Je länger die Liste, desto öfter, deshalb war es bei 2000
Titeln unbrauchbar. Der Schlüssel ist jetzt `Unit`; die wechselnden Werte liest die Geste über
`rememberUpdatedState` zur Laufzeit nach.
- Gescrollt wird außerdem nicht mehr im Zeigerereignis, sondern in **einem** Effekt, der die
Zielposition verfolgt. Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte
konkurrierende Scroll-Aufrufe, die sich gegenseitig überholten.
- **Nachtrag 1.21: die Songzeilen halten rechts 24 dp frei.** Dort steht der Rollbalken, und wo
sein Daumen gerade liegt, wäre ein ⋮ darunter weiterhin nicht zu treffen gewesen — der Daumen
wandert ja mit. Die Zeilen rücken deshalb überall ein, auch dort, wo es gar keinen Balken gibt,
damit sie gleich aussehen.
- **Nachtrag 1.20: der Balken verschluckte das ⋮ der Songzeilen.** Compose reicht eine Berührung
nur an das **oberste** getroffene Element weiter — der Greifstreifen lag über die volle Höhe
am rechten Rand und nahm damit jeden Tipper auf das Kontextmenü entgegen, obwohl er ihn gar
nicht wollte. Jetzt hört nur noch der **Daumen** zu (mit 10 dp Luft darüber und darunter); der
Rahmen darum hat kein `pointerInput` und ist dadurch durchlässig. Der Streifen ist außerdem von
28 auf 24 dp geschrumpft.
- **„Neu von deinen Künstlern" sitzt jetzt in den Favoriten (Web)** — es stand links unter
*Entdecken*, in Android dagegen als Knopf „✨ Neu" in den Favoriten. Zwei Plattformen, zwei Orte
für dieselbe Sache. Das Web folgt jetzt Android: der Eintrag in der Seitenleiste ist weg, in der
Kopfzeile der Favoriten steht der Knopf — dort, wo auch die gefolgten Künstler stehen.
- Der Knopf erscheint nur, wenn man überhaupt jemandem folgt, genau wie in Android.
- Die Seite hat dafür einen Zurück-Knopf bekommen (vorher unnötig, weil sie über die Seitenleiste
erreichbar war) und zählt für die Seitenleiste jetzt zur Bibliothek statt zu Entdecken.
### Fixed
- **Künstler schlägt Dauer: die Kaskade sucht in anderer Reihenfolge (Backend)** — Nachtrag zum
Fehlgriff bei „Dearly Beloved" (TPR). Die Kette war vollständig nachvollziehbar:
- Deezer kennt **keinen** Künstler „TPR"; das Album läuft dort unter *Project Destati*, der Titel
heißt *Dearly Beloved I* und dauert **230 s**. Von dort kam die erwartete Dauer.
- Der richtige Upload dauert **136 s** und fiel damit aus dem Fenster 207–253 s. Eine Rockband,
die tatsächlich **DEARLY BELOVED** heißt, liegt mit ihrem Stück „LSD" bei **250 s** mitten
drin — und ihr **Bandname** erfüllte die Titelprüfung.
- Entschieden hat die Reihenfolge: die Stufe *Dauer behalten, Künstler fallen lassen* lief vor
der Stufe *Künstler behalten, Dauer lockern*. Die erste griff zu, die zweite kam nie dran.
- **Die beiden sind jetzt vertauscht.** Der Künstler ist das stärkere Merkmal: die erwartete
Dauer stammt aus Metadaten und gehört oft zu einer anderen Fassung, während Kanalname und
`artist`-Feld den Song selbst benennen. Es fällt **kein** Schritt weg — was vorher gefunden
wurde, wird weiter gefunden, gegebenenfalls eine Stufe später.
- **Belegt statt behauptet:** `backend/scripts/filter-probe.mjs` holt für bekannte Fälle die
echten Suchtreffer — **aus beiden Quellen, YT Music zuerst** — und spielt beide Reihenfolgen
durch. Fünf Fälle, **ein** Unterschied (der gemeldete), **null** Regressionen: BTS auf dem
Label-Kanal BANGTANTV, der japanische Soundtrack-Titel, Rising Insane und Daft Punk kommen
unverändert heraus.
- Die erste Fassung der Probe sah **nur** die gewöhnliche YouTube-Suche und meldete deshalb für
„Get Lucky" einen Grammy-Mitschnitt und für TPR ein Tutorial-Video. Beides war erfunden: über
YT Music steht dort an erster Stelle die offizielle Tonspur („Get Lucky (feat. Pharrell
Williams and Nile Rodgers)", 370 s) beziehungsweise TPRs echtes „Dearly Beloved" (138 s) — und
genau die lädt die Kaskade. Eine Probe, die eine Stufe auslässt, erfindet Fehler; deshalb fragt
sie jetzt beide Quellen ab.
- **Der Download-Filter konnte den falschen Titel speichern — zwei Löcher, ein Grund (Backend)** —
gemeldet mit „銀髪の少年" (Yoshihisa Hirano, HUNTER×HUNTER) und „Dearly Beloved" (TPR): beide Male
kam etwas anderes an.
- **Die Kaskadenschritte 3 und 4 lassen die Künstlerprüfung fallen** — bewusst, damit
Label-Kanäle durchkommen. Übrig bleibt dann Titel + Dauer. Bei „Dearly Beloved" reicht das
nicht: es gibt Rocksongs, die genau so heißen, und einer davon lag im Dauerfenster.
- **Für japanische Titel gab es überhaupt keine Titelprüfung.** `buildTitleKeywords` lieferte
dafür eine leere Liste (japanische Titel haben keine Wortgrenzen, und Art Tracks sind oft
romanisiert). Zusammen mit Schritt 3/4 blieb damit **allein die Dauer** übrig — und auf einem
Soundtrack-Album passt fast jedes andere Stück desselben Komponisten in dasselbe Fenster.
Genau das ist bei „銀髪の少年" passiert.
- **Behoben in drei Schritten:** japanische Titel bekommen ein Muster aus *Originaltitel oder
Romaji-Fassung* (Art Tracks tragen üblicherweise das eine oder das andere; Leerzeichen und
Bindestriche bleiben frei, weil Uploader sie unterschiedlich setzen). Ein Schritt **ohne**
Titelprüfung läuft jetzt immer **mit** Künstlerprüfung, egal was die Kaskade lockern wollte.
Und japanische Künstlernamen werden gegen das `artist`-Feld geprüft — der Kanalname steht kaum
je in Kanji, das Feld der Art Tracks dagegen schon; vorher hatten sie gar keine Prüfung.
- Bleibt am Ende weder ein Titel- noch ein Künstlermerkmal übrig, wird **gar nicht gesucht**:
ohne Merkmal landet sonst der erste Treffer passender Länge auf der Platte. Der Direktlink im
Importer ist dafür der vorgesehene Weg.
- **Die Umschrift ist nur *eine* Schreibweise.** Der Tokenizer liefert „Ginpatsu No Shounen",
Uploader schreiben „Ginpatsu no Shonen", setzen Bindestriche oder lassen den
Katakana-Längungsstrich weg (der bei „テーゼ" sogar unübersetzt stehenbleibt: „TeーZe"). Das
Muster lässt diese Abweichungen zu — langes o/u optional, Längungsstrich weg, Trennzeichen
frei. Sonst wäre die Alternative so streng, dass sie praktisch nie greift.
- Dieselbe Hilfe für **Kanji-Künstlernamen**: Kanäle und Videotitel schreiben sie meist
lateinisch, also zählt die Umschrift jetzt auch als Künstlermerkmal. Vorher blieb dort nur das
`artist`-Feld in Originalschrift.
- Sieben neue Prüfungen in `backend/test/ytdlp.test.js` (61 grün), darunter „anderes Stück
desselben Albums darf nicht passen" und die Schreibvarianten der Umschrift.
- **Das Mischen-Symbol zeigte nie an, dass es an ist (Android)** — und der Grund lag nicht in der
Anzeige. `toggleShuffle` baute den Rest der Warteschlange als Schleife aus `removeMediaItem` +
`addMediaItem` um; dazwischen hatte der Controller vorübergehend weniger Titel als unsere Liste.
Genau darauf reagiert `onTimelineChanged` mit „die Titel kamen von außen", baute die
Warteschlange neu auf — und setzte dabei `isManuallyShuffled` **zurück**. Gemischt wurde also,
aber der Zustand war eine Zehntelsekunde später wieder weg. Jetzt wird der Rest in **einem** Zug
ersetzt (`replaceMediaItems`), der Zwischenzustand entfällt.
- **Mischen und Wiederholen sind jetzt auch zu sehen (Android)** — ein eingefärbtes Symbol allein
reicht auf einem dunklen Cover nicht, das liest sich wie eine Schattierung. Ist der Schalter an,
liegt ein getönter Kreis darunter und ein Punkt darunter.
- **Der Rollbalken lief über die Player-Leiste (Web)** — sofort nach dem Verbreitern gemeldet. Der
Balken gehört zum Scrollbereich und läuft über dessen ganze Höhe, und `.main` reichte per
`padding-bottom` bis an den Fensterboden, also bis **unter** die Leiste. Bei 4 px fiel das nie
auf, bei 12 px sofort. Der Scrollbereich endet jetzt per `margin-bottom` über der Leiste — auf
dem Handy über Leiste **und** unterer Navigation. Die Platzreserve für den Inhalt, die das
`padding-bottom` war, ist damit überflüssig geworden und entfällt.
---
## [3.0] — 2026-09-19
**Android:** 1.13 (versionCode 16) → 1.15 (versionCode 18). 1.13 brachte Folgen, Zeitleiste und
den Gefolgt-Filter, 1.14 die begrenzte Reihe in den Favoriten, 1.15 die Knopfleiste im Album.
Erster Sprung auf eine neue Hauptversion seit 2.0: „Künstlern folgen" ist kein weiteres Fenster,
sondern ein neuer Umgang mit der Bibliothek — die App erzählt jetzt, was **draußen** neu ist, statt
nur zu zeigen, was drinsteht.
### Added
- **Künstlern folgen und Alben merken (Web + Android)** — Grundlage für die geplanten Meldungen zu
neuen Veröffentlichungen und Konzerten. „Folgen" ist **kein eigener Speicher**, sondern Navidromes
Stern auf dem Künstler: `star&id=<ndArtistId>`. Damit ist der Zustand sofort auf allen Geräten da,
überlebt Neuinstallationen und taucht sogar in Navidromes eigener Oberfläche auf.
- Möglich ist das, weil Navidrome `id`, `albumId` und `artistId` beim Markieren in **eine** Liste
wirft und gleich behandelt (`media_annotation.go`) — unser vorhandener Aufruf trug also ohne
Änderung. Den Zustand liefert `starred` aus `getArtist`/`getAlbum`.
- Das Herz sitzt **im Banner** des Künstlers, direkt neben dem ⚙ für den Pin — auf beiden
Plattformen gleich. Die Aktionsleiste darunter ist „was ich jetzt tue" (abspielen, mischen,
Konzerte); Folgen ist dagegen ein Zustand und gehört an den Künstler selbst. Beim Album bleibt
das Herz in der Aktionsleiste, dort gibt es kein Banner.
- **Übersicht, wem man folgt:** die Favoritenseite zeigt jetzt Abschnitte *Künstler, denen du
folgst*, *Gemerkte Alben* und *Songs*. Das kostet **keinen zusätzlichen Aufruf** — `getStarred2`
lieferte Künstler und Alben die ganze Zeit im selben Paket mit, wir haben sie nur weggeworfen.
- Beide Plattformen schalten **optimistisch** um und fallen bei einem Fehlschlag zurück — Android
über den vorhandenen `StarState`, der ohnehin über IDs arbeitet und deshalb unverändert taugt.
- Wer ohne Künstler-Pin folgt, bekommt einen Hinweis: ohne Pinnung läuft die Deezer-Zuordnung über
den Namen, und bei gleichnamigen Künstlern kämen dauerhaft die Neuheiten des Falschen.
- **Filter „❤ Gefolgt" auf der Künstlerseite (Web)** — die Künstlerseite hat jetzt zwei Chips
(*Alle* / *❤ Gefolgt (n)*) und ein Suchfeld. Damit ist die Liste, wem man folgt, dort, wo man
Künstler ohnehin sucht — statt nur als waagerechte Reihe unter den Favoriten. `getArtists` liefert
kein `starred` mit, die Markierungen kommen deshalb aus `getStarred2` und werden 30 s gehalten,
damit das Umschalten der Chips nicht jedes Mal nachfragt. Ein Herz-Klick verwirft diesen Zwischen-
speicher sofort — sonst zeigte der Filter nach dem Entfolgen noch eine halbe Minute den alten Stand.
- **Die Reihe der gefolgten Künstler ist begrenzt, mit „Alle anzeigen" (Web + Android)** — vorher
landeten **alle** gefolgten Künstler in der waagerechten Reihe der Favoriten. Bei 200 Gefolgten
sind das 200 Karten samt Bildern, von denen man vier sieht — und zum Durchsehen taugt eine endlose
Reihe ohnehin nicht. Am Ende steht jetzt eine Karte „Alle anzeigen (+n weitere)", die in die
Künstlerliste mit aktivem Gefolgt-Filter springt.
- **Im Web richtet sich die Anzahl nach der Fensterbreite** statt nach einer festen Zahl: es wird
gerechnet, wie viele Karten nebeneinander passen (Kartenbreite plus Abstand), minus eine, damit
„Alle anzeigen" nicht angeschnitten am Rand klebt. Untergrenze zwei. Bei einer Größenänderung
wird neu aufgeteilt — sonst bliebe die Zahl auf dem Stand des ersten Aufbaus.
- Android zeigt zehn und dann den Knopf; die `LazyRow` baut ohnehin nur Sichtbares, dort ging es
allein um die Durchsehbarkeit. Weil das `LibraryViewModel` am Navigationseintrag hängt und einen
Zielwechsel nicht überlebt, reicht `LibraryIntent` die Absicht einmalig durch.
- **Die Knopfleiste im Album sieht aus wie die der Playlist (Android)** — im Album trugen *Play*
und *Shuffle* noch Beschriftungen und teilten sich die Breite, während Herz und Download auf feste
46 dp gesetzt waren. Vier Knöpfe in einer Reihe lassen dafür keinen Platz: die Schrift wurde
abgeschnitten, und die Reihe war ungleich geteilt. Jetzt vier gleich breite reine Symbolknöpfe wie
im Playlist-Detail — Play gefüllt als Hauptaktion, der Rest umrandet und erst accent gefärbt, wenn
er aktiv ist (gemerkt, vollständig geladen). Die Symbole tragen jetzt Beschriftungen für die
Vorlesefunktion, vorher stand dort bei Play und Shuffle `null`.
- **Zeitfenster-Chips der Zeitleiste beginnen mit 90 Tagen (Android)** — das ist die Vorgabe, also
steht der aktive Chip jetzt vorn statt in der Mitte. Web war schon so.
- **Filter „❤ Gefolgt" im Künstler-Reiter (Android)** — zwei Chips über der Liste, gleiche Stelle
wie im Web. Bewusst ein Filter und kein eigener Reiter: die Bibliotheksleiste trägt schon
Playlists, Alben, Künstler und Offline. Die markierten IDs werden bei jedem Einschalten frisch
geholt, damit ein eben entfolgter Künstler nicht noch dasteht.
- **Zeitleiste „Neu von deinen Künstlern" (Android)** — erreichbar über „✨ Neu" in den Favoriten,
dort, wo auch die gefolgten Künstler stehen. Gleiche Darstellung wie im Web: nach Monat gruppiert,
neueste zuerst, Vorhandenes mit Haken statt Download-Knopf, Künstlername anklickbar. Der
Download-Knopf hängt am bekannten Weg samt Benachrichtigung über den Fortschritt.
- **Zeitleiste „Neu von deinen Künstlern" (Web)** — eigener Punkt in der Seitenleiste. Zeigt die
jüngsten Veröffentlichungen aller Künstler, denen du folgst: nach Monat gruppiert, neueste zuerst,
pro Eintrag Cover, Künstler, Art (Album/Single/EP), Titelzahl und Datum. Umschaltbar auf 30 Tage,
90 Tage oder ein Jahr.
- **Was du schon hast, ist markiert.** Verglichen wird über Künstler + Albumname (akzent- und
zeichenunabhängig) — Deezers Album-Id hat mit Navidromes nichts zu tun. Alles andere bekommt
direkt einen Laden-Knopf, der den vertrauten Download-Dialog öffnet.
- **Neuer Backend-Endpunkt `POST /api/artist-releases`** (`backend/lib/releases.js`). Die
Deezer-Zuordnung passiert dort, weil die Künstler-Pins ohnehin serverseitig liegen: der Client
schickt nur seine Navidrome-Künstler und muss über Deezer nichts wissen. Gepinnte Künstler nutzen
die feste ID, ungepinnte werden über den Namen gesucht — sortiert nach Fan-Zahl, weil Deezers
Suche nicht nach Bekanntheit ordnet. Künstler ohne Treffer kommen als `ohneZuordnung` zurück und
die Oberfläche rät zum Pinnen, statt still die Neuheiten des Falschen anzuzeigen.
- **Der Cache liegt im Backend, nicht im Client.** Bei 30 gefolgten Künstlern wären das sonst 30
Deezer-Aufrufe pro Gerät und App-Start. 6 h für Alben, 24 h für die Namenszuordnung, höchstens
vier Anfragen gleichzeitig; bei einem Deezer-Fehler wird der abgelaufene Stand ausgeliefert
statt einer leeren Liste.
### Fixed
- **Favoriten überleben ein Umbenennen (Web)** — Umbenennen und Verschieben geben dem Song beim
nächsten Scan eine **neue** Navidrome-ID. Für Playlists wurde die Zugehörigkeit längst umgehängt,
für den Stern nicht: wer einen markierten Song bearbeitete, verlor die Markierung stillschweigend.
Der Zustand wird jetzt vor dem Schreiben gelesen (`starred` aus `getSong`) und nach dem Wiederfinden
auf die neue ID gesetzt. Schlägt das fehl, steht es als Warnung im Fenster statt unbemerkt zu
verschwinden.
**Grenze:** der Stern liegt in Navidrome **pro Nutzer**. Übertragen lässt sich nur der des
bearbeitenden Nutzers — fremde Markierungen sind über die Subsonic-API weder lesbar noch setzbar.
- **Dubletten lassen sich im Editor selbst auflösen (Web)** — wollte das Speichern die Datei in den
Ordner der neuen Tags verschieben und lag dort schon eine gleichnamige, blieb sie liegen und der
Hinweis schickte einen auf die Konsole. Derselbe Song lag danach doppelt in der Bibliothek — genau
das, was die ganze Dateizuordnung durcheinanderbringt.
- Das Fenster stellt jetzt **beide Kopien gegenüber** (Pfad, Künstler, Titel, Länge, Größe) und
sagt, ob es dieselbe Aufnahme ist: gleiche Länge (± 2 s) **und** gleicher Titel-Tag. Darunter
steht ein Knopf, der die überzählige löscht — mit derselben Rückfrage wie beim Song-Löschen.
- **Neuer Endpunkt `POST /api/resolve-duplicate`.** Er nimmt Pfade entgegen und prüft deshalb
strenger als das Löschen per ID: beide müssen im Musikordner liegen, Audiodateien sein und
existieren, und dieselbe Länge und denselben Titel tragen. Sonst 409 und es wird nichts gelöscht.
Geprüft mit echten Dateien: sechs Fälle, von „fehlende Felder" über einen Pfad außerhalb des
Musikordners bis zum anderen Song mit ähnlichem Namen — nur die echte Dublette fiel.
- **Ein „/" im Künstler- oder Albumnamen führte den Metadaten-Editor auf die falsche Datei** —
beim Bearbeiten von „K/DA – VILLAIN" meldete das Backend, die gefundene Datei enthalte
„Bad Omens – Like A Villain". Zwei Fehler trafen aufeinander:
- **Der gemeldete Pfad kann es gar nicht geben.** Navidrome baut ihn aus den Tags
(`fakePath()`): `{AlbumArtist}/{Album}/{DD}-{TT} - {Titel}`. Steht ein Schrägstrich **im**
Namen, wird daraus eine zusätzliche Verzeichnisebene — „K/DA/ALL OUT/…" statt des Ordners
`K_DA`, den unser `sanitize()` beim Herunterladen angelegt hat. Dasselbe bei „POP/STARS" →
`POP_STARS`. Der Pfad zeigt also ins Leere, und die Suche fällt auf Ähnlichkeit zurück.
- **Die Ähnlichkeitssuche war zu großzügig.** Sie nahm jede Datei, deren Stamm auf den Titel
**endet** — und „bad omens - like a villain" endet nun einmal auf „villain". Getroffen wird
jetzt nur noch ein vollständiges Feld des Dateinamens: der ganze Stamm, oder der Teil hinter
einem „ - ". Die Endsilben-Regel ist ersatzlos weg.
- Neu davor: der Ordner wird **aus den Tags nachgebaut**, genau so wie der Downloader ihn
angelegt hätte (Schrägstrich → `_`, erster Künstler), und zuerst dort gesucht. Das ist gezielt
statt geraten und macht die Ähnlichkeitssuche für diesen Fall überflüssig.
- Dass der Fehler überhaupt sichtbar wurde, lag am Tag-Abgleich aus 2.3: er hat den Fehlgriff
erkannt und abgebrochen, statt die falsche Datei zu bearbeiten.
- **Das Löschen hatte diesen Abgleich nicht** — es nahm den Treffer der Ähnlichkeitssuche ohne
Gegenprobe und löschte. Bei „VILLAIN" hätte es die Datei von Bad Omens erwischt, unwiderruflich
und ohne Hinweis. Jetzt prüft auch `/api/delete-song` die Tags der gefundenen Datei gegen das,
was Navidrome für diesen Song meldet, und bricht im Zweifel mit 409 ab. Als letzten Suchschritt
bekommt es zusätzlich `findExistingTrack` — der Weg, der bei „K/DA" von sich aus richtig liegt,
weil er `/` und `_` beim Vergleich gleich behandelt.
- Die Suchhelfer liegen jetzt in [metadata.js](backend/lib/metadata.js) statt im Server, damit sie
geprüft werden können: sieben neue Fälle in `backend/test/metadata.test.js` (49 Prüfungen grün).
### Changed
- **Nur noch ein Künstler im Dateinamen (Backend + Editor)** — bisher stand dort die vollständige
Gästeliste: `01 - K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine,League of Legends - MORE.mp3`.
Üblich ist das nirgends — Picard, beets und Lidarr schreiben höchstens **einen** Künstler in den
Namen —, und es hatte zwei handfeste Nachteile: `sanitize()` schneidet bei 100 Zeichen hart ab,
also mitten im letzten Namen, und mit dem Dateisystemlimit von 255 Byte wird es bei mehrbytigen
Namen eng, was Umbenennen und Verschieben scheitern lässt. Jetzt `01 - K_DA - MORE.mp3`.
- Genommen wird der **erste Künstler des Titels**, nicht der des Albums — so trägt ein
Soundtrack-Titel im Namen weiterhin seinen eigenen Interpreten, auch wenn der Ordner am
Album-Künstler hängt. Die vollständige Liste steht unverändert im `artist`-Tag, und nur den
liest Navidrome.
- Gilt für **neue** Downloads und für Dateien, die bearbeitet werden; Bestehendes bleibt liegen.
Beide Namensformen stehen also eine Weile nebeneinander. Die Dublettenprüfung erkennt beide —
eigener Testfall, damit die alten Dateien nicht ein zweites Mal geladen werden.
- **Künstlerbilder laden endlich zügig (Web)** — im Künstlerraster lief **pro Künstler ein eigener
`getArtistInfo2`-Aufruf**, nur um die Bildadresse zu erfahren. Bei 200 Künstlern sind das 200
API-Aufrufe auf einen Schlag, die der Browser zu sechst abarbeitet (mehr Verbindungen erlaubt er
pro Host nicht) — und jeder davon konnte Navidrome zu einer **externen** Abfrage bei Last.fm
zwingen. Dass manche Bilder gar nicht kamen, lag an derselben Quelle: Last.fm liefert für viele
Künstler nur noch einen Platzhalter, den der Code als „kein Bild" erkennt.
Dabei liefert `getArtists` das Bild längst mit: `toArtistID3` setzt `coverArt`, und Navidrome löst
Künstlerbilder selbst auf (`ArtistArtPriority` = `artist.*, album/artist.*, external`) und legt sie
im eigenen Cache ab. Jetzt geht pro Künstler nur noch das Bild selbst über die Leitung, kein
API-Aufruf davor; ist `coverArt` leer, hat der Künstler kein Bild und wir sparen uns den Versuch.
Gemessen im Test: **0 statt 120 Zusatzaufrufe** bei 120 Künstlern. Gleiches gilt jetzt für die
Künstlerkarten in der lokalen Suche und die gefolgten Künstler in den Favoriten.
**Android war nie betroffen** — dort lief es immer schon über `coverArt`.
- **Top Songs fragen jetzt über die Künstler-ID (Web + Android)** — Navidrome 0.64 nimmt bei
`getTopSongs` zusätzlich `id`. Bisher lief die Abfrage nur über den **Namen**, und der Namensweg
sucht mit `LIKE` und `Max: 1` (`core/external/provider.go`): bei zwei gleichnamigen Künstlern in
der Bibliothek gewann schlicht der erste Treffer. Wir schicken jetzt `id` **und** `artist` — ältere
Navidrome-Versionen ignorieren `id`, neuere fallen bei unbekannter ID von selbst auf den Namen
zurück. In Erkunden bleibt es bewusst beim Namen: die dortige `artistId` ist **Deezers**, nicht
Navidromes.
---
## [2.6] — 2026-09-12
**Android:** 1.9 (versionCode 12) → 1.12 (versionCode 15). 1.9 brachte den Besitzer geteilter
Playlists und die Offline-Korrekturen, 1.10 den Zählfehler im Downloader, 1.11 den WLAN-Schalter,
1.12 das vollständige Löschen.
### Fixed
- **Zurück und Vorwärts gingen im installierten Fenster am PC gar nicht (Web)** — im Browser-Tab
funktionierten sie, im PWA-Fenster nicht. Das Manifest setzt `"display": "standalone"`: dort gibt
es keine Zurück-/Vorwärts-Knöpfe, und die Seitentasten der Maus werden nicht auf den Verlauf
umgelenkt. Die App übernimmt die Maustasten X1/X2 jetzt selbst — **nur** im Standalone-Fenster,
denn im Tab macht das der Browser und wir würden doppelt navigieren (beides mitgetestet).
Zurück geht über denselben Weg wie der In-App-Knopf, schließt also erst ein offenes
Now-Playing-Fenster. `Alt`+`←`/`→` bleibt dem Browser überlassen, das kann er auch im Fenster.
- **Zwei Node-Dienste hießen in Navidromes Player-Liste fast gleich** — das Backend meldete sich als
`novasonic`, der Scrobble-Dienst als `NovaSonic`; da Navidrome den Namen als `client [User-Agent]`
bildet, standen dort zwei Zeilen `novasonic [node]` und `NovaSonic [node]`, unterscheidbar nur an
einem Großbuchstaben. Genau die Zeile, an der „Report Real Path" hängt, war so nicht auffindbar.
Jetzt `NovaSonic-Backend` und `NovaSonic-Scrobble`. Navidrome legt dafür **neue** Player-Zeilen an;
die alten können weg. Eine Player-Zeile hält nur Client, User-Agent, IP, Transcoding, Bitratengrenze
und die beiden Schalter — keine Wiedergabezahlen, keine Historie. Löschen ist also gefahrlos, und
die neuen Zeilen erben `ND_SUBSONIC_DEFAULTREPORTREALPATH`.
- **„Pfad (Navidrome)" im Info-Fenster wieder zu „Pfad"** — der Zusatz war doppelt gemoppelt: der
Wert kommt ohnehin aus der Subsonic-API. Was er bedeutet, steht als Kommentar im Code und in den
Known Bugs, nicht als Etikett im Fenster.
- **Der Zurück-Knopf tat oft nichts (Web)** — `navigate()` prüfte zwar, ob die Ansicht sich
überhaupt geändert hat, und ließ den Verlaufseintrag dann weg — **rief `history.pushState` aber
trotzdem**. Zweimal auf denselben Tab geklickt hieß also zwei Browser-Einträge auf dieselbe
Ansicht, und jeder davon kostete einen Zurück-Druck, bei dem sichtbar nichts passierte. Gemessen
im Test: dreimal „Alben" erzeugte **drei** Browser-Einträge bei **einem** Stack-Eintrag, danach
bewegten vier Zurück-Drücke die Ansicht genau einmal. Jetzt bekommt nur eine wirklich neue Ansicht
einen Verlaufseintrag, sonst wird der bestehende ersetzt. Der allererste Aufruf ersetzt ebenfalls,
weil `main.js` die schon offene Seite bereits auf `nsIdx 0` gesetzt hatte — auch das war ein
toter Zurück-Druck gleich zu Beginn. Vorwärts funktioniert unverändert (mitgetestet).
### Fixed
- **Offline-Downloads löschen ließ Dateien zurück (Android)** — „komplett löschen und neu laden" ist
der naheliegende Ausweg, wenn eine Gruppe durcheinandergeraten ist; dafür muss Löschen aber wirklich
alles erwischen. Es fehlte an drei Stellen:
- **Cover blieben liegen.** Entfernt wurde nur die Audiodatei. Bei einer Playlist über viele Alben
sammeln sich so hunderte JPEGs an. Jetzt werden sie mitgelöscht — aber nur, wenn kein anderer
Titel dasselbe Cover mehr benutzt, denn Cover werden geteilt gespeichert.
- **Unfertige Titel behielten ihre Datei.** Der Pfad kam aus der Karte der *fertigen* Titel; ein
abgebrochener oder fehlgeschlagener Download tauchte darin nicht auf und blieb unberührt.
Der Pfad kommt jetzt aus der Datenbankzeile selbst, dazu die angefangene `.tmp`.
- **Laufende Downloadarbeit lief weiter.** Nach dem Löschen lud der Worker noch minutenlang ins
Leere; jetzt wird die Arbeit der Gruppe abgebrochen.
- **Ein zu hoch gezählter Fortschritt konnte sich nie mehr korrigieren (Android)** — Gruppen aus der
Zeit vor 1.10 tragen womöglich einen Wert über `totalTracks` und gelten damit als „Vollständig":
der Herunterladen-Knopf ist unantastbar, und das Fortsetzen beim Start sucht nur Gruppen *unter*
dem Sollwert — die Gruppe fiel also durch jedes Raster. Beim App-Start wird der Fortschritt aller
Gruppen jetzt einmal aus dem tatsächlichen Zustand der Titel neu berechnet (eine SQL-Anweisung),
danach greifen Knopf und Fortsetzen wieder.
- **Halb geladene Gruppen meldeten sich als „Vollständig" (Android)** — der Worker startete seinen
Zähler bei `group.downloadedTracks` und zählte dieselben fertigen Titel beim Überspringen **gleich
noch einmal**: nach einem Fortsetzen mit 412 fertigen Titeln meldete die Gruppe 824. Überschreitet
der Wert `totalTracks`, gilt sie als vollständig — der Fortschrittsbalken verschwindet und der
Herunterladen-Knopf wird unantastbar, obwohl Titel fehlen. Der Fehler steckte schon länger drin,
fiel aber kaum auf, solange Fortsetzen nur von Hand passierte; mit der automatischen Wiederholung
hätte er bei jedem Lauf zugeschlagen. Der Zähler beginnt jetzt bei 0 und zählt jeden fertigen Titel
genau einmal.
- Nebenbei: beim Überspringen wurde der Fortschritt in die Datenbank geschrieben, obwohl er sich
nicht ändert — 1900 überflüssige Schreibzugriffe je Lauf. Geschrieben wird jetzt nur nach einem
tatsächlichen Versuch und einmal beim Abbruch.
- **Große Offline-Downloads liefen nie zu Ende (Android)** — WorkManager räumt einem
Hintergrund-Worker rund **10 Minuten** ein und stoppt ihn dann. Ein Vordergrunddienst ist keine
Option, `FOREGROUND_SERVICE_TYPE_DATA_SYNC` bringt die App unter Android 15 zum Absturz. Der
Downloader prüfte aber nie `isStopped`, lief nach dem Stoppsignal blind weiter bis zum harten
Abbruch, und meldete am Ende `Result.failure()` statt `Result.retry()` — ohne Backoff. WorkManager
startete deshalb **nichts** von allein neu: bei 1900 Titeln musste man immer wieder von Hand auf
Herunterladen tippen. Jetzt:
- die Schleife steigt bei `isStopped` sauber aus und fordert den nächsten Lauf an (`Result.retry()`,
linearer Backoff 30 s). Fertige Titel überspringt sie ohnehin, es wird also fortgesetzt statt neu
geladen.
- bleiben danach noch Titel offen, fordert der Worker selbst weitere Läufe an — begrenzt auf 20
Versuche, damit dauerhaft kaputte Titel (auf dem Server gelöscht, immer 404) keine Endlosschleife
auslösen. Danach steht in der Gruppenzeile weiter `x/y Titel`, und ein Tipp startet die Kette neu.
- ein einzelner Netzfehler beendete bisher die **ganze** Kette endgültig (`Result.failure()` im
äußeren `catch`); jetzt wird auch das wiederholt.
- beim App-Start werden unvollständige Gruppen weitergeführt (`setzeUnvollstaendigeFort()`), falls
die Kette doch einmal reißt — Geräteneustart, aufgebrauchte Versuche.
- Nebenbei: die Zahl der offenen Titel kam aus einer Abfrage **pro Titel** am Ende jedes Laufs
(1900 Stück); jetzt eine einzige `COUNT`-Abfrage mit JOIN.
- **Große Offline-Playlists ließen sich nicht abspielen (Android)** — bei ~100 Titeln ging alles, bei
1900 nicht. Ursache war nicht die Größe an sich: die Warteschlange wurde aus **allen** Titeln der
Gruppe gebaut, ohne Rücksicht auf den Download-Zustand. Die Karte, aus der die lokale Datei kommt,
kennt dagegen nur fertige (`WHERE state = 'DONE'`) — für wartende oder fehlgeschlagene Titel
lieferte `streamUrl()` deshalb die **Netz-Adresse**, und die tut offline nichts. Bei 100 Titeln ist
der Download längst durch, bei 1900 praktisch nie; liegt so ein Titel vorn, scheitert die
Wiedergabe sofort und sieht aus, als ginge die ganze Playlist nicht. Play und Shuffle nehmen jetzt
nur fertige Titel, ein Hinweis nennt die Zahl der übersprungenen. Die Liste zeigt weiterhin alle,
damit man sieht, was noch fehlt; ein Tipp auf einen unfertigen Titel sagt das auch.
- Verworfen: die naheliegende Vermutung, Media3 stoße bei 1900 Einträgen an die 1-MB-Grenze einer
Binder-Transaktion. Tut es nicht — `MediaControllerImplBase` reicht die Liste als
`BundleListRetriever` durch, den die Session seitenweise abholt. Die Größe ist unschuldig.
### Added
- **„Nur über WLAN herunterladen" (Android)** — die Downloadarbeit lief bisher mit
`NetworkType.CONNECTED`, also über **jede** Verbindung. Das war selbstbegrenzend, solange nach zehn
Minuten Schluss war; mit der automatischen Wiederholung hätte sich eine Playlist unterwegs
ununterbrochen weitergeladen — bei 1900 Titeln grob 9–10 GB über Mobilfunk. Neuer Schalter in den
Einstellungen unter **📥 Offline-Downloads**, **standardmäßig an**: dann gilt `UNMETERED`, der
Download pausiert ohne WLAN und läuft von selbst weiter, sobald es zurück ist. Abschalten geht nur
über eine Rückfrage, die das Datenvolumen ausdrücklich benennt.
- Beim Speichern werden laufende Gruppen neu eingereiht (`REPLACE`): WorkManager ändert die
Bedingungen einer bereits eingereihten Arbeit nicht mehr, der Schalter hätte sonst erst beim
nächsten App-Start gewirkt. Unbedenklich, weil der Worker fortsetzt statt neu zu laden.
- **Bei geteilten Playlists steht jetzt dabei, von wem sie sind (Web + Android)** — Navidrome
liefert `owner` in `getPlaylists`/`getPlaylist` mit, im Web wurde das Feld nie benutzt. Übersicht
und Detailseite zeigen es als `· von <Name>`, und zwar **nur bei fremden** Listen — bei den
eigenen wäre „von mir" nur Rauschen. Android zeigte den Besitzer auf der Detailseite bisher bei
**jeder** Liste und unbeschriftet, sodass der eigene Name wie ein Teil des Titels aussah; jetzt
verhalten sich beide Plattformen gleich, und die Playlist-Übersicht in der Bibliothek zeigt es
ebenfalls.
---
## [2.5] — 2026-09-10
**Android:** 1.8 (versionCode 11) — Sprung vom Album zum Künstler.
### Added
- **Vom Album zum Künstler springen (Web + Android)** — in der globalen Suche führte ein Album
bisher in eine Sackgasse: der Künstlername darunter war reiner Text. Wer von dort zum Künstler
wollte, musste zurück und neu suchen. Der Name ist jetzt anklickbar und öffnet dieselbe
Deezer-Künstlerseite wie eine Künstler-Kachel. Betrifft im Web die Inline-Albumansicht in der
globalen Suche und die Vollbild-Albumseite, in Android die Album-Detailansicht in Erkunden —
dort stand vorher **gar kein** Künstler, nur der Albumtitel.
- **Kostet keinen zusätzlichen API-Aufruf.** Deezer legt `/search/album` und `/album/{id}` das
vollständige `artist`-Objekt samt `id` bei (in 120 von 120 Treffern über zwölf Suchen geprüft);
beide Mappings (`_dzAlbum` im Web, `DzAlbum.toItunesItem()` in Android) übernahmen die id schon,
sie wurde nur nirgends benutzt.
- **Ausnahme mit Absicht:** `/artist/{id}/albums` lässt das `artist`-Objekt weg. Alben aus dieser
Liste haben keine id, dort bleibt der Name deshalb reiner Text statt ins Leere zu zeigen — man
kommt ja gerade von der Künstlerseite. Ein Ersatz-Lookup wäre ein Aufruf für einen Sprung, den
niemand macht.
- Deezer nennt nur den **Haupt**künstler: bei „A feat. B" landet man auf A.
- Android lädt Bild und Fanzahl nach, wenn der Künstler aus einem Album konstruiert wurde — sonst
sähe die Kopfzeile anders aus als beim Weg über die Künstlersuche.
- **Der Künstler in den Albumansichten ist überhaupt erst zu sehen (Web)** — er stand in `--text2`
(`#8888aa`) und 14px unter einem 28px starken Albumtitel und ging darin schlicht unter; dass man
ihn anklicken kann, war nirgends erkennbar. Jetzt hell (`--text`), 15px und halbfett, und wo
wirklich ein Ziel dahintersteht, zusätzlich dezent unterstrichen (beim Überfahren in Akzentfarbe).
Gilt für die lokale Albumseite, die Deezer-Albumseite und die Inline-Albumansicht in der globalen
Suche. Die Teil-Album-Seite bleibt bewusst ohne Link — dorthin kommt man nur von der Künstlerseite.
- Nebenbei behoben: `.album-artist-name` trug `cursor:pointer` unabhängig davon, ob der Name
irgendwohin führte — der Zeiger versprach auf der Deezer- und der Teil-Album-Seite einen Klick,
den es nicht gab. Jetzt nur noch dort, wo wirklich ein Ziel dahintersteht.
---
## [2.4] — 2026-09-06
**Android:** 1.4 (versionCode 7) → 1.7 (versionCode 10). 1.4 brachte Warteschlangen-Wiederherstellung
und die Streitpunkte im Duell, 1.6 machte das Wiederherstellen rein lokal und damit sofort, 1.7 leert
den Cache nach einem Update selbst.
### Added
- **Cache räumt sich nach einem Update selbst auf (Android)** — im `cacheDir`
liegen Coils Bildspeicher (Cover) und heruntergeladene APK-Updates. Blieb dort nach einer neuen
Version etwas Altes liegen, half nur „Cache leeren" von Hand in den Systemeinstellungen — nichts,
das man einem normalen Nutzer erklären möchte. Beim ersten Start nach einem Versionswechsel wird
der Cache jetzt einmalig geleert; im laufenden Betrieb passiert nichts, sonst wären die Cover bei
jedem Start neu zu laden. **Offline-Downloads sind nicht betroffen**, die liegen unter
`filesDir/offline` und damit außerhalb des Caches.
- **Service-Worker-Cache versioniert sich beim Bauen selbst** — die Cache-Version stand handgepflegt
als `ns-v78` in `sw.js` und wurde beim Ausliefern zuverlässig vergessen. Da die Aktivierung nur
Caches mit *abweichendem* Namen löscht, blieb der alte bestehen und Nutzer mussten den Browsercache
von Hand leeren. Ein Vite-Plugin setzt die Version jetzt aus der Bauzeit (`ns-JJJJMMTThhmmss`), der
alte Cache räumt sich beim ersten Start selbst ab. Die Quelldatei bleibt unverändert.
- **Zuletzt Gehörtes und Warteschlange überleben den Neustart (Web + Android)** — Android sicherte
bisher nur den letzten Song, ohne Warteschlange: die Now-Playing-Leiste blieb nach dem Öffnen
**leer, bis man einmal auf Play drückte**. Jetzt merken sich beide Plattformen Warteschlange und
aktuellen Titel und stellen sie beim Start wieder her — Android **vorbereitet, aber nicht
abgespielt**, damit einen die App beim Öffnen nicht überfällt; läuft schon etwas, passiert nichts.
Der Titel beginnt von vorn: die Sekunde im Titel wird bewusst **nicht** gespeichert, das spart
dauerndes Schreiben während der Wiedergabe.
- Der erste Anlauf (1.4) sicherte nur Song-Ids und lud sie beim Start **einzeln nacheinander über
das Netz** nach: das dauerte Sekunden, klappte mal und mal nicht und ohne Verbindung gar nicht.
Seit 1.6 liegen die vollständigen Songs als JSON im Gerätespeicher, das Wiederherstellen ist ein
rein lokaler Lesevorgang wie im Web. Preis: zwischenzeitlich geänderte Titel oder Cover zeigen
bis zum nächsten Abspielen den alten Stand.
- `MusicService.onTaskRemoved` sichert die Warteschlange auch, wenn die App aus den letzten Apps
gewischt wird — dabei feuert `onIsPlayingChanged` nicht zuverlässig, genau dieser Fall ging
vorher verloren. Protokoll unter dem Tag `NsQueue` (`adb logcat -s NsQueue`).
- Hinweis fürs Testen: der gespeicherte Zustand entsteht erst ab dieser Version, direkt nach der
Installation ist die Leiste also noch leer — einmal abspielen, App schließen, wieder öffnen.
- **Streitpunkte im Musik-Duell (Web + Android)** — gemeinsame Künstler, sortiert nach dem größten
**Ungleichgewicht** statt nach der Summe: wo einer 120 Plays hat und der andere 3. Ein Balken
zeigt das Kräfteverhältnis. Mindestens vier gemeinsame Plays und 50 % Unwucht, damit ein einzelner
Play gegen null nicht als Streit gilt. Beide Plattformen nutzen dieselben Schwellen.
---
## [2.3] — 2026-09-04
**Android:** versionCode 3 → 1.3 (versionCode 6). versionCode 3 lieferte den Alben-Tab-Fix aus und
erstmals das Scroll- und Suchgedächtnis der Künstlerseite (2.2 enthielt den Code, das damalige APK
war älter); 1.1 brachte Teilen-Link, Songlöschen, Playlist aus dem Import und die Duell-Anzeigetafel;
1.2 und 1.3 nahmen die Adressen aus den Einstellungen in den Build. Alle mit demselben Debug-Schlüssel
signiert, Updates laufen also in-place und App-Daten bleiben.
### Fixed
- **YT-Music-Suche war praktisch tot** — `getYtmVideoUrls` verwarf die komplette Trefferliste, sobald
irgendwo ein Kanal-Eintrag (`browse/UC…`) auftauchte, samt der bereits gesammelten Song-URLs. An
acht echten Suchen gemessen passierte das in **sieben** Fällen: Schritt 1 der Kaskade — die Stufe
mit den offiziellen Art Tracks — lieferte nie etwas, faktisch lief jeder Download über den
YouTube-Fallback. Das erklärt eine ganze Serie falscher Treffer (u. a. „Easy" als Mixtape- statt
Albumversion). Kanal- und Album-Einträge werden jetzt übersprungen statt abzubrechen; über 20 000
Zufallsausgaben geprüft ist die neue Auswahl eine Obermenge der alten, kann also nur Kandidaten
hinzufügen.
- **Zurück im Album führte in die Albenliste** — statt dorthin, wo man das Album angeklickt hatte.
Der Knopf navigierte vorwärts, legte dabei einen History-Eintrag an und brachte so auch den
Browser-Zurück-Knopf durcheinander. Jetzt geht er einen Schritt im Verlauf zurück wie jede andere
Detailseite.
- **Metadaten bearbeiten zerlegte die Hörstatistik** — Top-Titel gruppierten nach `song_id`, und beim
Bearbeiten umbenannte Dateien bekommen von Navidrome eine neue ID: der Titel erschien doppelt mit
geteilten Zählern, Künstler ebenso bei geänderter Schreibweise. Gruppiert wird jetzt über einen
normalisierten Schlüssel (Titel + Künstler), Anzeigenamen kommen aus dem jüngsten Play. Playlists
waren nie betroffen, die werden beim Bearbeiten ohnehin auf die neue ID übertragen.
- **Downloader grabbed a same-shaped title with a different number** — searching DAT ADAM's - **Downloader grabbed a same-shaped title with a different number** — searching DAT ADAM's
"700 Main Street" saved **"777 Main Street" by an unrelated artist**. `buildTitleKeywords` required "700 Main Street" saved **"777 Main Street" by an unrelated artist**. `buildTitleKeywords` required
only the two longest words (Street, Main); "700" was dropped for being short, and the cascade step only the two longest words (Street, Main); "700" was dropped for being short, and the cascade step
@@ -84,16 +768,89 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
live recordings. The variant blocklist (remix/cover/acoustic/…) now includes `live`. As with the live recordings. The variant blocklist (remix/cover/acoustic/…) now includes `live`. As with the
other rules it switches itself off when the searched title says live, so "Live Forever" and other rules it switches itself off when the searched title says live, so "Live Forever" and
deliberately requested live versions are unaffected. deliberately requested live versions are unaffected.
- **Album-Position im Web wurde doch nicht wiederhergestellt** — die gespeicherte Scrollposition wurde
in genau einem `requestAnimationFrame` gesetzt. Solange Cover und Listeneinträge noch nachluden, war
`scrollHeight` kleiner als das Ziel, und der Browser kappte den Wert stillschweigend: man landete
wieder oben. Der Wert wird jetzt über bis zu ~0,5 s erneut gesetzt, bis er sitzt.
--- - **Metadaten-Änderungen „sprangen zurück" — bei manchen Songs immer, bei anderen nie.** Findet das
Backend die von Navidrome gemeldete Datei nicht am angegebenen Pfad (Navidrome liefert oft einen
## [2.3] — 2026-09-03 aus Tags gebauten *virtuellen* Pfad), sucht es über Dateiname bzw. Künstler+Titel weiter. Liegt
derselbe Song mehrfach auf der Platte — etwa ein Fehlgriff des Downloaders neben der richtigen
A deployment-and-delivery release: production **pulls pre-built images** instead of compiling on the Datei —, konnte das die **falsche Kopie** treffen: die wurde getaggt, der bearbeitete Song blieb
server, the **APK download works again** (it had been broken by Gitea's own link host), and the unverändert. Genau deshalb ging es bei Songs ohne Dublette immer und bei anderen nie. Der über
scroll memory from 2.2 finally behaves on the Alben tab — plus it reaches an actual build. Ähnlichkeit gefundene Kandidat wird jetzt gegen die Tags geprüft, die Navidrome für den Song
meldet, und bei Widerspruch mit einer erklärenden Meldung abgebrochen, statt zu raten.
- **Dieselbe Playlist zweimal geladen ⇒ Songs doppelt auf der Platte.** Die Dublettenprüfung
verglich Künstlernamen zeichengenau, sodass „Cécile Corbel" und „Cecile Corbel" als zwei
verschiedene Personen galten — welche Schreibweise ankommt, hängt davon ab, ob die Quelle Deezer
oder Spotify war. Der Vergleich ignoriert jetzt Akzente (nur der **Vergleich** — Tags und
Dateinamen behalten die Originalschreibweise) und ebenso typografische Apostrophe, an denen
`Arrietty’s Song` und `Arrietty's Song` bisher auseinanderfielen.
- **Soundtracks landeten doppelt** — die Prüfung durchsucht nur den Ordner des Album-Künstlers.
Bei Soundtracks und Compilations weicht der vom Titel-Künstler ab: als Album geladen landet der
Song unter `Arcane/`, einzeln geladen unter `Bea Miller/` — die Prüfung sah jeweils im falschen
Ordner nach. Weicht der Titel-Künstler ab, wird jetzt auch dessen Ordner geprüft. Gemessen an
28.801 Dateien kostet der zweite Durchlauf ~0,6 ms, gegenüber 3–9 s pro Download.
- **Stumm übersprungene Umbenennungen** — lag am Zielnamen schon eine Datei, blieb der Song unter
altem Namen bzw. im alten Ordner liegen, ohne Hinweis. Solche Fälle kommen jetzt als Warnung
zurück; der Editor zeigt sie samt bearbeitetem Dateipfad an und bleibt offen.
### Added ### Added
- **Musik-Match wird zum Duell (Web + Android)** — statt eines Prozentrings zeigt die Ansicht jetzt
eine Anzeigetafel mit fünf Kategorien, die je einen Sieger haben: Hörzeit, Vielfalt, Tiefe
(Plays je Künstler), Nachteule (Anteil vor 5 Uhr) und Entdeckerdrang (Anteil exklusiver Künstler).
Bewusst Kennzahlen zur *Hörweise* statt zur schieren Menge, sonst gewinnt immer, wer länger dabei
ist. Unentschieden zählt für niemanden.
- **Vergleichsansicht im Web zerfiel am Desktop** — sie lebte ausschließlich von Inline-Stilen mit
festen Flex-Verhältnissen. Jetzt ein echtes Raster (eine Spalte am Handy, zwei ab 780 px) mit
eigenen CSS-Klassen; das Fenster ist von 720 auf 940 px verbreitert, sonst hätte die zweite Spalte
nie ausgelöst.
- **Gemeinsame Songs wurden über `song_id` abgeglichen** — hatten beide denselben Song aus
verschiedenen Dateien gehört oder wurde eine Datei zwischendurch umbenannt, galt er als nicht
gemeinsam. Jetzt derselbe normalisierte Schlüssel wie in der Statistik.
- **Titelwechsel erreichte den Linux-Desktop nicht** — die Media-Session-Metadaten wurden feldweise
mutiert, um unter Windows das Neuaufbauen der Medienkachel zu vermeiden. Über MPRIS kam davon
nichts an: Plasma-Widgets zeigten dauerhaft den zuerst gespielten Titel. Bei echtem Titelwechsel
wird jetzt ein neues `MediaMetadata` zugewiesen, sonst weiter in-place (Cover-Nachladen).
- **Keine Adressen mehr eintippen (Android)** — Navidrome-, Backend- und
Scrobble-Adresse kommen als Vorgabe aus dem Build (`-PnovasonicNavidrome=` / `-PnovasonicBackend=`
/ `-PnovasonicScrobble=`, gepflegt in `scripts/.env.release`). Sie greifen, wenn im Gerätespeicher
nichts steht; die Felder in den Einstellungen bleiben sichtbar und überschreibbar, damit ein Umzug
am Gerät korrigierbar ist statt per neuem APK. Beim ersten Start ist nur noch **Benutzer und
Passwort** einzugeben — Zugangsdaten werden bewusst *nicht* eingebacken, die gehören pro Person.
- **Die Web-Adresse ist keine Einstellung mehr (Android)** — sie kommt aus
`BuildConfig.SHARE_HOST`, also aus **derselben** Angabe (`-PnovasonicHost=…`), die ohnehin für die
App-Links ins Manifest wandert. Damit gibt es keine vierte Adresse zu pflegen, „Teilen" ist immer
sichtbar, und ein- und ausgehende Links können nicht mehr auseinanderlaufen.
- **Falle beim Veröffentlichen** — der In-App-Updater vergleicht den `versionCode` aus
der Gitea-Release-Beschreibung mit dem der installierten App. Wird die Version nicht angehoben
**oder** die Beschreibung nicht mitgepflegt (etwa beim Hochladen von Hand statt über
`npm run publish-apk`), meldet die App korrekt „kein Update" — obwohl ein neues APK bereitliegt.
- **Teilen per Link (Web + Android)** — ein Song oder Album lässt sich per `https://…?song=<id>`
an jemanden auf derselben Instanz schicken. Der Link trägt **nur die Id, keine Zugangsdaten**:
der Empfänger sieht den Inhalt mit seinem eigenen Navidrome-Konto. Web teilt über
`navigator.share()` und fällt sonst auf die Zwischenablage zurück; beim Öffnen wird das Album
angezeigt, der Song gestartet und der Parameter aus der Adresszeile entfernt, damit ein Neuladen
nicht erneut losspielt. Als installierte PWA landet der Link dank `launch_handler` im bereits
offenen Fenster. Android teilt über den Systemdialog (`ACTION_SEND`) und empfängt Links per
App-Link-Intent-Filter; der Host kommt beim Bauen aus `-PnovasonicHost=…`, die Web-Adresse für
ausgehende Links aus derselben Angabe (seit 1.2, vorher eine eigene Einstellung). Für das Öffnen ohne Nachfrage muss der SHA-256 des
Release-Schlüssels in `public/.well-known/assetlinks.json` stehen — Anleitung in
`docs/share-links.md`. Ohne diesen Schritt öffnet der Link die Web-App im Browser.
- **Android: Songs von der Festplatte löschen** — im Song-Kontextmenü, unten abgesetzt und rot, mit
derselben Sicherheitsabfrage wie im Web. Nutzt denselben Backend-Endpunkt (`/api/delete-song`) und
erscheint nur, wenn ein Backend konfiguriert ist — Navidrome allein kann keine Dateien entfernen.
- **Android: Playlist direkt aus dem Import anlegen** — der Importer hat jetzt wie das Web ein
Häkchen „Playlist daraus anlegen" samt Namensfeld (ab zwei Titeln). Nach dem Download stößt er
Navidromes Scan an, sucht jeden Titel und legt die Playlist aus den gefundenen an; nicht
gefundene werden gezählt statt die Erstellung scheitern zu lassen.
Zusätzlich zum Auslieferungsteil dieser Version: production **pulls pre-built images** instead of
compiling on the server, the **APK download works again** (it had been broken by Gitea's own link
host), and the scroll memory from 2.2 finally behaves on the Alben tab.
### Added (Auslieferung)
- **Pull-based production stack** — `docker-compose.yml` now references - **Pull-based production stack** — `docker-compose.yml` now references
`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}` and builds nothing on the host: `${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}` and builds nothing on the host:
`docker compose pull && docker compose up -d`. Building locally from the Dockerfiles moved to `docker compose pull && docker compose up -d`. Building locally from the Dockerfiles moved to
@@ -113,12 +870,6 @@ scroll memory from 2.2 finally behaves on the Alben tab — plus it reaches an a
an album discarded the position the ViewModel had correctly kept. Scrolling to the top now happens an album discarded the position the ViewModel had correctly kept. Scrolling to the top now happens
in `LibraryViewModel.setAlbumSort()`, on a real sort change only. in `LibraryViewModel.setAlbumSort()`, on a real sort change only.
### Changed
- **APK release at versionCode 3** — carries the Alben-tab fix above and, for the first time in a
published build, the artist-page and "Alle anzeigen" overlay scroll/search memory (2.2 shipped the
code, but the release APK predated it). Signed with the established debug key, so it installs
in place over versionCode 2 and app data survives.
--- ---
## [2.2] — 2026-08-22 ## [2.2] — 2026-08-22
@@ -624,7 +1375,6 @@ Polish release: Android navigation and offline resilience, download accuracy for
## Planned / Backlog ## Planned / Backlog
### Features ### Features
- **Web + Android** — **Share by link** — share a song/album to another user of the same instance via one `https://…` link that deep-links into the **PWA on desktop** and the **Android app on phone** (like Spotify/YouTube links). Android **App Links** (`intent-filter` + hosted `/.well-known/assetlinks.json` with the *release* signing SHA-256) open the app directly; PWA `launch_handler`/`handle_links` capture links on desktop. Link format leaning query param `?song=<id>` (no server SPA-rewrite; PWA reads it on load). Carries only the song id — no secrets; playback uses the recipient's own Navidrome creds. Songs first → albums/artists/playlists. Web: `navigator.share()`; Android: `ACTION_SEND`.
- **Web + Android** — **Yearly Wrapped** — a yearly story (`/scrobble/wrapped` + web story page + Android pager), built on the existing stats aggregations. - **Web + Android** — **Yearly Wrapped** — a yearly story (`/scrobble/wrapped` + web story page + Android pager), built on the existing stats aggregations.
- **Web + Android** — **Household leaderboard (>2 users)** — "who listened most this month" + top artists across all users; the broader multi-user version of the already-built 2-user Musik-Match. Needs wider cross-user reads (admin/household mode, or an aggregate endpoint exposing per-user totals only, never raw histories). - **Web + Android** — **Household leaderboard (>2 users)** — "who listened most this month" + top artists across all users; the broader multi-user version of the already-built 2-user Musik-Match. Needs wider cross-user reads (admin/household mode, or an aggregate endpoint exposing per-user totals only, never raw histories).
- **Backend (optional)** — **ListenBrainz relay** — mirror counted plays to ListenBrainz. - **Backend (optional)** — **ListenBrainz relay** — mirror counted plays to ListenBrainz.
@@ -634,23 +1384,69 @@ Polish release: Android navigation and offline resilience, download accuracy for
- **Keep what you like:** per-track (or whole-mix) option to **promote into the permanent library** instead of letting it expire. - **Keep what you like:** per-track (or whole-mix) option to **promote into the permanent library** instead of letting it expire.
- Goal: completely random/unfamiliar music you don't already know — like Spotify's curated/discovery playlists, but self-hosted. - Goal: completely random/unfamiliar music you don't already know — like Spotify's curated/discovery playlists, but self-hosted.
- Open questions: where downloaded-but-temporary tracks live vs. the main library (separate folder + retention job), how Navidrome should/shouldn't index them, and dedupe against tracks you already own. - Open questions: where downloaded-but-temporary tracks live vs. the main library (separate folder + retention job), how Navidrome should/shouldn't index them, and dedupe against tracks you already own.
- **Web + Android** — **Artist following + new-release & concert notifications** — follow artists and get notified when they drop something new or play nearby. Idea: - **Web + Android** — **Meldungen zu gefolgten Künstlern** — der Rest des Künstler-folgen-Vorhabens.
- A **follow list** of artists (stored per user, e.g. in app settings / a small backend table). Folgen selbst, die Übersicht „wem folge ich" und die Zeitleiste neuer Veröffentlichungen sind
- A **"Following" view** listing all artists you follow (grid of artist photos), as an entry point to their pages — a dedicated overview so you can see and manage who you follow, plus a follow/unfollow toggle on the artist page. **seit 3.0 gebaut** (siehe dort); offen sind nur noch die beiden Meldewege:
- **New-release alerts:** periodic check for new albums/singles by followed artists (sources: iTunes lookup / MusicBrainz / Last.fm), surfaced in the existing in-app notification bell (and optionally push). - **Glocke für neue Veröffentlichungen:** die Zeitleiste muss man heute selbst aufschlagen. Die
- **Concert alerts:** reuse the existing **Ticketmaster** events integration to notify when a followed artist has nearby/upcoming shows. Daten liegen schon vor (`POST /api/artist-releases`), es fehlt der Vergleich „was ist seit
- Ties into the importer: a "download this new release" action straight from the notification. deinem letzten Besuch dazugekommen" und der Eintrag in der vorhandenen Benachrichtigungsglocke
(wahlweise Push).
- **Konzert-Meldungen:** die vorhandene **Ticketmaster**-Anbindung wiederverwenden und zeigen,
wann gefolgte Künstler spielen. Bewusst zurückgestellt — erst die Neuheiten. Der Entwurf steht
aber schon (25.9.2026):
- **Ein Einstieg, zwei Reiter**, kein zweiter Knopf in der Favoriten-Kopfzeile. Neuheiten und
Konzerte sind dasselbe Thema; zwei Knöpfe drängeln sich, und man muss sich merken, welcher
was zeigt. Die Zeitleiste bekommt oben *Neu erschienen* / *Konzerte*, die Tages-Chips
(90/30/365) gelten nur für die Neuheiten.
- **Nicht in eine gemeinsame Liste mischen.** Veröffentlichungen liegen in der Vergangenheit,
Konzerte in der Zukunft. Chronologisch gemischt stünde „heute" mittendrin und man wüsste nie,
ob man gerade vorwärts oder rückwärts liest.
- **Ort erst später, dann per `.env` im Backend** (Land, optional Stadt und Umkreis). Fürs
Erste reicht, was die API von sich aus liefert — die Abfrage sucht heute weltweit
(`/api/events/:artist`, `keyword=`, `size=20`, nach Datum sortiert).
- **Offene Schwachstelle: die Abfrage läuft über den Namen, nicht über eine ID.** Dieselbe
Falle wie bei der Deezer-Zuordnung — „TPR" oder „MORE" treffen als Stichwort alles Mögliche.
Ticketmaster hat dafür `attractionId`; die einmal pro Künstler auflösen und zwischenspeichern,
so wie die Deezer-IDs.
- Aufwandsseite unkritisch: eine Anfrage pro Künstler, im Backend gecacht wie die Releases. Bei
50 Künstlern und sechs Stunden Haltbarkeit bleibt das weit unter Ticketmasters Tagesgrenze.
- **Android** — Playlist drag-and-drop / sort order - **Android** — Playlist drag-and-drop / sort order
- **Android** — Artist page iTunes fallback (for artists not in Navidrome) - **Android** — Artist page iTunes fallback (for artists not in Navidrome)
- **Android** — Romaji / Japanese sort support - **Android** — Romaji / Japanese sort support
- **Web + Android** — Backend settings UI rework (move, reorganize) - **Web + Android** — Backend settings UI rework (move, reorganize)
- **Web + Android** — yt-dlp backend update / maintenance - **Web + Android** — yt-dlp backend update / maintenance
- **Web + Andoird** - Safe last played Queue, where it stopped and show last played song loaded (rn only on web kinda with the last song played loaded)
- **Android** - Andorid Auto Sync last played and queue too with Phone then, rn its not really syncing or safin what last played on Android Auto, also check if selecting a song to play on Android Auto works that song is getting played and in a playlist it will play the queue a this point then like it works already in Android and Web - **Android** - Andorid Auto Sync last played and queue too with Phone then, rn its not really syncing or safin what last played on Android Auto, also check if selecting a song to play on Android Auto works that song is getting played and in a playlist it will play the queue a this point then like it works already in Android and Web
### Ideen / Backlog
- (Der Punkt „Künstler und Alben liken, daraus ‚folgen' bauen" ist mit 3.0 umgesetzt und dort
beschrieben.)
### Known Bugs ### Known Bugs
- **Android (lyrics)** — Synced-lyrics auto-scroll is **not smooth**: it follows playback and highlights the right line, but the list jumps to it rather than gliding. Web autoscroll is smooth. Not yet fixed. - **Android (lyrics)** — Synced-lyrics auto-scroll is **not smooth**: it follows playback and highlights the right line, but the list jumps to it rather than gliding. Web autoscroll is smooth. Not yet fixed.
- **Web (PWA, desktop)** — The Windows media tile (SMTC) briefly closes and reopens when skipping tracks. Swapping the `<audio>` element's source leaves a momentary gap with no active media output, so Windows tears down and rebuilds the tile. A real fix needs one continuous source (MSE), which requires streaming an MSE-compatible format. The native Android app is unaffected. (The two-element gapless handoff added in 1.1 makes skips effectively instant but does **not** fix this — switching elements still ends and restarts the OS session.) - **Web (PWA, desktop)** — The Windows media tile (SMTC) briefly closes and reopens when skipping tracks. Swapping the `<audio>` element's source leaves a momentary gap with no active media output, so Windows tears down and rebuilds the tile. A real fix needs one continuous source (MSE), which requires streaming an MSE-compatible format. The native Android app is unaffected. (The two-element gapless handoff added in 1.1 makes skips effectively instant but does **not** fix this — switching elements still ends and restarts the OS session.)
- **Backend (download)** — The duplicate check requires at least a `firstArtist` folder match. If the same song was previously stored under a very different artist name (e.g. before an iTunes-name fix), it will not be detected as a duplicate and will be re-downloaded. - **Backend (download)** — Die Dublettenprüfung sucht in den Ordnern von Album- **und** Titel-Künstler
und ignoriert Akzente sowie Apostrophe (2.3). Ein Rest bleibt: steht derselbe Song unter einem
**deutlich anderen** Künstlernamen (etwa vor einer Namenskorrektur), wird er weiterhin nicht
erkannt und erneut geladen.
- ~~**Backend (Dateinamen)** — `NN-NN - {Titel}` statt `{Tracknummer} - {Künstler} - {Titel}`~~
**Kein Bug — am 11.9.2026 widerlegt.** Der Pfad `Rising Insane/DEATHRACE/01-02 - DEATHRACE.mp3`
steht so **nirgends auf der Platte**. Navidrome gibt über die Subsonic-API standardmäßig gar nicht
den echten Pfad heraus, sondern baut einen aus den Tags zusammen
([`fakePath()` in `server/subsonic/helpers.go`](https://github.com/navidrome/navidrome/blob/master/server/subsonic/helpers.go)):
`{AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{Endung}` — `01` ist die **Disc**, `02` die
**Tracknummer**. An der Stelle des Künstlers steht also nichts Kaputtes, dort steht überhaupt kein
Künstler. Der Info-Dialog zeigte diesen Pfad nur unbeschriftet als „Pfad" an (`s.path` aus
`getSong`), was wie ein falscher Dateiname aussah.
**Folge fürs Backend:** dass Navidrome den echten Pfad verschweigt, ist die Wurzel der ganzen
Datei-Ratearbeit in `metadata.js` — und damit auch der „Metadaten springen zurück"-Fehler (2.3),
bei dem die Suche nach Ähnlichkeit die falsche Kopie traf. Navidrome hat dafür einen Schalter:
**Einstellungen → Players → Zeile `novasonic` → „Report Real Path"**. Ist er an, liefert `getSong`
den echten absoluten Pfad; das Backend nimmt den bereits ohne Codeänderung direkt
([novasonic-server.js:1638](../backend/novasonic-server.js)), und das Raten entfällt. Es gibt zwei
Player-Zeilen, weil Web/Android als `NovaSonic` und das Backend als `novasonic` anfragen —
entscheidend ist die des **Backends**.
- **Backend (download)** — Der Variantenfilter nutzt `\b`-Wortgrenzen, und die zählen den Unterstrich
als Wortzeichen. Titel wie `song_instrumental_cover` oder `Easy_live` rutschen deshalb durch,
während `Easy (Instrumental)` korrekt abgewiesen wird.
- **Backend (metadata/sorting)** — MusicBrainz `release['artist-credit']` is often absent in the standard recording search response (requires `inc=artist-credits` which can slow the endpoint). The albumArtist field from MusicBrainz autofill may fall back to the recording artist rather than the release artist. - **Backend (metadata/sorting)** — MusicBrainz `release['artist-credit']` is often absent in the standard recording search response (requires `inc=artist-credits` which can slow the endpoint). The albumArtist field from MusicBrainz autofill may fall back to the recording artist rather than the release artist.
- **Backend (metadata/sorting)** — MBIDs are not embedded in downloaded tracks; Navidrome cannot use them for authoritative album grouping (low priority — album splitting is mostly solved via consistent `album_artist`). - **Backend (metadata/sorting)** — MBIDs are not embedded in downloaded tracks; Navidrome cannot use them for authoritative album grouping (low priority — album splitting is mostly solved via consistent `album_artist`).
+8
View File
@@ -110,6 +110,14 @@ On the deploy host the music lives at `/opt/navidrome/music`.
`live` rule like remix/cover/…. It self-disables when the searched title itself says live, so `live` rule like remix/cover/…. It self-disables when the searched title itself says live, so
"Live Forever" or a deliberately requested live version still works — that check uses a `\blive\b` "Live Forever" or a deliberately requested live version still works — that check uses a `\blive\b`
regex (`skipRe`), because a plain substring test would let "deliver"/"delivery" switch the rule off. regex (`skipRe`), because a plain substring test would let "deliver"/"delivery" switch the rule off.
- **A channel hit in the YTM results is a line to skip, not a reason to abort.** `getYtmVideoUrls`
used to treat any `/browse/UC…` in the output as "yt-dlp expanded an artist channel" and returned
`[]` — discarding the song URLs it had already collected. Measured on 8 real searches, that fired
in **7 of 8**, so step 1 (YTM, where the official Art Tracks live) was effectively dead and almost
every download came from the YouTube fallback. With `--flat-playlist` a channel is printed as ONE
line, so expansion cannot happen; `pickYtmVideoUrls()` now keeps every `watch?v=` line and skips
`browse/UC…` (channel) and `browse/MPREb_…` (album) alike. It is a strict superset of the old
behaviour — fuzz-checked over 20k random outputs — so it can only add candidates, never remove any.
- **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2× - **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2×
expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through. expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through.
- **Leading aesthetic characters:** artists often style their name with leading punctuation - **Leading aesthetic characters:** artists often style their name with leading punctuation
+29 -2
View File
@@ -14,8 +14,35 @@ android {
applicationId = "com.novasonic.app" applicationId = "com.novasonic.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 3 versionCode = 29
versionName = "1.0" versionName = "1.26"
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
// Platzhalter stehen und der App-Link greift schlicht nicht.
val host1 = (project.findProperty("novasonicHost") as String?) ?: "music.example.com"
// Zweiter Host fuer einen Domainwechsel: solange beide Adressen erreichbar sind, oeffnen
// Links von der alten UND der neuen in der App. Ohne Angabe wird host1 wiederholt — ein
// doppelter Eintrag ist harmlos, ein Platzhalter-Host waere es NICHT: auf Android 11 und
// aelter verifiziert das System nur, wenn ALLE angegebenen Hosts eine gueltige
// assetlinks.json ausliefern, ein toter Host wuerde also alle blockieren.
val host2 = (project.findProperty("novasonicHost2") as String?) ?: host1
manifestPlaceholders["shareHost"] = host1
manifestPlaceholders["shareHost2"] = host2
// Derselbe Host auch fuer AUSGEHENDE Links. Er steht ohnehin schon fest im Manifest, eine
// zusaetzliche Einstellung in der App waere also nur eine weitere Adresse zum Pflegen —
// und eine, die vom Manifest abweichen koennte, ohne dass es jemand merkt.
buildConfigField("String", "SHARE_HOST", "\"$host1\"")
// Vorgabe-Adressen fuer Backend und Scrobble-Dienst. Sie stehen ohnehin in der
// docker-compose.yml — sie zusaetzlich auf jedem Geraet einzutippen ist Arbeit ohne Nutzen.
// Leer gelassen verhaelt sich die App wie bisher (Felder sichtbar, nichts vorbelegt).
// Zugangsdaten bleiben bewusst aussen vor: die gehoeren pro Person, nicht ins APK.
val backendUrl = (project.findProperty("novasonicBackend") as String?) ?: ""
val scrobbleUrl = (project.findProperty("novasonicScrobble") as String?) ?: ""
val navidromeUrl= (project.findProperty("novasonicNavidrome") as String?) ?: ""
buildConfigField("String", "DEFAULT_BACKEND_URL", "\"$backendUrl\"")
buildConfigField("String", "DEFAULT_SCROBBLE_URL", "\"$scrobbleUrl\"")
buildConfigField("String", "DEFAULT_ND_URL", "\"$navidromeUrl\"")
} }
buildFeatures { buildFeatures {
+6
View File
@@ -6,3 +6,9 @@
# Keep Media3 session / exoplayer classes used by reflection / service binding. # Keep Media3 session / exoplayer classes used by reflection / service binding.
-keep class androidx.media3.** { *; } -keep class androidx.media3.** { *; }
-keep class com.google.android.exoplayer2.** { *; } -keep class com.google.android.exoplayer2.** { *; }
# Ktors Logging referenziert ueber slf4j den Binder aus slf4j 1.x. Auf Android existiert der nicht
# (slf4j 2.x sucht Implementierungen per ServiceLoader, und wir binden ohnehin keine), deshalb bricht
# R8 den Release-Build mit "Missing class org.slf4j.impl.StaticLoggerBinder" ab. Die Referenz ist zur
# Laufzeit unerreichbar — slf4j faengt das Fehlen selbst ab und schaltet auf einen No-op-Logger.
-dontwarn org.slf4j.**
+13
View File
@@ -33,6 +33,19 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- Geteilte Links (https://<host>/?song=…) direkt in der App oeffnen.
autoVerify greift erst, wenn unter https://<host>/.well-known/assetlinks.json der
SHA-256 des RELEASE-Signaturschluessels hinterlegt ist. Ohne das bleibt der Filter
wirkungslos, bis der Nutzer die App in den Systemeinstellungen unter
"Standardmaessig oeffnen" fuer diese Domain zulaesst. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="${shareHost}"/>
<data android:scheme="https" android:host="${shareHost2}"/>
</intent-filter>
</activity> </activity>
<service <service
@@ -24,13 +24,41 @@ class NovaSonicApp : Application() {
// 30s preview player — app-level so its control bar survives navigation (see AppNavigation). // 30s preview player — app-level so its control bar survives navigation (see AppNavigation).
val previewController: PreviewController = PreviewController() val previewController: PreviewController = PreviewController()
/**
* Raeumt den Cache nach einem App-Update einmalig auf.
*
* Im cacheDir liegen Coils Bildspeicher (Cover) und heruntergeladene APK-Updates — beides
* jederzeit neu beschaffbar. Nach einem Update konnten dort Reste liegen, die zu alten Anzeigen
* fuehrten; die einzige Abhilfe war bisher "Cache leeren" von Hand in den Systemeinstellungen,
* was man einem normalen Nutzer nicht erklaeren moechte. Das passiert jetzt automatisch und
* ausschliesslich beim Versionswechsel, nicht im laufenden Betrieb — sonst waeren die Cover bei
* jedem Start neu zu laden.
*
* Offline-Downloads sind NICHT betroffen: die liegen unter filesDir/offline, nicht im cacheDir.
*/
private fun cacheNachUpdateLeeren() {
val prefs = getSharedPreferences("app_state", MODE_PRIVATE)
val zuletzt = prefs.getInt("last_version_code", -1)
val jetzt = BuildConfig.VERSION_CODE
if (zuletzt == jetzt) return
if (zuletzt != -1) {
runCatching { cacheDir.deleteRecursively() }
.onSuccess { android.util.Log.i("NsCache", "Cache nach Update $zuletzt -> $jetzt geleert") }
.onFailure { android.util.Log.w("NsCache", "Cache-Leeren fehlgeschlagen", it) }
}
prefs.edit().putInt("last_version_code", jetzt).apply()
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
cacheNachUpdateLeeren()
offlineRepository = OfflineRepository(applicationContext) // must be first offlineRepository = OfflineRepository(applicationContext) // must be first
repository = MusicRepository(applicationContext) repository = MusicRepository(applicationContext)
playerController = PlayerController(applicationContext, repository) playerController = PlayerController(applicationContext, repository)
playerController.connect() playerController.connect()
absPlayerController = AbsPlayerController(applicationContext) absPlayerController = AbsPlayerController(applicationContext)
// Angefangene Downloads weiterlaufen lassen (grosse Playlists brauchen viele Laeufe).
offlineRepository.setzeUnvollstaendigeFort()
// Flush any plays queued while offline to the scrobble service. // Flush any plays queued while offline to the scrobble service.
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
runCatching { repository.flushPlayLog() } runCatching { repository.flushPlayLog() }
@@ -8,6 +8,7 @@ import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -54,5 +55,18 @@ class ImporterClient(private val backendUrl: String) {
suspend fun status(jobId: String): DownloadStatus = suspend fun status(jobId: String): DownloadStatus =
http.get("${base()}/api/status/$jobId").body() http.get("${base()}/api/status/$jobId").body()
/**
* Loescht einen Song endgueltig von der Platte — derselbe Endpunkt, den der Web-Client nutzt.
* Das Backend braucht die Navidrome-Zugangsdaten, weil es den Dateipfad ueber getSong aufloest.
* Wirft bei HTTP != 2xx (Ktor `expectSuccess` ist hier aus, deshalb der explizite Check).
*/
suspend fun deleteSong(ndUrl: String, user: String, pass: String, songId: String) {
val res = http.post("${base()}/api/delete-song") {
contentType(ContentType.Application.Json)
setBody(DeleteSongRequest(ndUrl.trimEnd('/'), user, pass, songId))
}
if (!res.status.isSuccess()) error("HTTP ${res.status.value}")
}
fun close() = http.close() fun close() = http.close()
} }
@@ -0,0 +1,51 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.ArtistRef
import com.novasonic.app.api.model.ArtistReleasesRequest
import com.novasonic.app.api.model.ArtistKonzerteRequest
import com.novasonic.app.api.model.ArtistKonzerteResponse
import com.novasonic.app.api.model.ArtistReleasesResponse
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
/**
* Neue Veröffentlichungen der gefolgten Künstler. Die Deezer-Auflösung macht das Backend — dort
* liegen auch die Künstler-Pins, und der Cache gilt dann für alle Geräte gemeinsam statt pro App.
*/
object ReleasesClient {
private val http = HttpClient(Android) {
expectSuccess = true
// Der erste Aufruf kann dauern: das Backend holt pro Künstler die Deezer-Diskografie.
// Danach liegt es dort im Cache.
install(HttpTimeout) { requestTimeoutMillis = 60_000; connectTimeoutMillis = 10_000 }
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
}
}
suspend fun fetch(backendUrl: String, artists: List<ArtistRef>, days: Int): ArtistReleasesResponse {
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistReleasesResponse()
return http.post("${backendUrl.trimEnd('/')}/api/artist-releases") {
contentType(ContentType.Application.Json)
setBody(ArtistReleasesRequest(artists, days))
}.body()
}
/** Anstehende Konzerte derselben Kuenstler — gleicher Weg, anderer Endpunkt. */
suspend fun konzerte(backendUrl: String, artists: List<ArtistRef>): ArtistKonzerteResponse {
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistKonzerteResponse()
// Ohne `days`: das Backend liefert alles Angekuendigte.
return http.post("${backendUrl.trimEnd('/')}/api/artist-concerts") {
contentType(ContentType.Application.Json)
setBody(ArtistKonzerteRequest(artists))
}.body()
}
}
@@ -149,9 +149,16 @@ class SubsonicClient(
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2 get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
?: com.novasonic.app.api.model.ArtistInfo() ?: com.novasonic.app.api.model.ArtistInfo()
suspend fun getTopSongs(artist: String, count: Int = 10): List<com.novasonic.app.api.model.Song> = /**
get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song * id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
?: emptyList() * Namen zurueck, wenn es sie nicht kennt; aeltere Versionen ignorieren id. Der Namensweg sucht
* mit LIKE und Max:1 — bei gleichnamigen Kuenstlern gewann bisher der erste Treffer.
*/
suspend fun getTopSongs(artist: String, count: Int = 10, artistId: String? = null): List<com.novasonic.app.api.model.Song> =
get("getTopSongs.view", buildMap {
put("artist", artist); put("count", count)
artistId?.takeIf { it.isNotBlank() }?.let { put("id", it) }
}).topSongs?.song ?: emptyList()
suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> = suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
get("getGenres.view").genres?.genre ?: emptyList() get("getGenres.view").genres?.genre ?: emptyList()
@@ -163,6 +170,9 @@ class SubsonicClient(
suspend fun addSongToPlaylist(playlistId: String, songId: String) = suspend fun addSongToPlaylist(playlistId: String, songId: String) =
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId)) getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
/** Stoesst Navidromes Bibliotheks-Scan an — nach einem Import, damit frische Dateien auffindbar sind. */
suspend fun startScan() = get("startScan.view")
suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs { suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs {
val params = mutableListOf("name" to name) val params = mutableListOf("name" to name)
songIds.forEach { params += "songId" to it } songIds.forEach { params += "songId" to it }
@@ -42,6 +42,15 @@ data class DownloadRequest(
@Serializable @Serializable
data class ResolveRequest(val url: String) data class ResolveRequest(val url: String)
/** Body fuer POST {backend}/api/delete-song — loescht die Datei von der Platte (wie im Web). */
@Serializable
data class DeleteSongRequest(
val ndUrl: String,
val user: String,
val pass: String,
val songId: String,
)
/** Response from GET {backend}/api/status/{jobId} */ /** Response from GET {backend}/api/status/{jobId} */
@Serializable @Serializable
data class DownloadStatus( data class DownloadStatus(
@@ -0,0 +1,59 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
/** Eine Veröffentlichung eines gefolgten Künstlers, wie sie das Backend liefert. */
@Serializable
data class ArtistRelease(
val artistId: String = "",
val artistName: String = "",
val deezerArtistId: String? = null,
val albumId: String = "",
val title: String = "",
val cover: String = "",
val releaseDate: String = "",
val recordType: String = "album",
val trackCount: Int? = null,
val explicit: Boolean = false,
)
@Serializable
data class ArtistReleasesResponse(
val releases: List<ArtistRelease> = emptyList(),
/** Künstler, zu denen kein Deezer-Profil gefunden wurde — dort hilft nur ein Pin. */
val ohneZuordnung: List<String> = emptyList(),
)
@Serializable
data class ArtistRef(val id: String, val name: String)
@Serializable
data class ArtistReleasesRequest(val artists: List<ArtistRef>, val days: Int)
/** Ein anstehendes Konzert eines gefolgten Künstlers. */
@Serializable
data class ArtistKonzert(
val artistId: String = "",
val artistName: String = "",
val eventId: String = "",
val titel: String = "",
/** Ortszeit ohne Zone, z. B. "2026-11-04T20:00:00" — bewusst nicht UTC. */
val datum: String = "",
val ort: String = "",
val stadt: String = "",
val land: String = "",
val url: String = "",
val abgesagt: Boolean = false,
)
@Serializable
data class ArtistKonzerteResponse(
val konzerte: List<ArtistKonzert> = emptyList(),
/** Künstler ohne anstehende Termine — nur zur Erklärung einer leeren Liste. */
val ohneTermine: List<String> = emptyList(),
/** Im Backend ist kein Ticketmaster-Schlüssel hinterlegt. */
val keinSchluessel: Boolean = false,
)
@Serializable
data class ArtistKonzerteRequest(val artists: List<ArtistRef>)
@@ -85,6 +85,7 @@ data class CompareResult(
val userA: String = "", val userA: String = "",
val userB: String = "", val userB: String = "",
val matchScore: Int = 0, val matchScore: Int = 0,
val battle: Battle? = null,
val totalsA: CompareTotals = CompareTotals(), val totalsA: CompareTotals = CompareTotals(),
val totalsB: CompareTotals = CompareTotals(), val totalsB: CompareTotals = CompareTotals(),
val sharedArtists: List<SharedArtist> = emptyList(), val sharedArtists: List<SharedArtist> = emptyList(),
@@ -93,6 +94,23 @@ data class CompareResult(
val onlyB: List<UniqueArtist> = emptyList(), val onlyB: List<UniqueArtist> = emptyList(),
) )
/** Anzeigetafel: je Kategorie ein Sieger ("A"/"B"/null bei Gleichstand) plus Gesamtstand. */
@Serializable
data class Battle(
val kategorien: List<BattleKategorie> = emptyList(),
val punkteA: Int = 0,
val punkteB: Int = 0,
)
@Serializable
data class BattleKategorie(
val key: String = "",
val label: String = "",
val textA: String = "",
val textB: String = "",
val sieger: String? = null,
)
@Serializable @Serializable
data class CompareTotals( data class CompareTotals(
val plays: Int = 0, val plays: Int = 0,
@@ -0,0 +1,26 @@
package com.novasonic.app.data
import android.net.Uri
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Uebergabepunkt fuer geteilte Links (https://<host>/?song=… bzw. ?album=…).
*
* Die Activity kann nicht selbst navigieren — der NavController lebt in der Compose-Hierarchie.
* Deshalb legt MainActivity den eingehenden Link hier ab, und AppNavigation holt ihn sich, sobald
* es bereit ist. Ein einzelner Slot genuegt: der letzte geoeffnete Link gewinnt.
*/
object DeepLinkState {
val pending = MutableStateFlow<Uri?>(null)
fun offer(uri: Uri?) {
// Nur Links mit einer fuer uns verwertbaren Kennung annehmen — ein blosser Start ueber das
// App-Symbol darf keine Navigation ausloesen.
if (uri == null) return
if (uri.getQueryParameter("song") == null && uri.getQueryParameter("album") == null) return
pending.value = uri
}
/** Holt den Link und leert den Slot, damit er bei Drehung o. ae. nicht erneut greift. */
fun consume(): Uri? = pending.value.also { pending.value = null }
}
@@ -0,0 +1,11 @@
package com.novasonic.app.data
/**
* Einmalige Absicht beim Öffnen der Bibliothek. Das LibraryViewModel hängt am Navigationseintrag,
* überlebt also keinen Wechsel des Ziels — ein Zustand, den ein anderer Screen setzen will, muss
* deshalb hier durch. Wird beim Verarbeiten sofort zurückgesetzt, damit er nicht nachwirkt.
*/
object LibraryIntent {
/** Direkt in den Künstler-Reiter mit aktivem "Gefolgt"-Filter springen. */
var oeffneGefolgte: Boolean = false
}
@@ -101,11 +101,15 @@ class MusicRepository(private val context: Context) {
} }
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id) suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName) /** artistId ist die NAVIDROME-Kuenstler-ID (nicht Deezers) — ohne sie sucht Navidrome
* ueber den Namen und nimmt bei Gleichnamigen den ersten Treffer. */
suspend fun getTopSongs(artistName: String, artistId: String? = null) =
api().getTopSongs(artistName, artistId = artistId)
suspend fun getGenres() = api().getGenres() suspend fun getGenres() = api().getGenres()
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre) suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId) suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds) suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
suspend fun startScan() = api().startScan()
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment) suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment)
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices) suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices)
suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId) suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId)
@@ -168,6 +172,9 @@ class MusicRepository(private val context: Context) {
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId) suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) =
prefs.saveQueueState(songs, index)
suspend fun readQueueState() = prefs.readQueueState()
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) = suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt) prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
@@ -3,9 +3,11 @@ package com.novasonic.app.data
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore("novasonic_prefs") private val Context.dataStore by preferencesDataStore("novasonic_prefs")
@@ -25,10 +27,19 @@ class PrefsStore(private val context: Context) {
val KEY_ABS_URL = stringPreferencesKey("abs_url") val KEY_ABS_URL = stringPreferencesKey("abs_url")
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key") val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
val KEY_ABS_CHAPTER_TRACK = booleanPreferencesKey("abs_chapter_track") val KEY_ABS_CHAPTER_TRACK = booleanPreferencesKey("abs_chapter_track")
val KEY_DL_WIFI_ONLY = booleanPreferencesKey("download_wifi_only")
val KEY_WISCH_TITEL = booleanPreferencesKey("swipe_changes_track")
val KEY_LYRICS_FOLGEN = booleanPreferencesKey("lyrics_autoscroll")
val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id") val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id")
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title") val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist") val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover") val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
// Warteschlange fortsetzen. Gespeichert werden die VOLLSTAENDIGEN Songs als JSON, nicht nur
// Ids: mit Ids musste beim Start jeder Titel einzeln ueber das Netz nachgeladen werden, was
// Sekunden dauerte und ohne Verbindung gar nicht ging. So ist es ein lokaler Lesevorgang.
// Ohne Sekunde im Titel — der zuletzt gehoerte Titel beginnt von vorn.
val KEY_QUEUE_JSON = stringPreferencesKey("queue_json")
val KEY_QUEUE_INDEX = intPreferencesKey("queue_index")
} }
data class Credentials(val serverUrl: String, val username: String, val password: String) { data class Credentials(val serverUrl: String, val username: String, val password: String) {
@@ -48,6 +59,16 @@ class PrefsStore(private val context: Context) {
val absUrl: String = "", val absUrl: String = "",
val absApiKey: String = "", val absApiKey: String = "",
val absChapterTrackMode: Boolean = false, val absChapterTrackMode: Boolean = false,
/** Offline-Downloads nur ueber WLAN. Standard an: eine grosse Playlist sind schnell
* mehrere Gigabyte, und die Wiederholkette laeuft ohne Zutun weiter. */
val downloadWifiOnly: Boolean = true,
/** Waagerechtes Wischen im Now-Playing wechselt den Titel statt der Ansicht.
* Standard an — Titelwechsel ist die Geste, die man dort staendig braucht. */
val wischenWechseltTitel: Boolean = true,
/** Im Lyrics-Tab automatisch zur laufenden Zeile scrollen. Standard AUS: wer selbst
* blaettert, will nicht, dass der Text unter dem Finger wegspringt. Umschalten direkt
* im Lyrics-Tab, wirkt nur bei synchronen Texten. */
val lyricsFolgenAutomatisch: Boolean = false,
) )
data class LastPlayedSong( data class LastPlayedSong(
@@ -59,7 +80,9 @@ class PrefsStore(private val context: Context) {
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs -> val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
Credentials( Credentials(
serverUrl = prefs[KEY_SERVER] ?: "", // Adresse vorbelegt, Zugangsdaten bewusst nicht — die gehoeren pro Person.
serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
username = prefs[KEY_USERNAME] ?: "", username = prefs[KEY_USERNAME] ?: "",
password = prefs[KEY_PASSWORD] ?: "", password = prefs[KEY_PASSWORD] ?: "",
) )
@@ -67,18 +90,26 @@ class PrefsStore(private val context: Context) {
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs -> val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
AppSettings( AppSettings(
serverUrl = prefs[KEY_SERVER] ?: "", serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
username = prefs[KEY_USERNAME] ?: "", username = prefs[KEY_USERNAME] ?: "",
password = prefs[KEY_PASSWORD] ?: "", password = prefs[KEY_PASSWORD] ?: "",
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "", lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
backendUrl = prefs[KEY_BACKEND_URL] ?: "", backendUrl = prefs[KEY_BACKEND_URL]?.ifBlank { null }
scrobbleUrl = prefs[KEY_SCROBBLE_URL] ?: "", ?: com.novasonic.app.BuildConfig.DEFAULT_BACKEND_URL,
// Leerer Speicherwert => eingebaute Vorgabe. So muss auf einem frischen Geraet nichts
// eingetippt werden, ein eigener Eintrag hat aber weiterhin Vorrang.
scrobbleUrl = prefs[KEY_SCROBBLE_URL]?.ifBlank { null }
?: com.novasonic.app.BuildConfig.DEFAULT_SCROBBLE_URL,
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s", scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false, showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
theme = prefs[KEY_THEME] ?: "dark-ocean", theme = prefs[KEY_THEME] ?: "dark-ocean",
absUrl = prefs[KEY_ABS_URL] ?: "", absUrl = prefs[KEY_ABS_URL] ?: "",
absApiKey = prefs[KEY_ABS_API_KEY] ?: "", absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false, absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false,
downloadWifiOnly = prefs[KEY_DL_WIFI_ONLY] ?: true,
wischenWechseltTitel = prefs[KEY_WISCH_TITEL] ?: true,
lyricsFolgenAutomatisch = prefs[KEY_LYRICS_FOLGEN] ?: false,
) )
} }
@@ -114,9 +145,21 @@ class PrefsStore(private val context: Context) {
prefs[KEY_ABS_URL] = settings.absUrl prefs[KEY_ABS_URL] = settings.absUrl
prefs[KEY_ABS_API_KEY] = settings.absApiKey prefs[KEY_ABS_API_KEY] = settings.absApiKey
prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode
prefs[KEY_DL_WIFI_ONLY] = settings.downloadWifiOnly
prefs[KEY_WISCH_TITEL] = settings.wischenWechseltTitel
// KEY_LYRICS_FOLGEN bewusst NICHT: der Schalter sitzt im Lyrics-Tab und wird mit
// saveLyricsFolgen() einzeln geschrieben. Wuerde er hier mitgeschrieben, setzte jedes
// Speichern in den Einstellungen ihn auf die Vorgabe zurueck — der Bildschirm fuehrt
// dieses Feld gar nicht mit.
} }
} }
/** Einzeln speicherbar: der Schalter sitzt im Lyrics-Tab, nicht in den Einstellungen, und
* darf dort nicht den ganzen Einstellungssatz ueberschreiben. */
suspend fun saveLyricsFolgen(an: Boolean) {
context.dataStore.edit { prefs -> prefs[KEY_LYRICS_FOLGEN] = an }
}
suspend fun saveTheme(themeId: String) { suspend fun saveTheme(themeId: String) {
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId } context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
} }
@@ -130,6 +173,39 @@ class PrefsStore(private val context: Context) {
} }
} }
private val queueJson = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
/**
* Sichert die Warteschlange als VOLLSTAENDIGE Songs, nicht nur als Ids. Mit Ids musste beim
* Start jeder Titel einzeln ueber das Netz nachgeladen werden — das dauerte Sekunden und ging
* ohne Verbindung gar nicht. So ist das Wiederherstellen ein rein lokaler Lesevorgang.
* Der Preis: zwischenzeitlich geaenderte Titel oder Cover zeigen bis zum naechsten Abspielen
* den alten Stand. Das ist die deutlich kleinere Unschoenheit.
*/
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) {
val kodiert = runCatching {
queueJson.encodeToString(kotlinx.serialization.builtins.ListSerializer(
com.novasonic.app.api.model.Song.serializer()), songs)
}.getOrNull() ?: return
context.dataStore.edit { prefs ->
prefs[KEY_QUEUE_JSON] = kodiert
prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0)
}
}
data class QueueState(val songs: List<com.novasonic.app.api.model.Song>, val index: Int)
suspend fun readQueueState(): QueueState? {
val prefs = context.dataStore.data.first()
val roh = prefs[KEY_QUEUE_JSON]?.takeIf { it.isNotBlank() } ?: return null
val songs = runCatching {
queueJson.decodeFromString(kotlinx.serialization.builtins.ListSerializer(
com.novasonic.app.api.model.Song.serializer()), roh)
}.getOrNull().orEmpty()
if (songs.isEmpty()) return null
return QueueState(songs, (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, songs.lastIndex))
}
suspend fun clear() { suspend fun clear() {
context.dataStore.edit { it.clear() } context.dataStore.edit { it.clear() }
} }
@@ -21,13 +21,18 @@ class DownloadWorker(
companion object { companion object {
const val EXTRA_GROUP_ID = "group_id" const val EXTRA_GROUP_ID = "group_id"
private const val TAG = "DownloadWorker"
/** Obergrenze fuer selbst angeforderte Wiederholungen, damit dauerhaft kaputte Titel
* (auf dem Server geloescht, immer 404) die Kette nicht endlos weiterlaufen lassen. */
private const val MAX_VERSUCHE = 20
} }
private val offlineRepo = applicationContext.app.offlineRepository private val offlineRepo = applicationContext.app.offlineRepository
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val groupId = inputData.getString(EXTRA_GROUP_ID) ?: return Result.failure() val groupId = inputData.getString(EXTRA_GROUP_ID) ?: return Result.failure()
val group = offlineRepo.getGroup(groupId) ?: return Result.failure() // Nur die Existenz zaehlt — der Fortschritt wird unten neu gezaehlt, nicht fortgeschrieben.
offlineRepo.getGroup(groupId) ?: return Result.failure()
val songIds = offlineRepo.songIdsForGroup(groupId) val songIds = offlineRepo.songIdsForGroup(groupId)
if (songIds.isEmpty()) return Result.success() if (songIds.isEmpty()) return Result.success()
@@ -35,14 +40,26 @@ class DownloadWorker(
if (!creds.isValid) return Result.failure() if (!creds.isValid) return Result.failure()
val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password) val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password)
var done = group.downloadedTracks // Bei 0 anfangen und JEDEN fertigen Titel zaehlen — auch die aus frueheren Laeufen, die
// gleich uebersprungen werden. Frueher stand hier group.downloadedTracks, und die Schleife
// zaehlte dieselben Titel gleich nochmal: nach einem Fortsetzen mit 412 fertigen Titeln
// meldete die Gruppe 824. Ueberschreitet der Wert totalTracks, gilt die Gruppe als
// "Vollstaendig" und der Herunterladen-Knopf wird unantastbar, obwohl Titel fehlen.
var done = 0
val dao = OfflineDatabase.get(applicationContext).offlineDao() val dao = OfflineDatabase.get(applicationContext).offlineDao()
return try { return try {
var gestoppt = false
for (songId in songIds) { for (songId in songIds) {
// WorkManager raeumt Hintergrundarbeit rund 10 Minuten ein und stoppt sie dann.
// Ohne diese Pruefung laeuft die Schleife blind weiter, bis sie hart abgebrochen
// wird — der angefangene Titel geht verloren und niemand startet neu. So steigen
// wir sauber aus und fordern den naechsten Lauf an; Fertiges wird uebersprungen.
if (isStopped) { gestoppt = true; break }
if (offlineRepo.getTrackState(songId) == DownloadState.DONE) { if (offlineRepo.getTrackState(songId) == DownloadState.DONE) {
// Kein Schreibvorgang beim Ueberspringen: der Fortschritt aendert sich dabei
// nicht, und bei 1900 Titeln waeren das 1900 unnoetige Schreibzugriffe je Lauf.
done++ done++
dao.updateGroupProgress(groupId, done)
continue continue
} }
@@ -69,17 +86,41 @@ class DownloadWorker(
offlineRepo.markDone(songId, audioFile.absolutePath, coverPath) offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
done++ done++
} catch (e: Exception) { } catch (e: Exception) {
Log.e("DownloadWorker", "Failed to download song $songId", e) Log.e(TAG, "Titel $songId fehlgeschlagen", e)
audioFile.delete() audioFile.delete()
offlineRepo.markFailed(songId) offlineRepo.markFailed(songId)
} }
dao.updateGroupProgress(groupId, done) dao.updateGroupProgress(groupId, done)
} }
Result.success() // Beim Abbruch ist der letzte Stand sonst nicht gesichert.
if (gestoppt) dao.updateGroupProgress(groupId, done)
val offen = dao.offeneTitel(groupId)
when {
gestoppt -> {
Log.i(TAG, "Gestoppt bei $done/${songIds.size} — fordere naechsten Lauf an")
Result.retry()
}
offen == 0 -> {
Log.i(TAG, "Gruppe $groupId vollstaendig (${songIds.size} Titel)")
Result.success()
}
runAttemptCount < MAX_VERSUCHE -> {
Log.i(TAG, "$offen Titel offen (Versuch $runAttemptCount) — noch ein Lauf")
Result.retry()
}
else -> {
// Aufgeben, aber nicht stillschweigend: die Gruppenzeile zeigt weiterhin
// "x/y Titel", und ein Tipp auf Herunterladen startet die Kette neu.
Log.w(TAG, "$offen Titel bleiben offen — nach $runAttemptCount Versuchen aufgegeben")
Result.success()
}
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e("DownloadWorker", "Worker failed for group $groupId", e) Log.e(TAG, "Lauf fuer Gruppe $groupId abgebrochen", e)
Result.failure() // Frueher Result.failure(): ein einzelner Netzfehler beendete die ganze Kette endgueltig.
if (runAttemptCount < MAX_VERSUCHE) Result.retry() else Result.failure()
} }
} }
@@ -53,6 +53,26 @@ interface OfflineDao {
@Query("SELECT * FROM download_groups WHERE groupId = :groupId") @Query("SELECT * FROM download_groups WHERE groupId = :groupId")
suspend fun getGroup(groupId: String): DownloadGroupEntity? suspend fun getGroup(groupId: String): DownloadGroupEntity?
/**
* Rechnet den gespeicherten Fortschritt ALLER Gruppen aus dem tatsaechlichen Zustand der Titel
* neu. Noetig, weil der Worker bis 1.10 doppelt zaehlte: der Wert konnte totalTracks
* uebersteigen, die Gruppe galt als "Vollstaendig", der Herunterladen-Knopf wurde unantastbar
* und das Fortsetzen uebersprang sie — der falsche Wert konnte sich also nie mehr korrigieren.
* Eine Anweisung fuer alle Gruppen, laeuft beim App-Start.
*/
@Query("""
UPDATE download_groups SET downloadedTracks = (
SELECT COUNT(*) FROM group_track_cross_refs r
INNER JOIN downloaded_tracks t ON t.songId = r.songId
WHERE r.groupId = download_groups.groupId AND t.state = 'DONE'
)
""")
suspend fun fortschrittNeuBerechnen()
/** Gruppen, bei denen noch Titel fehlen — Grundlage fuers Fortsetzen beim App-Start. */
@Query("SELECT * FROM download_groups WHERE totalTracks > 0 AND downloadedTracks < totalTracks")
suspend fun incompleteGroups(): List<DownloadGroupEntity>
@Query("DELETE FROM download_groups WHERE groupId = :groupId") @Query("DELETE FROM download_groups WHERE groupId = :groupId")
suspend fun deleteGroup(groupId: String) suspend fun deleteGroup(groupId: String)
@@ -84,9 +104,21 @@ interface OfflineDao {
""") """)
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>> fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>>
/** Wie viele Titel der Gruppe noch nicht fertig sind — eine Abfrage statt einer pro Titel. */
@Query("""
SELECT COUNT(*) FROM downloaded_tracks t
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
WHERE r.groupId = :groupId AND t.state != 'DONE'
""")
suspend fun offeneTitel(groupId: String): Int
@Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId") @Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId")
suspend fun removeCrossRef(groupId: String, songId: String) suspend fun removeCrossRef(groupId: String, songId: String)
/** Wie viele Titel dieses Cover noch benutzen — Cover werden geteilt gespeichert. */
@Query("SELECT COUNT(*) FROM downloaded_tracks WHERE coverArt = :coverArtId")
suspend fun titelMitCover(coverArtId: String): Int
/** Find a locally-stored cover path by Navidrome coverArt ID (deduped storage). */ /** Find a locally-stored cover path by Navidrome coverArt ID (deduped storage). */
@Query("SELECT coverPath FROM downloaded_tracks WHERE coverArt = :coverArtId AND state = 'DONE' AND coverPath IS NOT NULL LIMIT 1") @Query("SELECT coverPath FROM downloaded_tracks WHERE coverArt = :coverArtId AND state = 'DONE' AND coverPath IS NOT NULL LIMIT 1")
suspend fun localCoverPathByCoverArt(coverArtId: String): String? suspend fun localCoverPathByCoverArt(coverArtId: String): String?
@@ -154,18 +154,98 @@ class OfflineRepository(context: Context) {
} }
dao.updateGroupProgress(groupId, alreadyDone) dao.updateGroupProgress(groupId, alreadyDone)
starteWorker(groupId)
}
/**
* Startet (oder setzt fort) die Downloadarbeit einer Gruppe. KEEP heisst: laeuft schon etwas
* fuer diese Gruppe, passiert nichts — sonst beginnt ein Lauf, der alles Fertige ueberspringt.
*/
private suspend fun starteWorker(groupId: String, ersetzen: Boolean = false) {
// UNMETERED = WLAN (bzw. jede nicht getaktete Verbindung). WorkManager pausiert den Download
// dann von selbst, sobald das WLAN weg ist, und setzt fort, sobald es zurueck ist. Mit
// CONNECTED liefe die Wiederholkette auch ueber Mobilfunk durch — bei einer grossen Playlist
// sind das schnell mehrere Gigabyte ohne Zutun des Nutzers.
val nurWlan = runCatching {
com.novasonic.app.data.PrefsStore(appContext).appSettings.first().downloadWifiOnly
}.getOrDefault(true)
val request = OneTimeWorkRequestBuilder<DownloadWorker>() val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId)) .setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
.setConstraints( .setConstraints(
Constraints.Builder() Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) .setRequiredNetworkType(if (nurWlan) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresStorageNotLow(true) .setRequiresStorageNotLow(true)
.build() .build()
) )
// Ohne Backoff wuerde ein Result.retry() sofort erneut anlaufen; 30 s linear geben dem
// Geraet Luft und halten die Kette trotzdem in Gang.
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, java.util.concurrent.TimeUnit.SECONDS)
.build() .build()
WorkManager.getInstance(appContext) WorkManager.getInstance(appContext).enqueueUniqueWork(
.enqueueUniqueWork(groupId, ExistingWorkPolicy.KEEP, request) groupId,
if (ersetzen) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
request,
)
}
/**
* Nach einer Aenderung an "nur ueber WLAN" die laufende Arbeit neu einreihen. WorkManager
* aendert die Bedingungen einer bereits eingereihten Arbeit nicht mehr — ohne das bliebe der
* Schalter bis zum naechsten App-Start wirkungslos. REPLACE ist unbedenklich, weil der Worker
* ohnehin fortsetzt statt von vorn zu laden.
*/
fun netzbedingungAktualisieren() {
scope.launch {
runCatching { dao.incompleteGroups() }.getOrDefault(emptyList())
.forEach { starteWorker(it.groupId, ersetzen = true) }
}
}
/**
* Beim App-Start unvollstaendige Gruppen weiterlaufen lassen. WorkManager stoppt einen
* Hintergrund-Worker nach rund 10 Minuten; eine Playlist mit 1900 Titeln braucht daher viele
* Laeufe. Der Worker fordert selbst einen neuen an (Result.retry()), aber wenn die Kette doch
* einmal reisst — Neustart des Geraets, aufgebrauchte Versuche — faengt das hier es auf, statt
* darauf zu warten, dass jemand von Hand erneut auf Herunterladen tippt.
*/
fun setzeUnvollstaendigeFort() {
scope.launch {
// Erst den gespeicherten Fortschritt begradigen: Gruppen mit einem zu hohen Wert aus
// der Zeit vor 1.10 sehen sonst vollstaendig aus und werden hier gar nicht gefunden.
runCatching { dao.fortschrittNeuBerechnen() }
.onFailure { Log.w("OfflineRepository", "Fortschritt neu berechnen fehlgeschlagen", it) }
val offen = runCatching { dao.incompleteGroups() }.getOrDefault(emptyList())
offen.forEach { g ->
Log.i("OfflineRepository",
"Setze fort: ${g.groupId} (${g.downloadedTracks}/${g.totalTracks})")
starteWorker(g.groupId)
}
}
}
/**
* Entfernt alle Dateien eines Titels und dann die Datenbankzeile.
*
* Frueher lief das ueber _audioMap — die kennt aber nur FERTIGE Titel. Ein abgebrochener oder
* fehlgeschlagener Download hinterliess seine Datei also unberuehrt. Jetzt kommt der Pfad aus
* der Zeile selbst, dazu die angefangene .tmp und das Cover, sofern es kein anderer Titel mehr
* benutzt (Cover werden geteilt gespeichert).
*/
private suspend fun loescheTitelVollstaendig(songId: String) {
val track = dao.getTrackById(songId)
val pfade = listOfNotNull(track?.localPath, _audioMap.value[songId])
pfade.distinct().forEach { p ->
runCatching { File(p).delete(); File("$p.tmp").delete() }
}
track?.coverArt?.let { coverArtId ->
// Diese Zeile zaehlt noch mit, deshalb ist 1 der letzte Benutzer.
if (dao.titelMitCover(coverArtId) <= 1) {
runCatching { File(coversDir(), "$coverArtId.jpg").delete() }
track.coverPath?.let { runCatching { File(it).delete() } }
}
}
dao.deleteTrackById(songId)
} }
// ── Delete with reference-count file cleanup ────────────────────────────── // ── Delete with reference-count file cleanup ──────────────────────────────
@@ -173,22 +253,18 @@ class OfflineRepository(context: Context) {
/** Removes a group and any tracks that are no longer referenced by another group. */ /** Removes a group and any tracks that are no longer referenced by another group. */
suspend fun deleteGroup(groupId: String) { suspend fun deleteGroup(groupId: String) {
val songIds = dao.songIdsForGroup(groupId) val songIds = dao.songIdsForGroup(groupId)
// Laufende Downloadarbeit stoppen, sonst laedt sie noch Minuten weiter ins Leere.
WorkManager.getInstance(appContext).cancelUniqueWork(groupId)
dao.deleteGroup(groupId) // cascades GroupTrackCrossRef rows dao.deleteGroup(groupId) // cascades GroupTrackCrossRef rows
for (songId in songIds) { for (songId in songIds) {
if (dao.groupCountForSong(songId) == 0) { if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
_audioMap.value[songId]?.let { File(it).delete() }
dao.deleteTrackById(songId)
}
} }
} }
/** Removes a single track from a group, deleting the file if no other group still needs it. */ /** Removes a single track from a group, deleting the file if no other group still needs it. */
suspend fun removeSongFromGroup(groupId: String, songId: String) { suspend fun removeSongFromGroup(groupId: String, songId: String) {
dao.removeCrossRef(groupId, songId) dao.removeCrossRef(groupId, songId)
if (dao.groupCountForSong(songId) == 0) { if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
_audioMap.value[songId]?.let { File(it).delete() }
dao.deleteTrackById(songId)
}
recountGroup(groupId) recountGroup(groupId)
} }
@@ -238,7 +238,14 @@ class MusicService : MediaLibraryService() {
override fun onGetSession(info: MediaSession.ControllerInfo) = session override fun onGetSession(info: MediaSession.ControllerInfo) = session
override fun onTaskRemoved(rootIntent: Intent?) { player.stop(); stopSelf() } override fun onTaskRemoved(rootIntent: Intent?) {
// Wischt jemand die App aus den letzten Apps, wird der Prozess gleich beendet — hier ist die
// letzte Gelegenheit, die Warteschlange festzuhalten. Ohne das ginge sie genau in dem Fall
// verloren, in dem man sie beim naechsten Oeffnen am ehesten erwartet.
runCatching { (application as? com.novasonic.app.NovaSonicApp)?.playerController?.saveQueueNow() }
player.stop()
stopSelf()
}
override fun onDestroy() { override fun onDestroy() {
scope.cancel() scope.cancel()
@@ -37,6 +37,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
private val _state = MutableStateFlow(PlayerState()) private val _state = MutableStateFlow(PlayerState())
val state: StateFlow<PlayerState> = _state.asStateFlow() val state: StateFlow<PlayerState> = _state.asStateFlow()
private val TAG_QUEUE = "NsQueue"
private val scope = CoroutineScope( private val scope = CoroutineScope(
Dispatchers.Main + SupervisorJob() + Dispatchers.Main + SupervisorJob() +
CoroutineExceptionHandler { _, t -> android.util.Log.e("PlayerController", "Uncaught coroutine exception", t) } CoroutineExceptionHandler { _, t -> android.util.Log.e("PlayerController", "Uncaught coroutine exception", t) }
@@ -56,24 +58,95 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
controller = runCatching { future.get() }.getOrNull() controller = runCatching { future.get() }.getOrNull()
controller?.addListener(PlayerListener()) controller?.addListener(PlayerListener())
syncState() syncState()
restoreQueue()
}, context.mainExecutor) }, context.mainExecutor)
} }
} }
/**
* Haelt Warteschlange und Listenposition fest — ohne die Sekunde im Titel: der zuletzt gehoerte
* Titel beginnt von vorn. ABS-Hoerbuecher bleiben aussen vor, die haben ihren eigenen
* Fortschritt auf dem Server.
*/
private fun persistQueueState() {
val ctrl = controller ?: return
val songs = queue
if (songs.isEmpty() || songs.any { it.id.startsWith("abs:") }) return
val index = ctrl.currentMediaItemIndex
scope.launch {
runCatching { repo.saveQueueState(songs, index) }
.onSuccess { android.util.Log.i(TAG_QUEUE, "gesichert: ${songs.size} Titel, Index $index") }
.onFailure { android.util.Log.w(TAG_QUEUE, "Sichern fehlgeschlagen", it) }
}
}
/** Von aussen aufrufbar (Dienst-Ende beim Wegwischen der App). */
fun saveQueueNow() = persistQueueState()
/**
* Stellt die zuletzt gehoerte Warteschlange wieder her — vorbereitet, aber NICHT abgespielt.
* Wer die App oeffnet, will nicht ueberfallen werden; ein Druck auf Play macht dort weiter.
* Laeuft schon etwas (etwa ueber Android Auto gestartet), passiert nichts.
*
* Die Aufloesung der Song-Ids braucht die Navidrome-Zugangsdaten. connect() laeuft aber schon in
* Application.onCreate(), wo der DataStore unter Umstaenden noch nicht gelesen ist — dann
* scheitern alle Abfragen und die Warteschlange bliebe still leer. Deshalb wird es mehrfach mit
* wachsendem Abstand versucht, statt beim ersten Fehlschlag aufzugeben.
*/
fun restoreQueue() {
scope.launch {
val zustand = runCatching { repo.readQueueState() }.getOrNull()
if (zustand == null) {
android.util.Log.i(TAG_QUEUE, "nichts gesichert — nichts wiederherzustellen")
return@launch
}
val ctrl = controller
if (ctrl == null) {
android.util.Log.w(TAG_QUEUE, "kein Controller — Wiederherstellen verworfen")
return@launch
}
if (ctrl.mediaItemCount > 0) {
android.util.Log.i(TAG_QUEUE, "Player hat bereits Titel — nicht angefasst")
return@launch
}
val songs = zustand.songs
val index = zustand.index.coerceIn(0, songs.lastIndex)
queue = songs
originalQueue = songs
runCatching {
ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, 0L)
ctrl.prepare()
}.onFailure { android.util.Log.w(TAG_QUEUE, "setMediaItems fehlgeschlagen", it) }
syncState()
android.util.Log.i(TAG_QUEUE, "wiederhergestellt: ${songs.size} Titel, Index $index")
}
}
// ── Playback commands ───────────────────────────────────────────────────── // ── Playback commands ─────────────────────────────────────────────────────
/**
* Startet eine Liste. Ein `startIndex` KLEINER 0 bedeutet "gemischt starten".
*
* Warum das Mischen hier passiert und nicht beim Aufrufer: vorher mischte jeder
* Shuffle-Knopf selbst (`liste.shuffled()`) und uebergab die fertige Reihenfolge. Damit war
* `originalQueue` schon die gemischte Liste, `isManuallyShuffled` blieb false — das
* Shuffle-Symbol leuchtete nicht, obwohl gemischt gespielt wurde, und ein Druck auf
* "Shuffle aus" stellte die Albumreihenfolge nie wieder her, weil es keine mehr gab.
*/
fun playSongs(songs: List<Song>, startIndex: Int = 0) { fun playSongs(songs: List<Song>, startIndex: Int = 0) {
// If ABS is active, close it — user explicitly switched to music // If ABS is active, close it — user explicitly switched to music
(context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs -> (context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs ->
if (abs.isActive) abs.close() if (abs.isActive) abs.close()
} }
val ctrl = controller ?: return val ctrl = controller ?: return
queue = songs val gemischt = startIndex < 0
originalQueue = songs val reihenfolge = if (gemischt) songs.shuffled() else songs
isManuallyShuffled = false queue = reihenfolge
originalQueue = songs // immer die UNGEMISCHTE Liste — Rueckweg fuer "Shuffle aus"
isManuallyShuffled = gemischt
ctrl.shuffleModeEnabled = false ctrl.shuffleModeEnabled = false
val items = songs.map { it.toMediaItem(repo) } val items = reihenfolge.map { it.toMediaItem(repo) }
ctrl.setMediaItems(items, startIndex, 0L) ctrl.setMediaItems(items, if (gemischt) 0 else startIndex.coerceIn(0, maxOf(0, songs.lastIndex)), 0L)
ctrl.prepare() ctrl.prepare()
ctrl.play() ctrl.play()
syncState() syncState()
@@ -142,6 +215,40 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
ctrl.addMediaItem(song.toMediaItem(repo)) ctrl.addMediaItem(song.toMediaItem(repo))
} }
// ── Warteschlange bearbeiten ────────────────────────────────────────────
// queue (lokal) und die MediaItems des Controllers muessen im Gleichschritt bleiben:
// syncState() liest den laufenden Index vom Controller, den Titel dazu aber aus queue.
// Liefen die beiden auseinander, zeigte die Oberflaeche den falschen Song an.
/** Entfernt Position [index]. Traf es den laufenden Titel, rueckt Media3 selbst weiter. */
fun entferneAusQueue(index: Int) {
val ctrl = controller ?: return
if (index !in queue.indices) return
val raus = queue[index]
queue = queue.toMutableList().apply { removeAt(index) }
// originalQueue haelt die Reihenfolge fuer das Zuruecknehmen des Mischens — dort denselben
// Titel EINMAL entfernen, sonst taucht er beim Entmischen wieder auf.
originalQueue = originalQueue.toMutableList().apply {
val i = indexOfFirst { it.id == raus.id }
if (i >= 0) removeAt(i)
}
ctrl.removeMediaItem(index)
if (queue.isEmpty()) {
ctrl.stop()
ctrl.clearMediaItems()
}
syncState()
}
/** Verschiebt Position [von] auf [nach]. */
fun verschiebeInQueue(von: Int, nach: Int) {
val ctrl = controller ?: return
if (von == nach || von !in queue.indices || nach !in queue.indices) return
queue = queue.toMutableList().apply { add(nach, removeAt(von)) }
ctrl.moveMediaItem(von, nach)
syncState()
}
fun toggleRepeat() { fun toggleRepeat() {
val ctrl = controller ?: return val ctrl = controller ?: return
ctrl.repeatMode = when (ctrl.repeatMode) { ctrl.repeatMode = when (ctrl.repeatMode) {
@@ -155,22 +262,22 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val ctrl = controller ?: return val ctrl = controller ?: return
val currentIdx = ctrl.currentMediaItemIndex val currentIdx = ctrl.currentMediaItemIndex
if (!isManuallyShuffled) { // Der Rest hinter dem laufenden Titel wird in EINEM Zug ersetzt. Vorher lief das als
// Schleife aus removeMediaItem + addMediaItem, und dazwischen hatte der Controller
// voruebergehend weniger Titel als unsere Liste. Genau darauf reagiert onTimelineChanged
// mit "die Titel kamen von aussen" — es baute die Warteschlange neu auf und setzte dabei
// isManuallyShuffled ZURUECK. Das Mischen lief, aber das Symbol leuchtete nie.
val neuerRest = if (!isManuallyShuffled) {
isManuallyShuffled = true isManuallyShuffled = true
val played = queue.take(currentIdx + 1) queue.drop(currentIdx + 1).shuffled()
val remaining = queue.drop(currentIdx + 1).shuffled()
queue = played + remaining
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
} else { } else {
isManuallyShuffled = false isManuallyShuffled = false
val currentSong = queue.getOrNull(currentIdx) val currentSong = queue.getOrNull(currentIdx)
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0) val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
val restOfOriginal = originalQueue.drop(origIdx + 1) originalQueue.drop(origIdx + 1)
queue = originalQueue.take(origIdx + 1) + restOfOriginal
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
restOfOriginal.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
} }
queue = queue.take(currentIdx + 1) + neuerRest
ctrl.replaceMediaItems(currentIdx + 1, ctrl.mediaItemCount, neuerRest.map { it.toMediaItem(repo) })
syncState() syncState()
} }
@@ -207,7 +314,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
} }
if (candidates.isEmpty()) { if (candidates.isEmpty()) {
// Letzte Rueckfallebene: Wiederholungen sind erlaubt, damit Musik weiterlaeuft — aber
// nicht der gerade laufende Titel. Der haengte sich sonst direkt hinter sich selbst.
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList()) candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
.filter { it.id != currentSong.id }
} }
val ctrl = controller ?: return val ctrl = controller ?: return
@@ -317,6 +427,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) startTicker() else stopTicker() if (isPlaying) startTicker() else stopTicker()
syncState() syncState()
// Beim Pausieren die Position festhalten: von da soll es beim naechsten Start weitergehen.
if (!isPlaying) persistQueueState()
} }
override fun onPlaybackStateChanged(state: Int) = syncState() override fun onPlaybackStateChanged(state: Int) = syncState()
@@ -336,6 +448,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0) val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0)
scheduleScrobble(song, dur) scheduleScrobble(song, dur)
scope.launch { runCatching { repo.saveLastSong(song) } } scope.launch { runCatching { repo.saveLastSong(song) } }
persistQueueState()
prefetchLyrics(song) prefetchLyrics(song)
} }
@@ -123,11 +123,37 @@ fun AppNavigation() {
} }
val navController = rememberNavController() val navController = rememberNavController()
// Geteilten Link aufloesen, sobald die Navigation steht: Song abspielen und sein Album oeffnen,
// bzw. direkt zum Album. Laeuft ueber denselben Weg wie im Web, damit sich beide gleich
// verhalten. Fehlschlaege (geloeschter Song, fremde Instanz) enden in einer Meldung statt still.
LaunchedEffect(Unit) {
com.novasonic.app.data.DeepLinkState.pending.collect { uri ->
if (uri == null) return@collect
com.novasonic.app.data.DeepLinkState.consume()
val songId = uri.getQueryParameter("song")
val albumId = uri.getQueryParameter("album")
runCatching {
when {
songId != null -> {
val song = repo.getSong(songId)
song.albumId?.let { navController.navigate("album/$it") }
player.playSongs(listOf(song), 0)
}
albumId != null -> navController.navigate("album/$albumId")
}
}.onFailure {
Toast.makeText(context, "Link konnte nicht geöffnet werden: ${it.message}",
Toast.LENGTH_LONG).show()
}
}
}
var showNowPlaying by remember { mutableStateOf(false) } var showNowPlaying by remember { mutableStateOf(false) }
var contextMenuSong by remember { mutableStateOf<Song?>(null) } var contextMenuSong by remember { mutableStateOf<Song?>(null) }
var playlistPickerSong by remember { mutableStateOf<Song?>(null) } var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
var infoSheetSong by remember { mutableStateOf<Song?>(null) } var infoSheetSong by remember { mutableStateOf<Song?>(null) }
var songToDelete by remember { mutableStateOf<Song?>(null) }
var showNotifications by remember { mutableStateOf(false) } var showNotifications by remember { mutableStateOf(false) }
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) } var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
var showAbsPlayer by remember { mutableStateOf(false) } var showAbsPlayer by remember { mutableStateOf(false) }
@@ -350,7 +376,7 @@ fun AppNavigation() {
onGenrePlay = { genre -> onGenrePlay = { genre ->
scope.launch { runCatching { scope.launch { runCatching {
val songs = repo.getSongsByGenre(genre.value) val songs = repo.getSongsByGenre(genre.value)
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0) if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
}} }}
}, },
onShowAllGenres = { navController.navigate("genres") }, onShowAllGenres = { navController.navigate("genres") },
@@ -389,6 +415,19 @@ fun AppNavigation() {
onPlay = { songs, idx -> player.playSongs(songs, idx) }, onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) }, onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu, onShowContextMenu = ::showContextMenu,
onArtistClick = { id -> navController.navigate("artist/$id") },
onAlbumClick = { id -> navController.navigate("album/$id") },
onOpenNews = { navController.navigate("artist-news") },
onOpenFollowed = {
com.novasonic.app.data.LibraryIntent.oeffneGefolgte = true
navController.navigate(TopLevel.LIBRARY.route)
},
)
}
composable("artist-news") {
ArtistNewsScreen(
onBack = { navController.popBackStack() },
onArtistClick = { id -> navController.navigate("artist/$id") },
) )
} }
composable(TopLevel.ABS.route) { composable(TopLevel.ABS.route) {
@@ -508,7 +547,7 @@ fun AppNavigation() {
onGenrePlay = { genre -> onGenrePlay = { genre ->
scope.launch { runCatching { scope.launch { runCatching {
val songs = repo.getSongsByGenre(genre.value) val songs = repo.getSongsByGenre(genre.value)
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0) if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
}} }}
}, },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
@@ -582,6 +621,58 @@ fun AppNavigation() {
Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show() Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show()
contextMenuSong = null contextMenuSong = null
}, },
// Link auf die eigene Web-App, den der Empfaenger mit SEINEN Zugangsdaten oeffnet —
// uebertragen wird nur die Song-Id, kein Geheimnis. Der Host kommt aus BuildConfig
// (dieselbe Angabe wie fuer die App-Links im Manifest), damit es keine vierte
// Adresse zum Pflegen gibt und beide Richtungen nie auseinanderlaufen koennen.
onShare = {
val link = "https://${com.novasonic.app.BuildConfig.SHARE_HOST}/?song=${song.id}"
val text = listOfNotNull(song.title, song.artist).joinToString(" — ")
context.startActivity(android.content.Intent.createChooser(
android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(android.content.Intent.EXTRA_SUBJECT, text)
putExtra(android.content.Intent.EXTRA_TEXT, "$text\n$link")
}, "Song teilen"))
},
// Nur mit Backend: das Loeschen laeuft ueber /api/delete-song, Navidrome allein
// kann keine Dateien entfernen.
onDelete = if (appSettings.backendUrl.isNotBlank()) {
{ songToDelete = song; contextMenuSong = null }
} else null,
)
}
// Sicherheitsabfrage vor dem endgueltigen Loeschen — Wortlaut wie im Web.
songToDelete?.let { song ->
AlertDialog(
onDismissRequest = { songToDelete = null },
title = { Text("Song löschen") },
text = { Text("\"${song.title}\" wird unwiderruflich von der Festplatte gelöscht. " +
"Leere Album- und Künstlerordner werden ebenfalls entfernt.") },
confirmButton = {
TextButton(onClick = {
songToDelete = null
scope.launch {
val creds = repo.lastCredentials
val client = com.novasonic.app.api.ImporterClient(appSettings.backendUrl)
val res = runCatching {
client.deleteSong(creds?.serverUrl ?: "", creds?.username ?: "",
creds?.password ?: "", song.id)
}
client.close()
Toast.makeText(
context,
res.fold({ "🗑 \"${song.title}\" gelöscht" },
{ "Löschen fehlgeschlagen: ${it.message}" }),
Toast.LENGTH_LONG,
).show()
// Navidrome braucht einen Moment zum Nachscannen, dann die Ansicht neu laden.
if (res.isSuccess) { kotlinx.coroutines.delay(3000); homeRefreshKey++ }
}
}) { Text("Löschen", color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = { songToDelete = null }) { Text("Abbrechen") } },
) )
} }
@@ -0,0 +1,16 @@
package com.novasonic.app.ui
/**
* Gesamtdauer einer Playlist oder eines Albums, in Sekunden hereingereicht.
*
* Ab einer Stunde werden Stunden ausgewiesen: "134 Min." muss man sonst selbst umrechnen, und bei
* einer Playlist mit 2000 Titeln staende dort eine vierstellige Minutenzahl. Gleiche Regel und
* gleiche Schreibweise wie `formatGesamtdauer` im Web (src/utils.js).
*/
fun formatGesamtdauer(sekunden: Int): String {
val min = Math.round((sekunden.coerceAtLeast(0)) / 60.0).toInt()
if (min < 60) return "$min Min."
val std = min / 60
val rest = min % 60
return if (rest > 0) "$std Std. $rest Min." else "$std Std."
}
@@ -19,6 +19,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Geteilter Link, mit dem die App gestartet wurde.
com.novasonic.app.data.DeepLinkState.offer(intent?.data)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
NovaSonicTheme { NovaSonicTheme {
@@ -28,6 +30,14 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
// launchMode=singleTask: laeuft die App bereits, kommt ein weiterer Link hier an statt in
// onCreate. Ohne das passierte beim zweiten geteilten Link schlicht nichts.
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
com.novasonic.app.data.DeepLinkState.offer(intent.data)
}
} }
@Composable @Composable
@@ -9,7 +9,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -26,6 +25,8 @@ import com.novasonic.app.ui.theme.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import androidx.compose.ui.res.painterResource
import com.novasonic.app.R
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -49,7 +50,7 @@ fun NotificationSheet(
.fillMaxWidth() .fillMaxWidth()
.padding(start = 20.dp, end = 8.dp, top = 18.dp, bottom = 12.dp), .padding(start = 20.dp, end = 8.dp, top = 18.dp, bottom = 12.dp),
) { ) {
Icon(Icons.Default.Notifications, null, Icon(painterResource(R.drawable.ic_honigtopf_brief), null,
tint = NsAccent, modifier = Modifier.size(18.dp)) tint = NsAccent, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
@@ -0,0 +1,174 @@
package com.novasonic.app.ui.component
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/**
* Rollbalken zum Ziehen fuer eine [LazyListState] — das Gegenstueck zum verbreiterten Balken im Web.
*
* Compose bringt keinen mit: eine LazyColumn zeigt gar nichts an, egal wie lang sie ist. Bei einer
* Playlist mit 2000 Titeln bleibt so nur Wischen, bis der Daumen glueht.
*
* Gerechnet wird in ITEMS, nicht in Pixeln: die tatsaechliche Gesamthoehe einer LazyColumn ist
* unbekannt, solange nicht alles gebaut wurde, und bei unterschiedlich hohen Zeilen laesst sie sich
* auch nicht ausrechnen. Ueber den Anteil sichtbarer an allen Eintraegen stimmt der Balken bei
* gleich hohen Zeilen — und genau die haben Song- und Albumlisten.
*
* Drei Fallen steckten hier drin, alle beim Ziehen:
*
* 1. **Die Greifflaeche muss breiter sein als der sichtbare Balken.** Sichtbar 6 dp, anfassbar
* [griffBreite]. 6 dp am Bildschirmrand trifft mit dem Finger niemand.
* 2. **Der erste Fingerkontakt muss verbraucht werden**, sonst startet die LazyColumn ihre eigene
* Scrollgeste, sobald die Wischschwelle faellt, und der Balken sieht die Bewegung nie.
* 3. **`pointerInput` darf NICHT auf die Geometrie geschluesselt sein.** Genau das war der Grund,
* warum das Ziehen unbrauchbar blieb: die Daumenposition steht in jedem Bild anders da, ein
* Schluessel darauf setzt die Gestenerkennung also mitten in der Geste neu auf — der Finger
* liegt noch auf dem Schirm, die Erkennung faengt aber wieder bei null an. Der Schluessel ist
* deshalb `Unit`; die wechselnden Werte liest die Geste ueber [rememberUpdatedState] zur
* Laufzeit nach.
*
* Gescrollt wird nicht im Zeigerereignis, sondern in einem eigenen Effekt, der [zielPos] verfolgt.
* Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte konkurrierende Scroll-Aufrufe.
*
* Und eine vierte, die erst nach dem Ausliefern auffiel: **nur der Daumen nimmt Beruehrungen an,
* nicht die ganze Spur.** Compose reicht eine Beruehrung nur an das oberste getroffene Element
* weiter — ein Streifen ueber die volle Hoehe verschluckte damit das ⋮ jeder Songzeile, obwohl er
* die Beruehrung gar nicht wollte. Der aeussere Rahmen hat deshalb KEIN `pointerInput` und ist
* dadurch durchlaessig; nur der Daumen selbst hoert zu, mit etwas Luft nach oben und unten.
*/
@Composable
fun Rollbalken(
state: LazyListState,
modifier: Modifier = Modifier,
breite: Dp = 6.dp,
griffBreite: Dp = 24.dp,
minHoehe: Dp = 44.dp,
farbe: Color = Color.White,
) {
val dichte = LocalDensity.current
var spurPx by remember { mutableFloatStateOf(0f) }
var ziehen by remember { mutableStateOf(false) }
// Beim Ziehen fuehrt diese Kommazahl. Wuerde jeder Zeigerschritt aus firstVisibleItemIndex neu
// rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal verloren — der Balken
// bliebe bei langsamem Ziehen stehen.
var zielPos by remember { mutableFloatStateOf(0f) }
val info = state.layoutInfo
val gesamt = info.totalItemsCount
val zuSehen = info.visibleItemsInfo.size
if (gesamt == 0 || zuSehen == 0 || gesamt <= zuSehen) return
val aktiv = state.isScrollInProgress || ziehen
val alpha by animateFloatAsState(
targetValue = if (aktiv) 0.9f else 0.3f,
animationSpec = tween(if (aktiv) 120 else 450),
label = "rollbalken-alpha",
)
val maxIndex = (gesamt - zuSehen).coerceAtLeast(1)
val minHoehePx = with(dichte) { minHoehe.toPx() }
val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx)
val frei = (spurPx - daumenPx).coerceAtLeast(0f)
val anteil = if (ziehen) zielPos / maxIndex
else state.firstVisibleItemIndex.toFloat() / maxIndex
val daumenY = frei * anteil.coerceIn(0f, 1f)
val spielPx = with(dichte) { 10.dp.toPx() } // Luft ober- und unterhalb des Daumens
// Was die Geste zur Laufzeit braucht — nicht als pointerInput-Schluessel, siehe oben.
val geo = rememberUpdatedState(Geometrie(maxIndex.toFloat(), frei, daumenY, daumenPx, spielPx))
// Die Liste folgt zielPos in EINER Koroutine statt in einer je Fingerbewegung.
LaunchedEffect(ziehen) {
if (!ziehen) return@LaunchedEffect
snapshotFlow { zielPos.roundToInt() }.collect { state.scrollToItem(it) }
}
Box(
modifier
.width(griffBreite)
.fillMaxHeight()
.onSizeChanged { spurPx = it.height.toFloat() }
) {
val luftDp = with(dichte) { spielPx.toDp() }
Box(
Modifier
.align(Alignment.TopEnd)
// Der Greifbereich beginnt eine Spielbreite ueber dem Daumen und ist entsprechend
// hoeher; der sichtbare Balken sitzt durch das Innenmass wieder genau auf daumenY.
.offset { IntOffset(0, (daumenY - spielPx).roundToInt()) }
.width(griffBreite)
.height(with(dichte) { (daumenPx + 2 * spielPx).toDp() })
.pointerInput(Unit) {
awaitEachGesture {
val start = awaitFirstDown(requireUnconsumed = false)
if (geo.value.frei <= 0f) return@awaitEachGesture
start.consume()
zielPos = state.firstVisibleItemIndex.toFloat()
ziehen = true
try {
while (true) {
val ereignis = awaitPointerEvent()
val punkt = ereignis.changes.firstOrNull { it.id == start.id } ?: break
if (!punkt.pressed) break
val delta = punkt.positionChange().y
punkt.consume()
if (delta != 0f) {
val jetzt = geo.value
if (jetzt.frei > 0f) {
zielPos = (zielPos + delta * jetzt.maxIndex / jetzt.frei)
.coerceIn(0f, jetzt.maxIndex)
}
}
}
} finally {
ziehen = false
}
}
}
.padding(vertical = luftDp, horizontal = 0.dp),
contentAlignment = Alignment.CenterEnd,
) {
Box(
Modifier
.padding(end = 2.dp)
.width(breite)
.fillMaxHeight()
.clip(RoundedCornerShape(50))
.background(farbe.copy(alpha = alpha))
)
}
}
}
/** Nur ein Buendel der Werte, die die Geste zur Laufzeit nachliest. */
private data class Geometrie(
val maxIndex: Float,
val frei: Float,
val daumenY: Float,
val daumenPx: Float,
val spiel: Float,
)
@@ -33,6 +33,8 @@ fun SongContextSheet(
onGoToAlbum: (() -> Unit)? = null, onGoToAlbum: (() -> Unit)? = null,
onShowInfo: (() -> Unit)? = null, onShowInfo: (() -> Unit)? = null,
onDownload: (() -> Unit)? = null, onDownload: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
onShare: (() -> Unit)? = null,
) { ) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -77,6 +79,16 @@ fun SongContextSheet(
onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) } onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) }
onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", onClick = { onDismiss(); it() }) } onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", onClick = { onDismiss(); it() }) }
onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) } onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) }
onShare?.let { CtxItem(Icons.Default.Share, "Teilen", onClick = { onDismiss(); it() }) }
}
// Loeschen steht bewusst unten, abgesetzt und rot: es entfernt die Datei unwiderruflich von
// der Platte. Die Sicherheitsabfrage sitzt beim Aufrufer, wie im Web auch.
onDelete?.let {
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
CtxItem(Icons.Default.DeleteForever, "Von Festplatte löschen",
tint = MaterialTheme.colorScheme.error,
onClick = { onDismiss(); it() })
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
@@ -45,7 +45,10 @@ fun SongRow(
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onPlay) .clickable(onClick = onPlay)
.background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent) .background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent)
.padding(start = if (isPlaying) 0.dp else 4.dp), // Rechts 24 dp frei halten: genau dort liegt der Rollbalken, und ein ⋮ darunter
// waere nicht mehr zu treffen — Compose gibt eine Beruehrung nur an das oberste
// getroffene Element. Gilt ueberall, damit die Zeilen gleich aussehen.
.padding(start = if (isPlaying) 0.dp else 4.dp, end = 24.dp),
) { ) {
// Accent left bar when playing // Accent left bar when playing
if (isPlaying) { if (isPlaying) {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -11,6 +12,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import com.novasonic.app.data.StarState
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -34,6 +38,8 @@ import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumViewModel import com.novasonic.app.viewmodel.AlbumViewModel
import com.novasonic.app.ui.component.Rollbalken
import com.novasonic.app.ui.formatGesamtdauer
@Composable @Composable
fun AlbumScreen( fun AlbumScreen(
@@ -72,152 +78,178 @@ fun AlbumScreen(
return return
} }
LazyColumn(Modifier.fillMaxSize()) { val listState = rememberLazyListState()
item { Box(Modifier.fillMaxSize()) {
// ── Hero ───────────────────────────────────────────────────────── LazyColumn(Modifier.fillMaxSize(), state = listState) {
Box(Modifier.fillMaxWidth()) { item {
AsyncImage( // ── Hero ─────────────────────────────────────────────────────────
model = coverUrl(album.coverArt), Box(Modifier.fillMaxWidth()) {
contentDescription = album.name, AsyncImage(
contentScale = ContentScale.Crop, model = coverUrl(album.coverArt),
modifier = Modifier contentDescription = album.name,
.fillMaxWidth() contentScale = ContentScale.Crop,
.aspectRatio(1f) modifier = Modifier
.background(NsSurface2), .fillMaxWidth()
) .aspectRatio(1f)
// gradient overlay .background(NsSurface2),
Box( )
Modifier // gradient overlay
.fillMaxWidth() Box(
.aspectRatio(1f) Modifier
.background( .fillMaxWidth()
Brush.verticalGradient( .aspectRatio(1f)
0.0f to Color.Transparent, .background(
0.40f to NsBackground.copy(alpha = 0.35f), Brush.verticalGradient(
1.0f to NsBackground, 0.0f to Color.Transparent,
0.40f to NsBackground.copy(alpha = 0.35f),
1.0f to NsBackground,
)
) )
)
// back button — frosted circle
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp),
) )
}
// album info overlaid at bottom
Column(
Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
album.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
album.artist!!, fontFamily = Outfit,
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
Modifier.clickable { onArtistClick(album.artistId!!) }
else Modifier,
)
}
val meta = listOfNotNull(
album.year?.toString(),
album.genre?.takeIf { it.isNotBlank() },
if (album.songCount > 0) "${album.songCount} Songs" else null,
if (album.duration > 0) formatGesamtdauer(album.duration) else null,
).joinToString(" · ")
if (meta.isNotBlank()) {
Spacer(Modifier.height(3.dp))
Text(
meta, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.55f),
)
}
}
}
// ── Play / Shuffle / Merken / Download ────────────────────────────
// Gleiche Leiste wie beim Playlist-Detail: vier gleich breite, reine Symbolknoepfe.
// Play und Shuffle trugen hier noch Beschriftungen, waehrend Herz und Download fest
// 46 dp breit waren — die Reihe war dadurch ungleich geteilt und die Schrift wurde auf
// schmalen Geraeten abgeschnitten. Play bleibt gefuellt als Hauptaktion; die uebrigen
// sind umrandet und faerben sich erst accent, wenn sie aktiv sind.
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { onPlay(album.song, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
}
OutlinedButton(
onClick = { onPlay(album.song, -1) }, // -1 = gemischt, der Player mischt
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
}
// Album merken — derselbe Weg wie beim Song: Navidrome behandelt Song-, Album- und
// Kuenstler-IDs beim Markieren gleich.
val overrides by StarState.overrides.collectAsStateWithLifecycle()
val gemerkt = overrides[album.id] ?: (album.starred != null)
OutlinedButton(
onClick = { vm.merkenUmschalten(album.id, album.starred != null) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (gemerkt) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(
if (gemerkt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
if (gemerkt) "Gemerkt" else "Album merken",
Modifier.size(20.dp), tint = if (gemerkt) NsAccent else NsOnSurface2,
)
}
val isFullyDownloaded = downloadGroup != null &&
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
downloadGroup!!.totalTracks > 0
OutlinedButton(
// Tappable unless fully done — resumes / re-downloads any missing songs.
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (isFullyDownloaded) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
when {
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", Modifier.size(20.dp), tint = NsAccent)
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsOnSurface2)
}
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(album.song, key = { _, s -> s.id }) { index, song ->
SongRow(
song = song,
coverUrl = null,
isPlaying = playerState.currentSong?.id == song.id,
trackNumber = song.track ?: (index + 1),
starred = song.starred != null,
onPlay = { onPlay(album.song, index) },
onStarToggle = onStarToggle?.let { cb -> { cb(song) } },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
) )
// back button — frosted circle
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp),
)
}
// album info overlaid at bottom
Column(
Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
album.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
album.artist!!, fontFamily = Outfit,
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
Modifier.clickable { onArtistClick(album.artistId!!) }
else Modifier,
)
}
val meta = listOfNotNull(
album.year?.toString(),
album.genre?.takeIf { it.isNotBlank() },
if (album.songCount > 0) "${album.songCount} Songs" else null,
).joinToString(" · ")
if (meta.isNotBlank()) {
Spacer(Modifier.height(3.dp))
Text(
meta, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.55f),
)
}
}
} }
item { Spacer(Modifier.height(16.dp)) }
// ── Play / Shuffle row ────────────────────────────────────────────
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { onPlay(album.song, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
OutlinedButton(
onClick = { onPlay(album.song.shuffled(), 0) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
}
val isFullyDownloaded = downloadGroup != null &&
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
downloadGroup!!.totalTracks > 0
OutlinedButton(
// Tappable unless fully done — resumes / re-downloads any missing songs.
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
modifier = Modifier.height(46.dp).width(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(
when {
isFullyDownloaded -> MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
else -> NsAccent.copy(alpha = 0.55f)
}
)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
when {
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Downloaded", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsAccent)
}
}
}
HorizontalDivider(color = NsBorder)
} }
Rollbalken(
itemsIndexed(album.song, key = { _, s -> s.id }) { index, song -> state = listState,
SongRow( modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
song = song, farbe = NsOnSurface2,
coverUrl = null, )
isPlaying = playerState.currentSong?.id == song.id,
trackNumber = song.track ?: (index + 1),
starred = song.starred != null,
onPlay = { onPlay(album.song, index) },
onStarToggle = onStarToggle?.let { cb -> { cb(song) } },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(16.dp)) }
} }
} }
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Event import androidx.compose.material.icons.filled.Event
import com.novasonic.app.data.StarState
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Shuffle
@@ -82,7 +85,7 @@ fun ArtistDetailScreen(
LaunchedEffect(state.pendingShufflePlay) { LaunchedEffect(state.pendingShufflePlay) {
state.pendingShufflePlay?.let { songs -> state.pendingShufflePlay?.let { songs ->
onPlay(songs, 0) onPlay(songs, -1) // -1 = gemischt starten, der Player mischt
vm.consumeShufflePlay() vm.consumeShufflePlay()
} }
} }
@@ -260,6 +263,24 @@ fun ArtistDetailScreen(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
// Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste darunter: die Leiste
// ist "was ich jetzt tue", Folgen ist ein Zustand. Deshalb als Herz ins Banner,
// direkt neben das Zahnrad — genau wie im Web.
val overrides by StarState.overrides.collectAsStateWithLifecycle()
val folgt = overrides[artist.id] ?: (artist.starred != null)
Surface(
onClick = { vm.folgenUmschalten(artist.id, artist.starred != null) },
shape = RoundedCornerShape(999.dp),
color = Color(0x59000000),
modifier = Modifier.align(Alignment.TopEnd).padding(top = 10.dp, end = 52.dp),
) {
Icon(
if (folgt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
if (folgt) "Du folgst ${artist.name}" else "${artist.name} folgen",
tint = if (folgt) NsAccent else Color.White.copy(0.72f),
modifier = Modifier.padding(7.dp).size(17.dp),
)
}
// "Wrong artist?" pin — minimal icon-only chip, top-right over the hero. // "Wrong artist?" pin — minimal icon-only chip, top-right over the hero.
Surface( Surface(
onClick = { vm.openPinPicker() }, onClick = { vm.openPinPicker() },
@@ -0,0 +1,352 @@
package com.novasonic.app.ui.screen
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.ArtistRelease
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ArtistNewsViewModel
import com.novasonic.app.viewmodel.NewsReiter
import com.novasonic.app.api.model.ArtistKonzert
import androidx.compose.ui.platform.LocalUriHandler
import java.text.SimpleDateFormat
import java.util.Locale
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.style.TextDecoration
/**
* Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. Nach Monat gruppiert,
* neueste zuerst. Was schon in der Bibliothek liegt, ist markiert statt anklickbar.
*/
@Composable
fun ArtistNewsScreen(
onBack: () -> Unit,
onArtistClick: (String) -> Unit,
) {
val vm: ArtistNewsViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val ctx = LocalContext.current
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.statusBarsPadding().padding(start = 4.dp, top = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = MaterialTheme.colorScheme.onSurface)
}
val konzerte = state.reiter == NewsReiter.KONZERTE
Text(
if (konzerte) "Konzerte deiner Künstler" else "Neu von deinen Künstlern",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface,
)
}
// Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind dasselbe
// Thema. Bewusst NICHT in eine gemeinsame Liste gemischt — Veroeffentlichungen liegen
// hinter, Konzerte vor dem heutigen Tag.
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
) {
listOf(
NewsReiter.RELEASES to "Neu erschienen",
NewsReiter.KONZERTE to "Konzerte",
).forEach { (r, label) ->
FilterChip(
selected = state.reiter == r,
onClick = { vm.zeigeReiter(r) },
label = { Text(label, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent,
),
)
}
}
// Das Zeitfenster gilt nur fuer die Veroeffentlichungen. Konzerte sind ohnehin Zukunft,
// dort will man alles Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
if (state.reiter == NewsReiter.RELEASES) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 10.dp),
) {
// 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn.
listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) ->
FilterChip(
selected = state.tage == tage,
onClick = { vm.setzeTage(tage) },
label = { Text(label, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent,
),
)
}
}
} else {
Spacer(Modifier.height(10.dp))
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
when {
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
state.keinBackend -> Hinweis("🔌", "Kein Backend konfiguriert",
"Neue Veröffentlichungen kommen über das Backend von Deezer.")
state.keineGefolgten -> Hinweis("❤", "Du folgst noch niemandem",
"Auf einer Künstlerseite oben rechts aufs Herz tippen.")
state.error != null -> Hinweis("⚠", "Laden fehlgeschlagen", state.error!!)
state.reiter == NewsReiter.KONZERTE && state.keinSchluessel -> Hinweis("🎟",
"Kein Ticketmaster-Schlüssel hinterlegt", "Im Backend TICKETMASTER_API_KEY setzen.")
state.reiter == NewsReiter.KONZERTE && state.konzerte.isEmpty() -> Hinweis("🎪",
"Keine anstehenden Termine",
"Gesucht wird weltweit über den Künstlernamen.")
state.reiter == NewsReiter.KONZERTE -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
var letzterMonat = ""
state.konzerte.forEach { k ->
val monat = monatVon(k.datum)
if (monat != letzterMonat) {
letzterMonat = monat
item(key = "km_$monat") {
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
}
}
item(key = "k_${k.eventId}_${k.artistId}") {
KonzertZeile(k) { onArtistClick(k.artistId) }
}
}
}
state.releases.isEmpty() -> Hinweis("🌙",
"Nichts Neues in den letzten ${state.tage} Tagen", "")
else -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
if (state.ohneZuordnung.isNotEmpty()) {
item { ZuordnungsHinweis(state.ohneZuordnung) }
}
var letzterMonat = ""
state.releases.forEach { rel ->
val monat = monatVon(rel.releaseDate)
if (monat != letzterMonat) {
letzterMonat = monat
item(key = "m_$monat") {
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
}
}
item(key = "${rel.artistId}_${rel.albumId}") {
ReleaseZeile(
rel = rel,
schonDa = vm.hatSchon(rel),
onArtist = { onArtistClick(rel.artistId) },
onDownload = {
vm.herunterladen(rel) { _, msg ->
Toast.makeText(ctx, msg, Toast.LENGTH_SHORT).show()
}
},
)
}
}
}
}
}
}
@Composable
private fun Hinweis(symbol: String, titel: String, text: String) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(symbol, fontSize = 34.sp)
Spacer(Modifier.height(10.dp))
Text(titel, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface)
if (text.isNotBlank()) {
Spacer(Modifier.height(6.dp))
Text(text, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
}
}
}
@Composable
private fun ZuordnungsHinweis(namen: List<String>) {
Box(
Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
.clip(RoundedCornerShape(10.dp))
.background(NsAccent.copy(alpha = 0.08f))
.border(1.dp, NsAccent.copy(alpha = 0.28f), RoundedCornerShape(10.dp))
.padding(12.dp),
) {
Text(
"⚠ Für ${namen.size} Künstler wurde kein Deezer-Profil gefunden: " +
namen.take(4).joinToString(", ") + (if (namen.size > 4) " …" else "") +
". Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, lineHeight = 17.sp,
)
}
}
private val TYP = mapOf("album" to "Album", "single" to "Single", "ep" to "EP",
"compile" to "Compilation")
@Composable
private fun ReleaseZeile(
rel: ArtistRelease,
schonDa: Boolean,
onArtist: () -> Unit,
onDownload: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 7.dp),
) {
AsyncImage(
model = rel.cover.takeIf { it.isNotBlank() },
contentDescription = rel.title,
contentScale = ContentScale.Crop,
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(rel.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(rel.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(onClick = onArtist))
Text(
" · " + (TYP[rel.recordType] ?: rel.recordType) +
(rel.trackCount?.let { " · $it Titel" } ?: ""),
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
Text(datumVon(rel.releaseDate), fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface3, modifier = Modifier.padding(top = 2.dp))
}
Spacer(Modifier.width(8.dp))
if (schonDa) {
Icon(Icons.Default.CheckCircle, "In deiner Bibliothek",
tint = NsOnSurface3, modifier = Modifier.size(20.dp))
} else {
IconButton(onClick = onDownload) {
Icon(Icons.Default.Download, "Herunterladen", tint = NsAccent,
modifier = Modifier.size(20.dp))
}
}
}
}
// Deezer liefert "JJJJ-MM-TT".
private val EIN = SimpleDateFormat("yyyy-MM-dd", Locale.GERMAN)
private val MONAT = SimpleDateFormat("LLLL yyyy", Locale.GERMAN)
private val DATUM = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN)
private fun monatVon(s: String) = runCatching { MONAT.format(EIN.parse(s)!!) }.getOrDefault(s)
private fun datumVon(s: String) = runCatching { DATUM.format(EIN.parse(s)!!) }.getOrDefault(s)
// Ticketmaster liefert die ORTSZEIT ohne Zone: "2026-11-04T20:00:00".
private val EIN_ZEIT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMAN)
private val TAG_KURZ = SimpleDateFormat("d", Locale.GERMAN)
private val MON_KURZ = SimpleDateFormat("MMM", Locale.GERMAN)
private val UHR = SimpleDateFormat("HH:mm", Locale.GERMAN)
private fun zeitVon(s: String) = runCatching { EIN_ZEIT.parse(s) }.getOrNull()
/**
* Eine Konzertzeile. Statt eines Covers steht links ein Kalenderblatt — ein Konzert hat kein Bild,
* aber ein Datum, und danach sucht das Auge in einer Liste, die nach vorn läuft.
*/
@Composable
private fun KonzertZeile(k: ArtistKonzert, onArtist: () -> Unit) {
val uri = LocalUriHandler.current
val d = zeitVon(k.datum)
val ort = listOf(k.ort, k.stadt, k.land).filter { it.isNotBlank() }.joinToString(" · ")
val uhr = d?.let { UHR.format(it) }?.takeIf { it != "00:00" }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.size(50.dp)
.clip(RoundedCornerShape(10.dp))
.background(NsSurface)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(10.dp))
.alpha(if (k.abgesagt) 0.45f else 1f),
) {
Text(d?.let { TAG_KURZ.format(it) } ?: "?", fontFamily = Outfit,
fontWeight = FontWeight.Bold, fontSize = 17.sp,
color = MaterialTheme.colorScheme.onSurface)
Text(d?.let { MON_KURZ.format(it) }?.uppercase() ?: "", fontFamily = Outfit,
fontSize = 10.sp, color = NsOnSurface2)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
k.titel.ifBlank { k.artistName },
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = if (k.abgesagt) NsOnSurface2 else MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
textDecoration = if (k.abgesagt) TextDecoration.LineThrough else null,
)
Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(k.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(onClick = onArtist))
val rest = listOfNotNull(ort.ifBlank { null }, uhr?.let { "$it Uhr" })
if (rest.isNotEmpty()) {
Text(" · " + rest.joinToString(" · "), fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
if (k.abgesagt) {
Text("abgesagt", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
} else if (k.url.isNotBlank()) {
TextButton(onClick = { uri.openUri(k.url) }) {
Text("Tickets", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp, color = NsAccent)
}
}
}
}
@@ -148,7 +148,7 @@ private fun CompareContent(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()) Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp), .padding(horizontal = 16.dp).padding(bottom = 24.dp),
) { ) {
ScoreRing(d) Scoreboard(d)
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Totals(d) Totals(d)
Spacer(Modifier.height(22.dp)) Spacer(Modifier.height(22.dp))
@@ -173,6 +173,8 @@ private fun CompareContent(
Spacer(Modifier.height(22.dp)) Spacer(Modifier.height(22.dp))
} }
Streitpunkte(d, onArtistClick)
SectionHeader("Nur bei…") SectionHeader("Nur bei…")
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f)) UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f))
@@ -182,46 +184,102 @@ private fun CompareContent(
} }
@Composable @Composable
private fun ScoreRing(d: CompareResult) { private fun Streitpunkte(d: CompareResult, onArtistClick: (String) -> Unit) {
val score = d.matchScore.coerceIn(0, 100) // Gemeinsame Kuenstler mit dem groessten Ungleichgewicht — das Gegenstueck zu "Gemeinsame
val verdict = when { // Kuenstler", das nach Summe sortiert. Mindestmasse, damit ein einzelner Play gegen null nicht
score >= 80 -> "Seelenverwandte 🎯" // als Streit gilt. Identische Schwellen wie im Web, damit beide dasselbe zeigen.
score >= 60 -> "Starkes Match 🔥" val items = d.sharedArtists
score >= 40 -> "Solide Überschneidung 👍" .map { a ->
score >= 20 -> "Ein paar Gemeinsamkeiten 🙂" val summe = a.playsA + a.playsB
else -> "Ziemlich unterschiedlich 🌗" Triple(a, summe, if (summe > 0) kotlin.math.abs(a.playsA - a.playsB) / summe.toFloat() else 0f)
}
.filter { (_, summe, unwucht) -> summe >= 4 && unwucht >= 0.5f }
.sortedWith(compareByDescending<Triple<SharedArtist, Int, Float>> { it.third }.thenByDescending { it.second })
.take(8)
SectionHeader("Streitpunkte")
if (items.isEmpty()) {
Text("Ihr hört gemeinsame Künstler erstaunlich ausgewogen.",
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
return
} }
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { items.forEach { (a, summe, _) ->
Box(Modifier.size(132.dp), contentAlignment = Alignment.Center) { val anteilA = a.playsA / summe.toFloat()
val accent = NsAccent Column(
val track = NsSurface2 Modifier.fillMaxWidth()
Canvas(Modifier.fillMaxSize()) { .clickable { a.artistId?.let(onArtistClick) }
val stroke = 14.dp.toPx() .padding(vertical = 6.dp)
val inset = stroke / 2 ) {
val arcSize = Size(size.width - stroke, size.height - stroke) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
drawArc( Text("${a.playsA}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
color = track, startAngle = 0f, sweepAngle = 360f, useCenter = false, fontWeight = FontWeight.Bold)
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize, Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
style = Stroke(width = stroke), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
) textAlign = TextAlign.Center,
drawArc( modifier = Modifier.weight(1f).padding(horizontal = 8.dp))
color = accent, startAngle = -90f, sweepAngle = score * 3.6f, useCenter = false, Text("${a.playsB}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize, fontWeight = FontWeight.Bold)
style = Stroke(width = stroke),
)
} }
Column(horizontalAlignment = Alignment.CenterHorizontally) { Spacer(Modifier.height(4.dp))
Text("$score%", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 30.sp, color = NsOnSurface) Row(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp))) {
Text("Match", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2) Box(Modifier.weight(anteilA.coerceAtLeast(0.001f)).fillMaxHeight().background(NsAccent))
Box(Modifier.weight((1f - anteilA).coerceAtLeast(0.001f)).fillMaxHeight()
.background(NsAccent.copy(alpha = .35f)))
} }
} }
Spacer(Modifier.height(10.dp))
Text(verdict, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsOnSurface)
Spacer(Modifier.height(4.dp))
Text("${d.userA} ⟷ ${d.userB}", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
} }
Spacer(Modifier.height(10.dp))
} }
@Composable
private fun Scoreboard(d: CompareResult) {
val b = d.battle
if (b == null || b.kategorien.isEmpty()) return
val urteil = when {
b.punkteA == b.punkteB -> "Kopf-an-Kopf — kein Sieger."
b.punkteA > b.punkteB -> "${d.userA} gewinnt ${b.punkteA}:${b.punkteB}."
else -> "${d.userB} gewinnt ${b.punkteB}:${b.punkteA}."
}
Column(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(d.userA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
Text(" ${b.punkteA} : ${b.punkteB} ", fontFamily = Outfit, fontWeight = FontWeight.Black,
fontSize = 30.sp, color = NsOnSurface)
Text(d.userB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
}
Text(urteil, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp),
textAlign = TextAlign.Center)
// Eine Zeile je Kategorie: Wert · Bezeichnung · Wert. Der Sieger wird farblich
// hervorgehoben, der Verlierer zurueckgenommen — identisch zum Web.
b.kategorien.forEach { k ->
val gewinnA = k.sieger == "A"
val gewinnB = k.sieger == "B"
Row(
Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(k.textA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = if (gewinnA) NsAccent else if (gewinnB) NsOnSurface3 else NsOnSurface,
textAlign = TextAlign.End, modifier = Modifier.weight(1f))
Text(k.label.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 11.sp, color = NsOnSurface2, textAlign = TextAlign.Center,
maxLines = 1, modifier = Modifier.weight(1.3f))
Text(k.textB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = if (gewinnB) NsAccent else if (gewinnA) NsOnSurface3 else NsOnSurface,
modifier = Modifier.weight(1f))
}
}
Spacer(Modifier.height(6.dp))
}
}
@Composable @Composable
private fun Totals(d: CompareResult) { private fun Totals(d: CompareResult) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@@ -126,6 +126,7 @@ fun ExploreScreen(
previewingId = state.previewingId, previewingId = state.previewingId,
onPreview = vm::togglePreview, onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet, onDownload = vm::showDownloadSheet,
onArtistClick = vm::openArtistOfAlbum,
onBack = vm::dismissAlbum, onBack = vm::dismissAlbum,
) )
} }
@@ -407,6 +408,7 @@ private fun GlobalExploreContent(
previewingId = state.previewingId, previewingId = state.previewingId,
onPreview = vm::togglePreview, onPreview = vm::togglePreview,
onDownload = onDownload, onDownload = onDownload,
onArtistClick = vm::openArtistOfAlbum,
onBack = vm::dismissAlbum, onBack = vm::dismissAlbum,
) )
return return
@@ -524,6 +526,7 @@ internal fun ItunesAlbumDetail(
previewingId: Long?, previewingId: Long?,
onPreview: (ItunesItem) -> Unit, onPreview: (ItunesItem) -> Unit,
onDownload: (ItunesItem) -> Unit = {}, onDownload: (ItunesItem) -> Unit = {},
onArtistClick: ((ItunesItem) -> Unit)? = null,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
@@ -535,9 +538,25 @@ internal fun ItunesAlbumDetail(
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = MaterialTheme.colorScheme.onSurface) tint = MaterialTheme.colorScheme.onSurface)
} }
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold, // Anklickbar nur, wenn Deezer eine Künstler-id mitgeliefert hat — /artist/{id}/albums
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, // lässt das artist-Objekt weg, dort bleibt der Name einfach Text.
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f)) val artistName = albumItem?.artistName
val artistTippbar = onArtistClick != null && albumItem?.artistId != null &&
!artistName.isNullOrBlank()
Column(
Modifier.weight(1f).then(
if (artistTippbar) Modifier.clickable { onArtistClick!!(albumItem!!) } else Modifier
)
) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface)
if (!artistName.isNullOrBlank()) {
Text(artistName, fontFamily = Outfit, fontSize = 12.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (artistTippbar) NsAccent else NsOnSurface2)
}
}
if (albumItem != null) { if (albumItem != null) {
IconButton(onClick = { onDownload(albumItem) }) { IconButton(onClick = { onDownload(albumItem) }) {
Icon(Icons.Default.Download, "Album herunterladen", Icon(Icons.Default.Download, "Album herunterladen",
@@ -2,6 +2,7 @@
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -18,6 +19,7 @@ import com.novasonic.app.app
import com.novasonic.app.player.PlayerState import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.ui.component.Rollbalken
@Composable @Composable
fun GenreSongsScreen( fun GenreSongsScreen(
@@ -77,19 +79,29 @@ fun GenreSongsScreen(
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text("Keine Songs gefunden", color = NsOnSurface2) Text("Keine Songs gefunden", color = NsOnSurface2)
} }
else -> LazyColumn { else -> {
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song -> val listState = rememberLazyListState()
SongRow( Box(Modifier.fillMaxSize()) {
song = song, LazyColumn(Modifier.fillMaxSize(), state = listState) {
coverUrl = coverUrl(song.coverArt), itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
isPlaying = playerState.currentSong?.id == song.id, SongRow(
starred = song.starred != null, song = song,
onPlay = { onPlay(songs, idx) }, coverUrl = coverUrl(song.coverArt),
onStarToggle = { onStarToggle(song) }, isPlaying = playerState.currentSong?.id == song.id,
onMore = onShowContextMenu?.let { cb -> { cb(song) } }, starred = song.starred != null,
onPlay = { onPlay(songs, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(8.dp)) }
}
Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
) )
} }
item { Spacer(Modifier.height(8.dp)) }
} }
} }
} }
@@ -8,7 +8,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -33,6 +32,8 @@ import com.novasonic.app.data.NotificationStore
import com.novasonic.app.ui.component.AlbumCard import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.HomeViewModel import com.novasonic.app.viewmodel.HomeViewModel
import androidx.compose.ui.res.painterResource
import com.novasonic.app.R
private val greetingsWithName = listOf( private val greetingsWithName = listOf(
"Ready for some beats,", "Ready for some beats,",
@@ -120,7 +121,7 @@ fun HomeScreen(
} }
) { ) {
IconButton(onClick = onBellClick) { IconButton(onClick = onBellClick) {
Icon(Icons.Default.Notifications, "Benachrichtigungen", Icon(painterResource(R.drawable.ic_honigtopf_brief), "Benachrichtigungen",
tint = if (unreadCount > 0) NsAccent else NsOnSurface2) tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
} }
} }
@@ -182,6 +182,38 @@ fun ImporterScreen(onBack: () -> Unit) {
} }
} }
// Playlist aus dem Import anlegen — wie im Web-Importer. Nur ab zwei Titeln sinnvoll;
// ein einzelner Track ergibt keine Playlist.
if (r.tracks.size > 1) {
item {
Column(Modifier.fillMaxWidth().padding(bottom = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = state.createPlaylist,
onCheckedChange = vm::setCreatePlaylist,
colors = CheckboxDefaults.colors(checkedColor = brand,
checkmarkColor = Color.Black),
)
Text("Playlist daraus anlegen", fontFamily = Outfit, fontSize = 14.sp,
color = NsOnSurface)
}
if (state.createPlaylist) {
OutlinedTextField(
value = state.playlistName.ifBlank { r.name ?: "" },
onValueChange = vm::setPlaylistName,
label = { Text("Playlist-Name", fontFamily = Outfit) },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
}
state.playlistStatus?.let {
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 6.dp))
}
}
}
}
// Download button / job progress // Download button / job progress
item { item {
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") { if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
@@ -225,7 +225,7 @@ fun ItunesArtistScreen(
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp) Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
} }
OutlinedButton( OutlinedButton(
onClick = { onSongPlay(artistState.topSongs.shuffled(), 0) }, onClick = { onSongPlay(artistState.topSongs, -1) },
modifier = Modifier.weight(1f).height(42.dp), modifier = Modifier.weight(1f).height(42.dp),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true) border = ButtonDefaults.outlinedButtonBorder(enabled = true)
@@ -64,6 +64,15 @@ fun LibraryScreen(
val vm: LibraryViewModel = viewModel() val vm: LibraryViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val offlineVm: OfflineViewModel = viewModel() val offlineVm: OfflineViewModel = viewModel()
// Absicht aus den Favoriten ("Alle anzeigen" bei den gefolgten Künstlern) einmalig einlösen.
LaunchedEffect(Unit) {
if (com.novasonic.app.data.LibraryIntent.oeffneGefolgte) {
com.novasonic.app.data.LibraryIntent.oeffneGefolgte = false
vm.selectTab(LibraryTab.ARTISTS)
vm.nurGefolgteUmschalten(true)
}
}
val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle() val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle()
// Fully-downloaded source IDs per type — drives the "downloaded" badge on cards. // Fully-downloaded source IDs per type — drives the "downloaded" badge on cards.
@@ -191,12 +200,20 @@ fun LibraryScreen(
LibraryTab.ARTISTS -> { LibraryTab.ARTISTS -> {
val listState = vm.artistsListState // hoisted → survives artist drill-in/back val listState = vm.artistsListState // hoisted → survives artist drill-in/back
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } } // "Gefolgt" filtert die vorhandene Liste — ein eigener Reiter wäre in einer Leiste,
// die schon vier Kacheln trägt, nur Gedränge.
val indizes = remember(state.artistIndexes, state.nurGefolgte, state.gefolgteIds) {
if (!state.nurGefolgte) state.artistIndexes
else state.artistIndexes
.map { idx -> idx.copy(artist = idx.artist.filter { it.id in state.gefolgteIds }) }
.filter { it.artist.isNotEmpty() }
}
val letters = remember(indizes) { indizes.map { it.name } }
// Map letter → flat LazyColumn index of its header item // Map letter → flat LazyColumn index of its header item
val letterToIndex = remember(state.artistIndexes) { val letterToIndex = remember(indizes) {
buildMap<String, Int> { buildMap<String, Int> {
var flat = 0 var flat = 0
state.artistIndexes.forEach { idx -> indizes.forEach { idx ->
put(idx.name, flat) put(idx.name, flat)
flat += 1 + idx.artist.size flat += 1 + idx.artist.size
} }
@@ -204,13 +221,54 @@ fun LibraryScreen(
} }
var activeLetter by remember { mutableStateOf<String?>(null) } var activeLetter by remember { mutableStateOf<String?>(null) }
Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, top = 6.dp, bottom = 6.dp),
) {
FilterChip(
selected = !state.nurGefolgte,
onClick = { vm.nurGefolgteUmschalten(false) },
label = { Text("Alle", fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent),
)
FilterChip(
selected = state.nurGefolgte,
onClick = { vm.nurGefolgteUmschalten(true) },
label = {
val n = state.gefolgteIds.size
Text("❤ Gefolgt" + if (state.nurGefolgte && n > 0) " ($n)" else "",
fontSize = 12.sp)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent),
)
}
if (state.nurGefolgte && indizes.isEmpty()) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("❤", fontSize = 30.sp)
Spacer(Modifier.height(8.dp))
Text("Du folgst noch niemandem", fontFamily = Outfit,
fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.height(4.dp))
Text("Auf einer Künstlerseite oben rechts aufs Herz tippen.",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
}
return@Column
}
Box(Modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
state = listState, state = listState,
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
state.artistIndexes.forEach { idx -> indizes.forEach { idx ->
item(key = "header_${idx.name}") { item(key = "header_${idx.name}") {
Text( Text(
text = idx.name, text = idx.name,
@@ -316,6 +374,7 @@ fun LibraryScreen(
} }
} }
} }
}
} }
} }
@@ -327,9 +386,11 @@ fun LibraryScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(state.playlists, key = { it.id }) { pl -> items(state.playlists, key = { it.id }) { pl ->
// Besitzer nur bei fremden (geteilten) Listen — bei den eigenen waere er Rauschen.
val von = pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }
PlaylistCard( PlaylistCard(
name = pl.name, name = pl.name,
subtitle = "${pl.songCount} Titel", subtitle = "${pl.songCount} Titel" + (von?.let { " · von $it" } ?: ""),
coverUrl = coverUrl(pl.coverArt), coverUrl = coverUrl(pl.coverArt),
downloaded = pl.id in downloadedPlaylistIds, downloaded = pl.id in downloadedPlaylistIds,
onClick = { onPlaylistClick(pl) }, onClick = { onPlaylistClick(pl) },
@@ -24,7 +24,8 @@ fun LoginScreen(onLoggedIn: () -> Unit) {
val vm: LoginViewModel = viewModel() val vm: LoginViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
var serverUrl by remember { mutableStateOf("") } // Serveradresse vorbelegen, falls im Build hinterlegt — dann bleiben nur Benutzer und Passwort.
var serverUrl by remember { mutableStateOf(com.novasonic.app.BuildConfig.DEFAULT_ND_URL) }
var username by remember { mutableStateOf("") } var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var showPw by remember { mutableStateOf(false) } var showPw by remember { mutableStateOf(false) }
@@ -60,6 +60,11 @@ import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.LyricsViewModel import com.novasonic.app.viewmodel.LyricsViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.ui.input.pointer.positionChange
private enum class NpTab { PLAYER, LYRICS, QUEUE } private enum class NpTab { PLAYER, LYRICS, QUEUE }
@@ -78,9 +83,14 @@ fun NowPlayingScreen(
val song = state.currentSong val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) } var tab by remember { mutableStateOf(NpTab.PLAYER) }
// Swipe left/right between Player / Lyrics / Queue, kept in sync with the tab pills (like // Waagerechtes Wischen: standardmaessig wechselt es den TITEL — das ist die Geste, die man hier
// Erkunden). A horizontal drag that starts on the cover still skips tracks — that gesture is a // staendig braucht. Wer lieber zwischen Player, Warteschlange und Text blaettert, stellt es in
// child pointerInput and consumes the drag before the pager sees it. // den Einstellungen um; dann uebernimmt wieder der Pager. Die Reiter oben schalten in beiden
// Faellen weiter, es geht also nie eine Ansicht verloren.
val prefsCtx = LocalContext.current
val wischTitel by produceState(initialValue = true, prefsCtx) {
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings.collect { value = it.wischenWechseltTitel }
}
val pagerState = rememberPagerState(initialPage = tab.ordinal) { NpTab.entries.size } val pagerState = rememberPagerState(initialPage = tab.ordinal) { NpTab.entries.size }
LaunchedEffect(tab) { LaunchedEffect(tab) {
if (pagerState.currentPage != tab.ordinal) pagerState.animateScrollToPage(tab.ordinal) if (pagerState.currentPage != tab.ordinal) pagerState.animateScrollToPage(tab.ordinal)
@@ -227,11 +237,31 @@ fun NowPlayingScreen(
// ── Swipeable tab content (Player / Lyrics / Queue) ──────────── // ── Swipeable tab content (Player / Lyrics / Queue) ────────────
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
userScrollEnabled = !wischTitel,
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f).fillMaxWidth(),
) { page -> ) { page ->
// Die ganze Player-Seite nimmt die Wischgeste an, nicht nur das Cover: sonst muesste
// man genau das Bild treffen, und darunter liegt die halbe Seite. Auf Warteschlange
// und Text liegt sie NICHT — dort wuerde sie mit dem Ziehen in der Liste streiten.
var seitenDrag by remember { mutableStateOf(0f) }
val wischbar = wischTitel && NpTab.entries[page] == NpTab.PLAYER
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(
if (!wischbar) Modifier else Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
if (seitenDrag < -80) controller.skipNext()
else if (seitenDrag > 80) controller.skipPrev()
seitenDrag = 0f
},
onDragCancel = { seitenDrag = 0f },
onHorizontalDrag = { _, d -> seitenDrag += d },
)
}
),
) { ) {
when (NpTab.entries[page]) { when (NpTab.entries[page]) {
// ── PLAYER TAB ───────────────────────────────────────────────── // ── PLAYER TAB ─────────────────────────────────────────────────
@@ -386,7 +416,7 @@ fun NowPlayingScreen(
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) { ) {
NpCircleBtn(onClick = { controller.toggleShuffle() }) { NpCircleBtn(onClick = { controller.toggleShuffle() }, aktiv = state.shuffled) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp), Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f)) tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
} }
@@ -412,7 +442,8 @@ fun NowPlayingScreen(
NpCircleBtn(onClick = { controller.skipNext() }) { NpCircleBtn(onClick = { controller.skipNext() }) {
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White) Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
} }
NpCircleBtn(onClick = { controller.toggleRepeat() }) { val wiederholt = state.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF
NpCircleBtn(onClick = { controller.toggleRepeat() }, aktiv = wiederholt) {
val (icon, tint) = when (state.repeatMode) { val (icon, tint) = when (state.repeatMode) {
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
@@ -430,6 +461,13 @@ fun NowPlayingScreen(
val lyricsListState = rememberLazyListState() val lyricsListState = rememberLazyListState()
val density = LocalDensity.current val density = LocalDensity.current
var lyricsViewH by remember { mutableIntStateOf(0) } var lyricsViewH by remember { mutableIntStateOf(0) }
// Mitlaufen: Standard AUS. Der Schalter sitzt hier im Reiter, nicht in den
// Einstellungen — man entscheidet beim Lesen, nicht vorher.
val folgtMit by produceState(initialValue = false, prefsCtx) {
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings
.collect { value = it.lyricsFolgenAutomatisch }
}
val folgenScope = rememberCoroutineScope()
// Anchor the active line at 40% from the top — slightly above middle reads more naturally. // Anchor the active line at 40% from the top — slightly above middle reads more naturally.
val anchorFrac = 0.40f val anchorFrac = 0.40f
@@ -440,7 +478,10 @@ fun NowPlayingScreen(
// Center the active line using its *measured* position — no cumulative drift, so // Center the active line using its *measured* position — no cumulative drift, so
// wrapped (multi-line) lyrics stay centered. Re-runs once the viewport is measured. // wrapped (multi-line) lyrics stay centered. Re-runs once the viewport is measured.
LaunchedEffect(activeIdx, lyricsViewH) { LaunchedEffect(activeIdx, lyricsViewH, folgtMit) {
// Ohne Mitlaufen wird nur hervorgehoben, nicht gescrollt — sonst springt der
// Text unter dem Finger weg, sobald die naechste Zeile beginnt.
if (!folgtMit) return@LaunchedEffect
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
val info = lyricsListState.layoutInfo val info = lyricsListState.layoutInfo
val anchor = info.viewportStartOffset + val anchor = info.viewportStartOffset +
@@ -458,6 +499,41 @@ fun NowPlayingScreen(
} }
} }
if (lyricsState.lines.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.End,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(
if (folgtMit) NsAccent.copy(0.18f) else Color.White.copy(0.08f)
)
.clickable {
folgenScope.launch {
com.novasonic.app.data.PrefsStore(prefsCtx)
.saveLyricsFolgen(!folgtMit)
}
}
.padding(horizontal = 11.dp, vertical = 6.dp),
) {
Icon(
Icons.Default.KeyboardArrowDown, null,
modifier = Modifier.size(15.dp),
tint = if (folgtMit) NsAccent else Color.White.copy(0.55f),
)
Spacer(Modifier.width(5.dp))
Text(
if (folgtMit) "Folgt" else "Mitlaufen",
fontFamily = Outfit, fontSize = 11.sp, fontWeight = FontWeight.Bold,
color = if (folgtMit) NsAccent else Color.White.copy(0.55f),
)
}
}
}
when { when {
lyricsState.isLoading -> Box( lyricsState.isLoading -> Box(
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center, Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
@@ -573,18 +649,38 @@ fun NowPlayingScreen(
color = Color.White.copy(0.4f)) color = Color.White.copy(0.4f))
} }
} else { } else {
// ── Warteschlange bearbeiten ────────────────────────────
// Gezogen wird am Griff, nicht an der Zeile: die Liste selbst scrollt, und
// beides an derselben Stelle liesse sich nicht auseinanderhalten.
// Getauscht wird LAUFEND, sobald der Finger eine Zeilenhoehe zurueckgelegt
// hat — dadurch braucht es kein Geisterbild, die Liste zeigt die neue
// Reihenfolge sofort.
val zeilePx = with(LocalDensity.current) { 56.dp.toPx() }
var ziehIdx by remember { mutableIntStateOf(-1) }
var ziehOffset by remember { mutableFloatStateOf(0f) }
val queueLaenge = rememberUpdatedState(state.queue.size)
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(vertical = 4.dp), contentPadding = PaddingValues(vertical = 4.dp),
) { ) {
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong -> itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
val active = state.currentIndex == idx val active = state.currentIndex == idx
val wirdGezogen = ziehIdx == idx
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.graphicsLayer {
if (wirdGezogen) { translationY = ziehOffset; shadowElevation = 12f }
}
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background(if (active) Color.White.copy(0.08f) else Color.Transparent) .background(
when {
wirdGezogen -> Color.White.copy(0.16f)
active -> Color.White.copy(0.08f)
else -> Color.Transparent
}
)
.clickable { controller.seekToItem(idx) } .clickable { controller.seekToItem(idx) }
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
) { ) {
@@ -612,6 +708,53 @@ fun NowPlayingScreen(
Icon(Icons.AutoMirrored.Filled.VolumeUp, null, Icon(Icons.AutoMirrored.Filled.VolumeUp, null,
tint = NsAccent, modifier = Modifier.size(16.dp)) tint = NsAccent, modifier = Modifier.size(16.dp))
} }
IconButton(
onClick = { controller.entferneAusQueue(idx) },
modifier = Modifier.size(32.dp),
) {
Icon(Icons.Default.Close, "Aus der Warteschlange entfernen",
tint = Color.White.copy(0.45f), modifier = Modifier.size(16.dp))
}
Icon(
Icons.Default.DragHandle, "Zum Verschieben ziehen",
tint = Color.White.copy(if (wirdGezogen) 0.9f else 0.45f),
modifier = Modifier
.size(32.dp)
.padding(6.dp)
.pointerInput(Unit) {
awaitEachGesture {
// Der erste Kontakt wird sofort verbraucht, sonst
// beansprucht die LazyColumn die Geste, sobald die
// Wischschwelle faellt.
val start = awaitFirstDown(requireUnconsumed = false)
start.consume()
var aktuell = idx
var rest = 0f
ziehIdx = aktuell
try {
while (true) {
val ev = awaitPointerEvent()
val p = ev.changes.firstOrNull { it.id == start.id } ?: break
if (!p.pressed) break
rest += p.positionChange().y
p.consume()
while (rest >= zeilePx && aktuell < queueLaenge.value - 1) {
controller.verschiebeInQueue(aktuell, aktuell + 1)
aktuell++; rest -= zeilePx
}
while (rest <= -zeilePx && aktuell > 0) {
controller.verschiebeInQueue(aktuell, aktuell - 1)
aktuell--; rest += zeilePx
}
ziehIdx = aktuell
ziehOffset = rest
}
} finally {
ziehIdx = -1; ziehOffset = 0f
}
}
},
)
} }
} }
} }
@@ -696,11 +839,34 @@ private fun NpSeekBar(
} }
@Composable @Composable
private fun NpCircleBtn(onClick: () -> Unit, content: @Composable () -> Unit) { private fun NpCircleBtn(
onClick: () -> Unit,
aktiv: Boolean = false,
content: @Composable () -> Unit,
) {
// Ein eingefaerbtes Symbol allein ist als "an" schwer zu erkennen — auf dem dunklen Cover
// sieht Akzentfarbe wie eine Schattierung aus. Ist der Schalter an, liegt deshalb zusaetzlich
// ein getoenter Kreis darunter und ein Punkt darunter, wie in gaengigen Playern.
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(onClick = onClick), modifier = Modifier
) { content() } .size(44.dp)
.clip(CircleShape)
.background(if (aktiv) NsAccent.copy(alpha = 0.22f) else Color.Transparent)
.clickable(onClick = onClick),
) {
content()
if (aktiv) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 5.dp)
.size(4.dp)
.clip(CircleShape)
.background(NsAccent)
)
}
}
} }
private fun formatMs(ms: Long): String { private fun formatMs(ms: Long): String {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -31,11 +32,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.toSong import com.novasonic.app.offline.toSong
import com.novasonic.app.player.PlayerState import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.OfflineDetailViewModel import com.novasonic.app.viewmodel.OfflineDetailViewModel
import com.novasonic.app.ui.component.Rollbalken
@Composable @Composable
fun OfflineDetailScreen( fun OfflineDetailScreen(
@@ -62,124 +65,154 @@ fun OfflineDetailScreen(
return return
} }
val songs = remember(tracks) { tracks.map { it.toSong() } } val songs = remember(tracks) { tracks.map { it.toSong() } }
// Nur FERTIGE Titel sind offline abspielbar. Die Liste zeigt weiter alle (auch wartende und
// fehlgeschlagene), aber in die Warteschlange dürfen sie nicht: für sie gibt es keine lokale
// Datei, also liefert streamUrl() die Netz-Adresse — und die tut offline genau nichts.
val spielbar = remember(tracks) {
tracks.filter { it.state == DownloadState.DONE }.map { it.toSong() }
}
val ctx = androidx.compose.ui.platform.LocalContext.current
val spieleAb: (List<com.novasonic.app.api.model.Song>, Int) -> Unit = { liste, i ->
val fehlend = songs.size - spielbar.size
if (fehlend > 0) android.widget.Toast.makeText(
ctx, "$fehlend Titel noch nicht heruntergeladen — übersprungen",
android.widget.Toast.LENGTH_SHORT).show()
onPlay(liste, i)
}
LazyColumn(Modifier.fillMaxSize()) { val listState = rememberLazyListState()
item { Box(Modifier.fillMaxSize()) {
// ── Hero ───────────────────────────────────────────────────────── LazyColumn(Modifier.fillMaxSize(), state = listState) {
Box(Modifier.fillMaxWidth()) { item {
AsyncImage( // ── Hero ─────────────────────────────────────────────────────────
model = coverUrl(g.coverArt), Box(Modifier.fillMaxWidth()) {
contentDescription = g.name, AsyncImage(
contentScale = ContentScale.Crop, model = coverUrl(g.coverArt),
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2), contentDescription = g.name,
) contentScale = ContentScale.Crop,
Box( modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2),
Modifier )
.fillMaxWidth() Box(
.aspectRatio(1f) Modifier
.background( .fillMaxWidth()
Brush.verticalGradient( .aspectRatio(1f)
0.0f to Color.Transparent, .background(
0.40f to NsBackground.copy(alpha = 0.35f), Brush.verticalGradient(
1.0f to NsBackground, 0.0f to Color.Transparent,
0.40f to NsBackground.copy(alpha = 0.35f),
1.0f to NsBackground,
)
) )
)
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp))
}
Column(
Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
g.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
) )
) Spacer(Modifier.height(4.dp))
Box( Text(
Modifier "Offline · ${tracks.size} Titel",
.statusBarsPadding() fontFamily = Outfit, fontSize = 13.sp,
.padding(start = 14.dp, top = 14.dp) color = Color.White.copy(alpha = 0.65f),
.size(36.dp) )
.clip(CircleShape) }
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp))
} }
Column(
Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp), // ── Play / Shuffle / Edit row ─────────────────────────────────────
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Button(
g.name, onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, 0) },
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, modifier = Modifier.weight(1f).height(46.dp),
fontSize = 26.sp, color = Color.White, colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
maxLines = 2, overflow = TextOverflow.Ellipsis, shape = RoundedCornerShape(14.dp),
) ) {
Spacer(Modifier.height(4.dp)) Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
Text( Spacer(Modifier.width(6.dp))
"Offline · ${tracks.size} Titel", Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
fontFamily = Outfit, fontSize = 13.sp, }
color = Color.White.copy(alpha = 0.65f), OutlinedButton(
) onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, -1) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
}
OutlinedButton(
onClick = { editMode = !editMode },
modifier = Modifier.height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
) {
Icon(
if (editMode) Icons.Default.Done else Icons.Default.Edit, null,
modifier = Modifier.size(18.dp),
tint = if (editMode) NsAccent else NsOnSurface2,
)
}
} }
HorizontalDivider(color = NsBorder)
} }
// ── Play / Shuffle / Edit row ───────────────────────────────────── itemsIndexed(songs, key = { _, s -> s.id }) { index, song ->
Row( Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), Box(Modifier.weight(1f)) {
horizontalArrangement = Arrangement.spacedBy(10.dp), SongRow(
) { song = song,
Button( coverUrl = coverUrl(song.coverArt),
onClick = { if (songs.isNotEmpty()) onPlay(songs, 0) }, isPlaying = playerState.currentSong?.id == song.id,
modifier = Modifier.weight(1f).height(46.dp), // Position in der abspielbaren Liste suchen — die Anzeige enthält auch
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White), // Titel, die noch fehlen, die Indizes decken sich also nicht.
shape = RoundedCornerShape(14.dp), onPlay = {
) { val i = spielbar.indexOfFirst { it.id == song.id }
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp)) if (i >= 0) onPlay(spielbar, i)
Spacer(Modifier.width(6.dp)) else android.widget.Toast.makeText(
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp) ctx, "Dieser Titel ist noch nicht heruntergeladen",
} android.widget.Toast.LENGTH_SHORT).show()
OutlinedButton( },
onClick = { if (songs.isNotEmpty()) onPlay(songs.shuffled(), 0) }, )
modifier = Modifier.weight(1f).height(46.dp), }
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))), if (editMode) {
shape = RoundedCornerShape(14.dp), IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) {
) { Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen",
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent) tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(6.dp)) }
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
}
OutlinedButton(
onClick = { editMode = !editMode },
modifier = Modifier.height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
) {
Icon(
if (editMode) Icons.Default.Done else Icons.Default.Edit, null,
modifier = Modifier.size(18.dp),
tint = if (editMode) NsAccent else NsOnSurface2,
)
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(songs, key = { _, s -> s.id }) { index, song ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
onPlay = { onPlay(songs, index) },
)
}
if (editMode) {
IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) {
Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen",
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp))
} }
} }
} }
item { Spacer(Modifier.height(16.dp)) }
} }
item { Spacer(Modifier.height(16.dp)) } Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
)
} }
} }
@@ -132,7 +132,7 @@ fun PartialAlbumScreen(
.weight(1f) .weight(1f)
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background(NsSurface2) .background(NsSurface2)
.clickable { onPlay(downloadedSongs.shuffled(), 0) } .clickable { onPlay(downloadedSongs, -1) }
.padding(vertical = 11.dp), .padding(vertical = 11.dp),
) { ) {
Icon(Icons.Default.Shuffle, null, tint = NsOnSurface, modifier = Modifier.size(18.dp)) Icon(Icons.Default.Shuffle, null, tint = NsOnSurface, modifier = Modifier.size(18.dp))
@@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -39,6 +40,8 @@ import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.PlaylistViewModel import com.novasonic.app.viewmodel.PlaylistViewModel
import com.novasonic.app.ui.component.Rollbalken
import com.novasonic.app.ui.formatGesamtdauer
@Composable @Composable
fun PlaylistDetailScreen( fun PlaylistDetailScreen(
@@ -91,205 +94,218 @@ fun PlaylistDetailScreen(
return return
} }
LazyColumn(Modifier.fillMaxSize()) { val listState = rememberLazyListState()
item { Box(Modifier.fillMaxSize()) {
// ── Hero ───────────────────────────────────────────────────────── LazyColumn(Modifier.fillMaxSize(), state = listState) {
Box(Modifier.fillMaxWidth()) { item {
AsyncImage( // ── Hero ─────────────────────────────────────────────────────────
model = coverUrl(pl.coverArt), Box(Modifier.fillMaxWidth()) {
contentDescription = pl.name, AsyncImage(
contentScale = ContentScale.Crop, model = coverUrl(pl.coverArt),
modifier = Modifier contentDescription = pl.name,
.fillMaxWidth() contentScale = ContentScale.Crop,
.aspectRatio(1f) modifier = Modifier
.background(NsSurface2), .fillMaxWidth()
) .aspectRatio(1f)
Box( .background(NsSurface2),
Modifier )
.fillMaxWidth() Box(
.aspectRatio(1f) Modifier
.background( .fillMaxWidth()
Brush.verticalGradient( .aspectRatio(1f)
0.0f to Color.Transparent, .background(
0.40f to NsBackground.copy(alpha = 0.35f), Brush.verticalGradient(
1.0f to NsBackground, 0.0f to Color.Transparent,
0.40f to NsBackground.copy(alpha = 0.35f),
1.0f to NsBackground,
)
) )
)
)
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp),
) )
}
// Edit + Delete buttons (top-right)
Row(
Modifier
.statusBarsPadding()
.padding(end = 14.dp, top = 14.dp)
.align(Alignment.TopEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box( Box(
Modifier Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp) .size(36.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f)) .background(Color.Black.copy(alpha = 0.45f))
.clickable { showRenameDialog = true }, .clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Edit, "Bearbeiten",
tint = Color.White, modifier = Modifier.size(18.dp))
}
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { showDeleteDialog = true },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Delete, "Löschen",
tint = Color.White, modifier = Modifier.size(18.dp))
}
}
Column(
Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
pl.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
val meta = listOfNotNull(
pl.owner?.takeIf { it.isNotBlank() },
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
).joinToString(" · ")
if (meta.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
meta, fontFamily = Outfit, fontSize = 13.sp,
color = Color.White.copy(alpha = 0.65f),
)
}
if (!pl.comment.isNullOrBlank()) {
Spacer(Modifier.height(3.dp))
Text(
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.45f),
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
}
}
}
// ── Play / Shuffle / Edit / Download ──────────────────────────────
// Four buttons in one row left no room for labels (the Play/Shuffle text was being
// clipped away), so all four are equal-width and icon-only. Play stays filled as the
// primary action; the rest are outlined and tint accent when active/done.
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
}
OutlinedButton(
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
}
// Toggle edit mode for removing songs
OutlinedButton(
onClick = { editMode = !editMode },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(
if (editMode) Icons.Default.Done else Icons.Default.Edit,
if (editMode) "Bearbeiten beenden" else "Titel entfernen",
modifier = Modifier.size(20.dp),
tint = if (editMode) NsAccent else NsOnSurface2,
)
}
// Download button — tappable unless fully done; resumes / re-downloads missing songs
val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false
OutlinedButton(
onClick = { if (!dlDone) vm.downloadPlaylist() },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
when {
dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent)
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent)
else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2)
}
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(pl.entry, idx) },
onStarToggle = { onStarToggle(song) },
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
)
}
if (editMode) {
IconButton(
onClick = { vm.removeSong(idx) },
modifier = Modifier.padding(end = 4.dp),
) { ) {
Icon( Icon(
Icons.Default.RemoveCircleOutline, Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
"Entfernen", tint = Color.White, modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(22.dp),
) )
} }
// Edit + Delete buttons (top-right)
Row(
Modifier
.statusBarsPadding()
.padding(end = 14.dp, top = 14.dp)
.align(Alignment.TopEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { showRenameDialog = true },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Edit, "Bearbeiten",
tint = Color.White, modifier = Modifier.size(18.dp))
}
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { showDeleteDialog = true },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Delete, "Löschen",
tint = Color.White, modifier = Modifier.size(18.dp))
}
}
Column(
Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
pl.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
// Der Besitzer stand hier bisher bei JEDER Liste, auch bei den eigenen, und ohne
// Beschriftung — bei einer geteilten Liste ist genau das die interessante Angabe.
val meta = listOfNotNull(
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
// Gesamtdauer: Subsonic liefert sie beim Playlist-Objekt mit, es kostet
// also keinen zusaetzlichen Aufruf.
if (pl.duration > 0) formatGesamtdauer(pl.duration) else null,
pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }?.let { "von $it" },
).joinToString(" · ")
if (meta.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
meta, fontFamily = Outfit, fontSize = 13.sp,
color = Color.White.copy(alpha = 0.65f),
)
}
if (!pl.comment.isNullOrBlank()) {
Spacer(Modifier.height(3.dp))
Text(
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.45f),
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
}
}
}
// ── Play / Shuffle / Edit / Download ──────────────────────────────
// Four buttons in one row left no room for labels (the Play/Shuffle text was being
// clipped away), so all four are equal-width and icon-only. Play stays filled as the
// primary action; the rest are outlined and tint accent when active/done.
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
}
OutlinedButton(
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, -1) }, // -1 = gemischt, der Player mischt
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
}
// Toggle edit mode for removing songs
OutlinedButton(
onClick = { editMode = !editMode },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(
if (editMode) Icons.Default.Done else Icons.Default.Edit,
if (editMode) "Bearbeiten beenden" else "Titel entfernen",
modifier = Modifier.size(20.dp),
tint = if (editMode) NsAccent else NsOnSurface2,
)
}
// Download button — tappable unless fully done; resumes / re-downloads missing songs
val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false
OutlinedButton(
onClick = { if (!dlDone) vm.downloadPlaylist() },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
when {
dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent)
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent)
else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2)
}
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(pl.entry, idx) },
onStarToggle = { onStarToggle(song) },
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
)
}
if (editMode) {
IconButton(
onClick = { vm.removeSong(idx) },
modifier = Modifier.padding(end = 4.dp),
) {
Icon(
Icons.Default.RemoveCircleOutline,
"Entfernen",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(22.dp),
)
}
}
} }
} }
item { Spacer(Modifier.height(16.dp)) }
} }
item { Spacer(Modifier.height(16.dp)) } Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
)
} }
// ── Rename dialog ───────────────────────────────────────────────────────── // ── Rename dialog ─────────────────────────────────────────────────────────
@@ -122,6 +122,76 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {})
) )
} }
// ── ▶ Wiedergabe ────────────────────────────────────────────────────
SectionHeader("▶ Wiedergabe")
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.weight(1f)) {
Text("Wischen wechselt den Titel", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text(
if (state.wischenWechseltTitel)
"Im Now-Playing: seitwärts wischen springt zum nächsten oder vorherigen Titel"
else
"Im Now-Playing: seitwärts wischen blättert zwischen Player, Warteschlange und Text",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
Switch(
checked = state.wischenWechseltTitel,
onCheckedChange = { vm.onWischenWechseltTitel(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
uncheckedThumbColor = NsOnSurface2,
),
)
}
// ── 📥 Downloads ─────────────────────────────────────────────────────
SectionHeader("📥 Offline-Downloads")
var wlanWarnung by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.weight(1f)) {
Text("Nur über WLAN herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text("Pausiert unterwegs und macht im WLAN von selbst weiter", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Switch(
// Ausschalten nur mit Rueckfrage: eine grosse Playlist sind schnell mehrere Gigabyte,
// und der Downloader arbeitet sich ohne Zutun durch, bis alles da ist.
checked = state.downloadWifiOnly,
onCheckedChange = { an -> if (an) vm.onDownloadWifiOnly(true) else wlanWarnung = true },
colors = SwitchDefaults.colors(
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
uncheckedThumbColor = NsOnSurface2,
),
)
}
if (wlanWarnung) {
AlertDialog(
onDismissRequest = { wlanWarnung = false },
title = { Text("Über mobile Daten laden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
Text(
"Downloads laufen dann auch ohne WLAN weiter — und zwar so lange, bis alles " +
"geladen ist. Eine Playlist mit 1000 Titeln sind leicht mehrere Gigabyte.",
fontFamily = Outfit, fontSize = 14.sp,
)
},
confirmButton = {
TextButton(onClick = { vm.onDownloadWifiOnly(false); wlanWarnung = false }) {
Text("Trotzdem erlauben", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { wlanWarnung = false }) {
Text("Abbrechen", fontFamily = Outfit, color = NsAccent)
}
},
)
}
Spacer(Modifier.height(4.dp))
ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) { ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) {
OutlinedTextField( OutlinedTextField(
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden", value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
@@ -194,6 +264,9 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {})
label = "Last.fm API Key", label = "Last.fm API Key",
hint = "Kostenlos unter last.fm/api/account/create", hint = "Kostenlos unter last.fm/api/account/create",
) )
// Vorbelegt aus BuildConfig (siehe build.gradle.kts): auf einem frischen Geraet muss nichts
// eingetippt werden. Das Feld bleibt trotzdem bedienbar — zieht der Dienst um, laesst sich
// das hier korrigieren, ohne ein neues APK zu bauen.
NsTextField( NsTextField(
value = state.backendUrl, onValueChange = vm::onBackendUrl, value = state.backendUrl, onValueChange = vm::onBackendUrl,
label = "NovaSonic Backend URL", label = "NovaSonic Backend URL",
@@ -210,6 +283,7 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {})
) )
ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble) ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
// ── 🌐 Navidrome Server ────────────────────────────────────────────── // ── 🌐 Navidrome Server ──────────────────────────────────────────────
@@ -1,8 +1,19 @@
package com.novasonic.app.ui.screen package com.novasonic.app.ui.screen
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import com.novasonic.app.ui.component.AlbumCard
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -18,9 +29,13 @@ import com.novasonic.app.app
import com.novasonic.app.player.PlayerState import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.ui.component.Rollbalken
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST } enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
/** So viele gefolgte Kuenstler stehen in der Reihe, der Rest hinter "Alle anzeigen". */
private const val GEZEIGT = 10
@Composable @Composable
fun StarredScreen( fun StarredScreen(
coverUrl: (String?) -> String?, coverUrl: (String?) -> String?,
@@ -28,18 +43,29 @@ fun StarredScreen(
onPlay: (List<Song>, Int) -> Unit, onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit, onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null, onShowContextMenu: ((Song) -> Unit)? = null,
onArtistClick: (String) -> Unit = {},
onAlbumClick: (String) -> Unit = {},
onOpenNews: () -> Unit = {},
onOpenFollowed: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val repo = context.app.repository val repo = context.app.repository
var songs by remember { mutableStateOf<List<Song>>(emptyList()) } var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
// getStarred2 liefert Kuenstler und Alben im SELBEN Paket wie die Songs — die Uebersicht
// "wem folge ich" kostet also keinen zusaetzlichen Aufruf.
var gefolgteKuenstler by remember { mutableStateOf<List<com.novasonic.app.api.model.Artist>>(emptyList()) }
var gemerkteAlben by remember { mutableStateOf<List<com.novasonic.app.api.model.Album>>(emptyList()) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var sort by remember { mutableStateOf(StarredSort.NEWEST) } var sort by remember { mutableStateOf(StarredSort.NEWEST) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
songs = repo.getStarred().song val markiert = repo.getStarred()
songs = markiert.song
gefolgteKuenstler = markiert.artist.sortedBy { it.name.lowercase() }
gemerkteAlben = markiert.album.sortedByDescending { it.starred ?: "" }
loading = false loading = false
} catch (e: Exception) { } catch (e: Exception) {
error = e.message error = e.message
@@ -57,12 +83,25 @@ fun StarredScreen(
} }
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Text( Row(
"Favoriten", verticalAlignment = Alignment.CenterVertically,
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp, modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 24.dp, bottom = 8.dp),
color = MaterialTheme.colorScheme.onSurface, ) {
modifier = Modifier.padding(start = 20.dp, top = 24.dp, bottom = 8.dp), Text(
) "Favoriten",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
// Einstieg in die Zeitleiste: hier stehen die gefolgten Künstler, also liegt die Frage
// "was gibt es Neues von denen" nahe.
if (gefolgteKuenstler.isNotEmpty()) {
TextButton(onClick = onOpenNews) {
Text("✨ Neu", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsAccent)
}
}
}
// Sort chips // Sort chips
Row( Row(
@@ -100,25 +139,118 @@ fun StarredScreen(
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(error!!, color = MaterialTheme.colorScheme.error) Text(error!!, color = MaterialTheme.colorScheme.error)
} }
} else if (sorted.isEmpty()) { } else if (sorted.isEmpty() && gefolgteKuenstler.isEmpty() && gemerkteAlben.isEmpty()) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text("Noch keine Favoriten", color = NsOnSurface2) Text("Noch keine Favoriten", color = NsOnSurface2)
} }
} else { } else {
LazyColumn { val listState = rememberLazyListState()
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song -> Box(Modifier.fillMaxSize()) {
SongRow( LazyColumn(state = listState) {
song = song, if (gefolgteKuenstler.isNotEmpty()) {
coverUrl = coverUrl(song.coverArt), item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") }
isPlaying = playerState.currentSong?.id == song.id, item {
starred = song.starred != null, LazyRow(
onPlay = { onPlay(sorted, idx) }, contentPadding = PaddingValues(horizontal = 16.dp),
onStarToggle = { onStarToggle(song) }, horizontalArrangement = Arrangement.spacedBy(12.dp),
onMore = onShowContextMenu?.let { cb -> { cb(song) } }, ) {
) // Die LazyRow baut zwar nur Sichtbares, aber eine endlose Reihe ist
// zum Durchsehen untauglich — der Rest gehoert in die gefilterte
// Kuenstlerliste. GEZEIGT deckt zwei Bildschirmbreiten ab.
items(gefolgteKuenstler.take(GEZEIGT), key = { it.id }) { a ->
GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) }
}
if (gefolgteKuenstler.size > GEZEIGT) {
item(key = "alle_kuenstler") {
AlleAnzeigen(gefolgteKuenstler.size - GEZEIGT, onOpenFollowed)
}
}
}
Spacer(Modifier.height(8.dp))
}
}
if (gemerkteAlben.isNotEmpty()) {
item { FavoritenLabel("Gemerkte Alben (${gemerkteAlben.size})") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(gemerkteAlben, key = { it.id }) { al ->
AlbumCard(album = al, coverUrl = coverUrl(al.coverArt)) { onAlbumClick(al.id) }
}
}
Spacer(Modifier.height(8.dp))
}
}
if (sorted.isNotEmpty() && (gefolgteKuenstler.isNotEmpty() || gemerkteAlben.isNotEmpty())) {
item { FavoritenLabel("Songs (${sorted.size})") }
}
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(sorted, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(8.dp)) }
} }
item { Spacer(Modifier.height(8.dp)) } Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
)
} }
} }
} }
} }
@Composable
private fun FavoritenLabel(titel: String) {
Text(
titel,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
color = NsOnSurface2,
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 8.dp),
)
}
@Composable
private fun GefolgterKuenstler(name: String, bild: String?, albumCount: Int, onClick: () -> Unit) {
Column(Modifier.width(110.dp).clickable(onClick = onClick)) {
AsyncImage(
model = bild, contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface)
Text("$albumCount Alben", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 1)
}
}
@Composable
private fun AlleAnzeigen(weitere: Int, onClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(110.dp).clickable(onClick = onClick),
) {
Box(
Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
contentAlignment = Alignment.Center,
) {
Text("→", fontSize = 26.sp, color = NsAccent)
}
Spacer(Modifier.height(6.dp))
Text("Alle anzeigen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
maxLines = 1, color = MaterialTheme.colorScheme.onSurface)
Text("+$weitere weitere", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2, maxLines = 1)
}
}
@@ -85,6 +85,20 @@ val NS_THEMES = listOf(
Color(0xFF162A21), Color(0xFF1E3A2D), Color(0xFF162A21), Color(0xFF1E3A2D),
Color(0xFFE8FFF1), Color(0xFF8FBFA4), Color(0xFF557A63), Color(0xFFE8FFF1), Color(0xFF8FBFA4), Color(0xFF557A63),
), ),
NsTheme(
"violet", "Violet",
Color(0xFFA78BFA), Color(0xFF7C5CF0),
Color(0xFF0D0916), Color(0xFF140E20),
Color(0xFF221835), Color(0xFF2D2145),
Color(0xFFF2EDFF), Color(0xFF9B8BBD), Color(0xFF655780),
),
NsTheme(
"crimson", "Crimson",
Color(0xFFE05263), Color(0xFFB03445),
Color(0xFF120708), Color(0xFF1A0B0D),
Color(0xFF2B1316), Color(0xFF3A1A1E),
Color(0xFFFFEEF0), Color(0xFFBF8D94), Color(0xFF7D545C),
),
) )
fun applyNsTheme(themeId: String) { fun applyNsTheme(themeId: String) {
@@ -21,6 +21,10 @@ data class AlbumUiState(
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) { class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository private val repo = app.app.repository
/** Album merken — optimistisch ueber StarState, faellt bei einem Fehlschlag zurueck. */
fun merkenUmschalten(albumId: String, aktuellMarkiert: Boolean) =
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, albumId, aktuellMarkiert)
private val offlineRepo = app.app.offlineRepository private val offlineRepo = app.app.offlineRepository
private val albumId: String? = savedState["albumId"] private val albumId: String? = savedState["albumId"]
@@ -0,0 +1,169 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.ReleasesClient
import com.novasonic.app.api.model.ArtistRef
import com.novasonic.app.api.model.ArtistKonzert
import com.novasonic.app.api.model.ArtistRelease
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.app
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Welcher Reiter gezeigt wird. Getrennte Listen, weil die Zeit in verschiedene Richtungen läuft. */
enum class NewsReiter { RELEASES, KONZERTE }
data class ArtistNewsUiState(
val reiter: NewsReiter = NewsReiter.RELEASES,
val releases: List<ArtistRelease> = emptyList(),
val konzerte: List<ArtistKonzert> = emptyList(),
val keinSchluessel: Boolean = false,
/** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */
val vorhanden: Set<String> = emptySet(),
val ohneZuordnung: List<String> = emptyList(),
val tage: Int = 90,
val isLoading: Boolean = true,
val keineGefolgten: Boolean = false,
val keinBackend: Boolean = false,
val error: String? = null,
)
/**
* "Neu von deinen Künstlern". Gefolgt = Navidromes Stern, die Künstlerliste kommt also aus
* getStarred2. Die Deezer-Auflösung macht das Backend — dort liegen die Pins und der gemeinsame
* Cache, sonst würde jedes Gerät bei jedem Öffnen dieselben Abfragen wiederholen.
*/
class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(ArtistNewsUiState())
val state: StateFlow<ArtistNewsUiState> = _state
init { laden(90) }
/** Reiter wechseln und, falls noch nichts geladen ist, nachladen. */
fun zeigeReiter(r: NewsReiter) {
_state.value = _state.value.copy(reiter = r)
if (r == NewsReiter.KONZERTE && _state.value.konzerte.isEmpty()) ladeKonzerte()
}
/** Zeitfenster setzen — gibt es nur bei den Veroeffentlichungen. */
fun setzeTage(tage: Int) = laden(tage)
// Kein Zeitfenster: Ticketmaster liefert ohnehin nur Anstehendes, und dort will man alles
// Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
fun ladeKonzerte() = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null,
keineGefolgten = false, keinBackend = false, keinSchluessel = false)
try {
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
if (backendUrl.isBlank()) {
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
return@launch
}
val kuenstler = repo.getStarred().artist.map { ArtistRef(it.id, it.name) }
if (kuenstler.isEmpty()) {
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
return@launch
}
val antwort = ReleasesClient.konzerte(backendUrl, kuenstler)
_state.value = _state.value.copy(
konzerte = antwort.konzerte,
keinSchluessel = antwort.keinSchluessel,
isLoading = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(isLoading = false, error = e.message)
}
}
fun laden(tage: Int = _state.value.tage) = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, tage = tage, error = null,
reiter = NewsReiter.RELEASES, keineGefolgten = false, keinBackend = false)
try {
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
if (backendUrl.isBlank()) {
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
return@launch
}
val markiert = repo.getStarred()
val kuenstler = markiert.artist.map { ArtistRef(it.id, it.name) }
if (kuenstler.isEmpty()) {
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
return@launch
}
val antwort = ReleasesClient.fetch(backendUrl, kuenstler, tage)
// Was liegt schon in der Bibliothek? Verglichen wird über Künstler + Albumname —
// Deezers Album-Id hat mit Navidromes nichts zu tun.
val vorhanden = withContext(Dispatchers.IO) {
val ausFavoriten = markiert.album.map { schluessel(it.artist ?: "", it.name) }
val ausBibliothek = kuenstler.chunked(6).flatMap { haeppchen ->
haeppchen.map { k ->
async {
runCatching { repo.getArtist(k.id).album.map { schluessel(k.name, it.name) } }
.getOrDefault(emptyList())
}
}.awaitAll().flatten()
}
(ausFavoriten + ausBibliothek).toSet()
}
_state.value = _state.value.copy(
releases = antwort.releases,
ohneZuordnung = antwort.ohneZuordnung,
vorhanden = vorhanden,
isLoading = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(isLoading = false, error = e.message)
}
}
fun hatSchon(rel: ArtistRelease): Boolean =
schluessel(rel.artistName, rel.title) in _state.value.vorhanden
fun herunterladen(rel: ArtistRelease, onDone: (Boolean, String) -> Unit) {
viewModelScope.launch {
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
if (backendUrl.isBlank()) { onDone(false, "Kein Backend konfiguriert"); return@launch }
val request = DownloadRequest(
jobId = "job_${System.currentTimeMillis()}",
type = "album",
title = rel.title,
artist = rel.artistName,
artworkUrl = rel.cover.takeIf { it.isNotBlank() },
collectionId = rel.albumId.toLongOrNull(),
itunesId = rel.albumId.toLongOrNull(),
)
val client = ImporterClient(backendUrl)
runCatching { client.download(request) }
.onSuccess {
NotificationStore.startPoll(
backendUrl, request.jobId, request.title, request.artist ?: "",
navType = "album", navAlbum = rel.title, navArtist = rel.artistName,
)
onDone(true, "✓ Download gestartet")
}
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
client.close()
}
}
private companion object {
fun norm(s: String) = s.lowercase()
.replace(Regex("[^a-z0-9]"), "")
fun schluessel(kuenstler: String, album: String) = "${norm(kuenstler)}~~${norm(album)}"
}
}
@@ -63,6 +63,12 @@ data class ArtistUiState(
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) { class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository private val repo = app.app.repository
/** Folgen = Navidromes Stern auf dem Kuenstler. Laeuft ueber StarState, damit der Zustand
* sofort umspringt und bei einem Fehlschlag von selbst zurueckfaellt. */
fun folgenUmschalten(artistId: String, aktuellMarkiert: Boolean) =
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, artistId, aktuellMarkiert)
private val player = app.app.playerController private val player = app.app.playerController
private val preview = app.app.previewController private val preview = app.app.previewController
private val artistId: String? = savedState["artistId"] private val artistId: String? = savedState["artistId"]
@@ -101,7 +107,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
val artist = artistDef.await() val artist = artistDef.await()
val info = infoDef.await() val info = infoDef.await()
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) } val topDef = async { runCatching { repo.getTopSongs(artist.name, artist.id) }.getOrDefault(emptyList()) }
// A manual pin (shared, server-side) fixes same-name collisions — resolve it up front // A manual pin (shared, server-side) fixes same-name collisions — resolve it up front
// so the Deezer enrichment below uses the pinned artist, not a name-guess. // so the Deezer enrichment below uses the pinned artist, not a name-guess.
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() } val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() }
@@ -284,7 +290,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
} }
_state.value = _state.value.copy( _state.value = _state.value.copy(
isShuffling = false, isShuffling = false,
pendingShufflePlay = allSongs.shuffled().ifEmpty { null }, pendingShufflePlay = allSongs.ifEmpty { null }, // gemischt wird im Player
) )
} }
} }
@@ -180,6 +180,16 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(albumTracks = null, albumItem = null) _state.value = _state.value.copy(albumTracks = null, albumItem = null)
} }
/** Vom geöffneten Album zu dessen Künstler springen. Deezer legt das artist-Objekt samt id
* jedem Albumtreffer bei, der Sprung kostet also keinen zusätzlichen Suchaufruf. Die
* Albumansicht wird geschlossen, sonst läge sie weiter über der Künstlerseite. */
fun openArtistOfAlbum(album: ItunesItem) {
val id = album.artistId ?: return
val name = album.artistName ?: return
_state.value = _state.value.copy(albumTracks = null, albumItem = null, partialAlbum = null)
openArtistDetail(ItunesItem(wrapperType = "artist", artistId = id, artistName = name))
}
fun openArtistAllAlbums(title: String) { _state.value = _state.value.copy(artistAllAlbums = title) } fun openArtistAllAlbums(title: String) { _state.value = _state.value.copy(artistAllAlbums = title) }
fun closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) } fun closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) }
@@ -193,15 +203,22 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
val artistName = artist.artistName ?: return@launch val artistName = artist.artistName ?: return@launch
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) } val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) }
// Bewusst ohne id: artistId ist hier DEEZERS Kuenstler-ID, nicht Navidromes. Sie hier
// mitzugeben wuerde Navidrome eine fremde ID unterschieben.
val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) } val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) }
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() } val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() } val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
val ndAlbums = ndAlbumsD.await() val ndAlbums = ndAlbumsD.await()
val iAlbums = iAlbumsD.await() val iAlbums = iAlbumsD.await()
// Aus einem Album konstruierte Künstler tragen weder Bild noch Fanzahl — einmal
// nachschlagen, damit die Kopfzeile genauso aussieht wie aus der Künstlersuche.
val voll = if (artistId != null && artist.artworkUrl100 == null && artist.fanCount == null)
runCatching { ItunesClient.lookupArtist(artistId) }.getOrNull() ?: artist
else artist
_state.value = _state.value.copy( _state.value = _state.value.copy(
artistDetail = ItunesArtistState( artistDetail = ItunesArtistState(
artist = artist, artist = voll,
isLoading = false, isLoading = false,
ndAlbums = ndAlbums, ndAlbums = ndAlbums,
iAlbums = iAlbums, iAlbums = iAlbums,
@@ -29,6 +29,11 @@ data class ImporterUiState(
val jobTotal: Int = 0, val jobTotal: Int = 0,
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null // 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null
val previewingId: Long? = null, val previewingId: Long? = null,
// Playlist aus dem Import anlegen (wie im Web-Importer). Nur sinnvoll ab zwei Titeln.
val createPlaylist: Boolean = true,
val playlistName: String = "",
val playlistStatus: String? = null,
) )
class ImporterViewModel(app: Application) : AndroidViewModel(app) { class ImporterViewModel(app: Application) : AndroidViewModel(app) {
@@ -69,6 +74,9 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
fun stopPreview() = preview.stop() fun stopPreview() = preview.stop()
fun setCreatePlaylist(v: Boolean) { _state.value = _state.value.copy(createPlaylist = v) }
fun setPlaylistName(v: String) { _state.value = _state.value.copy(playlistName = v) }
fun onLink(v: String) { fun onLink(v: String) {
_state.value = _state.value.copy( _state.value = _state.value.copy(
link = v, link = v,
@@ -173,6 +181,11 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
) )
if (st.status == "done" || st.status == "error") { if (st.status == "done" || st.status == "error") {
_state.value = _state.value.copy(isDownloading = false) _state.value = _state.value.copy(isDownloading = false)
val cur = _state.value
if (st.status == "done" && cur.createPlaylist && cur.resolved!!.tracks.size > 1) {
buildPlaylist(cur.playlistName.ifBlank { cur.resolved.name ?: "Import" },
cur.resolved.tracks)
}
return return
} }
} }
@@ -182,6 +195,47 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
/**
* Legt nach dem Import eine Navidrome-Playlist aus den importierten Titeln an — dieselbe Abfolge
* wie im Web: Scan anstossen, kurz warten, jeden Titel suchen, gefundene IDs in eine neue
* Playlist. Titel, die Navidrome (noch) nicht kennt, werden gezaehlt statt zu scheitern.
*/
private suspend fun buildPlaylist(name: String, tracks: List<ImportTrack>) {
_state.value = _state.value.copy(playlistStatus = "Navidrome scannt…")
runCatching { repo.startScan() }
delay(4000)
val norm = { s: String -> s.lowercase().replace(Regex("[^\\p{L}\\p{N}]+"), "") }
val ids = mutableListOf<String>()
var fehlend = 0
for (t in tracks) {
val titel = t.trackName ?: continue
val treffer = runCatching {
val q = listOfNotNull(t.artistName, titel).joinToString(" ")
val gefunden = repo.search(q).song
gefunden.firstOrNull { norm(it.title) == norm(titel) }
?: repo.search(titel).song.firstOrNull { norm(it.title) == norm(titel) }
}.getOrNull()
if (treffer != null) ids += treffer.id else fehlend++
_state.value = _state.value.copy(
playlistStatus = "Playlist: ${ids.size}/${tracks.size} gefunden…")
}
if (ids.isEmpty()) {
_state.value = _state.value.copy(
playlistStatus = "Keine Titel in Navidrome gefunden — Playlist nicht angelegt.")
return
}
val res = runCatching { repo.createPlaylist(name, ids) }
_state.value = _state.value.copy(
playlistStatus = res.fold(
{ "Playlist \"$name\" angelegt: ${ids.size}/${tracks.size} Titel" +
if (fehlend > 0) " ($fehlend nicht gefunden)" else "" },
{ "Playlist fehlgeschlagen: ${it.message}" },
),
)
}
private fun mapError(code: String): String = when (code) { private fun mapError(code: String): String = when (code) {
"EDITORIAL_PLAYLIST" -> "EDITORIAL_PLAYLIST" ->
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese." "Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
@@ -11,6 +11,7 @@ import com.novasonic.app.api.model.Playlist
import com.novasonic.app.app import com.novasonic.app.app
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -24,7 +25,12 @@ data class LibraryUiState(
val albumsByName: List<Album> = emptyList(), // type=alphabeticalByName (for AZ + Oldest) val albumsByName: List<Album> = emptyList(), // type=alphabeticalByName (for AZ + Oldest)
val albumSort: AlbumSort = AlbumSort.NEWEST, val albumSort: AlbumSort = AlbumSort.NEWEST,
val artistIndexes: List<ArtistIndex> = emptyList(), val artistIndexes: List<ArtistIndex> = emptyList(),
/** IDs der markierten ("gefolgten") Künstler — getArtists liefert kein `starred` mit. */
val gefolgteIds: Set<String> = emptySet(),
val nurGefolgte: Boolean = false,
val playlists: List<Playlist> = emptyList(), val playlists: List<Playlist> = emptyList(),
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
val currentUser: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
) )
@@ -42,7 +48,13 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
val albumsGridState = LazyGridState() val albumsGridState = LazyGridState()
val artistsListState = LazyListState() val artistsListState = LazyListState()
init { loadPlaylists() } init {
loadPlaylists()
viewModelScope.launch {
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
_state.value = _state.value.copy(currentUser = u)
}
}
fun setAlbumSort(sort: AlbumSort) { fun setAlbumSort(sort: AlbumSort) {
if (sort == _state.value.albumSort) return if (sort == _state.value.albumSort) return
@@ -84,6 +96,18 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
fun nurGefolgteUmschalten(an: Boolean) {
_state.value = _state.value.copy(nurGefolgte = an)
if (an) ladeGefolgte()
}
/** Markierte Künstler nachladen. Frisch bei jedem Einschalten, damit ein eben entfolgter
* Künstler nicht noch in der Liste steht. */
private fun ladeGefolgte() = viewModelScope.launch {
runCatching { repo.getStarred().artist.map { it.id }.toSet() }
.onSuccess { _state.value = _state.value.copy(gefolgteIds = it) }
}
private fun loadArtists() = viewModelScope.launch { private fun loadArtists() = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null) _state.value = _state.value.copy(isLoading = true, error = null)
try { try {
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
import com.novasonic.app.app import com.novasonic.app.app
import com.novasonic.app.offline.DownloadGroupEntity import com.novasonic.app.offline.DownloadGroupEntity
import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.toSong import com.novasonic.app.offline.toSong
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -27,6 +28,11 @@ class OfflineViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch { offlineRepo.deleteGroup(groupId) } viewModelScope.launch { offlineRepo.deleteGroup(groupId) }
} }
/** Nur FERTIGE Titel: wartende oder fehlgeschlagene haben keine lokale Datei und wuerden aus
* dem Netz gezogen — offline schlaegt die Wiedergabe dann fehl, bei grossen Playlists
* gleich beim ersten Titel. */
suspend fun songsForGroup(groupId: String): List<Song> = suspend fun songsForGroup(groupId: String): List<Song> =
offlineRepo.tracksForGroup(groupId).map { it.toSong() } offlineRepo.tracksForGroup(groupId)
.filter { it.state == DownloadState.DONE }
.map { it.toSong() }
} }
@@ -11,6 +11,7 @@ import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.GroupType import com.novasonic.app.offline.GroupType
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -22,6 +23,8 @@ data class PlaylistUiState(
val isSaving: Boolean = false, val isSaving: Boolean = false,
val deleted: Boolean = false, val deleted: Boolean = false,
val error: String? = null, val error: String? = null,
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
val currentUser: String = "",
) )
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) { class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
@@ -42,7 +45,13 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } } .map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
init { load() } init {
load()
viewModelScope.launch {
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
_state.value = _state.value.copy(currentUser = u)
}
}
fun load() { fun load() {
val id = playlistId val id = playlistId
@@ -36,6 +36,8 @@ data class SettingsUiState(
val absUrl: String = "", val absUrl: String = "",
val absApiKey: String = "", val absApiKey: String = "",
val absChapterTrackMode: Boolean = false, val absChapterTrackMode: Boolean = false,
val downloadWifiOnly: Boolean = true,
val wischenWechseltTitel: Boolean = true,
val isSaving: Boolean = false, val isSaving: Boolean = false,
val savedOk: Boolean = false, val savedOk: Boolean = false,
val error: String? = null, val error: String? = null,
@@ -46,7 +48,8 @@ data class SettingsUiState(
class SettingsViewModel(app: Application) : AndroidViewModel(app) { class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository private val repo = app.app.repository
private val offlineRepo = app.app.offlineRepository
// Seed the theme with the already-applied one so the picker doesn't jump before DataStore emits. // Seed the theme with the already-applied one so the picker doesn't jump before DataStore emits.
private val _state = MutableStateFlow(SettingsUiState(theme = NsColors.themeId)) private val _state = MutableStateFlow(SettingsUiState(theme = NsColors.themeId))
@@ -68,6 +71,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
absUrl = s.absUrl, absUrl = s.absUrl,
absApiKey = s.absApiKey, absApiKey = s.absApiKey,
absChapterTrackMode = s.absChapterTrackMode, absChapterTrackMode = s.absChapterTrackMode,
downloadWifiOnly = s.downloadWifiOnly,
wischenWechseltTitel = s.wischenWechseltTitel,
) )
} }
} }
@@ -84,6 +89,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) } fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) } fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) }
fun onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) } fun onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) }
fun onDownloadWifiOnly(v: Boolean) = update { it.copy(downloadWifiOnly = v, savedOk = false) }
fun onWischenWechseltTitel(v: Boolean) = update { it.copy(wischenWechseltTitel = v, savedOk = false) }
fun onTheme(id: String) { fun onTheme(id: String) {
update { it.copy(theme = id) } update { it.copy(theme = id) }
@@ -177,7 +184,12 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
absUrl = s.absUrl.trimEnd('/'), absUrl = s.absUrl.trimEnd('/'),
absApiKey = s.absApiKey.trim(), absApiKey = s.absApiKey.trim(),
absChapterTrackMode = s.absChapterTrackMode, absChapterTrackMode = s.absChapterTrackMode,
downloadWifiOnly = s.downloadWifiOnly,
wischenWechseltTitel = s.wischenWechseltTitel,
)) ))
// Die Netzbedingung steckt in der bereits eingereihten Arbeit — ohne Neueinreihen
// wuerde der WLAN-Schalter erst beim naechsten App-Start greifen.
offlineRepo.netzbedingungAktualisieren()
_state.value = _state.value.copy(isSaving = false, savedOk = true) _state.value = _state.value.copy(isSaving = false, savedOk = true)
} catch (e: Exception) { } catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message) _state.value = _state.value.copy(isSaving = false, error = e.message)
@@ -0,0 +1,51 @@
<!-- Benachrichtigungen: Honigtopf mit Brief statt der ueblichen Glocke.
Reine Strichzeichnung wie die uebrigen Symbole, damit Icon(tint = …) sie einfaerben kann. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Die Zeichnung fuellt ihr Feld nur zu etwa zwei Dritteln, Material-Symbole fuellen es ganz.
Ohne dieses Aufziehen wirkt der Topf neben dem Zahnrad kleiner, obwohl beide 24 dp sind. -->
<group
android:pivotX="12"
android:pivotY="11.95"
android:scaleX="1.38"
android:scaleY="1.38">
<!-- Deckelrand -->
<path
android:pathData="M4.7,4.6 L19.3,4.6 A1.1,1.1 0 0 1 20.4,5.7 L20.4,6.5 A1.1,1.1 0 0 1 19.3,7.6 L4.7,7.6 A1.1,1.1 0 0 1 3.6,6.5 L3.6,5.7 A1.1,1.1 0 0 1 4.7,4.6 Z"
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="1.35"
android:strokeLineJoin="round" />
<!-- Topfkoerper, nach unten leicht verjuengt -->
<path
android:pathData="M5.5,7.6 c-1.1,3.9 -0.7,8.2 0.7,10.4 a2,2 0 0 0 1.7,0.9 h8.2 a2,2 0 0 0 1.7,-0.9 c1.4,-2.2 1.8,-6.5 0.7,-10.4"
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="1.35"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<!-- Brief -->
<path
android:pathData="M9,11.7 L15,11.7 A0.7,0.7 0 0 1 15.7,12.4 L15.7,15.6 A0.7,0.7 0 0 1 15,16.3 L9,16.3 A0.7,0.7 0 0 1 8.3,15.6 L8.3,12.4 A0.7,0.7 0 0 1 9,11.7 Z"
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="1.25"
android:strokeLineJoin="round" />
<!-- Brieflasche -->
<path
android:pathData="M8.6,12.1 L12,14.4 L15.4,12.1"
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="1.25"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</group>
</vector>
+130
View File
@@ -0,0 +1,130 @@
// ─── KONZERTE GEFOLGTER KÜNSTLER ──────────────────────────────────────────────
// Liefert zu einer Liste von Navidrome-Künstlern die anstehenden Termine, damit die App den
// Reiter "Konzerte" in der Zeitleiste füllen kann. Gegenstück zu releases.js.
//
// Warum serverseitig: bei 50 gefolgten Künstlern wären das 50 Ticketmaster-Aufrufe — pro Gerät und
// pro App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam.
//
// Bewusste Einschränkung (25.9.2026): gesucht wird über den NAMEN (`keyword=`), nicht über
// Ticketmasters `attractionId`. Das ist dieselbe Unschärfe wie bei der Deezer-Zuordnung — ein
// kurzer Künstlername trifft als Stichwort auch Fremdes. Deshalb wird jeder Treffer gegengeprüft:
// der Name muss im Veranstaltungs- oder Attraktionsnamen vorkommen. Wer es genauer braucht, löst
// später die attractionId einmal pro Künstler auf (siehe Backlog im CHANGELOG).
const https = require('https');
const _cache = new Map(); // nameLower → { ts, termine }
const TTL = 6 * 60 * 60 * 1000; // 6 h — Termine ändern sich nicht minütlich
const GLEICHZEITIG = 4; // höflich gegenüber Ticketmaster, trotzdem zügig
const norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '')
.replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
function holen(url) {
return new Promise((resolve, reject) => {
https.get(url, { headers: { Accept: 'application/json' } }, up => {
let body = '';
up.on('data', d => body += d);
up.on('end', () => {
try {
const data = JSON.parse(body);
if (up.statusCode !== 200 || data.fault || data.errors) {
return reject(new Error(`Ticketmaster ${up.statusCode}`));
}
resolve(data);
} catch (e) { reject(e); }
});
}).on('error', reject);
});
}
/** Anstehende Termine eines Künstlers, gecacht. Bei einem Fehler kommt der alte Stand zurück. */
async function termineFuer(name, apiKey) {
const key = norm(name);
if (!key) return [];
const c = _cache.get(key);
if (c && Date.now() - c.ts < TTL) return c.termine;
const ab = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const url = 'https://app.ticketmaster.com/discovery/v2/events.json' +
`?keyword=${encodeURIComponent(name)}&classificationName=music` +
`&startDateTime=${ab}&size=20&sort=date,asc&apikey=${apiKey}`;
try {
const data = await holen(url);
const roh = data?._embedded?.events || [];
const termine = roh.map(e => {
const ort = e._embedded?.venues?.[0] || {};
const start = e.dates?.start || {};
// Die ORTSZEIT nehmen, nicht die UTC-Zeit: ein Konzert um 20:00 sonst als 08:00Z angezeigt.
// Als naive lokale Zeichenkette ohne Zone weitergereicht, die Clients zeigen sie unverändert.
const zeit = (start.localDate
? `${start.localDate}T${start.localTime || '00:00:00'}`
: start.dateTime) || null;
const aus = e.dates?.status?.code === 'offsale' || e.dates?.status?.code === 'cancelled';
const attraktionen = (e._embedded?.attractions || []).map(a => a.name || '');
return {
eventId: e.id || '',
titel: e.name || '',
datum: zeit,
ort: ort.name || '',
stadt: ort.city?.name || '',
land: ort.country?.name || '',
url: e.url || '',
abgesagt: aus,
_namen: [e.name || '', ...attraktionen],
};
}).filter(t => t.datum);
// Gegenprobe zur Stichwortsuche: der Künstlername muss irgendwo im Veranstaltungs- oder
// Attraktionsnamen stehen. Ohne das liefert "MORE" oder "TPR" fremde Veranstaltungen.
const gesucht = key;
const gefiltert = termine.filter(t => t._namen.some(n => norm(n).includes(gesucht)));
const sauber = gefiltert.map(({ _namen, ...rest }) => rest);
_cache.set(key, { ts: Date.now(), termine: sauber });
return sauber;
} catch (e) {
// Bei einem Fehler den abgelaufenen Stand ausliefern statt einer leeren Liste — aber den
// Zeitstempel NICHT auffrischen, damit er beim nächsten Mal wieder frisch geholt wird.
if (c) return c.termine;
throw e;
}
}
async function inHaeppchen(items, groesse, fn) {
const raus = [];
for (let i = 0; i < items.length; i += groesse) {
raus.push(...await Promise.all(items.slice(i, i + groesse).map(fn)));
}
return raus;
}
/**
* @param {Array<{id,name}>} kuenstler Navidrome-Künstler
* @param {string} apiKey Ticketmaster-Schlüssel
* @param {number} tage Zeitfenster nach vorn; 0 = kein Fenster (Vorgabe)
* @returns {{konzerte:Array, ohneTermine:Array<string>}}
*/
async function kommendeKonzerte(kuenstler, apiKey, tage = 0) {
// Kein Fenster als Vorgabe: Ticketmaster liefert ohnehin nur Anstehendes, und bei Konzerten
// will man ALLES sehen, was angekuendigt ist — eine Tagesgrenze schneidet nur willkuerlich ab.
const grenze = tage > 0 ? Date.now() + tage * 24 * 60 * 60 * 1000 : Infinity;
const ohneTermine = [];
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
let termine = [];
try { termine = await termineFuer(k.name, apiKey); } catch { return []; }
if (!termine.length) { ohneTermine.push(k.name); return []; }
return termine
.filter(t => new Date(t.datum).getTime() <= grenze)
.map(t => ({ artistId: k.id, artistName: k.name, ...t }));
});
// Nach vorn sortiert: das naechste Konzert zuerst — anders als bei den Veroeffentlichungen,
// die rueckwaerts laufen.
const konzerte = proKuenstler.flat()
.sort((a, b) => new Date(a.datum) - new Date(b.datum));
return { konzerte, ohneTermine };
}
module.exports = { kommendeKonzerte, termineFuer, _cache };
+88 -3
View File
@@ -6,6 +6,19 @@ function sanitize(s) {
return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100); return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100);
} }
// Welcher Kuenstler in den DATEINAMEN gehoert. Bisher stand dort die vollstaendige Gaesteliste
// ("K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine,League of Legends"). Das ist nirgends
// ueblich — Picard, beets und Lidarr schreiben hoechstens einen Kuenstler in den Namen —, und es
// hatte zwei handfeste Nachteile: sanitize() schneidet bei 100 Zeichen hart ab, also mitten im
// letzten Namen, und mit dem Dateisystemlimit von 255 Byte wird es bei mehrbytigen Namen eng.
// Die vollstaendige Liste steht ohnehin im artist-Tag, und nur den liest Navidrome.
// Genommen wird der ERSTE Kuenstler — bei gemischten Alben der des Titels, nicht der des Albums,
// damit ein Soundtrack-Titel im Namen weiterhin seinen eigenen Interpreten traegt.
function dateinamensKuenstler(artist, fallback) {
const erster = (s) => String(s || '').split(/[;,]|\s+&\s+/)[0].trim();
return erster(artist) || erster(fallback) || '';
}
function navidromeArtist(name) { function navidromeArtist(name) {
if (!name) return name; if (!name) return name;
return name return name
@@ -61,14 +74,21 @@ const _VERSION_KW = /\b(?:remix|live|acoustic|instrumental|version|vip|flip|boot
// release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and // release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
// "recognize - Single.mp3" vs "recognize.mp3" (drift between runs). // "recognize - Single.mp3" vs "recognize.mp3" (drift between runs).
function findExistingTrack(musicRoot, firstArtist, cleanTitle) { function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
const norm = s => (s || '').toLowerCase() const norm = s => (s || '')
// Akzente abtrennen und verwerfen: dieselbe Kuenstlerin kommt je nach Quelle als
// "Cécile Corbel" oder "Cecile Corbel" — ohne das galt sie als zwei Personen und derselbe
// Song wurde ein zweites Mal geladen. Betrifft nur den VERGLEICH, nicht Tags oder Dateinamen.
.normalize('NFD').replace(/\p{M}+/gu, '')
.toLowerCase()
// Strip bracketed noise (feat. X, Official Video, Prod. by …) but KEEP version markers // Strip bracketed noise (feat. X, Official Video, Prod. by …) but KEEP version markers
// (Remix/Live/Acoustic/…) so distinct recordings aren't collapsed onto the base track. // (Remix/Live/Acoustic/…) so distinct recordings aren't collapsed onto the base track.
.replace(/[\[((]([^\]))]*)[\]))]/g, (m, inner) => _VERSION_KW.test(inner) ? m : '') .replace(/[\[((]([^\]))]*)[\]))]/g, (m, inner) => _VERSION_KW.test(inner) ? m : '')
// Filesystem-sanitized chars (/ \ : * ? " < > |) become "_" on disk — map those AND "_" // Filesystem-sanitized chars (/ \ : * ? " < > |) become "_" on disk — map those AND "_"
// to a space so a title tag "Kwik Flip | YTPMV" matches the file "Kwik Flip _ YTPMV". // to a space so a title tag "Kwik Flip | YTPMV" matches the file "Kwik Flip _ YTPMV".
.replace(/[/\\:*?"<>|_]/g, ' ') .replace(/[/\\:*?"<>|_]/g, ' ')
.replace(/[,.'!]/g, '') // Auch die typografischen Apostrophe: Anbieter schreiben "Arrietty’s Song" (U+2019), eine
// spaetere Anfrage kommt mit geradem "'" — ohne das galten beide als verschiedene Titel.
.replace(/[,.!'\u2018\u2019\u02bc\u00b4`]/g, '')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(); .trim();
const normArtist = norm(firstArtist); const normArtist = norm(firstArtist);
@@ -111,6 +131,70 @@ function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
return null; return null;
} }
const AUDIO_EXT_RE = /\.(mp3|flac|opus|m4a|ogg|wav)$/i;
// Vereinheitlicht einen Dateinamen-Stamm fuer den lockeren Vergleich:
// 1. fuehrende Track-Praefixe wie "01-15 – " oder "01 - " abschneiden
// 2. alle Strichvarianten (en/em dash) auf " - " bringen
function normStem(s) {
return s
.replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '')
.replace(/\s*[–—]\s*/g, ' - ');
}
// Tiefensuche nach einer Audiodatei, deren Stamm zum Titel passt. Letzter Ausweg, wenn der von
// Navidrome gemeldete Pfad nicht existiert (Ordner umbenannt, Tag-Pfad statt echtem Pfad).
//
// WICHTIG: der Titel muss ein VOLLSTAENDIGES Feld des Dateinamens sein, nie nur dessen Ende.
// Ein blosses endsWith(titel) liess "Bad Omens - Like A Villain" als Treffer fuer den Titel
// "VILLAIN" durchgehen — ein voellig anderer Song derselben Endsilbe. Genau solche Fehlgriffe
// landen sonst im Metadaten-Editor oder, schlimmer, im Loeschen-Endpunkt.
function findFileByStem(dir, titleStem, depth = 0) {
if (depth > 4) return null;
const normTitle = normStem(titleStem);
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const found = findFileByStem(path.join(dir, entry.name), titleStem, depth + 1);
if (found) return found;
} else if (AUDIO_EXT_RE.test(entry.name)) {
const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '');
const bareStem = normStem(bare.toLowerCase());
// Treffer: exakt, "Kuenstler - Titel", oder Release-Suffix-Versatz ("… - Single").
if (bareStem === normTitle ||
bareStem.endsWith(' - ' + normTitle) ||
bareStem.startsWith(normTitle + ' - ') ||
normTitle.startsWith(bareStem + ' - ')) {
return path.join(dir, entry.name);
}
}
}
} catch {}
return null;
}
// Navidrome gibt ueber die Subsonic-API standardmaessig keinen echten Pfad heraus, sondern baut
// einen aus den Tags: {AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{Endung}. Steht ein "/" IM Namen
// — "K/DA", "POP/STARS" —, wird daraus eine zusaetzliche Verzeichnisebene, waehrend auf der Platte
// sanitize() ein "_" geschrieben hat ("K_DA/POP_STARS"). Der gemeldete Pfad existiert dann nicht,
// und die Suche faellt auf Aehnlichkeit zurueck. Diese Funktion baut den Ordner stattdessen genau
// so zusammen, wie der Downloader ihn angelegt haette.
function albumDirFromTags(musicRoot, albumArtist, album) {
if (!musicRoot || !albumArtist) return null;
// Der Downloader legt den Ordner unter dem ERSTEN Kuenstler an (mainArtist.split(/[,;&]/)[0]).
const ersterKuenstler = String(albumArtist).split(/[,;&]/)[0].trim();
const kandidaten = [];
for (const k of new Set([ersterKuenstler, String(albumArtist).trim()])) {
if (!k) continue;
if (album) kandidaten.push(path.join(musicRoot, sanitize(k), sanitize(album)));
kandidaten.push(path.join(musicRoot, sanitize(k)));
}
for (const c of kandidaten) {
try { if (fs.statSync(c).isDirectory()) return c; } catch {}
}
return null;
}
// Fetch plain + synced lyrics from LRCLIB // Fetch plain + synced lyrics from LRCLIB
async function fetchLyrics(artist, title, album) { async function fetchLyrics(artist, title, album) {
try { try {
@@ -131,6 +215,7 @@ async function fetchLyrics(artist, title, album) {
// fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums. // fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums.
module.exports = { module.exports = {
sanitize, navidromeArtist, sanitize, navidromeArtist, dateinamensKuenstler,
downloadArtwork, findExistingTrack, downloadArtwork, findExistingTrack,
normStem, findFileByStem, albumDirFromTags,
}; };
+95
View File
@@ -0,0 +1,95 @@
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ───────────────────────────────
// Liefert zu einer Liste von Navidrome-Künstlern die jüngsten Deezer-Releases, damit die App eine
// Zeitleiste "Neu von deinen Künstlern" zeigen kann.
//
// Warum serverseitig: bei 30 gefolgten Künstlern wären das 30 Deezer-Aufrufe — pro Gerät und pro
// App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam.
const { dzGet } = require('./deezer');
const _cache = new Map(); // deezerArtistId → { ts, alben }
const _idCache = new Map(); // nameLower → { ts, id }
const TTL = 6 * 60 * 60 * 1000; // 6 h — Alben erscheinen nicht minütlich
const ID_TTL = 24 * 60 * 60 * 1000; // 24 h — die Zuordnung Name → Deezer ändert sich kaum
const GLEICHZEITIG = 4; // höflich gegenüber Deezer, trotzdem zügig
const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || '';
// Künstler stilisieren ihren Namen mit führenden Zeichen (".diedlonely", "†…") — ohne das Abschneiden
// findet Deezers Suche sie nicht. Gleiche Regel wie in deezer.js.
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
/** Deezer-ID zu einem Künstler: erst die gepinnte, sonst über den Namen gesucht (und gemerkt). */
async function deezerIdFuer(name, pinId) {
if (pinId) return String(pinId);
const key = stripLeadSym(name).toLowerCase();
if (!key) return null;
const c = _idCache.get(key);
if (c && Date.now() - c.ts < ID_TTL) return c.id;
try {
const treffer = (await dzGet(`search/artist?q=${encodeURIComponent(stripLeadSym(name))}&limit=5`)).data || [];
// Deezer sortiert nicht nach Bekanntheit — die Fan-Zahl trifft den gemeinten Künstler deutlich
// öfter als der erste Treffer. Ein Pin schlägt das trotzdem, siehe oben.
const best = treffer.slice().sort((a, b) => (b.nb_fan || 0) - (a.nb_fan || 0))[0];
const id = best ? String(best.id) : null;
_idCache.set(key, { ts: Date.now(), id });
return id;
} catch { return null; }
}
/** Albumliste eines Deezer-Künstlers, gecacht. */
async function albenFuer(deezerId) {
const c = _cache.get(deezerId);
if (c && Date.now() - c.ts < TTL) return c.alben;
try {
const alben = (await dzGet(`artist/${deezerId}/albums?limit=50`)).data || [];
_cache.set(deezerId, { ts: Date.now(), alben });
return alben;
} catch {
// Bei einem Fehler lieber den alten Stand als gar nichts — abgelaufen, aber nicht falsch.
return c ? c.alben : [];
}
}
/** Führt eine Aufgabenliste mit begrenzter Gleichzeitigkeit aus. */
async function inHaeppchen(items, groesse, fn) {
const raus = [];
for (let i = 0; i < items.length; i += groesse) {
raus.push(...await Promise.all(items.slice(i, i + groesse).map(fn)));
}
return raus;
}
/**
* @param {Array<{id,name,deezerArtistId?}>} kuenstler Navidrome-Künstler (id + name)
* @param {number} tage Zeitfenster
* @returns {{releases:Array, ohneZuordnung:Array<string>}}
*/
async function neueReleases(kuenstler, tage = 90) {
const grenze = Date.now() - tage * 24 * 60 * 60 * 1000;
const ohneZuordnung = [];
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
const dzId = await deezerIdFuer(k.name, k.deezerArtistId);
if (!dzId) { ohneZuordnung.push(k.name); return []; }
const alben = await albenFuer(dzId);
return alben
.filter(a => a.release_date && new Date(a.release_date).getTime() >= grenze)
.map(a => ({
artistId: k.id,
artistName: k.name,
deezerArtistId: dzId,
albumId: String(a.id),
title: a.title || '',
cover: coverXl(a),
releaseDate: a.release_date,
recordType: a.record_type || 'album',
trackCount: a.nb_tracks ?? null,
explicit: !!a.explicit_lyrics,
}));
});
const releases = proKuenstler.flat()
.sort((a, b) => new Date(b.releaseDate) - new Date(a.releaseDate));
return { releases, ohneZuordnung };
}
module.exports = { neueReleases, deezerIdFuer, albenFuer, _cache, _idCache };
+1 -1
View File
@@ -115,7 +115,7 @@ const ROMAJI_TTL = 20 * 60 * 1000; // 20 min
async function _fetchNdSongs(ndUrl, user, pass) { async function _fetchNdSongs(ndUrl, user, pass) {
const salt = Math.random().toString(36).slice(2, 10); const salt = Math.random().toString(36).slice(2, 10);
const token = createHash('md5').update(pass + salt).digest('hex'); const token = createHash('md5').update(pass + salt).digest('hex');
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json&albumCount=0&artistCount=0`; const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json&albumCount=0&artistCount=0`;
const songs = []; const songs = [];
let offset = 0; let offset = 0;
while (true) { while (true) {
+140 -54
View File
@@ -4,7 +4,7 @@ const fs = require('fs');
const CONFIG = require('./config'); const CONFIG = require('./config');
const { log } = require('./jobs'); const { log } = require('./jobs');
const { const {
sanitize, navidromeArtist, sanitize, navidromeArtist, dateinamensKuenstler,
downloadArtwork, findExistingTrack, downloadArtwork, findExistingTrack,
} = require('./metadata'); } = require('./metadata');
const { romajiForStorage, isTokenizerReady } = require('./romaji'); const { romajiForStorage, isTokenizerReady } = require('./romaji');
@@ -12,11 +12,37 @@ const { romajiForStorage, isTokenizerReady } = require('./romaji');
// Matches hiragana, katakana, CJK — used to detect Japanese content // Matches hiragana, katakana, CJK — used to detect Japanese content
const JP_RE = /[ぁ-んァ-ン一-龯]/; const JP_RE = /[ぁ-んァ-ン一-龯]/;
function buildTitleKeywords(title) { function buildTitleKeywords(title, romaji = '') {
// For Japanese titles: Art Tracks on YTM/YouTube are often romanized // Japanische Titel haben keine Wortgrenzen, die Zwei-laengste-Woerter-Regel greift also nicht.
// ("Souiunatsu" for "そういう夏") — a Japanese keyword filter would always // Frueher lieferte diese Funktion dafuer eine LEERE Liste — und damit hatte der Filter bei einem
// reject the correct video. Channel + duration are the quality gates instead. // japanischen Titel gar keine Titelpruefung mehr. Zusammen mit den Kaskadenschritten ohne
if (JP_RE.test(title)) return []; // Kuenstlerpruefung blieb allein die Dauer uebrig, und bei einem Soundtrack-Album passt fast
// jeder andere Titel desselben Komponisten in dasselbe Dauerfenster. Genau so kam am 24.9.2026
// ein falsches Stueck aus "HUNTER×HUNTER" herein.
//
// Stattdessen wird jetzt eine Alternative gefordert: der ORIGINALTITEL oder seine Romaji-Fassung.
// Art Tracks tragen ueblicherweise das eine oder das andere. Leerzeichen und Bindestriche der
// Romaji-Fassung bleiben dabei frei, weil Uploader sie unterschiedlich setzen
// ("Ginpatsu no Shounen" / "Ginpatsu-no-Shounen").
if (JP_RE.test(title)) {
const escRe = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const alternativen = [escRe(title.trim())];
const r = (romaji || '').trim();
if (r && r.toLowerCase() !== title.trim().toLowerCase() && !JP_RE.test(r)) {
// Die Umschrift des Tokenizers ist EINE Schreibweise von mehreren. Uploader schreiben
// "Shonen" statt "Shounen", setzen Bindestriche oder lassen den Katakana-Laengungsstrich
// weg. Das Muster laesst diese Abweichungen zu — sonst waere die Alternative so streng,
// dass sie praktisch nie greift.
alternativen.push(
escRe(r)
.replace(/ー/g, '') // kommt in lateinischen Titeln nie vor
.replace(/\s+/g, '[ -]?')
.replace(/ou/gi, '(?:ou|o|\u014d|oh)')
.replace(/uu/gi, '(?:uu|u|\u016b)')
);
}
return [`(?:${alternativen.join('|')})`];
}
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']); const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with','single','ep','album','remaster','remastered','deluxe','edition','version','live','bonus']); const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with','single','ep','album','remaster','remastered','deluxe','edition','version','live','bonus']);
const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit']; const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
@@ -55,6 +81,28 @@ function buildTitleKeywords(title) {
return result; return result;
} }
// Aus der YTM-Suchausgabe (--flat-playlist --print webpage_url) die Song-URLs herausziehen.
// Eine Suche liefert drei Sorten Zeilen: watch?v=… (ein Song), browse/MPREb_… (ein Album) und
// browse/UC… (ein Kuenstlerkanal). Nur die erste ist ein Kandidat; die anderen beiden sind
// Listeneintraege und werden uebersprungen.
//
// Frueher galt eine browse/UC-Zeile als "yt-dlp hat einen Kanal aufgeklappt" und die GESAMTE
// Trefferliste wurde verworfen — auch die bereits gesammelten Songs davor. Bei "DAT ADAM UFO" stand
// der Kanal auf Platz 9 und riss sieben gute Treffer mit, darunter den offiziellen Art Track auf
// Platz 1; uebrig blieb der YouTube-Fallback, der einen Live-Mitschnitt zog. Aufklappen kann hier
// ohnehin nicht passieren: mit --flat-playlist druckt yt-dlp einen Kanal als EINE Zeile.
function pickYtmVideoUrls(stdout) {
const urls = [];
let skipped = 0;
for (const line of (stdout || '').split('\n')) {
const u = line.trim();
if (!u) continue;
if (u.includes('watch?v=')) urls.push(u);
else if (u.includes('/browse/')) skipped++;
}
return { urls, skipped };
}
function buildVariantFilter(title) { function buildVariantFilter(title) {
const t = (title || '').toLowerCase(); const t = (title || '').toLowerCase();
// !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that // !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that
@@ -118,11 +166,22 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
const safeTitle = sanitize(cleanTitle); const safeTitle = sanitize(cleanTitle);
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
// Filename format: "{01 - }Artist1, Artist2 - Title.ext" // Filename format: "{01 - }Artist - Title.ext" — nur der erste Kuenstler, siehe
const safeArtistForFile = sanitize((artist || '').replace(/;/g, ',')); // dateinamensKuenstler() in lib/metadata.js. Die volle Liste steht im artist-Tag.
const safeArtistForFile = sanitize(dateinamensKuenstler(artist, mainArtist));
const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`; const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle); // Dublettenpruefung: findExistingTrack durchsucht NUR den Ordner des uebergebenen Kuenstlers.
// Bei Soundtracks und Compilations weicht der Album-Kuenstler vom Titel-Kuenstler ab, und je
// nachdem, ob der Song als Album oder einzeln geladen wird, landet er unter dem einen ODER dem
// anderen. Die Pruefung sah dann jeweils im falschen Ordner nach und lud ihn erneut. Deshalb wird
// bei abweichendem Titel-Kuenstler auch dessen Ordner geprueft — zweiter Durchlauf nur in genau
// diesem Fall, gemessen ~0,6 ms zusaetzlich bei knapp 29.000 Dateien.
const trackArtist = sanitize((artist || '').split(/[,;&]/)[0].trim());
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle)
|| (trackArtist && trackArtist !== safeArtist
? findExistingTrack(outputDir || CONFIG.musicPath, trackArtist, cleanTitle)
: null);
if (existing) { if (existing) {
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`); log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
return { success: true, dir, skipped: true }; return { success: true, dir, skipped: true };
@@ -140,7 +199,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
const searchTitle = cleanTitle; const searchTitle = cleanTitle;
const variantFilter = buildVariantFilter(searchTitle); const variantFilter = buildVariantFilter(searchTitle);
const titleKws = buildTitleKeywords(searchTitle); // Romaji nur, wenn der Tokenizer bereit ist — sonst liefert romajiForStorage den Text
// unveraendert zurueck und die Alternative waere doppelt.
const titleRomaji = isTokenizerReady() ? romajiForStorage(searchTitle) : '';
const titleKws = buildTitleKeywords(searchTitle, titleRomaji);
const variantPart = variantFilter ? ` & ${variantFilter}` : ''; const variantPart = variantFilter ? ` & ${variantFilter}` : '';
const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : ''; const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : '';
@@ -155,8 +217,24 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
// audio — even when the channel doesn't contain the artist name (BANGTANTV/HYBE/…). Verified with // audio — even when the channel doesn't contain the artist name (BANGTANTV/HYBE/…). Verified with
// "BTS Pied Piper": ONLY the official upload had artist=BTS; every fan/live/lyric reupload had // "BTS Pied Piper": ONLY the official upload had artist=BTS; every fan/live/lyric reupload had
// none. Used as an OR alternative to the channel check so those official uploads still pass strict. // none. Used as an OR alternative to the channel check so those official uploads still pass strict.
const artistMetaPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord)) // Fuer japanische Namen wird der KANAL nicht geprueft (Kanal-Namen stehen kaum je in Kanji),
// das Feld `artist` der Art Tracks dagegen sehr wohl — dort steht der Originalname. Ohne diese
// Ausnahme hatte ein japanischer Kuenstler ueberhaupt keine Identitaetspruefung.
const artistMetaPart = (artistFirstWord.length >= 3 || (JP_RE.test(artistFirstWord) && artistFirstWord.length >= 2))
? ` & artist ~= (?i)${escRe(artistFirstWord)}` : ''; ? ` & artist ~= (?i)${escRe(artistFirstWord)}` : '';
// Kanji-Kuenstler: Kanaele und Videotitel schreiben den Namen meist lateinisch. Ohne diese
// Alternative bliebe fuer sie nur das artist-Feld in Originalschrift — und steht dort die
// lateinische Schreibweise, faellt der richtige Upload durch.
const artistRomajiWord = (JP_RE.test(artistFirstWord) && isTokenizerReady())
? ((romajiForStorage(artistFirstWord) || '').split(/\s+/)
.filter(w => w.length >= 3 && !JP_RE.test(w))
.sort((a, b) => b.length - a.length)[0] || '')
: '';
const artistRomajiParts = artistRomajiWord
? [` & channel ~= (?i)${escRe(artistRomajiWord)}`, ` & artist ~= (?i)${escRe(artistRomajiWord)}`]
: [];
// Alle Wege, den Kuenstler zu erkennen. yt-dlp verodert mehrere --match-filter.
const identitaet = [artistChannelPart, artistMetaPart, ...artistRomajiParts].filter(Boolean);
// Use a space (not " - ") between artist and title: this matches how users type // Use a space (not " - ") between artist and title: this matches how users type
// in the YTM search box and ranks the exact official track first. A dash skews // in the YTM search box and ranks the exact official track first. A dash skews
@@ -231,14 +309,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel // actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
// result and treating its uploads as the search result set. // result and treating its uploads as the search result set.
// //
// When YTM returns an artist channel as the top hit, yt-dlp prints the channel's
// browse URL (/browse/UC…) instead of video URLs. We detect this on stdout and
// return [] so the caller falls through to the ytsearch fallback.
// NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search // NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search
// page processing, not just channel expansion, and would wrongly return [] always. // page processing, not just channel expansion, and would wrongly return [] always.
const getYtmVideoUrls = (maxItems) => new Promise(resolve => { const getYtmVideoUrls = (maxItems) => new Promise(resolve => {
const urls = []; let out = '';
let channelExpanded = false;
const proc = spawn(CONFIG.ytdlpBin, [ const proc = spawn(CONFIG.ytdlpBin, [
ytmUrl, ytmUrl,
'--flat-playlist', '--flat-playlist',
@@ -250,21 +324,13 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
// native Songs shelf (where the actual artist track lives). // native Songs shelf (where the actual artist track lives).
'--extractor-args', 'youtube:player_client=web_music,default', '--extractor-args', 'youtube:player_client=web_music,default',
], { env: { ...process.env, PYTHONUNBUFFERED: '1' } }); ], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
proc.stdout.on('data', d => { proc.stdout.on('data', d => { out += d.toString(); });
const text = d.toString();
if (text.includes('/browse/UC')) { channelExpanded = true; return; }
if (!channelExpanded) {
text.split('\n').forEach(line => {
const u = line.trim();
if (u.includes('watch?v=')) urls.push(u);
});
}
});
proc.on('error', () => resolve([])); proc.on('error', () => resolve([]));
proc.on('close', () => { proc.on('close', () => {
if (channelExpanded) log(jobId, `⚠️ YTM: Artist-Channel in Suchergebnissen — überspringe`); const { urls, skipped } = pickYtmVideoUrls(out);
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden`); if (!urls.length) log(jobId, `⚠️ YTM: keine Song-Treffer (${skipped} Listeneintrag/-eintraege)`);
resolve(channelExpanded ? [] : urls); else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden${skipped ? ` (${skipped} Album/Kanal uebersprungen)` : ''}`);
resolve(urls);
}); });
}); });
@@ -279,16 +345,24 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
durPart = ''; durPart = '';
} }
const base = `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`; const base = `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`;
// Non-strict, or no artist constraint (short / Japanese name): single filter. // Ohne Titelpruefung bliebe ohne Kuenstlerpruefung nur die Dauer — das ist keine Pruefung,
if (!strict || !artistChannelPart) return ['--match-filter', base]; // sondern ein Muenzwurf. Solche Schritte laufen deshalb IMMER mit Kuenstlerpruefung, auch
// Strict: pass if the channel name matches the artist OR yt-dlp's music `artist` metadata does // wenn die Kaskade sie eigentlich lockern wollte.
// (catches official label-channel uploads like BANGTANTV). Two --match-filter args = OR in yt-dlp. const mitKuenstler = strict || !titleKwPart;
return [ if (!mitKuenstler || !identitaet.length) return ['--match-filter', base];
'--match-filter', `${base}${artistChannelPart}`, // Mehrere --match-filter verodert yt-dlp: Kanalname ODER `artist`-Metadatum (faengt offizielle
'--match-filter', `${base}${artistMetaPart}`, // Label-Uploads wie BANGTANTV, deren Kanal den Kuenstler nicht im Namen traegt).
]; return identitaet.flatMap(teil => ['--match-filter', `${base}${teil}`]);
}; };
// Weder Titel- noch Kuenstlermerkmal pruefbar: dann gaebe es nichts, woran sich der richtige
// Treffer erkennen liesse, und der erste Suchtreffer passender Laenge wuerde gespeichert.
// Lieber sauber scheitern — der Direktlink im Importer ist dafuer der vorgesehene Weg.
if (!titleKwPart && !identitaet.length) {
log(jobId, '⚠️ Weder Titel- noch Künstlermerkmal prüfbar — bitte Direktlink im Importer nutzen');
return { success: false, notFound: true, dir };
}
const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1']; const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1'];
const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1']; const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1'];
const PI_UNFILTERED = ['--playlist-items', '1']; const PI_UNFILTERED = ['--playlist-items', '1'];
@@ -316,29 +390,41 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…'); log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]); await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]);
} }
// Findet sich im Dauerfenster nichts, wird ZUERST die DAUER gelockert und der Kuenstler
// BEHALTEN — nicht umgekehrt. Der Kuenstler ist das staerkere Merkmal: die erwartete Dauer
// stammt aus den Metadaten und gehoert oft zu einer anderen Fassung, waehrend Kanalname und
// artist-Feld den Song tatsaechlich benennen.
//
// Vorher lief es andersherum, und genau daran ist am 24.9.2026 "Dearly Beloved" von TPR
// gescheitert: erwartet wurden 230 s (Deezer kennt TPR nicht und lieferte "Dearly Beloved I"
// von Project Destati), der richtige Upload dauert 136 s und fiel aus dem Fenster — waehrend
// eine Rockband namens DEARLY BELOVED mit ihrem Stueck "LSD" bei 250 s mitten hinein passte.
// Deren BANDNAME erfuellte die Titelpruefung. Der Schritt ohne Kanalpruefung griff zuerst zu.
//
// Es faellt KEIN Schritt weg, nur die Reihenfolge dreht sich: was vorher gefunden wurde, wird
// weiter gefunden, nur gegebenenfalls eine Stufe spaeter (geprueft, siehe scripts/filter-probe.mjs).
if (!hasAudio()) { if (!hasAudio()) {
// 3. YTM all-10 — no channel check (label channels, etc.) // 3. YTM all — ohne Dauer, aber Kuenstler + Titel verlangt
log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…');
await tryYtm(0, mf(dur, true));
}
if (!hasAudio()) {
// 4. YT top-10 — no channel check
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
}
// No duration match. "Mittelweg": relax the duration constraint but REQUIRE the artist
// channel (+ title). This catches legit tracks whose YT upload duration drifts from the
// iTunes value, while never grabbing a same-titled video by someone else. If nothing
// matches title+channel we download NOTHING and the job fails cleanly (see end of fn) —
// far better than silently saving the wrong song (e.g. a "recognize" fan clip).
if (!hasAudio()) {
// 5. YTM all — no duration, but channel + title required
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…'); log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…');
await tryYtm(0, mf(null, true, true)); await tryYtm(0, mf(null, true, true));
} }
if (!hasAudio()) { if (!hasAudio()) {
// 6. YT top-10 — no duration, but channel + title required // 4. YT top-10 — ohne Dauer, aber Kuenstler + Titel verlangt
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(null, true, true), ...baseArgs]); await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(null, true, true), ...baseArgs]);
} }
// Letzter Ausweg: Kanalpruefung fallen lassen, dafuer die Dauer wieder verlangen. Das faengt
// offizielle Uploads von Label-Kanaelen, die den Kuenstler weder im Kanalnamen noch im
// artist-Feld tragen. Bleibt auch das ohne Treffer, wird NICHTS geladen und der Auftrag
// scheitert sauber — besser als still den falschen Song zu speichern.
if (!hasAudio()) {
// 5. YTM all-10 — ohne Kanalpruefung, dafuer im Dauerfenster
log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…');
await tryYtm(0, mf(dur, true));
}
if (!hasAudio()) {
// 6. YT top-10 — ohne Kanalpruefung, dafuer im Dauerfenster
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
}
} else { } else {
log(jobId, `🔍 Suche YT Music: "${query}"`); log(jobId, `🔍 Suche YT Music: "${query}"`);
// 1. YTM top-5 — strict: title + variant + channel check // 1. YTM top-5 — strict: title + variant + channel check
@@ -453,4 +539,4 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
return { success: true, dir }; return { success: true, dir };
} }
module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter }; module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter, pickYtmVideoUrls };
+306 -71
View File
@@ -18,7 +18,7 @@ const crypto = require('crypto');
const CONFIG = require('./lib/config'); const CONFIG = require('./lib/config');
const { jobs, updateJob, log } = require('./lib/jobs'); const { jobs, updateJob, log } = require('./lib/jobs');
const { sanitize, findExistingTrack } = require('./lib/metadata'); const { sanitize, findExistingTrack, findFileByStem, albumDirFromTags, dateinamensKuenstler } = require('./lib/metadata');
const dz = require('./lib/deezer'); const dz = require('./lib/deezer');
const tm = require('./lib/trackmeta'); // Deezer → MusicBrainz → clean fallback (single tracks) const tm = require('./lib/trackmeta'); // Deezer → MusicBrainz → clean fallback (single tracks)
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader'); const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
@@ -94,57 +94,17 @@ function _findFileInDir(dir, basename) {
return null; return null;
} }
// Last-resort: deep recursive search by title stem (handles folder renames after metadata edit). // Die Tiefensuche nach Titelstamm liegt jetzt in lib/metadata.js (dort getestet) — sie ist der
// Convention: MusicRoot/AlbumArtist/Album/{01 - }Artist - Title.ext // letzte Ausweg, wenn Navidromes Pfad nicht existiert, und darf deshalb nicht raten.
// The title stem is everything after the optional "NN - Artist - " prefix. // Wie findFileByStem, versucht zusaetzlich einen Romaji-Stamm.
// We match any audio file whose stem ends with the same title words.
// Normalize a file stem for loose matching:
// 1. Strip compound track prefixes like "01-15 – " or "01 - " (handles en-dash, em-dash, hyphen)
// 2. Normalize all dash variants (en-dash, em-dash) to " - "
function _normStem(s) {
return s
.replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '') // strip leading "01-15 – " or "01 - "
.replace(/\s*[–—]\s*/g, ' - '); // normalize remaining en/em-dash to " - "
}
function _findFileByStem(dir, titleStem, depth = 0) {
if (depth > 4) return null;
const normTitle = _normStem(titleStem);
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const found = _findFileByStem(path.join(dir, entry.name), titleStem, depth + 1);
if (found) return found;
} else if (AUDIO_EXT_RE.test(entry.name)) {
// Strip extension + leading track prefix, then normalize dashes
const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '');
const bareStem = _normStem(bare.toLowerCase());
// Match: exact, or "Artist - Title" ending with our title stem
if (bareStem === normTitle ||
bareStem.endsWith(' - ' + normTitle) ||
bareStem.endsWith(normTitle) ||
// Release-suffix desync: Navidrome's title-derived stem ("… - recognize")
// may lack the "- Single"/"- EP" suffix the on-disk filename still carries
// ("… - recognize - single"), or vice-versa. Match on the structured prefix.
bareStem.startsWith(normTitle + ' - ') ||
normTitle.startsWith(bareStem + ' - ')) {
return path.join(dir, entry.name);
}
}
}
} catch {}
return null;
}
// Like _findFileByStem but also tries a romaji-converted stem.
// Handles the case where Navidrome has a Japanese path in its DB but the file // Handles the case where Navidrome has a Japanese path in its DB but the file
// on disk was stored with a romaji filename by the download pipeline. // on disk was stored with a romaji filename by the download pipeline.
function _findFileByStemOrRomaji(dir, stem) { function _findFileByStemOrRomaji(dir, stem) {
const direct = _findFileByStem(dir, stem); const direct = findFileByStem(dir, stem);
if (direct) return direct; if (direct) return direct;
try { try {
const rt = (romajiForStorage(stem) || '').toLowerCase().trim(); const rt = (romajiForStorage(stem) || '').toLowerCase().trim();
if (rt && rt !== stem) return _findFileByStem(dir, rt); if (rt && rt !== stem) return findFileByStem(dir, rt);
} catch {} } catch {}
return null; return null;
} }
@@ -266,6 +226,65 @@ app.post('/api/artist-overrides', (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ─────────────────────────────
// POST /api/artist-releases { artists: [{id, name}], days }
// Die Deezer-Zuordnung passiert HIER, weil die Pins ohnehin serverseitig liegen — der Client
// schickt also nur seine Navidrome-Künstler und muss nichts über Deezer wissen.
app.post('/api/artist-releases', async (req, res) => {
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
const days = Math.min(Math.max(parseInt(req.body?.days, 10) || 90, 1), 365);
if (!artists.length) return res.json({ releases: [], ohneZuordnung: [] });
// Obergrenze, damit ein kaputter Client nicht hunderte Deezer-Aufrufe auslöst.
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
const pins = _loadArtistOverrides();
const mitPin = artists
.filter(a => a && a.id && a.name)
.map(a => ({
id: String(a.id),
name: String(a.name),
deezerArtistId: pins[String(a.id)]?.deezerArtistId || null,
}));
try {
const { neueReleases } = require('./lib/releases');
const ergebnis = await neueReleases(mitPin, days);
res.json(ergebnis);
} catch (e) {
console.error('[releases]', e.message);
res.status(502).json({ error: e.message });
}
});
// POST /api/artist-concerts { artists: [{id, name}], days }
// Gegenstueck zu /api/artist-releases: anstehende Termine aller gefolgten Kuenstler.
// Ohne Ortsfilter — Ticketmaster sucht weltweit. Das ist bewusst so (siehe CHANGELOG-Backlog):
// eine Einschraenkung per .env kommt spaeter, fuers Erste reicht, was die API liefert.
app.post('/api/artist-concerts', async (req, res) => {
const key = CONFIG.ticketmasterKey;
if (!key) return res.json({ konzerte: [], ohneTermine: [], keinSchluessel: true });
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
// Ohne `days` gibt es KEIN Fenster — Konzerte sind ohnehin nur Zukunft, und dort will man
// alles Angekuendigte sehen. Ein mitgeschicktes Fenster wird weiterhin beachtet.
const roh = parseInt(req.body?.days, 10);
const days = Number.isFinite(roh) && roh > 0 ? Math.min(roh, 3650) : 0;
if (!artists.length) return res.json({ konzerte: [], ohneTermine: [] });
// Obergrenze, damit ein kaputter Client nicht hunderte Ticketmaster-Aufrufe ausloest.
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
const sauber = artists.filter(a => a && a.id && a.name)
.map(a => ({ id: String(a.id), name: String(a.name) }));
try {
const { kommendeKonzerte } = require('./lib/konzerte');
res.json(await kommendeKonzerte(sauber, key, days));
} catch (e) {
console.error('[concerts]', e.message);
res.status(502).json({ error: e.message });
}
});
// DELETE /api/artist-overrides/:ndArtistId — remove a pin // DELETE /api/artist-overrides/:ndArtistId — remove a pin
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => { app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
const id = decodeURIComponent(req.params.ndArtistId); const id = decodeURIComponent(req.params.ndArtistId);
@@ -982,7 +1001,7 @@ app.post('/api/fix-artist-sort', async (req, res) => {
try { try {
const { s, t } = mkTok(); const { s, t } = mkTok();
const r = await fetch( const r = await fetch(
`${ndUrl}/rest/search3?query=${encodeURIComponent(artistKanji)}&artistCount=0&albumCount=0&songCount=500&u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`, `${ndUrl}/rest/search3?query=${encodeURIComponent(artistKanji)}&artistCount=0&albumCount=0&songCount=500&u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json`,
{ signal: AbortSignal.timeout(15000) } { signal: AbortSignal.timeout(15000) }
); );
const d = await r.json(); const d = await r.json();
@@ -1104,7 +1123,7 @@ app.post('/api/fix-artist-sort', async (req, res) => {
// Trigger Navidrome rescan // Trigger Navidrome rescan
try { try {
const { s, t } = mkTok(); const { s, t } = mkTok();
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) }); await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(5000) });
} catch {} } catch {}
const firstErr = errorDetails[0] ? ` (${errorDetails[0]})` : ''; const firstErr = errorDetails[0] ? ` (${errorDetails[0]})` : '';
@@ -1243,13 +1262,14 @@ app.get('/api/musicbrainz/lookup', async (req, res) => {
app.get('/api/song-sorttags', async (req, res) => { app.get('/api/song-sorttags', async (req, res) => {
const { ndUrl, user, pass, songId } = req.query; const { ndUrl, user, pass, songId } = req.query;
if (!ndUrl || !user || !pass || !songId) return res.status(400).json({ error: 'ndUrl, user, pass, songId required' }); if (!ndUrl || !user || !pass || !songId) return res.status(400).json({ error: 'ndUrl, user, pass, songId required' });
let filePath; let filePath, ndSong;
try { try {
const salt = Math.random().toString(36).slice(2, 10); const salt = Math.random().toString(36).slice(2, 10);
const token = crypto.createHash('md5').update(pass + salt).digest('hex'); const token = crypto.createHash('md5').update(pass + salt).digest('hex');
const ndRes = await fetch(`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(8000) }); const ndRes = await fetch(`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(8000) });
const nd = await ndRes.json(); const nd = await ndRes.json();
filePath = nd?.['subsonic-response']?.song?.path; ndSong = nd?.['subsonic-response']?.song;
filePath = ndSong?.path;
if (!filePath) return res.status(404).json({ error: 'Song not found' }); if (!filePath) return res.status(404).json({ error: 'Song not found' });
} catch (e) { return res.status(502).json({ error: e.message }); } } catch (e) { return res.status(502).json({ error: e.message }); }
if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath); if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath);
@@ -1258,9 +1278,11 @@ app.get('/api/song-sorttags', async (req, res) => {
const rebased = _rebasePath(filePath, CONFIG.musicPath); const rebased = _rebasePath(filePath, CONFIG.musicPath);
const rebasedDir = rebased ? path.dirname(rebased) : null; const rebasedDir = rebased ? path.dirname(rebased) : null;
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
const found = rebased const found = rebased
|| _findFileRecursive(CONFIG.musicPath, fileName) || _findFileRecursive(CONFIG.musicPath, fileName)
|| _findFileInDir(rebasedDir || path.dirname(filePath), fileName) || _findFileInDir(rebasedDir || path.dirname(filePath), fileName)
|| (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem); || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
if (found) filePath = found; else return res.status(404).json({ error: 'File not found' }); if (found) filePath = found; else return res.status(404).json({ error: 'File not found' });
} }
@@ -1290,7 +1312,7 @@ async function _ndApi(ndUrl, user, pass, method, extra = '') {
const s = Math.random().toString(36).slice(2, 10); const s = Math.random().toString(36).slice(2, 10);
const t = crypto.createHash('md5').update(pass + s).digest('hex'); const t = crypto.createHash('md5').update(pass + s).digest('hex');
const r = await fetch( const r = await fetch(
`${ndUrl}/rest/${method}?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json${extra}`, `${ndUrl}/rest/${method}?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json${extra}`,
{ signal: AbortSignal.timeout(8000) } { signal: AbortSignal.timeout(8000) }
); );
return r.json(); return r.json();
@@ -1342,6 +1364,43 @@ async function _migratePlaylists(ndUrl, user, pass, plIds, oldId, newId) {
} }
// ─── METADATA EDIT ─────────────────────────────────────────────────────────── // ─── METADATA EDIT ───────────────────────────────────────────────────────────
// Liest die vorhandenen Tags einer Datei. Wird gebraucht, um einen ueber Namensaehnlichkeit
// gefundenen Kandidaten zu PRUEFEN, bevor er ueberschrieben wird.
async function _readTags(file) {
return new Promise((resolve) => {
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format_tags', file]);
let buf = '';
proc.stdout.on('data', d => buf += d);
proc.on('error', () => resolve({}));
proc.on('close', () => { try { resolve(JSON.parse(buf)?.format?.tags || {}); } catch { resolve({}); } });
});
}
const _tagNorm = (v) => (v || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
// Steckbrief einer Datei fuer den Dublettenvergleich: Groesse, Laenge, Titel/Kuenstler.
// Zwei Kopien desselben Songs unterscheiden sich fast immer nur in der Groesse (andere Bitrate),
// nie nennenswert in der Laenge — deshalb ist die Laenge das aussagekraeftige Feld.
async function _dateiSteckbrief(file) {
let groesse = null;
try { groesse = fs.statSync(file).size; } catch {}
const dauer = await new Promise((resolve) => {
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format=duration', file]);
let buf = '';
proc.stdout.on('data', d => buf += d);
proc.on('error', () => resolve(null));
proc.on('close', () => {
try { resolve(Math.round(parseFloat(JSON.parse(buf)?.format?.duration)) || null); } catch { resolve(null); }
});
});
const tags = await _readTags(file);
return {
pfad: file, groesse, dauer,
titel: tags.title || tags.TITLE || null,
artist: tags.artist || tags.ARTIST || null,
album: tags.album || tags.ALBUM || null,
};
}
app.post('/api/edit-metadata', async (req, res) => { app.post('/api/edit-metadata', async (req, res) => {
const { ndUrl, user, pass, songId, title, artist, albumArtist, album, year, trackNum, totalTracks, const { ndUrl, user, pass, songId, title, artist, albumArtist, album, year, trackNum, totalTracks,
discNum, discTotal, genre, artworkBase64, artworkMime, artworkUrl: newArtworkUrl, discNum, discTotal, genre, artworkBase64, artworkMime, artworkUrl: newArtworkUrl,
@@ -1364,7 +1423,7 @@ app.post('/api/edit-metadata', async (req, res) => {
try { try {
const salt = Math.random().toString(36).slice(2, 10); const salt = Math.random().toString(36).slice(2, 10);
const token = crypto.createHash('md5').update(pass + salt).digest('hex'); const token = crypto.createHash('md5').update(pass + salt).digest('hex');
const ndApi = `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`; const ndApi = `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`;
const ndRes = await fetch(ndApi, { signal: AbortSignal.timeout(8000) }); const ndRes = await fetch(ndApi, { signal: AbortSignal.timeout(8000) });
const ndData = await ndRes.json(); const ndData = await ndRes.json();
ndSong = ndData?.['subsonic-response']?.song; ndSong = ndData?.['subsonic-response']?.song;
@@ -1393,16 +1452,45 @@ app.post('/api/edit-metadata', async (req, res) => {
// but Navidrome's index is stale and still points to the old name. // but Navidrome's index is stale and still points to the old name.
const originalDir = path.dirname(filePath); const originalDir = path.dirname(filePath);
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
// Schraegstrich im Kuenstler-/Albumnamen ("K/DA", "POP/STARS"): Navidromes Tag-Pfad macht
// daraus eine Verzeichnisebene, auf der Platte steht "_". Den Ordner deshalb aus den Tags
// nachbauen und DORT suchen — das ist gezielt und geht jeder Aehnlichkeitssuche vor.
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
const found = rebased const found = rebased
|| _findFileRecursive(CONFIG.musicPath, fileName) || _findFileRecursive(CONFIG.musicPath, fileName)
|| _findFileInDir(rebasedDir || originalDir, fileName) || _findFileInDir(rebasedDir || originalDir, fileName)
|| (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem) || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem)
// Last resort: Subsonic getSong returns a *virtual* (tag-built) path, so the folder // Last resort: Subsonic getSong returns a *virtual* (tag-built) path, so the folder
// can be "[Unknown Album]" while the file actually sits in "Singles" with an artist // can be "[Unknown Album]" while the file actually sits in "Singles" with an artist
// prefix. findExistingTrack matches by normalized artist+title across all album folders. // prefix. findExistingTrack matches by normalized artist+title across all album folders.
|| findExistingTrack(CONFIG.musicPath, ndSong.albumArtist || ndSong.artist || '', ndSong.title || ''); || findExistingTrack(CONFIG.musicPath, ndSong.albumArtist || ndSong.artist || '', ndSong.title || '');
if (found) { filePath = found; } if (!found) {
else return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` }); return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
}
// Der Treffer stammt aus einer Aehnlichkeitssuche ueber Dateiname bzw. Kuenstler+Titel. Liegt
// derselbe Song mehrfach auf der Platte — etwa ein Fehlgriff des Downloaders neben der richtigen
// Datei —, kann das die FALSCHE Kopie sein. Die wurde dann getaggt, waehrend der bearbeitete Song
// unveraendert blieb: die Aenderung "sprang zurueck". Deshalb wird der Kandidat gegen die Tags
// geprueft, die Navidrome fuer diesen Song meldet, und im Zweifel abgebrochen statt geraten.
const kandidatTags = await _readTags(found);
const istTitel = _tagNorm(kandidatTags.title || kandidatTags.TITLE);
const istArtist = _tagNorm(kandidatTags.artist || kandidatTags.ARTIST);
const sollTitel = _tagNorm(ndSong?.title);
const sollArtist = _tagNorm(ndSong?.artist);
const titelPasst = !sollTitel || !istTitel || istTitel === sollTitel;
const artistPasst = !sollArtist || !istArtist ||
istArtist.includes(sollArtist) || sollArtist.includes(istArtist);
if (!titelPasst || !artistPasst) {
return res.status(409).json({
error: `Datei nicht eindeutig zuzuordnen. Navidrome meldet "${ndSong?.artist || '?'} – ` +
`${ndSong?.title || '?'}", die gefundene Datei enthaelt aber "` +
`${kandidatTags.artist || '?'} – ${kandidatTags.title || '?'}" (${found}). ` +
`Vermutlich liegt derselbe Song mehrfach in der Bibliothek — bitte die Dubletten ` +
`aufraeumen, sonst wuerde die falsche Datei bearbeitet.`,
});
}
filePath = found;
} }
// Resolve artwork: base64 > URL // Resolve artwork: base64 > URL
@@ -1481,19 +1569,33 @@ app.post('/api/edit-metadata', async (req, res) => {
// ── Post-save: rename file and/or move to correct artist/album folder ── // ── Post-save: rename file and/or move to correct artist/album folder ──
let currentFile = filePath; let currentFile = filePath;
// Umbenennen und Verschieben werden uebersprungen, wenn am Ziel schon eine Datei liegt. Das war
// bisher stumm — der Song blieb dann unter dem alten Namen bzw. im alten Ordner liegen, ohne dass
// es jemand erfuhr. Solche Faelle kommen jetzt als Warnung mit in die Antwort.
const warnungen = [];
// Faellt beim Verschieben eine gleichnamige Datei am Ziel auf, ist das bereits der
// Dublettenfall. Statt nur zu melden, werden beide Dateien verglichen und die Angaben
// mitgeschickt, damit die Oberflaeche das Aufraeumen direkt anbieten kann.
let dublette = null;
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext // 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
// Use original names (may be Japanese); romaji goes only into sort tags. // Use original names (may be Japanese); romaji goes only into sort tags.
if (title) { if (title) {
const safeTitle = sanitize(title); const safeTitle = sanitize(title);
const fileArtist = (normalArtist || ndSong?.artist || '').replace(/;/g, ','); const fileArtist = dateinamensKuenstler(normalArtist, ndSong?.artist);
const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : ''; const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : '';
const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`; const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`;
const newPath = path.join(path.dirname(currentFile), newName); const newPath = path.join(path.dirname(currentFile), newName);
if (newPath !== currentFile && !fs.existsSync(newPath)) { if (newPath !== currentFile && fs.existsSync(newPath)) {
warnungen.push(`Datei nicht umbenannt: "${path.basename(newPath)}" existiert bereits im ` +
`selben Ordner. Die Tags wurden geschrieben, der Dateiname bleibt alt.`);
} else if (newPath !== currentFile) {
try { fs.renameSync(currentFile, newPath); currentFile = newPath; } try { fs.renameSync(currentFile, newPath); currentFile = newPath; }
catch (e) { console.error('[edit-metadata] file rename:', e.message); } catch (e) {
console.error('[edit-metadata] file rename:', e.message);
warnungen.push(`Umbenennen fehlgeschlagen: ${e.message}`);
}
} }
} }
@@ -1508,7 +1610,23 @@ app.post('/api/edit-metadata', async (req, res) => {
try { try {
fs.mkdirSync(newDir, { recursive: true }); fs.mkdirSync(newDir, { recursive: true });
const destFile = path.join(newDir, path.basename(currentFile)); const destFile = path.join(newDir, path.basename(currentFile));
if (!fs.existsSync(destFile)) { if (fs.existsSync(destFile)) {
const [ziel, hier] = await Promise.all([
_dateiSteckbrief(destFile), _dateiSteckbrief(currentFile),
]);
// Gleiche Laenge (± 2 s) UND gleicher Titel: dann sind es zwei Kopien desselben Songs
// und die ueberzaehlige kann weg. Sonst nur melden — Loeschen ist unwiderruflich.
const laengeGleich = ziel.dauer != null && hier.dauer != null &&
Math.abs(ziel.dauer - hier.dauer) <= 2;
const titelGleich = _tagNorm(ziel.titel) === _tagNorm(hier.titel);
dublette = {
behalten: ziel, // liegt schon am richtigen Platz
ueberzaehlig: hier, // die gerade bearbeitete Datei im alten Ordner
gleich: !!(laengeGleich && titelGleich),
};
warnungen.push(`Nicht verschoben: "${destFile}" existiert bereits. Die Datei bleibt in ` +
`"${oldAlbumDir}" — dieselbe Aufnahme liegt doppelt in der Bibliothek.`);
} else {
fs.renameSync(currentFile, destFile); fs.renameSync(currentFile, destFile);
currentFile = destFile; currentFile = destFile;
} }
@@ -1535,20 +1653,42 @@ app.post('/api/edit-metadata', async (req, res) => {
try { try {
const salt2 = Math.random().toString(36).slice(2, 10); const salt2 = Math.random().toString(36).slice(2, 10);
const tok2 = crypto.createHash('md5').update(pass + salt2).digest('hex'); const tok2 = crypto.createHash('md5').update(pass + salt2).digest('hex');
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${tok2}&s=${salt2}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) }); await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${tok2}&s=${salt2}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(5000) });
} catch {} } catch {}
// Restore playlist membership after rename/move — the rescan gives the renamed song a new ID // Umbenennen und Verschieben geben dem Song beim naechsten Scan eine NEUE Navidrome-ID. Alles,
if (affectedPlaylists.length > 0) { // was an der alten ID hing, waere sonst weg: die Playlist-Zugehoerigkeit — und der Stern, der
// bisher stillschweigend verlorenging. Beides wird auf die neue ID uebertragen.
const warStarred = !!ndSong?.starred;
if (affectedPlaylists.length > 0 || warStarred) {
const effectiveTitle = title || ndSong?.title || ''; const effectiveTitle = title || ndSong?.title || '';
const effectiveArtist = (artist || ndSong?.artist || '').split(';')[0].trim(); const effectiveArtist = (artist || ndSong?.artist || '').split(';')[0].trim();
const newSong = await _findSongAfterRename(ndUrl, user, pass, effectiveTitle, effectiveArtist, songId); const newSong = await _findSongAfterRename(ndUrl, user, pass, effectiveTitle, effectiveArtist, songId);
if (newSong && newSong.id !== songId) { if (newSong && newSong.id !== songId) {
await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id); if (affectedPlaylists.length > 0) {
await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id);
}
if (warStarred) {
// Der Stern ist in Navidrome pro Nutzer gespeichert; uebertragen laesst sich nur der des
// bearbeitenden Nutzers — fremde Markierungen sind ueber die Subsonic-API weder lesbar
// noch setzbar. Schlaegt es fehl, bleibt es bei einer Warnung statt eines Abbruchs.
try {
const st = await _ndApi(ndUrl, user, pass, 'star', `&id=${encodeURIComponent(newSong.id)}`);
if (st?.['subsonic-response']?.status !== 'ok') throw new Error('star abgelehnt');
} catch (e) {
warnungen.push(`Favorit konnte nicht uebertragen werden (${e.message}) — der Song muss ` +
`erneut markiert werden.`);
}
}
} else if (warStarred && !newSong) {
warnungen.push('Der Song wurde nach dem Umbenennen nicht wiedergefunden — die Markierung ' +
'als Favorit konnte nicht uebertragen werden.');
} }
} }
res.json({ ok: true }); // Den tatsaechlich bearbeiteten Pfad mitgeben: wer eine Aenderung "zurueckspringen" sieht, kann
// damit sofort erkennen, ob die richtige Datei erwischt wurde.
res.json({ ok: true, file: currentFile, warnings: warnungen, duplicate: dublette });
} catch (e) { } catch (e) {
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {} try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}
if (artFile) try { fs.unlinkSync(artFile); } catch {} if (artFile) try { fs.unlinkSync(artFile); } catch {}
@@ -1567,16 +1707,17 @@ app.post('/api/delete-song', async (req, res) => {
return res.status(400).json({ error: 'ndUrl, user, pass, songId required' }); return res.status(400).json({ error: 'ndUrl, user, pass, songId required' });
// Resolve the file path from Navidrome // Resolve the file path from Navidrome
let filePath; let filePath, ndSong;
try { try {
const salt = Math.random().toString(36).slice(2, 10); const salt = Math.random().toString(36).slice(2, 10);
const token = crypto.createHash('md5').update(pass + salt).digest('hex'); const token = crypto.createHash('md5').update(pass + salt).digest('hex');
const ndRes = await fetch( const ndRes = await fetch(
`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`, `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`,
{ signal: AbortSignal.timeout(8000) } { signal: AbortSignal.timeout(8000) }
); );
const ndData = await ndRes.json(); const ndData = await ndRes.json();
filePath = ndData?.['subsonic-response']?.song?.path; ndSong = ndData?.['subsonic-response']?.song;
filePath = ndSong?.path;
if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' }); if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' });
} catch (e) { } catch (e) {
return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` }); return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` });
@@ -1587,14 +1728,36 @@ app.post('/api/delete-song', async (req, res) => {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
const fileName = path.basename(filePath); const fileName = path.basename(filePath);
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
const found = _rebasePath(filePath, CONFIG.musicPath) const found = _rebasePath(filePath, CONFIG.musicPath)
|| _findFileRecursive(CONFIG.musicPath, fileName) || _findFileRecursive(CONFIG.musicPath, fileName)
|| _findFileInDir(path.dirname(filePath), fileName) || _findFileInDir(path.dirname(filePath), fileName)
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem); || (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
if (found) filePath = found; || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem)
else return res.status(404).json({ || findExistingTrack(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist || '', ndSong?.title || '');
if (!found) return res.status(404).json({
error: `File not found on disk. Navidrome path: "${ndPath}" — music root: "${CONFIG.musicPath}" — searched for: "${fileName}"`, error: `File not found on disk. Navidrome path: "${ndPath}" — music root: "${CONFIG.musicPath}" — searched for: "${fileName}"`,
}); });
// Derselbe Abgleich wie im Metadaten-Editor, und hier WICHTIGER: der Treffer stammt aus einer
// Aehnlichkeitssuche, und geloescht wird unwiderruflich. Passen die Tags der gefundenen Datei
// nicht zu dem, was Navidrome fuer diesen Song meldet, wird abgebrochen statt geraten.
const delTags = await _readTags(found);
const istTitel = _tagNorm(delTags.title || delTags.TITLE);
const istArtist = _tagNorm(delTags.artist || delTags.ARTIST);
const sollTitel = _tagNorm(ndSong?.title);
const sollArtist = _tagNorm(ndSong?.artist);
const titelPasst = !sollTitel || !istTitel || istTitel === sollTitel;
const artistPasst = !sollArtist || !istArtist ||
istArtist.includes(sollArtist) || sollArtist.includes(istArtist);
if (!titelPasst || !artistPasst) {
return res.status(409).json({
error: `Datei nicht eindeutig zuzuordnen. Navidrome meldet "${ndSong?.artist || '?'} – ` +
`${ndSong?.title || '?'}", die gefundene Datei enthaelt aber "` +
`${delTags.artist || '?'} – ${delTags.title || '?'}" (${found}). ` +
`Es wird nichts geloescht — bitte die Dubletten aufraeumen.`,
});
}
filePath = found;
} }
// Safety: file must be inside the music root and must be an audio file // Safety: file must be inside the music root and must be an audio file
@@ -1636,7 +1799,7 @@ app.post('/api/delete-song', async (req, res) => {
const s2 = Math.random().toString(36).slice(2, 10); const s2 = Math.random().toString(36).slice(2, 10);
const t2 = crypto.createHash('md5').update(pass + s2).digest('hex'); const t2 = crypto.createHash('md5').update(pass + s2).digest('hex');
await fetch( await fetch(
`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=novasonic&f=json`, `${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=NovaSonic-Backend&f=json`,
{ signal: AbortSignal.timeout(5000) } { signal: AbortSignal.timeout(5000) }
); );
} catch {} } catch {}
@@ -1644,6 +1807,78 @@ app.post('/api/delete-song', async (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// ─── DUBLETTE AUFLOESEN ──────────────────────────────────────────────────────
// Nach einem abgelehnten Verschieben ("am Ziel liegt schon eine Datei") liegt derselbe Song
// zweimal auf der Platte. Dieser Endpunkt loescht die ueberzaehlige Kopie — aber nur, wenn er
// selbst nachgeprueft hat, dass es wirklich eine Dublette ist. Er nimmt Pfade entgegen, deshalb
// prueft er strenger als das Loeschen per songId:
// 1. beide Pfade liegen im Musikordner und sind Audiodateien
// 2. beide existieren
// 3. gleiche Laenge (± 2 s) UND gleicher Titel-Tag — sonst sind es zwei verschiedene Aufnahmen
// Erst dann faellt die zu loeschende Datei.
app.post('/api/resolve-duplicate', async (req, res) => {
const { ndUrl, user, pass, behalten, ueberzaehlig } = req.body;
if (!ndUrl || !user || !pass || !behalten || !ueberzaehlig)
return res.status(400).json({ error: 'ndUrl, user, pass, behalten, ueberzaehlig required' });
if (behalten === ueberzaehlig)
return res.status(400).json({ error: 'Beide Pfade sind identisch' });
const realRoot = path.resolve(CONFIG.musicPath);
const rootSlash = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
const pruefe = (pfad, name) => {
const abs = path.resolve(pfad);
if (!abs.startsWith(rootSlash)) return `${name}: Pfad liegt ausserhalb des Musikordners`;
if (!AUDIO_EXT_RE.test(path.extname(abs))) return `${name}: keine Audiodatei`;
if (!fs.existsSync(abs)) return `${name}: existiert nicht mehr`;
return null;
};
const fehler = pruefe(behalten, 'behalten') || pruefe(ueberzaehlig, 'ueberzaehlig');
if (fehler) return res.status(400).json({ error: fehler });
const [a, b] = await Promise.all([
_dateiSteckbrief(path.resolve(behalten)), _dateiSteckbrief(path.resolve(ueberzaehlig)),
]);
const laengeGleich = a.dauer != null && b.dauer != null && Math.abs(a.dauer - b.dauer) <= 2;
const titelGleich = _tagNorm(a.titel) === _tagNorm(b.titel);
if (!laengeGleich || !titelGleich) {
return res.status(409).json({
error: `Das sind keine zwei Kopien desselben Songs — "${a.titel || '?'}" (${a.dauer ?? '?'} s) ` +
`gegen "${b.titel || '?'}" (${b.dauer ?? '?'} s). Es wird nichts geloescht.`,
});
}
const ziel = path.resolve(ueberzaehlig);
try { fs.unlinkSync(ziel); }
catch (e) { return res.status(500).json({ error: `Loeschen fehlgeschlagen: ${e.message}` }); }
// Leere Album- und Kuenstlerordner aufraeumen — gleiche Tiefenbegrenzung wie beim Song-Loeschen.
const albumDir = path.dirname(ziel);
const artistDir = path.dirname(albumDir);
if (albumDir !== realRoot && albumDir.startsWith(rootSlash)) {
try {
if (fs.readdirSync(albumDir).filter(f => !f.startsWith('.')).length === 0) {
fs.rmdirSync(albumDir);
if (artistDir !== realRoot && artistDir.startsWith(rootSlash)) {
try {
if (fs.readdirSync(artistDir).filter(f => !f.startsWith('.')).length === 0) fs.rmdirSync(artistDir);
} catch {}
}
}
} catch {}
}
try {
const s2 = Math.random().toString(36).slice(2, 10);
const t2 = crypto.createHash('md5').update(pass + s2).digest('hex');
await fetch(
`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=NovaSonic-Backend&f=json`,
{ signal: AbortSignal.timeout(5000) }
);
} catch {}
res.json({ ok: true, geloescht: ziel, verbleibt: path.resolve(behalten) });
});
// ─── ABS PROXY ─────────────────────────────────────────────────────────────── // ─── ABS PROXY ───────────────────────────────────────────────────────────────
// Forwards API calls to Audiobookshelf to avoid CORS restrictions in the browser. // Forwards API calls to Audiobookshelf to avoid CORS restrictions in the browser.
// The browser sends X-ABS-URL and X-ABS-Token headers; audio streaming uses // The browser sends X-ABS-URL and X-ABS-Token headers; audio streaming uses
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
// Regressionsprobe fuer den Download-Filter.
//
// Warum es das gibt: jede Verschaerfung am Filter, die einen Fehlgriff verhindert, kann eine ganze
// KLASSE richtiger Treffer abweisen — und der naechste Bericht kommt dann aus der anderen Richtung.
// Damit der Umbau nicht im Kreis laeuft, wird vor jeder Aenderung gegen bekannte Faelle geprueft.
//
// Was es tut: holt fuer jeden Fall EINMAL die echten Suchtreffer (yt-dlp, mit Metadaten, damit auch
// das `artist`-Feld der Art Tracks vorliegt) und spielt dann die Kaskadenstufen lokal durch —
// in der ALTEN und der NEUEN Reihenfolge. Gemeldet wird, welcher Treffer jeweils gewinnt.
//
// WICHTIG: beide Quellen werden abgefragt, YT MUSIC ZUERST. Die erste Fassung dieser Probe sah nur
// die gewoehnliche YouTube-Suche und meldete deshalb fuer "Get Lucky" einen Grammy-Mitschnitt als
// Treffer — in Wahrheit liefert YT Music dort die offizielle Tonspur an erster Stelle, und genau
// die laedt die Kaskade. Eine Probe, die eine Stufe auslaesst, erfindet Fehler.
//
// docker run --rm --network host --entrypoint node <backend-image> /app/scripts/filter-probe.mjs
//
// Die Filterbedingungen sind hier nachgebaut, nicht importiert: geprueft werden soll, was yt-dlp
// mit --match-filter tut, und das ist ein Ausdruck ueber dieselben Felder.
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { buildTitleKeywords, buildVariantFilter } = require('../lib/ytdlp.js');
const JP_RE = /[ぁ-んァ-ン一-龯]/;
// ── Faelle ────────────────────────────────────────────────────────────────────
// Jeder Fall nennt, was herauskommen MUSS (erwartetFragment) — ein Stueck des Kanalnamens oder
// Titels des richtigen Uploads. Faelle mit `darfNicht` nennen zusaetzlich einen bekannten Fehlgriff.
const FAELLE = [
{
name: 'TPR — Dearly Beloved (Deezer kennt TPR nicht, Dauer gehoert zu anderer Fassung)',
artist: 'TPR', titel: 'Dearly Beloved', dauer: 230,
erwartetFragment: 'TPR', darfNicht: 'LSD',
},
{
name: 'BTS — Pied Piper (Label-Kanal BANGTANTV, Kuenstler nur im artist-Feld)',
artist: 'BTS', titel: 'Pied Piper', dauer: 246,
erwartetFragment: 'Pied Piper',
},
{
name: 'Yoshihisa Hirano — 銀髪の少年 (japanischer Titel, Soundtrack-Album)',
artist: 'Yoshihisa Hirano', titel: '銀髪の少年', dauer: 129,
// Der richtige Upload heisst romanisiert "Ginpatsu no Shonen" — ohne das lange o.
// Genau dafuer ist das tolerante Umschrift-Muster da.
romaji: 'Ginpatsu No Shounen', erwartetFragment: 'Hirano',
},
{
name: 'Rising Insane — DEATHRACE (normaler westlicher Fall)',
artist: 'Rising Insane', titel: 'DEATHRACE', dauer: 216,
erwartetFragment: 'Rising Insane',
},
{
name: 'Daft Punk — Get Lucky (Klassiker, viele Coverversionen)',
artist: 'Daft Punk', titel: 'Get Lucky', dauer: 369,
erwartetFragment: 'Daft Punk',
},
];
// ── Suchtreffer holen ─────────────────────────────────────────────────────────
const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
/** YT-Music-Suche: erst die URLs der Songs-Leiste, dann deren Metadaten. */
async function sucheYtm(query, n) {
const url = `https://music.youtube.com/search?q=${encodeURIComponent(query)}&params=${YTM_SONGS_PARAM}`;
const urls = await new Promise(resolve => {
const p = spawn('yt-dlp', [
url, '--flat-playlist', '--playlist-items', `1-${n}`, '--print', 'webpage_url',
'--no-warnings', '--extractor-args', 'youtube:player_client=web_music,default',
]);
let aus = '';
p.stdout.on('data', d => aus += d.toString());
p.on('error', () => resolve([]));
p.on('close', () => resolve(aus.split('\n').map(z => z.trim()).filter(z => z.includes('watch?v='))));
});
if (!urls.length) return [];
return metadaten(urls);
}
function suche(query, n) {
return metadaten([`ytsearch${n}:${query}`]);
}
/** Metadaten zu einer Liste von URLs (oder einer Suchangabe) holen. */
function metadaten(ziele) {
return new Promise(resolve => {
const felder = '%(duration)s%(channel)s%(artist)s%(title)s';
const p = spawn('yt-dlp', [
...ziele, '--simulate', '--no-warnings', '--ignore-errors',
'--print', felder,
]);
let aus = '';
p.stdout.on('data', d => aus += d.toString());
p.on('error', () => resolve([]));
p.on('close', () => resolve(
aus.split('\n').filter(Boolean).map(z => {
const [dauer, kanal, kuenstler, titel] = z.split('');
return {
dauer: Number(dauer) || 0,
kanal: kanal === 'NA' ? '' : kanal,
kuenstler: kuenstler === 'NA' ? '' : kuenstler,
titel: titel || '',
};
})
));
});
}
// ── Filterbedingungen, wie mf() sie baut ──────────────────────────────────────
function pruefer(fall) {
const kws = buildTitleKeywords(fall.titel, fall.romaji || '');
const variant = buildVariantFilter(fall.titel);
const ersterK = fall.artist.split(/[\s,/]+/)[0];
const rand = Math.max(10, Math.round(fall.dauer * 0.10));
const fenster = { min: Math.max(30, fall.dauer - rand), max: fall.dauer + rand };
const titelPasst = k =>
kws.every(muster => new RegExp(muster, 'i').test(k.titel));
// buildVariantFilter liefert einen yt-dlp-Ausdruck: mehrere "title !~= (?i)MUSTER", mit & verknuepft.
// Hier werden die Muster herausgeloest und als "darf auf KEINES passen" geprueft.
const verboten = [...(variant || '').matchAll(/title !~= \(\?i\)([^&]+)/g)]
.map(m => m[1].trim()).filter(Boolean);
const variantePasst = k => !verboten.some(m => new RegExp(m, 'i').test(k.titel));
const kuenstlerPasst = k => {
if (ersterK.length < 3 && !JP_RE.test(ersterK)) return true; // kein Merkmal -> keine Huerde
const re = new RegExp(ersterK.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
return re.test(k.kanal) || re.test(k.kuenstler);
};
const imFenster = k => k.dauer >= fenster.min && k.dauer <= fenster.max;
const unterKappe = k => k.dauer > 0 && k.dauer < fall.dauer * 2;
return {
fenster,
// streng: Dauer + Titel + Variante + Kuenstler
streng: k => imFenster(k) && titelPasst(k) && variantePasst(k) && kuenstlerPasst(k),
// Dauer behalten, Kuenstler fallen lassen
ohneKuenstler: k => imFenster(k) && titelPasst(k) && variantePasst(k),
// Kuenstler behalten, Dauer lockern (2x-Kappe bleibt)
ohneDauer: k => unterKappe(k) && titelPasst(k) && variantePasst(k) && kuenstlerPasst(k),
};
}
const ersterTreffer = (kandidaten, pruefung) => kandidaten.find(pruefung) || null;
function kaskade(ytm, yt, p, reihenfolge) {
const stufen = reihenfolge === 'neu'
? [['1/2 streng', p.streng], ['3/4 ohne Dauer, mit Künstler', p.ohneDauer], ['5/6 mit Dauer, ohne Künstler', p.ohneKuenstler]]
: [['1/2 streng', p.streng], ['3/4 mit Dauer, ohne Künstler', p.ohneKuenstler], ['5/6 ohne Dauer, mit Künstler', p.ohneDauer]];
for (const [stufe, pruefung] of stufen) {
// Jede Stufe fragt erst YT Music, dann die gewoehnliche YouTube-Suche — so laeuft die Kaskade.
const t = ersterTreffer(ytm, pruefung) || ersterTreffer(yt, pruefung);
if (t) return { stufe: `${stufe}${ytm.includes(t) ? ' · YTM' : ' · YT'}`, treffer: t };
}
return { stufe: 'kein Treffer', treffer: null };
}
const zeig = t => t ? `${String(t.dauer).padStart(4)}s | ${t.kanal || '?'} | ${t.titel}` : '— nichts —';
(async () => {
let abweichungen = 0, fehler = 0;
for (const fall of FAELLE) {
const query = `${fall.artist} ${fall.titel}`;
process.stdout.write(`\n▶ ${fall.name}\n Suche: "${query}" `);
const ytm = await sucheYtm(query, 10);
const yt = await suche(query, 10);
console.log(`(YTM ${ytm.length}, YouTube ${yt.length})`);
if (!ytm.length && !yt.length) { console.log(' ⚠️ keine Suchtreffer — übersprungen'); continue; }
const p = pruefer(fall);
console.log(` Dauerfenster ${p.fenster.min}–${p.fenster.max}s`);
const alt = kaskade(ytm, yt, p, 'alt');
const neu = kaskade(ytm, yt, p, 'neu');
console.log(` ALT [${alt.stufe}] ${zeig(alt.treffer)}`);
console.log(` NEU [${neu.stufe}] ${zeig(neu.treffer)}`);
const passt = t => t && (t.kanal + ' ' + t.titel).toLowerCase().includes(fall.erwartetFragment.toLowerCase());
const schlecht = t => fall.darfNicht && t && (t.kanal + ' ' + t.titel).toLowerCase().includes(fall.darfNicht.toLowerCase());
if (!neu.treffer && alt.treffer) { console.log(' ❌ REGRESSION: neu findet nichts, alt schon'); fehler++; }
else if (schlecht(neu.treffer)) { console.log(' ❌ neu trifft weiterhin den bekannten Fehlgriff'); fehler++; }
else if (!passt(neu.treffer)) { console.log(` ⚠️ neu trifft nicht "${fall.erwartetFragment}"`); fehler++; }
else { console.log(' ✅ neu in Ordnung'); }
if (zeig(alt.treffer) !== zeig(neu.treffer)) abweichungen++;
}
console.log(`\n${FAELLE.length} Fälle, ${abweichungen} Unterschied(e) zwischen alt und neu, ${fehler} Problem(e)`);
process.exit(fehler ? 1 : 0);
})();
+117
View File
@@ -0,0 +1,117 @@
const { test, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const https = require('https');
const { EventEmitter } = require('events');
const { kommendeKonzerte, _cache } = require('../lib/konzerte');
// ─── Ticketmaster-Antwort einschieben ────────────────────────────────────────
// Kein Schluessel, kein Netz: https.get wird ersetzt und liefert eine feste Antwort. Geprueft
// wird damit genau das, was uns gehoert — Ortszeit, Gegenprobe des Namens, Fenster, Sortierung.
let _antworten = {};
let _aufrufe = [];
const echtesGet = https.get;
function stub() {
https.get = (url, _opts, cb) => {
_aufrufe.push(url);
const name = decodeURIComponent((url.match(/keyword=([^&]*)/) || [])[1] || '');
const res = new EventEmitter();
res.statusCode = 200;
process.nextTick(() => {
res.emit('data', JSON.stringify(_antworten[name] ?? { _embedded: { events: [] } }));
res.emit('end');
});
cb(res);
return new EventEmitter(); // das Request-Objekt, auf dem .on('error') gesetzt wird
};
}
stub();
process.on('exit', () => { https.get = echtesGet; });
const inTagen = n => {
const d = new Date(Date.now() + n * 864e5);
return d.toISOString().slice(0, 10);
};
beforeEach(() => { _cache.clear(); _aufrufe = []; _antworten = {}; });
const ereignis = (name, tage, extra = {}) => ({
id: 'e' + tage,
name,
dates: { start: { localDate: inTagen(tage), localTime: '20:00:00', dateTime: `${inTagen(tage)}T18:00:00Z` }, ...extra.dates },
_embedded: { venues: [{ name: 'Halle', city: { name: 'Köln' }, country: { name: 'Germany' } }] },
url: 'https://tm.example/' + tage,
...extra,
});
test('Ortszeit schlaegt UTC — 20:00 bleibt 20:00', async () => {
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
assert.equal(konzerte.length, 1);
assert.ok(konzerte[0].datum.endsWith('T20:00:00'), `unerwartet: ${konzerte[0].datum}`);
});
test('Gegenprobe: fremde Veranstaltung mit demselben Stichwort faellt raus', async () => {
_antworten['MORE'] = { _embedded: { events: [
ereignis('MORE Tour 2026', 10),
ereignis('Sing Something MORE Festival', 20), // enthaelt das Wort → bleibt
ereignis('Ganz was anderes', 30), // enthaelt es nicht → faellt raus
] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'MORE' }], 'K', 365);
assert.deepEqual(konzerte.map(k => k.titel), ['MORE Tour 2026', 'Sing Something MORE Festival']);
});
test('Name im Attraktionsnamen zaehlt auch', async () => {
_antworten['K/DA'] = { _embedded: { events: [
{ ...ereignis('League Music Night', 15), _embedded: {
venues: [{ name: 'Arena', city: { name: 'Berlin' }, country: { name: 'Germany' } }],
attractions: [{ name: 'K/DA' }],
} },
] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'K/DA' }], 'K', 365);
assert.equal(konzerte.length, 1, 'ueber die Attraktion erkannt');
});
test('Termine hinter dem Fenster fallen weg', async () => {
_antworten['Coldplay'] = { _embedded: { events: [ereignis('Coldplay Live', 20), ereignis('Coldplay Later', 200)] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Coldplay' }], 'K', 30);
assert.equal(konzerte.length, 1);
assert.equal(konzerte[0].titel, 'Coldplay Live');
});
test('Sortierung laeuft NACH VORN — naechstes Konzert zuerst', async () => {
_antworten['A'] = { _embedded: { events: [ereignis('A spaet', 90)] } };
_antworten['B'] = { _embedded: { events: [ereignis('B frueh', 5)] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'A' }, { id: '2', name: 'B' }], 'K', 365);
assert.deepEqual(konzerte.map(k => k.titel), ['B frueh', 'A spaet']);
});
test('Kuenstler ohne Termine werden gemeldet, nicht verschwiegen', async () => {
_antworten['Leer'] = { _embedded: { events: [] } };
const { konzerte, ohneTermine } = await kommendeKonzerte([{ id: '1', name: 'Leer' }], 'K', 365);
assert.equal(konzerte.length, 0);
assert.deepEqual(ohneTermine, ['Leer']);
});
test('Zweiter Aufruf kommt aus dem Cache — kein weiterer Abruf', async () => {
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
const nachErstem = _aufrufe.length;
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
assert.equal(_aufrufe.length, nachErstem, 'zweiter Aufruf darf nicht erneut abrufen');
});
test('Abgesagt wird durchgereicht statt stillschweigend gezeigt', async () => {
_antworten['X'] = { _embedded: { events: [
{ ...ereignis('X Tour', 40), dates: { start: { localDate: inTagen(40), localTime: '20:00:00' }, status: { code: 'cancelled' } } },
] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'X' }], 'K', 365);
assert.equal(konzerte[0].abgesagt, true);
});
test('Ein Kuenstlername ohne Buchstaben loest keinen Abruf aus', async () => {
const { konzerte } = await kommendeKonzerte([{ id: '1', name: '???' }], 'K', 365);
assert.equal(konzerte.length, 0);
assert.equal(_aufrufe.length, 0);
});
+172
View File
@@ -22,6 +22,15 @@ before(() => {
'01-01 - Kingdom Hearts 2 - Dearly Beloved - Main Theme.mp3'); '01-01 - Kingdom Hearts 2 - Dearly Beloved - Main Theme.mp3');
// Normal "Artist - Title" file // Normal "Artist - Title" file
touch('Some Artist', 'Therapy - Single', '01 - Some Artist - Therapy.mp3'); touch('Some Artist', 'Therapy - Single', '01 - Some Artist - Therapy.mp3');
// Akzent-Schreibweise: liegt MIT Akzent auf der Platte
touch('Cécile Corbel', 'SongBook Vol. 2', '03 - Cécile Corbel - Arrietty\u2019s Song.mp3');
// Soundtrack: liegt unter dem ALBUM-Kuenstler, nicht unter dem Titel-Kuenstler
touch('Arcane', 'Arcane League of Legends (Soundtrack)', '01 - Bea Miller - Playground.mp3');
// Zwei verschiedene Kuenstler mit demselben Songtitel — duerfen sich NIE gegenseitig treffen
touch('Cro', 'Raop', '03 - Cro - Easy.mp3');
touch('Kraftklub', 'Mit K', '05 - Kraftklub - Easy.mp3');
// Aehnlicher, aber anderer Kuenstlername
touch('Cecil Taylor', 'Unit Structures', '01 - Cecil Taylor - Steps.mp3');
}); });
after(() => { try { fs.rmSync(ROOT, { recursive: true, force: true }); } catch {} }); after(() => { try { fs.rmSync(ROOT, { recursive: true, force: true }); } catch {} });
@@ -48,3 +57,166 @@ test('remix stays distinct from the base track (no regression from the version-m
assert.equal(findExistingTrack(ROOT, 'Some Artist', 'Therapy (Remix)'), null); assert.equal(findExistingTrack(ROOT, 'Some Artist', 'Therapy (Remix)'), null);
assert.ok(findExistingTrack(ROOT, 'Some Artist', 'Therapy')); // base still found assert.ok(findExistingTrack(ROOT, 'Some Artist', 'Therapy')); // base still found
}); });
// ── Akzente (Cécile Corbel) ────────────────────────────────────────────────────
test('findet den Song auch ohne Akzent in der Anfrage', () => {
assert.ok(findExistingTrack(ROOT, 'Cecile Corbel', "Arrietty's Song"),
'ASCII-Schreibweise muss die Datei mit Akzent finden');
});
test('findet ihn ebenso mit Akzent', () => {
assert.ok(findExistingTrack(ROOT, 'Cécile Corbel', "Arrietty's Song"));
});
test('kein Fehlalarm: aehnlicher Name ist nicht dieselbe Person', () => {
// "Cecil Taylor" darf nicht auf "Cécile Corbel" passen — und umgekehrt.
assert.equal(findExistingTrack(ROOT, 'Cécile Corbel', 'Steps'), null);
assert.equal(findExistingTrack(ROOT, 'Cecil Taylor', "Arrietty's Song"), null);
});
test('kein Fehlalarm: Akzent-Normalisierung kollabiert keine echten Titel', () => {
assert.equal(findExistingTrack(ROOT, 'Cecile Corbel', 'Ein anderer Song'), null);
});
// ── Gleicher Titel, verschiedene Kuenstler ─────────────────────────────────────
test('kein Fehlalarm: gleicher Titel bei zwei Kuenstlern bleibt getrennt', () => {
const cro = findExistingTrack(ROOT, 'Cro', 'Easy');
const kk = findExistingTrack(ROOT, 'Kraftklub', 'Easy');
assert.ok(cro && cro.includes('Cro'), 'Cro-Version muss im Cro-Ordner gefunden werden');
assert.ok(kk && kk.includes('Kraftklub'), 'Kraftklub-Version im Kraftklub-Ordner');
assert.notEqual(cro, kk, 'die beiden duerfen nicht dieselbe Datei sein');
// Ein dritter Kuenstler mit demselben Titel findet nichts.
assert.equal(findExistingTrack(ROOT, 'Irgendwer', 'Easy'), null);
});
// ── Soundtrack: Album-Kuenstler vs. Titel-Kuenstler ────────────────────────────
test('Soundtrack unter dem Album-Kuenstler wird ueber diesen gefunden', () => {
assert.ok(findExistingTrack(ROOT, 'Arcane', 'Playground'));
});
test('unter dem Titel-Kuenstler allein wird er NICHT gefunden (Grund fuer die Zweitpruefung)', () => {
// Genau diese Luecke erzeugte die Dublette: geprueft wurde nur "Bea Miller".
assert.equal(findExistingTrack(ROOT, 'Bea Miller', 'Playground'), null);
});
test('die Zweitpruefung schliesst die Luecke, ohne Fremdes zu treffen', () => {
const pruefe = (albumArtist, trackArtist, titel) =>
findExistingTrack(ROOT, albumArtist, titel)
|| (trackArtist !== albumArtist ? findExistingTrack(ROOT, trackArtist, titel) : null);
// Soundtrack-Fall: wird jetzt erkannt.
assert.ok(pruefe('Bea Miller', 'Bea Miller', 'Playground') === null,
'ohne Album-Kuenstler weiterhin nichts — richtig, die Datei liegt unter Arcane');
assert.ok(pruefe('Arcane', 'Bea Miller', 'Playground'), 'mit Album-Kuenstler gefunden');
// Fremder Titel im selben Ordner darf trotz zweier Durchlaeufe nicht anschlagen.
assert.equal(pruefe('Arcane', 'Bea Miller', 'Irgendein anderer Titel'), null);
// Remix bleibt auch ueber zwei Durchlaeufe hinweg eigenstaendig.
assert.equal(pruefe('Arcane', 'Bea Miller', 'Playground (Remix)'), null);
});
// ─── Dateisuche: Titelstamm und Schraegstrich im Namen ───────────────────────
// Regression zu "K/DA – VILLAIN": Navidromes Tag-Pfad wurde zu "K/DA/ALL OUT/…", auf der Platte
// steht "K_DA/ALL OUT/…". Der Pfad fand nichts, und die Aehnlichkeitssuche lieferte
// "Bad Omens - Like A Villain", weil deren Stamm auf "villain" ENDET.
const { findFileByStem, albumDirFromTags } = require('../lib/metadata');
let SLASH_ROOT;
before(() => {
SLASH_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-slash-'));
const put = (...segs) => {
const file = segs.pop();
const dir = path.join(SLASH_ROOT, ...segs);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, file), '');
};
put('K_DA', 'ALL OUT', '03 - K_DA - VILLAIN.mp3');
put('K_DA', 'POP_STARS', '01 - K_DA - POP_STARS.mp3');
put('Bad Omens', 'THE DEATH OF PEACE OF MIND', '06 - Bad Omens - Like A Villain.mp3');
});
after(() => { try { fs.rmSync(SLASH_ROOT, { recursive: true, force: true }); } catch {} });
test('Titelstamm trifft nicht auf blosse Endsilbe', () => {
// "Like A Villain" darf NICHT als "VILLAIN" durchgehen
const t = findFileByStem(path.join(SLASH_ROOT, 'Bad Omens'), 'villain');
assert.equal(t, null);
});
test('Titelstamm trifft "Kuenstler - Titel" weiterhin', () => {
const t = findFileByStem(path.join(SLASH_ROOT, 'Bad Omens'), 'like a villain');
assert.ok(t && t.endsWith('06 - Bad Omens - Like A Villain.mp3'));
});
test('Ganze Bibliothek: VILLAIN findet K_DA, nicht Bad Omens', () => {
const t = findFileByStem(SLASH_ROOT, 'villain');
assert.ok(t && t.includes(path.join('K_DA', 'ALL OUT')), `unerwartet: ${t}`);
});
test('Schraegstrich im Kuenstlernamen fuehrt zum richtigen Ordner', () => {
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'ALL OUT'), path.join(SLASH_ROOT, 'K_DA', 'ALL OUT'));
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'POP/STARS'), path.join(SLASH_ROOT, 'K_DA', 'POP_STARS'));
});
test('Unbekanntes Album faellt auf den Kuenstlerordner zurueck', () => {
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'Gibt Es Nicht'), path.join(SLASH_ROOT, 'K_DA'));
});
test('Mehrere Kuenstler: Ordner haengt am ersten', () => {
assert.equal(
albumDirFromTags(SLASH_ROOT, 'K/DA, Madison Beer, Kim Petras', 'ALL OUT'),
path.join(SLASH_ROOT, 'K_DA', 'ALL OUT'),
);
});
test('Kein Treffer bleibt null', () => {
assert.equal(albumDirFromTags(SLASH_ROOT, 'Niemand', 'Nichts'), null);
});
// ─── Kuenstler im Dateinamen ────────────────────────────────────────────────
// Frueher stand die vollstaendige Gaesteliste im Namen und wurde bei 100 Zeichen mitten im
// letzten Namen abgeschnitten. Jetzt nur noch der erste Kuenstler.
const { dateinamensKuenstler } = require('../lib/metadata');
test('Nur der erste Kuenstler landet im Dateinamen', () => {
assert.equal(
dateinamensKuenstler('K/DA;Madison Beer;i-dle;Lexie Liu;Jaira Burns;Seraphine;League of Legends'),
'K/DA',
);
assert.equal(dateinamensKuenstler('Dove Cameron, Sofia Carson'), 'Dove Cameron');
assert.equal(dateinamensKuenstler('Calvin Harris & Dua Lipa'), 'Calvin Harris');
});
test('Bandnamen mit & bleiben ganz, wenn sie keine Liste sind', () => {
// "&" trennt nur MIT umgebenden Leerzeichen — "Earth, Wind & Fire" waere ohnehin durch das
// Komma getrennt, aber ein reiner Bandname ohne Liste darf nicht zerlegt werden.
assert.equal(dateinamensKuenstler('AC/DC'), 'AC/DC');
assert.equal(dateinamensKuenstler('Simon&Garfunkel'), 'Simon&Garfunkel');
});
test('Leerer Kuenstler faellt auf den Album-Kuenstler zurueck', () => {
assert.equal(dateinamensKuenstler('', 'K/DA;Madison Beer'), 'K/DA');
assert.equal(dateinamensKuenstler(undefined, undefined), '');
});
test('Der Dateiname bleibt kurz genug fuers Dateisystem', () => {
const lang = 'K/DA;Madison Beer;(G)I-DLE;Lexie Liu;Jaira Burns;Seraphine;League of Legends;Bea Miller;Wolftyla;Duckwrth';
const teil = dateinamensKuenstler(lang);
assert.equal(teil, 'K/DA');
assert.ok(teil.length < 100, 'wird nie von sanitize() abgeschnitten');
});
test('Dublettenpruefung findet auch die alt benannten Dateien', () => {
// Nach der Umstellung liegen beide Namensformen nebeneinander. Beide muessen als "schon da"
// erkannt werden, sonst laedt der Downloader die alten Dateien ein zweites Mal.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-namen-'));
const put = (album, file) => {
const dir = path.join(root, 'K_DA', album);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, file), '');
};
put('MORE', '01 - K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine - MORE.mp3'); // alt
put('ALL OUT', '03 - K_DA - VILLAIN.mp3'); // neu
assert.ok(findExistingTrack(root, 'K/DA', 'MORE'), 'alte Namensform nicht erkannt');
assert.ok(findExistingTrack(root, 'K/DA', 'VILLAIN'), 'neue Namensform nicht erkannt');
assert.equal(findExistingTrack(root, 'K/DA', 'POP/STARS'), null);
fs.rmSync(root, { recursive: true, force: true });
});
+89
View File
@@ -76,3 +76,92 @@ test('"live" inside another word does not disable the rule', () => {
assert.equal(variantAccepts('Deliver', 'Artist - Deliver (Live at Wembley)'), false); assert.equal(variantAccepts('Deliver', 'Artist - Deliver (Live at Wembley)'), false);
assert.equal(variantAccepts('Deliver', 'Artist - Deliver'), true); assert.equal(variantAccepts('Deliver', 'Artist - Deliver'), true);
}); });
const { pickYtmVideoUrls } = require('../lib/ytdlp.js');
// Echte Ausgabe der YTM-Suche "DAT ADAM UFO": Songs, ein Album (MPREb_) und der
// Kuenstlerkanal (UC…) auf Platz 9.
const YTM_OUT = [
'https://music.youtube.com/watch?v=ZG4XhPn3Yb4',
'https://music.youtube.com/watch?v=2KVU6aikZF0',
'https://music.youtube.com/browse/MPREb_IhxJPixC1QT',
'https://music.youtube.com/watch?v=phLOTtJaqZk',
'https://music.youtube.com/watch?v=p935nH6aj8Y',
'https://music.youtube.com/watch?v=Pb2YcDBm5fA',
'https://music.youtube.com/browse/MPREb_S7MMKVMduma',
'https://music.youtube.com/watch?v=mNfsX2RbZ2M',
'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA',
'https://music.youtube.com/watch?v=O034JUlf1J4',
].join('\n');
test('a channel hit no longer discards the whole result list', () => {
const { urls, skipped } = pickYtmVideoUrls(YTM_OUT);
assert.equal(urls.length, 7);
assert.equal(skipped, 3); // 2 Alben + 1 Kanal
assert.match(urls[0], /ZG4XhPn3Yb4/); // der offizielle Art Track
assert.ok(!urls.some(u => u.includes('/browse/')));
});
test('nothing but list entries yields no candidates', () => {
const only = 'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA';
assert.deepEqual(pickYtmVideoUrls(only).urls, []);
assert.deepEqual(pickYtmVideoUrls('').urls, []);
});
// ─── Japanische Titel bekommen wieder eine Titelpruefung ────────────────────
// Regression zum 24.9.2026: "銀髪の少年" (HUNTER×HUNTER, Yoshihisa Hirano) lieferte ein anderes
// Stueck desselben Komponisten. Ursache: fuer japanische Titel gab buildTitleKeywords eine LEERE
// Liste zurueck, und die Kaskadenschritte 3/4 lassen die Kuenstlerpruefung fallen — uebrig blieb
// allein das Dauerfenster, in das auf einem Soundtrack-Album fast jeder Titel passt.
test('japanischer Titel liefert jetzt ein Muster statt gar nichts', () => {
const kws = buildTitleKeywords('銀髪の少年');
assert.equal(kws.length, 1);
assert.ok(new RegExp(kws[0], 'i').test('銀髪の少年'), 'Originaltitel muss passen');
assert.ok(!new RegExp(kws[0], 'i').test('闘う者たち'), 'anderes Stueck darf NICHT passen');
});
test('Romaji-Fassung wird als Alternative akzeptiert', () => {
const kws = buildTitleKeywords('銀髪の少年', 'ginpatsu no shounen');
const re = new RegExp(kws[0], 'i');
assert.ok(re.test('銀髪の少年'), 'Original');
assert.ok(re.test('Ginpatsu no Shounen'), 'Romaji mit Leerzeichen');
assert.ok(re.test('Ginpatsu-no-Shounen'), 'Romaji mit Bindestrichen');
assert.ok(re.test('Hunter x Hunter OST - Ginpatsu no Shounen'), 'als Teil eines laengeren Titels');
assert.ok(!re.test('Requiem Aranea'), 'anderes Stueck desselben Albums');
});
test('Romaji, die gar keine sind, werden nicht als Alternative genommen', () => {
// romajiForStorage gibt den Text unveraendert zurueck, wenn der Tokenizer fehlt
const kws = buildTitleKeywords('銀髪の少年', '銀髪の少年');
assert.equal(kws.length, 1);
assert.ok(!kws[0].includes('|'), 'keine doppelte Alternative');
});
test('Sonderzeichen im japanischen Titel bleiben woertlich', () => {
const kws = buildTitleKeywords('君の名は。(Movie ver.)');
const re = new RegExp(kws[0], 'i');
assert.ok(re.test('君の名は。(Movie ver.)'));
assert.ok(!re.test('君の名はX Movie verY'), 'Punkt darf nicht als Platzhalter wirken');
});
test('lateinische Titel bleiben unveraendert', () => {
assert.deepEqual(buildTitleKeywords('Bohemian Rhapsody'), ['Bohemian', 'Rhapsody']);
assert.deepEqual(buildTitleKeywords('Dearly Beloved'), ['Beloved', 'Dearly']);
});
test('Umschrift toleriert die ueblichen Schreibvarianten', () => {
const re = new RegExp(buildTitleKeywords('銀髪の少年', 'Ginpatsu No Shounen')[0], 'i');
assert.ok(re.test('Ginpatsu no Shounen'), 'wie der Tokenizer schreibt');
assert.ok(re.test('Ginpatsu no Shonen'), 'ohne langes o — die haeufigste Variante');
assert.ok(re.test('Ginpatsu-no-Shonen'), 'mit Bindestrichen');
assert.ok(re.test('GINPATSU NO SHŌNEN'), 'mit Makron');
assert.ok(!re.test('Ginpatsu no Otona'), 'anderer Titel faellt weiterhin durch');
});
test('Katakana-Laengungsstrich faellt aus der Umschrift', () => {
// romajiForStorage laesst "ー" stehen ("TeーZe"); in einem lateinischen Titel steht es nie.
const re = new RegExp(buildTitleKeywords('残酷な天使のテーゼ', 'Zankoku Na Tenshi No TeーZe')[0], 'i');
assert.ok(re.test('Zankoku na Tenshi no Teze'), 'ohne Strich geschrieben');
assert.ok(re.test('残酷な天使のテーゼ'), 'Originaltitel weiterhin');
});
+114
View File
@@ -0,0 +1,114 @@
# Teilen per Link
Ein geteilter Link sieht so aus:
```
https://<deine-web-adresse>/?song=<navidrome-song-id>
https://<deine-web-adresse>/?album=<navidrome-album-id>
```
Er trägt **nur die Id**, keine Zugangsdaten. Der Empfänger sieht den Inhalt mit seinem eigenen
Navidrome-Konto auf derselben Instanz — wer dort keinen Zugang hat, sieht nichts.
## Web
Funktioniert ohne weitere Einrichtung. Der Link wird beim Start ausgelesen (`?song=` / `?album=`),
das Album geöffnet und der Song abgespielt; danach verschwindet der Parameter aus der Adresszeile,
damit ein Neuladen nicht erneut startet. Als installierte PWA landet der Link dank
`launch_handler: navigate-existing` im bereits offenen Fenster statt in einem neuen.
## Android
Zwei Dinge sind nötig, damit ein Link **direkt in der App** landet statt im Browser:
**1. Web-Adresse in der App hinterlegen** — Einstellungen → „NovaSonic Web-Adresse". Ohne sie
erscheint der Teilen-Eintrag im Kontextmenü gar nicht erst, weil der Link sonst ins Leere zeigt.
**2. Den Host beim Bauen mitgeben** und die Signatur hinterlegen:
```bash
./gradlew assembleRelease -PnovasonicHost=music.example.com
```
Dann den SHA-256 des **Release**-Signaturschlüssels ermitteln …
```bash
keytool -list -v -keystore <dein-keystore>.jks -alias <alias> | grep SHA256
```
… und in `public/.well-known/assetlinks.json` anstelle von `PLATZHALTER_RELEASE_SHA256` eintragen.
Die Datei muss unter `https://<deine-web-adresse>/.well-known/assetlinks.json` erreichbar sein —
sie liegt im `public/`-Verzeichnis und wird vom Frontend-Image mit ausgeliefert.
## APK veröffentlichen
`npm run publish-apk` gibt den Host jetzt an Gradle weiter. Dafür in `scripts/.env.release`
ergänzen (die Datei wird nie eingecheckt):
```
NOVASONIC_HOST=music.deine-domain.de
# optional während eines Domainwechsels:
# NOVASONIC_HOST2=alt.deine-domain.de
```
Fehlt der Eintrag, warnt das Skript und baut mit dem Platzhalter weiter — das APK ist dann
installierbar, aber eingehende Links öffnen im Browser statt in der App.
## Domain wechseln
**Ausgehende Links** folgen der Einstellung „NovaSonic Web-Adresse" — Adresse ändern, fertig, kein
neues APK nötig.
**Eingehende Links** stehen fest im Manifest und brauchen ein neues APK. Damit während des Umzugs
beide Adressen funktionieren, lassen sich zwei Hosts angeben:
```bash
./gradlew assembleRelease -PnovasonicHost=neu.example.com -PnovasonicHost2=alt.example.com
```
Beide müssen eine gültige `assetlinks.json` ausliefern. Das ist keine Feinheit: **auf Android 11 und
älter verifiziert das System nur, wenn ALLE angegebenen Hosts eine passende Datei liefern** — ein
toter Host blockiert also auch den funktionierenden. Sobald die alte Domain abgeschaltet wird, das
APK deshalb ohne `novasonicHost2` neu bauen.
### Ablauf eines Umzugs
1. **Neue Domain aufsetzen**, Web-App und `/.well-known/assetlinks.json` dort erreichbar machen.
Die alte Domain bleibt vorerst unverändert online.
2. **APK mit beiden Hosts bauen** (`-PnovasonicHost=neu -PnovasonicHost2=alt`) und installieren.
Ab jetzt öffnen Links beider Adressen in der App.
3. **In der App die „NovaSonic Web-Adresse" auf die neue Domain umstellen.** Neu geteilte Links
zeigen ab sofort dorthin, alte funktionieren weiter.
4. **Warten**, bis auf allen Geräten das neue APK läuft.
5. **Alte Domain abschalten** — und danach das APK ohne `novasonicHost2` neu bauen.
Schritt 5 nicht vergessen: Android verifiziert die Domains bei jeder Installation neu, und auf
Android 11 und älter genügt ein einziger nicht erreichbarer Host, damit die Verifikation komplett
fehlschlägt. Bereits verifizierte Installationen laufen zwar weiter, aber die nächste Neuinstallation
hätte gar keine funktionierenden App Links mehr.
Zwei weitere Fallen:
- **Signaturschlüssel muss derselbe bleiben.** Ein anders signiertes APK lässt sich nur nach
Deinstallation installieren — und die nimmt die Offline-Downloads mit.
- **Alte Links nach der Abschaltung** öffnen nur dann noch etwas, wenn die alte Domain auf die neue
weiterleitet. Dann landen sie allerdings im Browser statt in der App, weil Android eine
Weiterleitung nicht erneut an die App übergibt.
Wildcards (`*.example.com`) sind für App Links nicht dokumentiert und werden hier bewusst nicht
verwendet — die Verifikation läuft pro Host.
Bereits verschickte Links auf die alte Domain öffnen nach deren Abschaltung gar nichts mehr; das
lässt sich nur mit einer Weiterleitung von alt nach neu abfangen.
Prüfen lässt sich das Ergebnis mit:
```bash
adb shell pm get-app-links com.novasonic.app
```
Steht dort `verified`, öffnet Android die Links ohne Nachfrage.
**Ohne diesen Schritt** ist der Link trotzdem nicht verloren: er öffnet im Browser die Web-App.
Wer die App bevorzugt, kann sie in den Systemeinstellungen unter *Standardmäßig öffnen* → *Links*
manuell für die Domain zulassen.
+78 -26
View File
@@ -98,32 +98,33 @@
<div class="mobile-overlay" id="mobile-overlay" onclick="closeSidebar()"></div> <div class="mobile-overlay" id="mobile-overlay" onclick="closeSidebar()"></div>
<!-- MAIN --> <!-- MAIN -->
<main class="main" id="main-content"> <!-- Kopfzeile liegt AUSSERHALB des Rollbereichs: sonst laeuft der Rollbalken bis ganz
nach oben neben die Suchleiste, obwohl dort nichts rollt. -->
<!-- Mobile header --> <div class="main-wrap">
<div id="mobile-header" style="position:sticky;top:0;background:var(--bg);z-index:10;border-bottom:1px solid var(--border)"> <div id="mobile-header" style="position:sticky;top:0;background:var(--bg);z-index:10;border-bottom:1px solid var(--border)">
<div style="display:flex;align-items:center;gap:8px;padding:10px 12px"> <div style="display:flex;align-items:center;gap:8px;padding:10px 12px">
<div class="header-search-wrap"> <div class="header-search-wrap">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input id="header-search-input" type="text" placeholder="Suchen…" oninput="onHeaderSearch(this.value)" autocomplete="off"> <input id="header-search-input" type="text" placeholder="Suchen…" oninput="onHeaderSearch(this.value)" autocomplete="off">
<button id="header-search-clear" onclick="closeHeaderSearch()" style="display:none;background:none;border:none;color:var(--text3);cursor:pointer;font-size:15px;padding:0;line-height:1;flex-shrink:0">✕</button> <button id="header-search-clear" onclick="closeHeaderSearch()" style="display:none;background:none;border:none;color:var(--text3);cursor:pointer;font-size:15px;padding:0;line-height:1;flex-shrink:0">✕</button>
</div>
<div style="display:flex;align-items:center;gap:2px;flex-shrink:0">
<button class="hbtn notif-bell-btn" onclick="toggleNotifPanel()" title="Benachrichtigungen" style="padding:8px;color:var(--text2)">
<!-- Honigtopf mit Brief statt Glocke --><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"><g transform="translate(-4.56 -4.54) scale(1.38)"><rect x="3.6" y="4.6" width="16.8" height="3" rx="1.1"/><path d="M5.5 7.6c-1.1 3.9-.7 8.2.7 10.4a2 2 0 0 0 1.7.9h8.2a2 2 0 0 0 1.7-.9c1.4-2.2 1.8-6.5.7-10.4"/><rect x="8.3" y="11.7" width="7.4" height="4.6" rx="0.7" stroke-width="1.25"/><path d="M8.6 12.1 12 14.4l3.4-2.3" stroke-width="1.25"/></g></svg>
<span class="notif-badge" style="display:none">0</span>
</button>
<button class="hbtn" onclick="openSettings()" title="Einstellungen" style="padding:8px;color:var(--text2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
</div> </div>
<div style="display:flex;align-items:center;gap:2px;flex-shrink:0"> <!-- Results panel: spans the full main-content width (anchored to the header, which already
<button class="hbtn notif-bell-btn" onclick="toggleNotifPanel()" title="Benachrichtigungen" style="padding:8px;color:var(--text2)"> sits right of the sidebar) so it never covers the sidebar and leaves no dead scroll strip -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> <div id="header-search-overlay" style="display:none">
<span class="notif-badge" style="display:none">0</span> <div id="header-search-results"></div>
</button>
<button class="hbtn" onclick="openSettings()" title="Einstellungen" style="padding:8px;color:var(--text2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div> </div>
</div> </div>
<!-- Results panel: spans the full main-content width (anchored to the header, which already <main class="main" id="main-content">
sits right of the sidebar) so it never covers the sidebar and leaves no dead scroll strip -->
<div id="header-search-overlay" style="display:none">
<div id="header-search-results"></div>
</div>
</div>
<!-- HOME --> <!-- HOME -->
<div class="page active" id="page-home"> <div class="page active" id="page-home">
@@ -144,6 +145,14 @@
<!-- ARTISTS --> <!-- ARTISTS -->
<div class="page" id="page-artists"> <div class="page" id="page-artists">
<div class="page-header"><div class="page-title">Künstler</div></div> <div class="page-header"><div class="page-title">Künstler</div></div>
<div id="artists-filter" class="sort-chips">
<button class="sort-chip active" data-filter="all" onclick="loadArtists('all')">Alle</button>
<button class="sort-chip" data-filter="followed" onclick="loadArtists('followed')">❤ Gefolgt <span id="artists-followed-count"></span></button>
</div>
<div class="list-search-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="artists-search" class="list-search" type="text" placeholder="Künstler suchen…" oninput="filterArtists(this.value)">
</div>
<div id="artists-grid" class="grid grid-4"></div> <div id="artists-grid" class="grid grid-4"></div>
</div> </div>
@@ -228,6 +237,7 @@
<div class="album-actions"> <div class="album-actions">
<button class="btn-play-all" id="btn-play-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Abspielen</button> <button class="btn-play-all" id="btn-play-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Abspielen</button>
<button class="btn-outline" id="btn-dl-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Download</button> <button class="btn-outline" id="btn-dl-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Download</button>
<button class="btn-outline" id="btn-star-album" title="Album markieren"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg><span>Merken</span></button>
</div> </div>
</div> </div>
</div> </div>
@@ -253,7 +263,14 @@
<!-- STARRED --> <!-- STARRED -->
<div class="page" id="page-starred"> <div class="page" id="page-starred">
<div class="page-header"><div class="page-title">Favoriten ⭐</div></div> <div class="page-header">
<div class="page-title">Favoriten ⭐</div>
<!-- Einstieg in die Zeitleiste steht dort, wo auch die gefolgten Kuenstler stehen —
gleiche Stelle wie in Android. Erscheint nur, wenn man ueberhaupt jemandem folgt. -->
<button id="starred-news-btn" class="btn-outline" style="display:none;font-size:12px;padding:8px 14px" onclick="navigate('artist-news')">
✨ Neu
</button>
</div>
<div id="starred-sort-chips" class="sort-chips"> <div id="starred-sort-chips" class="sort-chips">
<button class="sort-chip active" data-sort="newest" onclick="loadStarred('newest')">Neueste</button> <button class="sort-chip active" data-sort="newest" onclick="loadStarred('newest')">Neueste</button>
<button class="sort-chip" data-sort="oldest" onclick="loadStarred('oldest')">Älteste</button> <button class="sort-chip" data-sort="oldest" onclick="loadStarred('oldest')">Älteste</button>
@@ -265,6 +282,9 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="starred-search" class="list-search" type="text" placeholder="In Favoriten suchen…" oninput="filterStarred(this.value)"> <input id="starred-search" class="list-search" type="text" placeholder="In Favoriten suchen…" oninput="filterStarred(this.value)">
</div> </div>
<div id="starred-artists-section"></div>
<div id="starred-albums-section"></div>
<div id="starred-songs-title" class="search-section-title" style="display:none;margin:18px 0 8px">Songs</div>
<div id="starred-songs" class="song-list"></div> <div id="starred-songs" class="song-list"></div>
</div> </div>
@@ -402,6 +422,26 @@
</div> </div>
<!-- NEW RELEASES --> <!-- NEW RELEASES -->
<div class="page" id="page-artist-news">
<button class="back-btn" id="artist-news-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Favoriten</button>
<div class="page-header">
<div class="page-title">Neu von <em>deinen Künstlern</em></div>
</div>
<!-- Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind
dasselbe Thema. Nicht in EINE Liste gemischt — Veroeffentlichungen liegen hinter,
Konzerte vor dem heutigen Tag, chronologisch gemischt waere das nicht zu lesen. -->
<div id="artist-news-tabs" class="sort-chips" style="margin-bottom:6px">
<button class="sort-chip active" data-tab="releases" onclick="showArtistNewsTab('releases')">Neu erschienen</button>
<button class="sort-chip" data-tab="konzerte" onclick="showArtistNewsTab('konzerte')">Konzerte</button>
</div>
<div id="artist-news-sort" class="sort-chips">
<button class="sort-chip active" data-days="90" onclick="setArtistNewsDays(90)">90 Tage</button>
<button class="sort-chip" data-days="30" onclick="setArtistNewsDays(30)">30 Tage</button>
<button class="sort-chip" data-days="365" onclick="setArtistNewsDays(365)">1 Jahr</button>
</div>
<div id="artist-news-list"></div>
</div>
<div class="page" id="page-new-releases"> <div class="page" id="page-new-releases">
<div class="page-header"> <div class="page-header">
<div class="page-title">Neue <em>Releases</em></div> <div class="page-title">Neue <em>Releases</em></div>
@@ -446,12 +486,18 @@
</div> </div>
</main> </main>
</div>
</div> </div>
<!-- QUEUE PANEL --> <!-- QUEUE PANEL -->
<div class="queue-panel" id="queue-panel"> <div class="queue-panel" id="queue-panel">
<div class="queue-title">Warteschlange</div> <div class="queue-head">
<div class="queue-title">Warteschlange</div>
<button class="queue-close" onclick="toggleQueue()" title="Warteschlange schließen" aria-label="Schließen">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="queue-list" id="queue-list"></div> <div class="queue-list" id="queue-list"></div>
</div> </div>
@@ -1132,6 +1178,12 @@
<div id="np-lyrics-view"> <div id="np-lyrics-view">
<div id="np-lyrics-content"></div> <div id="np-lyrics-content"></div>
</div> </div>
<!-- Mitlaufen an/aus. Nur sichtbar, wenn es synchrone Zeilen gibt — bei reinem Text
gibt es nichts mitzuführen. -->
<button class="np-follow" id="np-follow-btn" onclick="toggleLyricsFolgen()" title="Automatisch mitlaufen">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="12 5 12 19"/><polyline points="6 13 12 19 18 13"/></svg>
<span id="np-follow-text">Mitlaufen</span>
</button>
<div id="np-queue-view"> <div id="np-queue-view">
<div id="np-queue-list"></div> <div id="np-queue-list"></div>
+14
View File
@@ -0,0 +1,14 @@
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "com.novasonic.app",
"sha256_cert_fingerprints": [
"10:39:A0:0E:D3:93:D3:93:CB:50:5D:C5:0D:73:97:2B:F7:40:7C:F8:08:CC:DB:EE:67:D0:E3:CD:64:AB:01:CF"
]
}
}
]
+6 -2
View File
@@ -27,5 +27,9 @@
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "maskable" "purpose": "maskable"
} }
] ],
} "launch_handler": {
"client_mode": "navigate-existing"
},
"handle_links": "preferred"
}
+23 -1
View File
@@ -37,6 +37,17 @@ const GITEA_REPO = process.env.GITEA_REPO || '';
const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
const TAG = process.env.GITEA_APK_TAG || 'apk-latest'; const TAG = process.env.GITEA_APK_TAG || 'apk-latest';
const TASK = process.env.APK_GRADLE_TASK || 'assembleDebug'; const TASK = process.env.APK_GRADLE_TASK || 'assembleDebug';
// Host fuer die App-Links geteilter Songs (https://<host>/?song=…). Steht fest im Manifest, muss
// also beim BAUEN mitgegeben werden — ohne ihn traegt das APK den Platzhalter aus build.gradle.kts
// und eingehende Links landen im Browser statt in der App. NOVASONIC_HOST2 optional fuer einen
// Domainwechsel (beide Adressen oeffnen dann in der App), siehe docs/share-links.md.
const HOST = process.env.NOVASONIC_HOST || '';
const HOST2 = process.env.NOVASONIC_HOST2 || '';
// Vorgabe-Adressen, die ins APK gebacken werden, damit auf einem frischen Geraet nur noch Benutzer
// und Passwort einzutippen sind. Zugangsdaten kommen hier bewusst NICHT vor.
const ND_URL = process.env.NOVASONIC_NAVIDROME || '';
const BACKEND_URL = process.env.NOVASONIC_BACKEND || '';
const SCROBBLE_URL= process.env.NOVASONIC_SCROBBLE || '';
function die(msg) { console.error(`\n✖ ${msg}`); process.exit(1); } function die(msg) { console.error(`\n✖ ${msg}`); process.exit(1); }
if (!GITEA_URL || !GITEA_REPO || !GITEA_TOKEN) if (!GITEA_URL || !GITEA_REPO || !GITEA_TOKEN)
@@ -68,7 +79,18 @@ const relBody = `versionCode=${versionCode}\nversionName=${version}`;
const isWin = process.platform === 'win32'; const isWin = process.platform === 'win32';
const gradlew = join(root, 'android', isWin ? 'gradlew.bat' : 'gradlew'); const gradlew = join(root, 'android', isWin ? 'gradlew.bat' : 'gradlew');
console.log(`▶ Baue APK: ${TASK} …`); console.log(`▶ Baue APK: ${TASK} …`);
const build = spawnSync(gradlew, [`:app:${TASK}`], { if (!HOST) {
console.warn('⚠ NOVASONIC_HOST ist nicht gesetzt — das APK bekommt den Platzhalter-Host aus');
console.warn(' build.gradle.kts, geteilte Links oeffnen dann NICHT in der App.');
console.warn(' In scripts/.env.release eintragen: NOVASONIC_HOST=music.deine-domain.de');
}
const gradleArgs = [`:app:${TASK}`];
if (HOST) gradleArgs.push(`-PnovasonicHost=${HOST}`);
if (HOST2) gradleArgs.push(`-PnovasonicHost2=${HOST2}`);
if (ND_URL) gradleArgs.push(`-PnovasonicNavidrome=${ND_URL}`);
if (BACKEND_URL) gradleArgs.push(`-PnovasonicBackend=${BACKEND_URL}`);
if (SCROBBLE_URL) gradleArgs.push(`-PnovasonicScrobble=${SCROBBLE_URL}`);
const build = spawnSync(gradlew, gradleArgs, {
cwd: join(root, 'android'), stdio: 'inherit', shell: isWin, cwd: join(root, 'android'), stdio: 'inherit', shell: isWin,
}); });
if (build.status !== 0) die('Gradle-Build fehlgeschlagen.'); if (build.status !== 0) die('Gradle-Build fehlgeschlagen.');
+829
View File
@@ -0,0 +1,829 @@
{
"name": "novasonic-scrobble",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "novasonic-scrobble",
"dependencies": {
"express": "^4.22.2"
},
"engines": {
"node": ">=22"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+4 -2
View File
@@ -2,11 +2,13 @@
"name": "novasonic-scrobble", "name": "novasonic-scrobble",
"private": true, "private": true,
"description": "Isolated timestamped play-log service for NovaSonic (stats + Wrapped).", "description": "Isolated timestamped play-log service for NovaSonic (stats + Wrapped).",
"engines": { "node": ">=22" }, "engines": {
"node": ">=22"
},
"scripts": { "scripts": {
"start": "node --experimental-sqlite scrobble-server.js" "start": "node --experimental-sqlite scrobble-server.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.0" "express": "^4.22.2"
} }
} }
+108 -20
View File
@@ -92,7 +92,7 @@ async function verifyUser(req) {
if (cached && cached.exp > now) return cached.ok ? u : null; if (cached && cached.exp > now) return cached.ok ? u : null;
try { try {
const url = `${NAVIDROME_URL}/rest/ping.view?u=${encodeURIComponent(u)}&t=${encodeURIComponent(t)}&s=${encodeURIComponent(s)}&v=1.16.1&c=NovaSonic&f=json`; const url = `${NAVIDROME_URL}/rest/ping.view?u=${encodeURIComponent(u)}&t=${encodeURIComponent(t)}&s=${encodeURIComponent(s)}&v=1.16.1&c=NovaSonic-Scrobble&f=json`;
const r = await fetch(url, { signal: AbortSignal.timeout(8000) }); const r = await fetch(url, { signal: AbortSignal.timeout(8000) });
const j = await r.json(); const j = await r.json();
const ok = j?.['subsonic-response']?.status === 'ok'; const ok = j?.['subsonic-response']?.status === 'ok';
@@ -172,6 +172,14 @@ app.get('/scrobble/history', async (req, res) => {
// ?tz= seconds east of UTC for local-day bucketing (default 0) // ?tz= seconds east of UTC for local-day bucketing (default 0)
// ?bucket= day | month | year — granularity of the time series (default day) // ?bucket= day | month | year — granularity of the time series (default day)
// ?limit= top-list size (default 20, max 100) // ?limit= top-list size (default 20, max 100)
// Gruppierungsschluessel fuer Texte: Klein-/Grossschreibung, Rand-Leerzeichen und mehrfache
// Leerzeichen duerfen keine zwei Eintraege aus einem machen. Genau das passiert sonst, sobald
// jemand Metadaten bearbeitet ("Dat Adam" -> "DAT ADAM" waeren zwei Kuenstler in der Statistik).
// SQLite kennt kein Regex, deshalb wird die Leerzeichen-Faltung zweimal angewandt (deckt bis zu
// vier aufeinanderfolgende Leerzeichen ab — mehr kommt in Tags praktisch nicht vor).
const normKey = (col) =>
`lower(trim(replace(replace(replace(COALESCE(${col},''), char(9), ' '), ' ', ' '), ' ', ' ')))`;
// Buckets are computed on the fly (GROUP BY strftime) — no rollup tables. // Buckets are computed on the fly (GROUP BY strftime) — no rollup tables.
const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' }; const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' };
@@ -202,31 +210,62 @@ app.get('/scrobble/stats', async (req, res) => {
// navigator so you can't step before logging began. // navigator so you can't step before logging began.
const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null; const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null;
// Anzeigename aus dem JUENGSTEN Play, nicht per MAX(): sonst gewinnt lexikografisch die alte
// Schreibweise und die Statistik zeigt nach einer Korrektur weiter den alten Namen.
const topArtists = db.prepare(` const topArtists = db.prepare(`
SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds WITH neueste AS (
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' SELECT ${normKey('artist')} AS k, artist, artist_id, MAX(played_at)
GROUP BY lower(artist) ORDER BY plays DESC, seconds DESC LIMIT :limit FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
), zaehler AS (
SELECT ${normKey('artist')} AS k, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
)
SELECT n.artist AS name, n.artist_id AS artistId, z.plays AS plays, z.seconds AS seconds
FROM neueste n JOIN zaehler z ON z.k = n.k
ORDER BY z.plays DESC, z.seconds DESC LIMIT :limit
`).all(winL); `).all(winL);
const topAlbums = db.prepare(` const topAlbums = db.prepare(`
SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist, WITH neueste AS (
MAX(cover_art) AS coverArt, COUNT(*) AS plays SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2,
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' album, album_id, artist, cover_art, MAX(played_at)
GROUP BY lower(album), lower(COALESCE(artist,'')) ORDER BY plays DESC LIMIT :limit FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
), zaehler AS (
SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
)
SELECT n.album AS name, n.album_id AS albumId, n.artist AS artist,
n.cover_art AS coverArt, z.plays AS plays
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
ORDER BY z.plays DESC LIMIT :limit
`).all(winL); `).all(winL);
// Nicht nach song_id gruppieren: wird eine Datei beim Metadaten-Bearbeiten umbenannt, vergibt
// Navidrome eine NEUE id — der Titel erschiene dann zweimal mit geteilten Zaehlern. Schluessel ist
// deshalb Titel+Kuenstler; Anzeigefelder (inkl. song_id fuer die Navigation) kommen aus dem
// JUENGSTEN Play, also mit den frisch bearbeiteten Tags. Das `MAX(played_at)` mit ansonsten
// blanken Spalten ist dokumentiertes SQLite-Verhalten: es liefert die Zeile des Maximums.
const topTracks = db.prepare(` const topTracks = db.prepare(`
SELECT MAX(title) AS title, MAX(artist) AS artist, MAX(album) AS album, WITH neueste AS (
MAX(album_id) AS albumId, MAX(artist_id) AS artistId, MAX(cover_art) AS coverArt, SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2,
song_id AS songId, COUNT(*) AS plays title, artist, album, album_id, artist_id, cover_art, song_id, MAX(played_at)
FROM plays WHERE ${WHERE} FROM plays WHERE ${WHERE}
GROUP BY song_id ORDER BY plays DESC LIMIT :limit GROUP BY k1, k2
), zaehler AS (
SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
FROM plays WHERE ${WHERE}
GROUP BY k1, k2
)
SELECT n.title AS title, n.artist AS artist, n.album AS album, n.album_id AS albumId,
n.artist_id AS artistId, n.cover_art AS coverArt, n.song_id AS songId, z.plays AS plays
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
ORDER BY z.plays DESC LIMIT :limit
`).all(winL); `).all(winL);
const topGenres = db.prepare(` const topGenres = db.prepare(`
SELECT MAX(genre) AS name, COUNT(*) AS plays SELECT MAX(genre) AS name, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND genre IS NOT NULL AND genre <> '' FROM plays WHERE ${WHERE} AND genre IS NOT NULL AND genre <> ''
GROUP BY lower(genre) ORDER BY plays DESC LIMIT :limit GROUP BY ${normKey('genre')} ORDER BY plays DESC LIMIT :limit
`).all(winL); `).all(winL);
const series = db.prepare(` const series = db.prepare(`
@@ -263,6 +302,9 @@ app.get('/scrobble/users', async (req, res) => {
res.json({ me, users: rows }); res.json({ me, users: rows });
}); });
// Gegenstueck zu normKey() in JS — fuer Abgleiche, die nicht in SQL stattfinden.
const _norm = (v) => (v || '').toLowerCase().replace(/[\t\s]+/g, ' ').trim();
// GET /scrobble/compare?with=NAME&from= — Head-to-Head "Musik-Match" of the authed user (A) vs // GET /scrobble/compare?with=NAME&from= — Head-to-Head "Musik-Match" of the authed user (A) vs
// another user (B). Taste similarity = cosine of the two artist-play vectors (robust, 0..100). // another user (B). Taste similarity = cosine of the two artist-play vectors (robust, 0..100).
// Also returns shared artists/tracks and each side's unique top artists. // Also returns shared artists/tracks and each side's unique top artists.
@@ -280,14 +322,14 @@ app.get('/scrobble/compare', async (req, res) => {
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to'; const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
const artistsOf = u => db.prepare(` const artistsOf = u => db.prepare(`
SELECT lower(artist) AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays SELECT ${normKey('artist')} AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
GROUP BY lower(artist) GROUP BY ${normKey('artist')}
`).all({ user: u, from, to }); `).all({ user: u, from, to });
const tracksOf = u => db.prepare(` const tracksOf = u => db.prepare(`
SELECT song_id AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId, SELECT MAX(song_id) AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays
FROM plays WHERE ${WHERE} GROUP BY song_id FROM plays WHERE ${WHERE} GROUP BY ${normKey('title')}, ${normKey('artist')}
`).all({ user: u, from, to }); `).all({ user: u, from, to });
const totalsOf = u => db.prepare( const totalsOf = u => db.prepare(
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}` `SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`
@@ -316,11 +358,15 @@ app.get('/scrobble/compare', async (req, res) => {
.sort((x, y) => y.plays - x.plays).slice(0, 8) .sort((x, y) => y.plays - x.plays).slice(0, 8)
.map(r => ({ name: r.name, artistId: r.artistId, plays: r.plays })); .map(r => ({ name: r.name, artistId: r.artistId, plays: r.plays }));
// Gemeinsame Titel ueber Titel+Kuenstler abgleichen, NICHT ueber song_id: haben beide denselben
// Song aus verschiedenen Dateien gehoert — oder wurde eine Datei zwischendurch umbenannt, wobei
// Navidrome eine neue id vergibt —, galt er sonst faelschlich als nicht gemeinsam.
const tKey = (r) => `${_norm(r.title)}\u0000${_norm(r.artist)}`;
const aTracks = tracksOf(userA); const aTracks = tracksOf(userA);
const bTrackMap = new Map(tracksOf(userB).map(r => [r.songId, r])); const bTrackMap = new Map(tracksOf(userB).map(r => [tKey(r), r]));
const sharedTracks = []; const sharedTracks = [];
for (const ta of aTracks) { for (const ta of aTracks) {
const tb = bTrackMap.get(ta.songId); const tb = bTrackMap.get(tKey(ta));
if (tb) sharedTracks.push({ if (tb) sharedTracks.push({
songId: ta.songId, title: ta.title, artist: ta.artist, artistId: ta.artistId, songId: ta.songId, title: ta.title, artist: ta.artist, artistId: ta.artistId,
albumId: ta.albumId, coverArt: ta.coverArt, playsA: ta.plays, playsB: tb.plays, albumId: ta.albumId, coverArt: ta.coverArt, playsA: ta.plays, playsB: tb.plays,
@@ -328,9 +374,51 @@ app.get('/scrobble/compare', async (req, res) => {
} }
sharedTracks.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB)); sharedTracks.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
// ── Anzeigetafel: Kategorien, die je einen Sieger haben ──────────────────────
// Bewusst Kennzahlen, die etwas ueber die HOERWEISE sagen statt ueber schiere Menge — sonst
// gewinnt immer, wer laenger dabei ist. Unentschieden ist erlaubt und zaehlt fuer niemanden.
const nachtAnteil = u => {
const r = db.prepare(`
SELECT COUNT(*) AS n,
SUM(CASE WHEN CAST(strftime('%H', played_at + :tz, 'unixepoch') AS INTEGER) < 5
THEN 1 ELSE 0 END) AS nacht
FROM plays WHERE ${WHERE}
`).get({ user: u, from, to, tz: Math.trunc(Number(req.query.tz) || 0) });
return r.n ? (r.nacht || 0) / r.n : 0;
};
const tA = totalsOf(userA), tB = totalsOf(userB);
const exklusivAnteil = (mine, other) =>
mine.length ? mine.filter(r => !other.has(r.k)).length / mine.length : 0;
const tiefe = (t, artists) => (artists ? t.plays / artists : 0);
const kat = (schluessel, label, a, b, format, hoeherGewinnt = true) => {
const gleich = Math.abs(a - b) < 1e-9;
return {
key: schluessel, label,
a, b,
textA: format(a), textB: format(b),
sieger: gleich ? null : ((a > b) === hoeherGewinnt ? 'A' : 'B'),
};
};
const std = v => `${(v / 3600).toFixed(1)} h`;
const proz = v => `${Math.round(v * 100)} %`;
const zahl = v => v.toLocaleString('de-DE');
const komma = v => v.toFixed(1);
const kategorien = [
kat('hoerzeit', 'Hörzeit', tA.seconds, tB.seconds, std),
kat('vielfalt', 'Vielfalt', aArtists.length, bArtists.length, zahl),
kat('tiefe', 'Tiefe', tiefe(tA, aArtists.length), tiefe(tB, bArtists.length), komma),
kat('nachteule', 'Nachteule', nachtAnteil(userA), nachtAnteil(userB), proz),
kat('entdecker', 'Entdeckerdrang', exklusivAnteil(aArtists, bMap), exklusivAnteil(bArtists, aMap), proz),
];
const punkteA = kategorien.filter(k => k.sieger === 'A').length;
const punkteB = kategorien.filter(k => k.sieger === 'B').length;
res.json({ res.json({
userA, userB, range: { from, to }, userA, userB, range: { from, to },
matchScore, matchScore,
battle: { kategorien, punkteA, punkteB },
totalsA: { ...totalsOf(userA), artists: aArtists.length }, totalsA: { ...totalsOf(userA), artists: aArtists.length },
totalsB: { ...totalsOf(userB), artists: bArtists.length }, totalsB: { ...totalsOf(userB), artists: bArtists.length },
sharedArtists: sharedArtists.slice(0, 30), sharedArtists: sharedArtists.slice(0, 30),
+41 -4
View File
@@ -20,7 +20,7 @@ import {
navigate, _navGoBack, _navOnPopState, _navLoad, onHeaderSearch, closeHeaderSearch, navigate, _navGoBack, _navOnPopState, _navLoad, onHeaderSearch, closeHeaderSearch,
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
searchSimilarArtist, searchAndPreview, loadDeezerAlbum, closeEventsModal, searchSimilarArtist, searchAndPreview, loadDeezerAlbum, closeEventsModal,
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, loadAlbums, loadArtists, filterArtists, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
} from './views/views.js'; } from './views/views.js';
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
@@ -37,7 +37,7 @@ import {
} from './views/abs.js'; } from './views/abs.js';
import { import {
openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum, openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum,
openNpMenu, closeNpMenu, openNpMenu, closeNpMenu, toggleLyricsFolgen,
} from './player/nowplaying.js'; } from './player/nowplaying.js';
import { import {
openSettings, closeSettings, saveSettings, confirmDisconnect, openSettings, closeSettings, saveSettings, confirmDisconnect,
@@ -111,6 +111,12 @@ async function startApp() {
} else { } else {
navigate('home'); navigate('home');
} }
// Geteilter Link (?song=… / ?album=…): erst NACH der Anmeldung aufloesen — vorher gibt es keine
// Zugangsdaten, mit denen sich der Inhalt laden liesse. Ueberschreibt bewusst die
// wiederhergestellte Ansicht: wer einen Link oeffnet, will dorthin und nicht dahin, wo er zuletzt war.
import('./ui/share.js')
.then(m => m.handleShareParam(navigate, playQueue))
.catch(() => {});
initNotifBell(); initNotifBell();
initPlayLog(); // flush any queued plays to the scrobble service (offline-safe) initPlayLog(); // flush any queued plays to the scrobble service (offline-safe)
loadArtistOverrides().catch(() => {}); // pinned same-name artist → Deezer mappings (shared) loadArtistOverrides().catch(() => {}); // pinned same-name artist → Deezer mappings (shared)
@@ -120,6 +126,8 @@ async function startApp() {
const sr = localStorage.getItem('ns_repeat'); const sr = localStorage.getItem('ns_repeat');
if(sr === 'all' || sr === 'one') S.repeat = sr; if(sr === 'all' || sr === 'one') S.repeat = sr;
if(localStorage.getItem('ns_shuffle') === '1') S.shuffle = true; if(localStorage.getItem('ns_shuffle') === '1') S.shuffle = true;
// Mitlaufen der Lyrics: Vorgabe AUS, nur ein ausdruecklich gesetztes '1' schaltet es ein.
S.lyricsFolgen = localStorage.getItem('ns_lyrics_folgen') === '1';
_applyShuffleRepeat(); _applyShuffleRepeat();
} }
@@ -210,6 +218,12 @@ window.addEventListener('load', () => {
// backgrounded (that routes through togglePlay → S._userPaused=true), which used to wrongly // backgrounded (that routes through togglePlay → S._userPaused=true), which used to wrongly
// auto-resume on foreground because _wasPlayingBeforeHide was still true from hide-time. // auto-resume on foreground because _wasPlayingBeforeHide was still true from hide-time.
if(!S.audio.paused) return; if(!S.audio.paused) return;
// Am ENDE der Warteschlange nicht fortsetzen. Ein beendetes <audio> ist zwar "paused", aber
// play() spult dann auf Anfang zurueck und spielt denselben Titel NOCH EINMAL. Bei
// ausgeschaltetem Repeat steht nach dem letzten Titel nichts mehr an, trotzdem lief er bei
// jedem Zurueckholen der Seite wieder los — genau das sah wie "der letzte Titel wiederholt
// sich mehrfach" aus, besonders bei Shuffle, wo der Letzte ein beliebiger ist.
if(S.audio.ended) return;
if(s && S.audio.src && _wasPlayingBeforeHide && !S._userPaused) S.audio.play().catch(() => { S._trackChanging = false; }); if(s && S.audio.src && _wasPlayingBeforeHide && !S._userPaused) S.audio.play().catch(() => { S._trackChanging = false; });
} }
@@ -310,6 +324,25 @@ window.addEventListener('load', () => {
}); });
window.addEventListener('popstate', (e) => _navOnPopState(e.state)); window.addEventListener('popstate', (e) => _navOnPopState(e.state));
// Im installierten Fenster ist "display": "standalone" — es gibt keine Zurueck-/Vorwaerts-Knoepfe,
// und die Maustasten werden dort nicht auf den Verlauf umgelenkt: Zurueck war im Programmfenster am
// PC also ueberhaupt nicht erreichbar, im Browser-Tab dagegen schon. Deshalb uebernehmen wir X1/X2
// selbst — aber NUR im Standalone-Fenster, im Tab macht das der Browser und wir wuerden doppelt
// navigieren. (Alt+Links/Rechts bleibt dem Browser ueberlassen, das kann er auch dort selbst.)
const _imAppFenster = () => {
try { return window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; }
catch { return false; }
};
window.addEventListener('mouseup', (e) => {
if (!_imAppFenster()) return;
if (e.button === 3) { e.preventDefault(); _navGoBack(); }
else if (e.button === 4) { e.preventDefault(); try { history.forward(); } catch {} }
});
// Ohne das zeigt Chrome im Fenster das Kontextmenue der Seitentaste.
window.addEventListener('auxclick', (e) => {
if (_imAppFenster() && (e.button === 3 || e.button === 4)) e.preventDefault();
});
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if(e.key !== 'Backspace') return; if(e.key !== 'Backspace') return;
const t = document.activeElement?.tagName; const t = document.activeElement?.tagName;
@@ -325,13 +358,17 @@ Object.assign(window, {
navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum, navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
loadDeezerAlbum, closeEventsModal, loadDeezerAlbum, closeEventsModal,
// Lazy: die Zeitleiste zieht nur beim Öffnen ihren Code nach.
loadArtistNews: (tage) => import('./views/artistNews.js').then(m => m.loadArtistNews(tage)),
showArtistNewsTab: (reiter) => import('./views/artistNews.js').then(m => m.showArtistNewsTab(reiter)),
setArtistNewsDays: (tage) => import('./views/artistNews.js').then(m => m.setArtistNewsDays(tage)),
// Player // Player
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat, playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview, toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
toggleStar, dlSongLocal, toggleRadioMode, stopPreview, npToggleStar, toggleStar, dlSongLocal, toggleRadioMode, stopPreview, npToggleStar,
// Now Playing // Now Playing
openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum, openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum,
openNpMenu, closeNpMenu, openNpMenu, closeNpMenu, toggleLyricsFolgen,
npSeek: () => {}, npSeek: () => {},
// UI / Modals // UI / Modals
openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend, openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend,
@@ -340,7 +377,7 @@ Object.assign(window, {
openCompare, closeCompare, openCompare, closeCompare,
closeArtistPin, clearArtistPin, closeArtistPin, clearArtistPin,
// Views // Views
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, loadAlbums, loadArtists, filterArtists, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
onImporterInput, resolveImporterLink, onGlobalInput, tryImporterResolve, onImporterInput, resolveImporterLink, onGlobalInput, tryImporterResolve,
+32 -3
View File
@@ -5,6 +5,7 @@ import { coverUrl } from '../api.js';
import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar, _syncOverlayState, isPlaying, seekToMs, updatePlayerUI } from './player.js'; import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar, _syncOverlayState, isPlaying, seekToMs, updatePlayerUI } from './player.js';
import { navigate } from '../views/views.js'; import { navigate } from '../views/views.js';
import { openPlModal, showSongInfo } from '../ui/ui.js'; import { openPlModal, showSongInfo } from '../ui/ui.js';
import { macheQueueBearbeitbar, QUEUE_WERKZEUGE } from './queueEdit.js';
// ─── NOW PLAYING OVERLAY ─── // ─── NOW PLAYING OVERLAY ───
export function openNowPlaying() { export function openNowPlaying() {
@@ -122,9 +123,32 @@ function _renderSyncedLines(content, lines) {
const bot = document.createElement('div'); const bot = document.createElement('div');
bot.style.cssText = 'height:40vh;flex-shrink:0'; bot.style.cssText = 'height:40vh;flex-shrink:0';
content.appendChild(bot); content.appendChild(bot);
_zeigeFolgenSchalter(true);
updateLyricsHighlight(S.audio.currentTime * 1000); updateLyricsHighlight(S.audio.currentTime * 1000);
} }
// ─── MITLAUFEN ───
// Der Schalter erscheint nur bei synchronen Zeilen: ohne Zeitstempel gibt es nichts mitzufuehren.
function _zeigeFolgenSchalter(hatSync) {
const btn = document.getElementById('np-follow-btn');
if (!btn) return;
btn.classList.toggle('hat-sync', !!hatSync);
btn.classList.toggle('active', !!S.lyricsFolgen);
const txt = document.getElementById('np-follow-text');
if (txt) txt.textContent = S.lyricsFolgen ? 'Folgt' : 'Mitlaufen';
btn.title = S.lyricsFolgen
? 'Mitlaufen aus — Text von Hand scrollen'
: 'Mitlaufen an — Text springt zur laufenden Zeile';
}
export function toggleLyricsFolgen() {
S.lyricsFolgen = !S.lyricsFolgen;
localStorage.setItem('ns_lyrics_folgen', S.lyricsFolgen ? '1' : '0');
_zeigeFolgenSchalter(_lyricsLines.length > 0);
// Beim Einschalten sofort zur laufenden Zeile — sonst wartet man bis zur naechsten.
if (S.lyricsFolgen) updateLyricsHighlight(S.audio.currentTime * 1000);
}
function _parseLrc(text) { function _parseLrc(text) {
let offsetMs = 0; let offsetMs = 0;
const lines = []; const lines = [];
@@ -142,7 +166,7 @@ async function _loadNpLyrics(songId) {
const content = document.getElementById('np-lyrics-content'); const content = document.getElementById('np-lyrics-content');
if(!content) return; if(!content) return;
if(_lyricsSongId === songId && (_lyricsLines.length || content.querySelector('.lyrics-plain,.lyrics-none'))) { if(_lyricsSongId === songId && (_lyricsLines.length || content.querySelector('.lyrics-plain,.lyrics-none'))) {
if(_lyricsLines.length) updateLyricsHighlight(S.audio.currentTime * 1000); if(_lyricsLines.length) { _zeigeFolgenSchalter(true); updateLyricsHighlight(S.audio.currentTime * 1000); }
return; return;
} }
_lyricsSongId = songId; _lyricsSongId = songId;
@@ -252,6 +276,7 @@ function _showLyricsResult(content, result) {
const parsed = _parseLrc(result.syncedLyrics); const parsed = _parseLrc(result.syncedLyrics);
if(parsed.length) { _renderSyncedLines(content, parsed); return; } if(parsed.length) { _renderSyncedLines(content, parsed); return; }
} }
_zeigeFolgenSchalter(false);
if(result?.plainLyrics) { if(result?.plainLyrics) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'lyrics-plain'; el.className = 'lyrics-plain';
@@ -278,7 +303,8 @@ export function updateLyricsHighlight(currentMs) {
line.el.classList.toggle('active', i === activeIdx); line.el.classList.toggle('active', i === activeIdx);
line.el.classList.toggle('past', i < activeIdx); line.el.classList.toggle('past', i < activeIdx);
}); });
if(activeIdx >= 0) { // Hervorheben passiert immer, Scrollen nur wenn Mitlaufen eingeschaltet ist.
if(activeIdx >= 0 && S.lyricsFolgen) {
const activeEl = _lyricsLines[activeIdx].el; const activeEl = _lyricsLines[activeIdx].el;
const container = document.getElementById('np-lyrics-view'); const container = document.getElementById('np-lyrics-view');
if(container) { if(container) {
@@ -293,6 +319,7 @@ export function onNpSongChange(songId) {
_lyricsLines = []; _lyricsLines = [];
const content = document.getElementById('np-lyrics-content'); const content = document.getElementById('np-lyrics-content');
if(content) content.innerHTML = ''; if(content) content.innerHTML = '';
_zeigeFolgenSchalter(false);
// Warm the cache shortly AFTER the track starts — deferred so the lyrics requests // Warm the cache shortly AFTER the track starts — deferred so the lyrics requests
// don't compete with the audio stream and delay playback start. // don't compete with the audio stream and delay playback start.
clearTimeout(_prefetchTimer); clearTimeout(_prefetchTimer);
@@ -313,11 +340,13 @@ export function renderNpQueue() {
S.queue.forEach((s, i) => { S.queue.forEach((s, i) => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'np-queue-item' + (i===S.qIdx ? ' nq-active' : ''); item.className = 'np-queue-item' + (i===S.qIdx ? ' nq-active' : '');
item.dataset.qidx = i;
const cv = coverUrl(s.coverArt, 128); const cv = coverUrl(s.coverArt, 128);
item.innerHTML = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>`; item.innerHTML = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>` + QUEUE_WERKZEUGE;
item.onclick = () => { S.qIdx=i; playCurrent(); renderNpQueue(); }; item.onclick = () => { S.qIdx=i; playCurrent(); renderNpQueue(); };
el.appendChild(item); el.appendChild(item);
}); });
macheQueueBearbeitbar('np-queue-list');
el.querySelector('.nq-active')?.scrollIntoView({block:'nearest'}); el.querySelector('.nq-active')?.scrollIntoView({block:'nearest'});
} }
+64 -15
View File
@@ -3,8 +3,10 @@ import { esc, formatDur, toast } from '../utils.js';
import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from '../api.js'; import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from '../api.js';
// views.js/ui.js circular imports — safe (only used inside function bodies) // views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from '../views/views.js'; import { loadRecommendations } from '../views/views.js';
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js'; import { updateLyricsHighlight, onNpSongChange, syncNowPlaying, renderNpQueue } from './nowplaying.js';
import { logPlay } from './scrobbleLog.js'; import { logPlay } from './scrobbleLog.js';
import { macheQueueBearbeitbar, QUEUE_WERKZEUGE } from './queueEdit.js';
import { queueNachEntfernen, queueNachVerschieben, mischeListe } from './queueMath.js';
// ─── NATIVE BRIDGE DETECTION ─────────────────────────────────────────────── // ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
// True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl). // True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl).
@@ -364,7 +366,7 @@ export function playQueue(songs, startIdx, opts = {}) {
// opts.noShuffle = true skips shuffle (e.g. clicking a specific song on the artist page). // opts.noShuffle = true skips shuffle (e.g. clicking a specific song on the artist page).
if (S.shuffle && songs.length > 1 && !opts.noShuffle) { if (S.shuffle && songs.length > 1 && !opts.noShuffle) {
S._origQueue = songs.map(s => ({...s})); S._origQueue = songs.map(s => ({...s}));
S.queue = [...songs].sort(() => Math.random() - 0.5).map(s => ({...s})); S.queue = mischeListe(songs).map(s => ({...s}));
S.qIdx = 0; S.qIdx = 0;
} else { } else {
S.queue = songs.map(s => ({...s})); S.queue = songs.map(s => ({...s}));
@@ -791,7 +793,7 @@ export function toggleShuffle() {
if (S.shuffle) { if (S.shuffle) {
const cur = S.queue[S.qIdx]; const cur = S.queue[S.qIdx];
S._origQueue = [...S.queue]; S._origQueue = [...S.queue];
const rest = S.queue.filter((_, i) => i !== S.qIdx).sort(() => Math.random() - 0.5); const rest = mischeListe(S.queue.filter((_, i) => i !== S.qIdx));
S.queue = [cur, ...rest]; S.queue = [cur, ...rest];
S.qIdx = 0; S.qIdx = 0;
} else if (S._origQueue) { } else if (S._origQueue) {
@@ -869,8 +871,12 @@ export async function fillRadioQueue() {
if (gen !== _queueGen) return; if (gen !== _queueGen) return;
const rr = await api('getRandomSongs', '&size=20'); const rr = await api('getRandomSongs', '&size=20');
const randomFresh = (rr.randomSongs?.song || []).filter(s => !recentIds.has(s.id)); const randomFresh = (rr.randomSongs?.song || []).filter(s => !recentIds.has(s.id));
// If random is also exhausted, allow repeats to keep music playing // Ist auch der Zufall erschoepft, sind Wiederholungen erlaubt, damit Musik laeuft — aber
newSongs = randomFresh.length > 0 ? randomFresh : newSongs.slice(0, 5); // NIE der gerade laufende Titel: der landete sonst direkt hinter sich selbst und spielte
// zweimal in Folge.
newSongs = randomFresh.length > 0
? randomFresh
: newSongs.filter(s => s.id !== seed?.id).slice(0, 5);
} else { } else {
newSongs = deduped; newSongs = deduped;
} }
@@ -1085,16 +1091,19 @@ export function updateMediaSession(s) {
{ src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' }, { src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' },
{ src: _cachedCoverUrl(s.coverArt, 1024), sizes:'1024x1024', type:'image/jpeg' }, { src: _cachedCoverUrl(s.coverArt, 1024), sizes:'1024x1024', type:'image/jpeg' },
] : []; ] : [];
// Mutate the existing metadata in place rather than assigning a new MediaMetadata — // Feldweise mutieren statt ein neues MediaMetadata zuzuweisen vermeidet unter Windows das
// replacing the object makes Windows tear down & rebuild the media tile (visible // Neuaufbauen der Medienkachel (sichtbares Flackern beim Skippen). Auf Linux kam davon bei MPRIS
// flicker when skipping). Spotify-style: keep one object, just update its fields. // aber nichts an: Desktop-Widgets zeigten dauerhaft den ZUERST gespielten Titel, weil nur die
if (ms.metadata) { // erste Zuweisung gemeldet wurde. Deshalb jetzt zweigeteilt — bei echtem Titelwechsel ein neues
ms.metadata.title = s.title; // Objekt (garantiert gemeldet), sonst weiter in-place (z. B. wenn nur das Cover nachgeladen wird).
ms.metadata.artist = s.artist || ''; const wechsel = !ms.metadata
ms.metadata.album = s.album || ''; || ms.metadata.title !== s.title
ms.metadata.artwork = artwork; || ms.metadata.artist !== (s.artist || '')
} else { || ms.metadata.album !== (s.album || '');
if (wechsel) {
ms.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork }); ms.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
} else {
ms.metadata.artwork = artwork;
} }
// Action handlers are stable — set them once, not on every track change. // Action handlers are stable — set them once, not on every track change.
if (_msHandlersSet) return; if (_msHandlersSet) return;
@@ -1184,14 +1193,53 @@ export function renderQueue() {
S.queue.forEach((s, i) => { S.queue.forEach((s, i) => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'queue-item' + (i === S.qIdx ? ' active' : ''); item.className = 'queue-item' + (i === S.qIdx ? ' active' : '');
item.dataset.qidx = i;
const cv = coverUrl(s.coverArt, 128); const cv = coverUrl(s.coverArt, 128);
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(dispTitle(s))}</div><div class="qi-artist">${esc(dispArtist(s))}</div></div>`; item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(dispTitle(s))}</div><div class="qi-artist">${esc(dispArtist(s))}</div></div>` + QUEUE_WERKZEUGE;
item.onclick = () => { S.qIdx = i; playCurrent(); }; item.onclick = () => { S.qIdx = i; playCurrent(); };
el.appendChild(item); el.appendChild(item);
}); });
macheQueueBearbeitbar('queue-list');
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' }); el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
} }
// ─── WARTESCHLANGE BEARBEITEN ─────────────────────────────────────────────
// S.queue ist eine einfache Liste, S.qIdx zeigt auf den laufenden Titel. Beim Aendern muss der
// Zeiger mitwandern — sonst spielt nach dem Entfernen eines Titels weiter oben ploetzlich der
// falsche. Gemerkt wird deshalb die ID des laufenden Titels, nicht seine Position.
/** Entfernt den Titel an Position i. Traf es den laufenden, laeuft der naechste an. */
export function queueEntfernen(i) {
if (i < 0 || i >= S.queue.length) return;
const { qIdx, warAktuell } = queueNachEntfernen(S.queue.length, S.qIdx, i);
S.queue.splice(i, 1);
S.qIdx = qIdx;
_renderedQueueKey = '';
if (!S.queue.length) {
S.qIdx = -1;
try { S._audioEls?.forEach(a => { a.pause(); }); } catch {}
setPlayIcon(false);
renderQueue(); renderNpQueue();
toast('Warteschlange geleert');
return;
}
if (warAktuell) playCurrent();
renderQueue(); renderNpQueue();
}
/** Verschiebt den Titel von Position `von` auf Position `nach`. */
export function queueVerschieben(von, nach) {
const n = S.queue.length;
if (von === nach || von < 0 || von >= n || nach < 0 || nach >= n) return;
const { qIdx } = queueNachVerschieben(S.queue.map(x => x.id), S.qIdx, von, nach);
const [titel] = S.queue.splice(von, 1);
S.queue.splice(nach, 0, titel);
S.qIdx = qIdx;
_renderedQueueKey = '';
renderQueue(); renderNpQueue();
}
export function toggleQueue() { export function toggleQueue() {
S.queueOpen = !S.queueOpen; S.queueOpen = !S.queueOpen;
document.getElementById('queue-panel').classList.toggle('open', S.queueOpen); document.getElementById('queue-panel').classList.toggle('open', S.queueOpen);
@@ -1385,5 +1433,6 @@ export function _restoreLastPlayed() {
// Web path: prime the audio element so play button works immediately. // Web path: prime the audio element so play button works immediately.
S.audio.src = streamUrl(s.id); S.audio.src = streamUrl(s.id);
S.audio.load(); S.audio.load();
} catch {} } catch {}
} }
+90
View File
@@ -0,0 +1,90 @@
import { queueEntfernen, queueVerschieben } from './player.js';
// ─── WARTESCHLANGE BEARBEITEN (Web) ─────────────────────────────────────────
// Ziehen und Entfernen fuer beide Queue-Listen: die Seitenleiste und die im Now-Playing.
//
// Bewusst ueber Zeiger-Ereignisse statt HTML5-Drag-and-Drop: dessen `dragstart` gibt es auf
// Touch-Geraeten gar nicht, die Warteschlange waere am Handy also unbearbeitbar geblieben.
// `setPointerCapture` liefert dafuer Maus und Finger ueber denselben Weg.
const ZIEH_SCHWELLE = 4; // px, bevor aus einem Tippen ein Ziehen wird
export function macheQueueBearbeitbar(listeId) {
const liste = document.getElementById(listeId);
if (!liste || liste.dataset.bearbeitbar) return;
liste.dataset.bearbeitbar = '1';
liste.addEventListener('click', (e) => {
const weg = e.target.closest('.q-del');
if (!weg) return;
e.stopPropagation();
const zeile = weg.closest('[data-qidx]');
if (zeile) queueEntfernen(Number(zeile.dataset.qidx));
});
liste.addEventListener('pointerdown', (e) => {
const griff = e.target.closest('.q-grip');
if (!griff || e.button > 0) return;
const zeile = griff.closest('[data-qidx]');
if (!zeile) return;
e.preventDefault();
e.stopPropagation();
ziehen(liste, zeile, e);
});
}
function ziehen(liste, zeile, start) {
const zeilen = [...liste.querySelectorAll('[data-qidx]')];
const vonIdx = Number(zeile.dataset.qidx);
const hoehe = zeile.offsetHeight || 1;
const startY = start.clientY;
let zielIdx = vonIdx;
let aktiv = false;
const bewegen = (e) => {
const dy = e.clientY - startY;
if (!aktiv) {
if (Math.abs(dy) < ZIEH_SCHWELLE) return;
aktiv = true;
zeile.classList.add('q-dragging');
liste.classList.add('q-dragging-aktiv');
}
zeile.style.transform = `translateY(${dy}px)`;
zielIdx = Math.max(0, Math.min(zeilen.length - 1, vonIdx + Math.round(dy / hoehe)));
// Die Zeilen zwischen Start und Ziel weichen um eine Zeilenhoehe aus, damit die Luecke
// dorthin wandert, wo losgelassen wird.
zeilen.forEach((z, i) => {
if (z === zeile) return;
let v = 0;
if (vonIdx < zielIdx && i > vonIdx && i <= zielIdx) v = -hoehe;
else if (vonIdx > zielIdx && i >= zielIdx && i < vonIdx) v = hoehe;
z.style.transform = v ? `translateY(${v}px)` : '';
});
};
const beenden = () => {
liste.releasePointerCapture?.(start.pointerId);
window.removeEventListener('pointermove', bewegen);
window.removeEventListener('pointerup', beenden);
window.removeEventListener('pointercancel', beenden);
zeilen.forEach(z => { z.style.transform = ''; z.classList.remove('q-dragging'); });
liste.classList.remove('q-dragging-aktiv');
if (aktiv && zielIdx !== vonIdx) queueVerschieben(vonIdx, zielIdx);
};
try { liste.setPointerCapture(start.pointerId); } catch {}
window.addEventListener('pointermove', bewegen);
window.addEventListener('pointerup', beenden);
window.addEventListener('pointercancel', beenden);
}
/** Griff und Entfernen-Knopf als HTML — in beiden Listen identisch. */
export const QUEUE_WERKZEUGE =
'<button class="q-del" title="Aus der Warteschlange entfernen" aria-label="Entfernen">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' +
'</button>' +
'<span class="q-grip" title="Zum Verschieben ziehen" aria-label="Verschieben">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/></svg>' +
'</span>';
+58
View File
@@ -0,0 +1,58 @@
// Reine Indexrechnung der Warteschlange — bewusst OHNE Importe, damit sie sich ohne DOM und
// ohne Wiedergabe pruefen laesst. Hier steckt der Teil, bei dem ein Fehler nicht auffaellt,
// sondern spaeter den falschen Titel spielt.
/**
* Entfernt Position `i`. Liefert den neuen Zeiger und ob der LAUFENDE Titel getroffen wurde.
* Der Zeiger wandert mit: faellt ein Titel VOR dem laufenden weg, rutscht dieser eine Stelle vor.
*/
export function queueNachEntfernen(laenge, qIdx, i) {
if (i < 0 || i >= laenge) return { qIdx, warAktuell: false, leer: laenge === 0 };
const warAktuell = i === qIdx;
const neueLaenge = laenge - 1;
if (neueLaenge === 0) return { qIdx: -1, warAktuell, leer: true };
let neu = qIdx;
if (!warAktuell && i < qIdx) neu--;
// Traf es den laufenden Titel, zeigt der Index jetzt auf den nachrueckenden — am Ende der
// Liste gibt es keinen, dann auf den davor.
if (warAktuell) neu = Math.min(neu, neueLaenge - 1);
return { qIdx: neu, warAktuell, leer: false };
}
/**
* Verschiebt `von` nach `nach` und liefert die neue Reihenfolge samt nachgefuehrtem Zeiger.
* Der Zeiger wird ueber die POSITION des laufenden Titels neu bestimmt, nicht gerechnet — das
* stimmt auch, wenn er selbst gezogen wurde oder ein anderer ueber ihn hinweggezogen ist.
*/
export function queueNachVerschieben(ids, qIdx, von, nach) {
const n = ids.length;
if (von === nach || von < 0 || von >= n || nach < 0 || nach >= n) return { ids, qIdx };
const laufende = ids[qIdx];
const neu = ids.slice();
const [x] = neu.splice(von, 1);
neu.splice(nach, 0, x);
const gefunden = neu.indexOf(laufende);
return { ids: neu, qIdx: gefunden >= 0 ? gefunden : qIdx };
}
/**
* Echte Mischung (Fisher-Yates). Liefert eine NEUE Liste, die Eingabe bleibt unberuehrt.
*
* Warum nicht `liste.sort(() => Math.random() - 0.5)`: ein Vergleicher, der bei derselben Paarung
* mal so und mal anders antwortet, ist fuer eine Sortierung ungueltig, und das Ergebnis ist nicht
* gleichverteilt. Gemessen (V8, 3000 Durchlaeufe je Laenge) bleibt der ERSTE Titel viel zu oft
* vorn: bei 10 Titeln in 18,8 % der Faelle statt 10 %, bei 100 in 3,1 % statt 1 %, bei 2000 in
* 0,23 % statt 0,05 % — also durchweg das Drei- bis Fuenffache. Die Grobordnung sieht dabei
* gemischt aus, weshalb es lange nicht auffaellt; systematisch bevorzugt werden trotzdem immer
* dieselben Titel. Fisher-Yates zieht fuer jede Position gleichverteilt aus dem Rest und traf in
* derselben Messung 9,7 % / 0,87 % / 0,03 %.
*/
export function mischeListe(liste, rnd = Math.random) {
const a = liste.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
+3
View File
@@ -22,6 +22,9 @@ export const S = {
npOpen: false, npOpen: false,
npQueueMode: false, npQueueMode: false,
npLyricsMode: false, npLyricsMode: false,
// Lyrics: automatisch mitlaufen oder von Hand scrollen. Standard AUS — wer liest, will nicht,
// dass der Text unter dem Finger wegspringt; einschalten geht im Lyrics-Tab mit einem Druck.
lyricsFolgen: false,
// Navigation history // Navigation history
_navStack: [], _navStack: [],
+57 -1
View File
@@ -124,8 +124,64 @@ export const THEMES = [
'--danger': '#e05555', '--danger': '#e05555',
}, },
} },
{
id: 'violet',
name: 'Violet',
accent: '#a78bfa',
bg: '#0d0916',
vars: {
'--bg': '#0d0916', // Tiefes Violettschwarz
'--bg2': '#140e20',
'--bg3': '#1a1229',
'--surface': '#221835',
'--surface2': '#2d2145',
'--border': 'rgba(255,255,255,0.07)',
'--border2': 'rgba(255,255,255,0.13)',
'--accent': '#a78bfa', // Helles Lila — auf dunklem Grund gut lesbar
'--accent2': '#c4b5fd',
'--accent-dark':'#7c5cf0',
'--accent-dim': 'rgba(167,139,250,0.15)',
'--accent-glow':'rgba(167,139,250,0.32)',
'--text': '#f2edff',
'--text2': '#9b8bbd',
'--text3': '#655780',
'--danger': '#e05555',
},
},
{
id: 'crimson',
name: 'Crimson',
accent: '#e05263',
bg: '#120708',
vars: {
'--bg': '#120708', // Dunkles Weinrot-Schwarz
'--bg2': '#1a0b0d',
'--bg3': '#210e11',
'--surface': '#2b1316',
'--surface2': '#3a1a1e',
'--border': 'rgba(255,255,255,0.07)',
'--border2': 'rgba(255,255,255,0.13)',
// Bewusst kein reines Rot: das laege zu nah an --danger, und Loeschknoepfe saehen
// dann aus wie gewoehnliche Akzente.
'--accent': '#e05263',
'--accent2': '#f2788a',
'--accent-dark':'#b03445',
'--accent-dim': 'rgba(224,82,99,0.16)',
'--accent-glow':'rgba(224,82,99,0.32)',
'--text': '#ffeef0',
'--text2': '#bf8d94',
'--text3': '#7d545c',
'--danger': '#ff7a6b', // heller als der Akzent, damit Warnungen abstechen
},
},
]; ];
+72 -34
View File
@@ -35,7 +35,7 @@ function _buildOverlay() {
_overlay.id = 'compare-overlay'; _overlay.id = 'compare-overlay';
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeCompare(); }); _overlay.addEventListener('click', e => { if (e.target === _overlay) closeCompare(); });
_overlay.innerHTML = ` _overlay.innerHTML = `
<div class="modal-card" style="max-width:720px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden"> <div class="modal-card" style="max-width:940px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
<div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)"> <div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
<span style="font-size:18px">🎧</span> <span style="font-size:18px">🎧</span>
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Musik-Match</div> <div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Musik-Match</div>
@@ -126,35 +126,41 @@ async function _loadCompare() {
} }
function _renderCompare(body, d) { function _renderCompare(body, d) {
const score = Math.max(0, Math.min(100, d.matchScore || 0)); const b = d.battle;
const verdict = const urteil = !b ? '' :
score >= 80 ? 'Seelenverwandte 🎯' : b.punkteA === b.punkteB ? 'Kopf-an-Kopf — kein Sieger.' :
score >= 60 ? 'Starkes Match 🔥' : b.punkteA > b.punkteB ? `${d.userA} gewinnt ${b.punkteA}:${b.punkteB}.` :
score >= 40 ? 'Solide Überschneidung 👍' : `${d.userB} gewinnt ${b.punkteB}:${b.punkteA}.`;
score >= 20 ? 'Ein paar Gemeinsamkeiten 🙂' :
'Ziemlich unterschiedlich 🌗';
body.innerHTML = [ body.innerHTML = `<div class="cmp-body">
_scoreRing(d, score, verdict), <div class="cmp-wide">${_scoreboard(d, b, urteil)}</div>
_totals(d), <div class="cmp-wide">${_totals(d)}</div>
_sharedArtists(d.sharedArtists), <div>${_streitpunkte(d)}</div>
_sharedTracks(d.sharedTracks), <div>${_sharedArtists(d.sharedArtists)}</div>
_uniqueCols(d), <div>${_sharedTracks(d.sharedTracks)}</div>
].join(''); <div class="cmp-wide">${_uniqueCols(d)}</div>
</div>`;
} }
function _scoreRing(d, score, verdict) { // Anzeigetafel: pro Kategorie ein Sieger, unten der Gesamtstand. Ersetzt den frueheren
const deg = Math.round(score * 3.6); // Prozentring — nicht "wie aehnlich seid ihr", sondern "wer gewinnt worin".
return `<div style="display:flex;flex-direction:column;align-items:center;gap:10px;margin:6px 0 22px"> function _scoreboard(d, b, urteil) {
<div style="width:132px;height:132px;border-radius:50%;background:conic-gradient(var(--accent) ${deg}deg, var(--surface2) ${deg}deg);display:flex;align-items:center;justify-content:center"> if (!b?.kategorien?.length) return '';
<div style="width:104px;height:104px;border-radius:50%;background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center"> const zeilen = b.kategorien.map(k => {
<div style="font-size:32px;font-weight:800;letter-spacing:-1px;line-height:1">${score}<span style="font-size:16px">%</span></div> const gewinnA = k.sieger === 'A', gewinnB = k.sieger === 'B';
<div style="font-size:11px;color:var(--text2);margin-top:2px">Match</div> return `<div class="cmp-cat ${gewinnA ? 'win-a' : gewinnB ? 'win-b' : ''}">
</div> <div class="cmp-val a ${gewinnA ? 'win' : gewinnB ? 'lose' : ''}">${esc(k.textA)}</div>
<div class="cmp-cat-label">${esc(k.label)}</div>
<div class="cmp-val b ${gewinnB ? 'win' : gewinnA ? 'lose' : ''}">${esc(k.textB)}</div>
</div>`;
}).join('');
return `<div class="cmp-head">
<div class="cmp-name">${esc(d.userA)}</div>
<div class="cmp-score">${b.punkteA} : ${b.punkteB}</div>
<div class="cmp-name">${esc(d.userB)}</div>
</div> </div>
<div style="font-size:15px;font-weight:700">${esc(verdict)}</div> <div class="cmp-verdict">${esc(urteil)}</div>
<div style="font-size:13px;color:var(--text2)"><b>${esc(d.userA)}</b> &nbsp;⟷&nbsp; <b>${esc(d.userB)}</b></div> ${zeilen}`;
</div>`;
} }
function _totals(d) { function _totals(d) {
@@ -172,20 +178,52 @@ function _h(t) {
// Shared artist row: a centered name with each side's play count on its flank + a // Shared artist row: a centered name with each side's play count on its flank + a
// mirrored bar so you can see who plays them more. // mirrored bar so you can see who plays them more.
// Streitpunkte: gemeinsame Künstler, bei denen die Zählerstände am weitesten auseinanderliegen.
// Umgekehrt zu "Gemeinsame Künstler", das nach Summe sortiert und damit zeigt, worin ihr euch einig
// seid. Sortiert wird nach relativem Ungleichgewicht, sonst stünden nur die meistgehörten oben.
function _streitpunkte(d) {
const items = (d.sharedArtists || [])
.map(a => {
const summe = a.playsA + a.playsB;
return { ...a, unwucht: summe ? Math.abs(a.playsA - a.playsB) / summe : 0, summe };
})
// Ein einzelner Play gegen null ist kein Streit, sondern Zufall — Mindestmasse verlangen.
.filter(a => a.summe >= 4 && a.unwucht >= 0.5)
.sort((x, y) => (y.unwucht - x.unwucht) || (y.summe - x.summe))
.slice(0, 8);
if (!items.length) return `${_h('Streitpunkte')}${_info('Ihr hört gemeinsame Künstler erstaunlich ausgewogen.')}`;
const rows = items.map(a => {
const anteilA = Math.round((a.playsA / a.summe) * 100);
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
return `<div ${nav} style="padding:7px 0">
<div style="display:flex;justify-content:space-between;gap:8px;font-size:13px;margin-bottom:4px">
<span class="cmp-num">${a.playsA}</span>
<span style="font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.name)}</span>
<span class="cmp-num">${a.playsB}</span>
</div>
<div style="display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--surface2)">
<div style="width:${anteilA}%;background:var(--accent)"></div>
<div style="width:${100 - anteilA}%;background:var(--accent);opacity:.35"></div>
</div>
</div>`;
}).join('');
return `${_h('Streitpunkte')}<div>${rows}</div>`;
}
function _sharedArtists(items) { function _sharedArtists(items) {
if (!items?.length) return `${_h('Gemeinsame Künstler')}${_info('Keine gemeinsamen Künstler in diesem Zeitraum.')}`; if (!items?.length) return `${_h('Gemeinsame Künstler')}${_info('Keine gemeinsamen Künstler in diesem Zeitraum.')}`;
const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1); const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1);
const rows = items.slice(0, 15).map(a => { const rows = items.slice(0, 12).map(a => {
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : ''; const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
return `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:7px 0"> return `<div ${nav} class="cmp-row">
<div style="width:34px;text-align:right;font-size:12px;color:var(--text2);font-weight:700">${a.playsA}</div> <div class="cmp-num" style="text-align:right">${a.playsA}</div>
<div style="flex:1;display:flex;justify-content:flex-end"><div style="height:6px;background:var(--accent);border-radius:3px;width:${Math.round((a.playsA / max) * 100)}%"></div></div> <div style="display:flex;justify-content:flex-end"><div class="cmp-bar" style="width:${Math.round((a.playsA / max) * 100)}%"></div></div>
<div style="flex:1.6;text-align:center;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">${esc(a.name)}</div> <div class="cmp-row-name">${esc(a.name)}</div>
<div style="flex:1;display:flex;justify-content:flex-start"><div style="height:6px;background:var(--accent-dark, var(--accent));opacity:.7;border-radius:3px;width:${Math.round((a.playsB / max) * 100)}%"></div></div> <div><div class="cmp-bar" style="width:${Math.round((a.playsB / max) * 100)}%;opacity:.65"></div></div>
<div style="width:34px;text-align:left;font-size:12px;color:var(--text2);font-weight:700">${a.playsB}</div> <div class="cmp-num">${a.playsB}</div>
</div>`; </div>`;
}).join(''); }).join('');
return `${_h('Gemeinsame Künstler')}<div style="margin-bottom:22px">${rows}</div>`; return `${_h('Gemeinsame Künstler')}<div>${rows}</div>`;
} }
function _sharedTracks(items) { function _sharedTracks(items) {
+80 -2
View File
@@ -38,6 +38,9 @@ function _renderInfoModal(s) {
['Bitrate', s.bitRate ? s.bitRate+' kbps' : null], ['Bitrate', s.bitRate ? s.bitRate+' kbps' : null],
['Format', s.suffix?.toUpperCase()], ['Format', s.suffix?.toUpperCase()],
['Dateigröße', s.size ? Math.round(s.size/1024/1024*10)/10+' MB' : null], ['Dateigröße', s.size ? Math.round(s.size/1024/1024*10)/10+' MB' : null],
// Kommt so aus der Subsonic-API. Ohne "Report Real Path" am Player ist das NICHT der echte
// Dateiname, sondern von Navidrome aus den Tags gebaut (fakePath(): AlbumArtist/Album/
// DD-TT - Titel.ext) — siehe CHANGELOG 2.4, Known Bugs.
['Pfad', s.path], ['Pfad', s.path],
['Plays', s.playCount], ['Plays', s.playCount],
].filter(([,v]) => v!=null && v!=='' && v!==undefined); ].filter(([,v]) => v!=null && v!=='' && v!==undefined);
@@ -281,16 +284,91 @@ export async function saveMetadata() {
}); });
const d = await r.json(); const d = await r.json();
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
status.textContent = '✅ Gespeichert! Navidrome scannt…';
toast('✅ Metadaten gespeichert');
invalidateRomajiIndex(); invalidateRomajiIndex();
invalidateApiCache('getArtists'); invalidateApiCache('getArtists');
// Warnungen des Backends (Dublette am Zielnamen, nicht verschoben, …) sichtbar machen und das
// Fenster offen lassen — sonst sieht es nach "gespeichert" aus, obwohl die Datei liegen blieb.
if (d.warnings?.length) {
status.innerHTML = '⚠ Gespeichert, aber: ' + d.warnings.map(esc).join('<br>⚠ ') +
'<br><span style="color:var(--text3)">Datei: ' + esc(d.file || '?') + '</span>';
// Liegt derselbe Song doppelt, kann das Aufraeumen hier passieren statt auf der Konsole.
if (d.duplicate) _zeigeDublette(status, d.duplicate);
toast('⚠ Gespeichert — mit Hinweisen, siehe Fenster');
return;
}
status.textContent = '✅ Gespeichert! Navidrome scannt…';
toast('✅ Metadaten gespeichert');
setTimeout(() => { _leaveEditMode(); closeInfoModal(); }, 1500); setTimeout(() => { _leaveEditMode(); closeInfoModal(); }, 1500);
} catch (e) { } catch (e) {
status.textContent = '❌ Fehler: ' + e.message; status.textContent = '❌ Fehler: ' + e.message;
} }
} }
// ─── DUBLETTE AUFLOESEN ───
// Das Verschieben wurde abgelehnt, weil am Ziel schon eine Datei liegt — derselbe Song liegt also
// zweimal auf der Platte. Statt den Nutzer zum Dateimanager zu schicken, werden beide Kopien
// gegenuebergestellt und die ueberzaehlige zum Loeschen angeboten.
const _mb = (n) => (n == null ? '?' : (n / 1048576).toFixed(1) + ' MB');
const _sek = (n) => (n == null ? '?' : formatDur(n));
function _zeigeDublette(status, dub) {
const box = document.createElement('div');
box.className = 'dublette-box';
const zeile = (titel, f) =>
`<div class="dublette-datei"><b>${esc(titel)}</b>` +
`<div class="dublette-pfad">${esc(f.pfad || '?')}</div>` +
`<div class="dublette-meta">${esc(f.artist || '?')} — ${esc(f.titel || '?')} · ` +
`${_sek(f.dauer)} · ${_mb(f.groesse)}</div></div>`;
box.innerHTML =
zeile('Bleibt liegen (richtiger Ordner)', dub.behalten) +
zeile('Überzählig (gerade bearbeitet)', dub.ueberzaehlig) +
(dub.gleich
? '<div class="dublette-urteil ok">Gleiche Länge und gleicher Titel — zwei Kopien desselben Songs.</div>'
: '<div class="dublette-urteil warn">Länge oder Titel weichen ab. Das Backend lehnt das Löschen ab, ' +
'solange das so ist — bitte erst selbst nachsehen.</div>');
const btn = document.createElement('button');
btn.className = 'btn-danger';
btn.textContent = '🗑 Überzählige Kopie löschen';
btn.onclick = () => _loescheDublette(btn, status, dub);
box.appendChild(btn);
status.appendChild(box);
}
async function _loescheDublette(btn, status, dub) {
const ok = await showConfirm(
'Dublette löschen',
`"${dub.ueberzaehlig.pfad}" wird unwiderruflich von der Festplatte gelöscht. ` +
`Die Kopie im richtigen Ordner bleibt bestehen.`,
'Löschen'
);
if (!ok) return;
btn.disabled = true;
btn.textContent = '🗑 Löschen…';
try {
const r = await fetch(`${S.cfg.backendUrl}/api/resolve-duplicate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass,
behalten: dub.behalten.pfad, ueberzaehlig: dub.ueberzaehlig.pfad,
}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
invalidateApiCache('getArtists');
invalidateRomajiIndex();
btn.remove();
status.insertAdjacentHTML('beforeend',
'<div class="dublette-urteil ok">✅ Gelöscht. Navidrome scannt — danach steht der Song nur noch einmal da.</div>');
toast('✅ Dublette gelöscht');
} catch (e) {
btn.disabled = false;
btn.textContent = '🗑 Überzählige Kopie löschen';
toast('❌ ' + e.message);
status.insertAdjacentHTML('beforeend',
'<div class="dublette-urteil warn">' + esc(e.message) + '</div>');
}
}
export async function deleteSong() { export async function deleteSong() {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const s = S.infoSong; const s = S.infoSong;
+1
View File
@@ -31,6 +31,7 @@ export function showCtxMenu(e, song, list, idx) {
}}, }},
{ sep: true }, { sep: true },
{ icon: '💿', label: 'Zum Album', fn: () => song.albumId && navigate('album-detail', { id: song.albumId, name: song.album||'', artist: song.artist||'', artistId: song.artistId||'', coverArt: song.coverArt||'' }) }, { icon: '💿', label: 'Zum Album', fn: () => song.albumId && navigate('album-detail', { id: song.albumId, name: song.album||'', artist: song.artist||'', artistId: song.artistId||'', coverArt: song.coverArt||'' }) },
{ icon: '🔗', label: 'Teilen', fn: () => import('./share.js').then(m => m.shareSong(song)) },
{ icon: 'ℹ', label: 'Song-Info', fn: () => import('./metadata.js').then(m => m.showSongInfo(song)) }, { icon: 'ℹ', label: 'Song-Info', fn: () => import('./metadata.js').then(m => m.showSongInfo(song)) },
{ icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) }, { icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) },
]; ];
+87
View File
@@ -0,0 +1,87 @@
import { S } from '../state.js';
import { toast } from '../utils.js';
import { api } from '../api.js';
// ─── TEILEN PER LINK ───
// Ein Link traegt NUR eine Navidrome-Id, keine Zugangsdaten: der Empfaenger sieht den Inhalt mit
// seinen eigenen Anmeldedaten auf derselben Instanz. Als Query-Parameter (?song=…) statt als Pfad,
// damit der Webserver keine SPA-Umschreibung braucht — die App liest ihn beim Start selbst aus.
export function buildShareLink(type, id) {
const base = `${location.origin}${location.pathname.replace(/index\.html$/, '')}`;
return `${base}?${type}=${encodeURIComponent(id)}`;
}
/**
* Teilt einen Link ueber das System (Handy: Teilen-Dialog) und faellt sonst auf die Zwischenablage
* zurueck. navigator.share gibt es nur in sicheren Kontexten und nicht auf jedem Desktop-Browser.
*/
export async function shareLink(type, id, title, subtitle) {
const url = buildShareLink(type, id);
const text = subtitle ? `${title} — ${subtitle}` : title;
if (navigator.share) {
try {
await navigator.share({ title, text, url });
return;
} catch (e) {
// AbortError = der Nutzer hat den Dialog geschlossen; das ist kein Fehler.
if (e?.name === 'AbortError') return;
}
}
try {
await navigator.clipboard.writeText(url);
toast('🔗 Link kopiert');
} catch {
// Zwischenablage kann blockiert sein (kein HTTPS, kein Nutzergesten-Kontext) — dann zeigen wir
// den Link, damit er wenigstens von Hand kopiert werden kann.
toast('🔗 ' + url);
}
}
export const shareSong = (s) => shareLink('song', s.id, s.title || 'Song', s.artist || '');
export const shareAlbum = (a) => shareLink('album', a.id, a.name || 'Album', a.artist || '');
/**
* Loest einen beim Start uebergebenen Link auf: Song abspielen bzw. Album oeffnen. Wird NACH der
* Anmeldung aufgerufen — ohne gueltige Zugangsdaten gibt es nichts abzuspielen. Der Parameter wird
* anschliessend aus der Adresszeile entfernt, damit ein Neuladen nicht erneut losspielt.
*/
export async function handleShareParam(navigate, playQueue) {
const p = new URLSearchParams(location.search);
const songId = p.get('song');
const albumId = p.get('album');
if (!songId && !albumId) return false;
const sauber = () => {
try { history.replaceState(history.state, '', location.pathname); } catch {}
};
try {
if (songId) {
const r = await api('getSong', `&id=${encodeURIComponent(songId)}`);
const song = r.song;
if (!song) { toast('Song nicht gefunden'); sauber(); return false; }
if (song.albumId) {
navigate('album-detail', {
id: song.albumId, name: song.album || '', artist: song.artist || '',
artistId: song.artistId || '', coverArt: song.coverArt || '',
});
}
playQueue([song], 0);
toast(`▶ ${song.title}`);
} else {
const r = await api('getAlbum', `&id=${encodeURIComponent(albumId)}`);
const al = r.album;
if (!al) { toast('Album nicht gefunden'); sauber(); return false; }
navigate('album-detail', {
id: al.id, name: al.name || '', artist: al.artist || '',
artistId: al.artistId || '', coverArt: al.coverArt || '',
});
}
sauber();
return true;
} catch (e) {
toast('Link konnte nicht geöffnet werden: ' + e.message);
sauber();
return false;
}
}
+11
View File
@@ -47,6 +47,17 @@ export function formatDur(s) {
return `${m}:${sec.toString().padStart(2,'0')}`; return `${m}:${sec.toString().padStart(2,'0')}`;
} }
// Gesamtdauer einer Playlist oder eines Albums. Ab einer Stunde werden Stunden ausgewiesen —
// "134 Min." muss man sonst selbst umrechnen, und bei einer Playlist mit 2000 Titeln steht dort
// sonst eine vierstellige Minutenzahl.
export function formatGesamtdauer(sekunden) {
const min = Math.round((sekunden || 0) / 60);
if (min < 60) return `${min} Min.`;
const std = Math.floor(min / 60);
const rest = min % 60;
return rest ? `${std} Std. ${rest} Min.` : `${std} Std.`;
}
// Fan/count formatting in German: full grouped number up to a million (559534 → "559.542"), // Fan/count formatting in German: full grouped number up to a million (559534 → "559.542"),
// then compact millions (1200000 → "1,2 Mio."). // then compact millions (1200000 → "1,2 Mio.").
export function fmtCount(n) { export function fmtCount(n) {
+309
View File
@@ -0,0 +1,309 @@
import { S } from '../state.js';
import { esc } from '../utils.js';
import { api } from '../api.js';
import { openDlModal } from '../ui/ui.js';
// Zirkulär — sicher, weil nur in Ereignisbehandlern aufgerufen
import { navigate, _navGoBack } from './navigation.js';
// ─── NEU VON DEINEN KÜNSTLERN ───────────────────────────────────────────────
// Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. "Folgen" ist
// Navidromes Stern, die Liste kommt also aus getStarred2 — kein eigener Speicher.
// Die Deezer-Auflösung macht das Backend, weil dort auch die Künstler-Pins liegen.
let _tage = 90;
let _reiter = 'releases';
let _laeuft = false;
/** Reiter wechseln: "releases" oder "konzerte". */
export function showArtistNewsTab(reiter) {
_reiter = reiter === 'konzerte' ? 'konzerte' : 'releases';
document.querySelectorAll('#artist-news-tabs .sort-chip').forEach(c =>
c.classList.toggle('active', c.dataset.tab === _reiter));
const titel = document.querySelector('#page-artist-news .page-title');
if (titel) {
titel.innerHTML = _reiter === 'konzerte'
? 'Konzerte <em>deiner Künstler</em>'
: 'Neu von <em>deinen Künstlern</em>';
}
// Das Zeitfenster gilt nur fuer die Veroeffentlichungen. Konzerte sind ohnehin Zukunft, und
// dort will man alles Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
const tageReihe = document.getElementById('artist-news-sort');
if (tageReihe) tageReihe.style.display = _reiter === 'konzerte' ? 'none' : '';
if (_reiter === 'konzerte') loadKonzerte();
else loadArtistNews();
}
/** Zeitfenster setzen — gibt es nur bei den Veroeffentlichungen. */
export function setArtistNewsDays(tage) {
_tage = tage;
loadArtistNews();
}
function _markiereTage(tage) {
document.querySelectorAll('#artist-news-sort .sort-chip').forEach(c =>
c.classList.toggle('active', Number(c.dataset.days) === tage));
}
export async function loadArtistNews(tage = _tage) {
_tage = tage;
_reiter = 'releases';
const tageReihe = document.getElementById('artist-news-sort');
if (tageReihe) tageReihe.style.display = '';
// Die Seite haengt an den Favoriten (Knopf "✨ Neu") und nicht mehr an der Seitenleiste,
// deshalb braucht sie einen Weg zurueck wie jede andere Unterseite.
const zurueck = document.getElementById('artist-news-back-btn');
if (zurueck) zurueck.onclick = () => _navGoBack();
_markiereTage(tage);
const el = document.getElementById('artist-news-list');
if (!el || _laeuft) return;
if (!S.cfg?.backendUrl) {
el.innerHTML = '<div class="empty-state"><div class="icon">🔌</div><p>Kein Backend konfiguriert</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Neue Veröffentlichungen kommen über das Backend von Deezer.</p></div>';
return;
}
_laeuft = true;
el.innerHTML = '<div class="spinner"></div>';
try {
const r = await api('getStarred2');
const kuenstler = (r.starred2?.artist || []).map(a => ({ id: a.id, name: a.name }));
if (!kuenstler.length) {
el.innerHTML = '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>';
return;
}
const res = await fetch(`${S.cfg.backendUrl}/api/artist-releases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artists: kuenstler, days: tage }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { releases = [], ohneZuordnung = [] } = await res.json();
// Was hat man schon? Verglichen wird über Künstler + Albumname — Deezers Album-Id hat mit
// Navidromes nichts zu tun.
const vorhanden = new Set();
(r.starred2?.album || []).forEach(a => vorhanden.add(_schluessel(a.artist, a.name)));
await _ergaenzeVorhandene(kuenstler, vorhanden);
el.innerHTML = '';
if (ohneZuordnung.length) el.appendChild(_hinweisOhneZuordnung(ohneZuordnung));
if (!releases.length) {
const leer = document.createElement('div');
leer.className = 'empty-state';
leer.innerHTML = `<div class="icon">🌙</div><p>Nichts Neues in den letzten ${tage} Tagen</p>`;
el.appendChild(leer);
return;
}
_renderZeitleiste(el, releases, vorhanden);
} catch (e) {
el.innerHTML = `<p style="color:var(--danger)">${esc(e.message)}</p>`;
} finally {
_laeuft = false;
}
}
const _norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '')
.replace(/[^a-z0-9]/g, '');
const _schluessel = (kuenstler, album) => `${_norm(kuenstler)}~~${_norm(album)}`;
/** Alben der gefolgten Künstler aus der eigenen Bibliothek nachtragen (parallel, begrenzt). */
async function _ergaenzeVorhandene(kuenstler, vorhanden) {
const haeppchen = 6;
for (let i = 0; i < kuenstler.length; i += haeppchen) {
await Promise.all(kuenstler.slice(i, i + haeppchen).map(async k => {
try {
const a = await api('getArtist', `&id=${encodeURIComponent(k.id)}`);
(a.artist?.album || []).forEach(al => vorhanden.add(_schluessel(k.name, al.name)));
} catch {}
}));
}
}
function _hinweisOhneZuordnung(namen) {
const d = document.createElement('div');
d.className = 'artist-news-hint';
d.innerHTML = `⚠ Für ${namen.length} Künstler wurde kein Deezer-Profil gefunden: `
+ `<strong>${namen.slice(0, 4).map(esc).join(', ')}</strong>${namen.length > 4 ? ' …' : ''}. `
+ `Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.`;
return d;
}
function _renderZeitleiste(el, releases, vorhanden) {
let letzterMonat = '';
releases.forEach(rel => {
const monat = new Date(rel.releaseDate)
.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
if (monat !== letzterMonat) {
letzterMonat = monat;
const h = document.createElement('div');
h.className = 'search-section-title';
h.style.cssText = 'margin:18px 0 8px';
h.textContent = monat;
el.appendChild(h);
}
el.appendChild(_zeile(rel, vorhanden.has(_schluessel(rel.artistName, rel.title))));
});
}
const _TYP = { album: 'Album', single: 'Single', ep: 'EP', compile: 'Compilation' };
function _zeile(rel, schonDa) {
const row = document.createElement('div');
row.className = 'artist-news-row';
const datum = new Date(rel.releaseDate).toLocaleDateString('de-DE',
{ day: '2-digit', month: '2-digit', year: 'numeric' });
const typ = _TYP[rel.recordType] || rel.recordType;
row.innerHTML = `
<div class="artist-news-art">${rel.cover ? `<img src="${esc(rel.cover)}" loading="lazy" alt="">` : '<div class="no-art">💿</div>'}</div>
<div class="artist-news-info">
<div class="artist-news-title">${esc(rel.title)}</div>
<div class="artist-news-sub"><span class="artist-news-artist">${esc(rel.artistName)}</span> · ${typ}${rel.trackCount ? ` · ${rel.trackCount} Titel` : ''}</div>
<div class="artist-news-date">${datum}</div>
</div>`;
row.querySelector('.artist-news-artist').onclick = (e) => {
e.stopPropagation();
navigate('artist-detail', { id: rel.artistId, name: rel.artistName });
};
const aktion = document.createElement('div');
aktion.className = 'artist-news-action';
if (schonDa) {
aktion.innerHTML = '<span class="artist-news-have" title="In deiner Bibliothek">✓ Hast du</span>';
} else {
const btn = document.createElement('button');
btn.className = 'btn-outline blue';
btn.style.cssText = 'font-size:12px;padding:7px 12px';
btn.textContent = '⬇ Laden';
btn.onclick = () => openDlModal({
artistName: rel.artistName,
trackName: rel.title,
collectionName: rel.title,
artworkUrl100: rel.cover || '',
isAlbum: true,
collectionId: rel.albumId,
deezerData: {
collectionId: rel.albumId, collectionName: rel.title,
artistName: rel.artistName, artworkUrl100: rel.cover || '',
releaseDate: rel.releaseDate, trackCount: rel.trackCount,
},
});
aktion.appendChild(btn);
}
row.appendChild(aktion);
return row;
}
// ─── KONZERTE DEINER KÜNSTLER ───────────────────────────────────────────────
// Gegenstück zur Release-Zeitleiste. Die Liste läuft NACH VORN: das nächste Konzert zuerst.
// Die Termine kommen über das Backend von Ticketmaster, gecacht für alle Geräte gemeinsam.
export async function loadKonzerte() {
_reiter = 'konzerte';
const zurueck = document.getElementById('artist-news-back-btn');
if (zurueck) zurueck.onclick = () => _navGoBack();
const tageReihe = document.getElementById('artist-news-sort');
if (tageReihe) tageReihe.style.display = 'none';
const el = document.getElementById('artist-news-list');
if (!el || _laeuft) return;
if (!S.cfg?.backendUrl) {
el.innerHTML = '<div class="empty-state"><div class="icon">🔌</div><p>Kein Backend konfiguriert</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Konzerttermine kommen über das Backend von Ticketmaster.</p></div>';
return;
}
_laeuft = true;
el.innerHTML = '<div class="spinner"></div>';
try {
const r = await api('getStarred2');
const kuenstler = (r.starred2?.artist || []).map(a => ({ id: a.id, name: a.name }));
if (!kuenstler.length) {
el.innerHTML = '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>';
return;
}
const res = await fetch(`${S.cfg.backendUrl}/api/artist-concerts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Ohne `days`: das Backend liefert alles Angekuendigte.
body: JSON.stringify({ artists: kuenstler }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { konzerte = [], keinSchluessel } = await res.json();
el.innerHTML = '';
if (keinSchluessel) {
el.innerHTML = '<div class="empty-state"><div class="icon">🎟</div><p>Kein Ticketmaster-Schlüssel hinterlegt</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Im Backend <code>TICKETMASTER_API_KEY</code> setzen.</p></div>';
return;
}
if (!konzerte.length) {
const leer = document.createElement('div');
leer.className = 'empty-state';
leer.innerHTML = '<div class="icon">🎪</div><p>Keine anstehenden Termine</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Gesucht wird weltweit über den Künstlernamen.</p>';
el.appendChild(leer);
return;
}
_renderKonzerte(el, konzerte);
} catch (e) {
el.innerHTML = `<p style="color:var(--danger)">${esc(e.message)}</p>`;
} finally {
_laeuft = false;
}
}
function _renderKonzerte(el, konzerte) {
let letzterMonat = '';
konzerte.forEach(k => {
const d = new Date(k.datum);
const monat = d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
if (monat !== letzterMonat) {
letzterMonat = monat;
const h = document.createElement('div');
h.className = 'search-section-title';
h.style.margin = '18px 0 8px';
h.textContent = monat;
el.appendChild(h);
}
const zeile = document.createElement('div');
zeile.className = 'artist-news-row konzert-row' + (k.abgesagt ? ' konzert-abgesagt' : '');
const ort = [k.ort, k.stadt, k.land].filter(Boolean).join(' · ');
const uhr = d.getHours() || d.getMinutes()
? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '';
zeile.innerHTML =
`<div class="konzert-datum"><span class="kd-tag">${d.getDate()}</span>`
+ `<span class="kd-monat">${d.toLocaleDateString('de-DE', { month: 'short' })}</span></div>`
+ `<div class="artist-news-info">`
+ `<div class="artist-news-title">${esc(k.titel || k.artistName)}</div>`
+ `<div class="artist-news-sub"><span class="artist-news-artist">${esc(k.artistName)}</span>`
+ `${ort ? ' · ' + esc(ort) : ''}${uhr ? ' · ' + uhr + ' Uhr' : ''}</div></div>`;
// Künstlername führt zur Künstlerseite, wie in der Release-Liste.
zeile.querySelector('.artist-news-artist').onclick = (e) => {
e.stopPropagation();
navigate('artist-detail', { id: k.artistId, name: k.artistName });
};
const rechts = document.createElement('div');
if (k.abgesagt) {
rechts.className = 'artist-news-have';
rechts.textContent = 'abgesagt';
} else if (k.url) {
const a = document.createElement('a');
a.className = 'btn-outline konzert-ticket';
a.href = k.url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.textContent = 'Tickets';
rechts.appendChild(a);
}
zeile.appendChild(rechts);
el.appendChild(zeile);
});
}
+95 -7
View File
@@ -1,6 +1,6 @@
import { S } from '../state.js'; import { S } from '../state.js';
import { esc, toast, fmtCount } from '../utils.js'; import { esc, toast, fmtCount, formatGesamtdauer } from '../utils.js';
import { api, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js'; import { api, invalidateApiCache, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
import { openArtistPin } from '../ui/artistPin.js'; import { openArtistPin } from '../ui/artistPin.js';
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js'; import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js'; import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js';
@@ -105,6 +105,15 @@ export async function loadArtistDetail(artist) {
pinBtn.style.cssText = `position:absolute;top:10px;right:10px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:${_pin ? 'var(--accent)' : 'rgba(255,255,255,.72)'};font-size:14px;line-height:1;cursor:pointer;backdrop-filter:blur(4px)`; pinBtn.style.cssText = `position:absolute;top:10px;right:10px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:${_pin ? 'var(--accent)' : 'rgba(255,255,255,.72)'};font-size:14px;line-height:1;cursor:pointer;backdrop-filter:blur(4px)`;
pinBtn.onclick = () => openArtistPin(artist.id, artist.name); pinBtn.onclick = () => openArtistPin(artist.id, artist.name);
hero.appendChild(pinBtn); hero.appendChild(pinBtn);
// Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste: die Leiste ist "was ich jetzt
// tue" (abspielen, mischen), Folgen ist ein Zustand. Deshalb als Herz ins Banner, neben das
// Zahnrad. Verdrahtet wird es weiter unten, sobald `starred` aus getArtist da ist.
hero.querySelector('.artist-follow-btn')?.remove();
const folgeBtn = document.createElement('button');
folgeBtn.className = 'artist-follow-btn';
folgeBtn.style.cssText = 'position:absolute;top:10px;right:46px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:rgba(255,255,255,.72);cursor:pointer;backdrop-filter:blur(4px)';
hero.appendChild(folgeBtn);
} }
// Fire all requests immediately so they run in parallel. // Fire all requests immediately so they run in parallel.
@@ -112,7 +121,11 @@ export async function loadArtistDetail(artist) {
: hasND ? getArtistImage(artist.id) : Promise.resolve(null); : hasND ? getArtistImage(artist.id) : Promise.resolve(null);
const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null); const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null);
const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null); const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null);
const topP = hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null); // id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
// Namen zurueck, wenn es sie nicht kennt (aeltere Versionen ignorieren id einfach). Der Namensweg
// sucht mit LIKE und Max:1 — bei zwei gleichnamigen Kuenstlern in der Bibliothek gewinnt also der
// erste Treffer, und das war nicht zwingend der gemeinte.
const topP = hasND ? api('getTopSongs', `&id=${encodeURIComponent(artist.id)}&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
const iArtistP = knownDeezer ? Promise.resolve([{ artistId: knownDeezer }]) : deezerSearch(artist.name, 'musicArtist', 5); const iArtistP = knownDeezer ? Promise.resolve([{ artistId: knownDeezer }]) : deezerSearch(artist.name, 'musicArtist', 5);
const eventsP = fetchEvents(artist.name); const eventsP = fetchEvents(artist.name);
@@ -122,6 +135,24 @@ export async function loadArtistDetail(artist) {
const [albumRes] = await Promise.allSettled([albumP]); const [albumRes] = await Promise.allSettled([albumP]);
const _ndArtist = albumRes.status === 'fulfilled' ? albumRes.value?.artist : null; const _ndArtist = albumRes.status === 'fulfilled' ? albumRes.value?.artist : null;
if (hasND && artist.id) {
const folgeBtn = hero?.querySelector('.artist-follow-btn');
_verdrahteMerken(
folgeBtn, artist.id, _ndArtist?.starred, '', '',
`Du folgst ${artist.name}`, `${artist.name} folgen`,
);
// Ohne Pinnung laeuft die Deezer-Zuordnung ueber den Namen — bei gleichnamigen Kuenstlern
// kaemen dauerhaft die Neuheiten des Falschen.
if (folgeBtn && !_pin && !_ndArtist?.starred) {
folgeBtn.addEventListener('click', () => {
setTimeout(() => {
if (folgeBtn.classList.contains('is-starred')) {
toast('Tipp: Über ⚙ daneben den richtigen Deezer-Künstler festpinnen');
}
}, 900);
}, { once: true });
}
}
if (_ndArtist) cacheArtistSortNames([_ndArtist]); if (_ndArtist) cacheArtistSortNames([_ndArtist]);
const _firstAlbums = _ndArtist?.album || []; const _firstAlbums = _ndArtist?.album || [];
const _bgCover = _firstAlbums[0] ? coverUrl(_firstAlbums[0].coverArt, 512) : null; const _bgCover = _firstAlbums[0] ? coverUrl(_firstAlbums[0].coverArt, 512) : null;
@@ -331,6 +362,7 @@ export async function loadArtistDetail(artist) {
evBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Konzerte${events.length ? ` <span style="font-size:11px;opacity:.7">(${events.length})</span>` : ''}`; evBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Konzerte${events.length ? ` <span style="font-size:11px;opacity:.7">(${events.length})</span>` : ''}`;
evBtn.onclick = () => openEventsModal(events, artist.name); evBtn.onclick = () => openEventsModal(events, artist.name);
actionBar.appendChild(evBtn); actionBar.appendChild(evBtn);
} }
if(albEl) _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist); if(albEl) _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist);
@@ -479,10 +511,57 @@ export async function loadArtistAllSongs({ albums, artist }) {
}); });
} }
// ─── MARKIEREN (Künstler + Album) ───
// Navidrome wirft beim Markieren `id`, `albumId` und `artistId` in eine Liste und behandelt sie
// gleich (media_annotation.go) — derselbe Aufruf taugt also für Songs, Alben und Künstler. Der
// Zustand kommt aus `starred` in getArtist/getAlbum.
const _HERZ = (voll) => `<svg width="13" height="13" viewBox="0 0 24 24" fill="${voll ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`;
/**
* Verdrahtet einen Knopf als Markieren-Umschalter. Optimistisch: der Knopf springt sofort um und
* kehrt zurück, wenn der Aufruf scheitert — sonst fühlt sich das Markieren träge an.
*/
function _verdrahteMerken(btn, id, markiert, textAn, textAus, meldungAn, meldungAus) {
if (!btn || !id) return;
let an = !!markiert;
const malen = () => {
const txt = an ? textAn : textAus;
btn.innerHTML = _HERZ(an) + (txt ? `<span>${txt}</span>` : '');
btn.classList.toggle('is-starred', an);
if (!txt) btn.title = an ? meldungAn : meldungAus;
};
malen();
btn.onclick = async () => {
const vorher = an;
an = !an; malen();
try {
await api(vorher ? 'unstar' : 'star', `&id=${encodeURIComponent(id)}`);
invalidateApiCache('getStarred2');
// Der "Gefolgt"-Filter auf der Künstlerseite hält eine eigene kurze Liste — die muss mit weg,
// sonst zeigt er nach dem Entfolgen noch eine halbe Minute den alten Stand.
import('./home.js').then(m => m.invalidateGefolgt?.()).catch(() => {});
toast(an ? meldungAn : meldungAus);
} catch (e) {
an = vorher; malen();
toast('Fehler: ' + e.message);
}
};
}
export async function loadDeezerAlbumDetail(album) { export async function loadDeezerAlbumDetail(album) {
document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack(); document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack();
document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—'; document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—';
document.getElementById('deezer-album-detail-artist').textContent = album.artistName || ''; // Deezer liefert die Künstler-ID in jedem Albumtreffer gleich mit — ohne sie ist der Name nur
// toter Text (z. B. bei Alben aus /artist/{id}/albums, wo Deezer das artist-Objekt weglässt).
const dzArtistEl = document.getElementById('deezer-album-detail-artist');
dzArtistEl.textContent = album.artistName || '';
dzArtistEl.onclick = null;
dzArtistEl.classList.toggle('is-link', !!(album.artistId && album.artistName));
if (album.artistId && album.artistName) {
dzArtistEl.onclick = () => navigate('artist-detail', {
id: null, name: album.artistName, deezerArtistId: album.artistId, deezerFanCount: null,
});
}
const cw = document.getElementById('deezer-album-cover-wrap'); const cw = document.getElementById('deezer-album-cover-wrap');
const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600'); const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600');
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>'; cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
@@ -572,8 +651,13 @@ export async function loadPartialAlbumDetail({ nd, deezer }) {
// ─── ALBUM DETAIL ─── // ─── ALBUM DETAIL ───
export async function loadAlbumDetail(album) { export async function loadAlbumDetail(album) {
if (S.cfg.showRomaji) await ensureArtistSortMap().catch(() => {}); if (S.cfg.showRomaji) await ensureArtistSortMap().catch(() => {});
// Zurück heißt zurück: einen Eintrag im Verlauf zurückgehen, wie auf jeder anderen Detailseite.
// Vorher navigierte dieser Knopf VORWÄRTS auf die Albenliste (bzw. den Artist) — das landete nicht
// dort, wo man das Album angeklickt hatte, und schob zusätzlich einen neuen History-Eintrag auf den
// Stapel, sodass der Browser-Zurück-Knopf sich durch diesen Müll arbeiten musste statt nach Home.
// Nur wenn es keinen Verlauf gibt (Direkteinstieg auf ein Album), bleibt die Albenliste das Ziel.
document.getElementById('album-back-btn').onclick = () => document.getElementById('album-back-btn').onclick = () =>
album._fromArtist ? navigate('artist-detail', album._fromArtist) : navigate('albums'); S._navCursor > 0 ? _navGoBack() : navigate('albums');
document.getElementById('album-detail-name').textContent = dispAlbumName(album); document.getElementById('album-detail-name').textContent = dispAlbumName(album);
const artistEl = document.getElementById('album-detail-artist'); const artistEl = document.getElementById('album-detail-artist');
artistEl.innerHTML = parseArtists(album) artistEl.innerHTML = parseArtists(album)
@@ -587,8 +671,12 @@ export async function loadAlbumDetail(album) {
try { try {
const r = await api('getAlbum', `&id=${album.id}`); const r = await api('getAlbum', `&id=${album.id}`);
const songs = r.album?.song || []; const songs = r.album?.song || [];
const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60); const gesamt = songs.reduce((s,t) => s+(t.duration||0), 0);
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`; document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${formatGesamtdauer(gesamt)}`;
_verdrahteMerken(
document.getElementById('btn-star-album'), album.id, r.album?.starred,
'Gemerkt', 'Merken', '❤ Album gemerkt', 'Aus den Favoriten entfernt',
);
// r.album has sortName (album sort tag) — use it; nav album may not have sortName // r.album has sortName (album sort tag) — use it; nav album may not have sortName
document.getElementById('album-detail-name').textContent = dispAlbumName(r.album); document.getElementById('album-detail-name').textContent = dispAlbumName(r.album);
// Album artist comes from the album's own album-artist tags (r.album.artists / .artist), NOT the // Album artist comes from the album's own album-artist tags (r.album.artists / .artist), NOT the
+56 -16
View File
@@ -3,6 +3,7 @@ import { esc } from '../utils.js';
import { api, apiCached, apiIsCached, invalidateApiCache, coverUrl, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames } from '../api.js'; import { api, apiCached, apiIsCached, invalidateApiCache, coverUrl, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames } from '../api.js';
import { makeCard, makeAlbumCard, makeSongItem } from '../ui/components.js'; import { makeCard, makeAlbumCard, makeSongItem } from '../ui/components.js';
import { playQueue } from '../player/player.js'; import { playQueue } from '../player/player.js';
import { mischeListe } from '../player/queueMath.js';
// Circular — safe (called inside event handlers) // Circular — safe (called inside event handlers)
import { navigate, _navGoBack } from './navigation.js'; import { navigate, _navGoBack } from './navigation.js';
@@ -161,7 +162,7 @@ async function playGenre(genre) {
const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=200`); const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=200`);
const songs = r.songsByGenre?.song || []; const songs = r.songsByGenre?.song || [];
if (!songs.length) return; if (!songs.length) return;
playQueue([...songs].sort(() => Math.random() - 0.5), 0); playQueue(mischeListe(songs), 0);
} catch {} } catch {}
} }
@@ -187,7 +188,7 @@ export async function loadGenreDetail(g) {
document.getElementById('genre-detail-stats').textContent = `${songs.length} Titel`; document.getElementById('genre-detail-stats').textContent = `${songs.length} Titel`;
document.getElementById('btn-play-genre').onclick = () => { document.getElementById('btn-play-genre').onclick = () => {
playQueue([...songs].sort(() => Math.random() - 0.5), 0); playQueue(mischeListe(songs), 0);
}; };
list.innerHTML = ''; list.innerHTML = '';
@@ -210,7 +211,12 @@ export async function loadAlbumRow(id, method, extra, force = false) {
} }
// ─── ARTISTS ─── // ─── ARTISTS ───
export async function loadArtists() { let _artistsFilter = 'all';
export async function loadArtists(filter = _artistsFilter) {
_artistsFilter = filter;
document.querySelectorAll('#artists-filter .sort-chip').forEach(c =>
c.classList.toggle('active', c.dataset.filter === filter));
const g = document.getElementById('artists-grid'); const g = document.getElementById('artists-grid');
if (!apiIsCached('getArtists')) g.innerHTML = '<div class="spinner"></div>'; if (!apiIsCached('getArtists')) g.innerHTML = '<div class="spinner"></div>';
try { try {
@@ -218,27 +224,61 @@ export async function loadArtists() {
const all = (r.artists?.index || []).flatMap(i => i.artist || []); const all = (r.artists?.index || []).flatMap(i => i.artist || []);
cacheArtistSortNames(all); cacheArtistSortNames(all);
if (S.cfg.showRomaji) await fetchRomajiIndex().catch(() => {}); if (S.cfg.showRomaji) await fetchRomajiIndex().catch(() => {});
// "Gefolgt" = Navidromes Stern. getArtists liefert kein `starred` mit, die Liste kommt also
// aus getStarred2 — zwischengespeichert, damit das Umschalten nicht jedes Mal nachfragt.
const gefolgt = await _gefolgteIds();
const zaehler = document.getElementById('artists-followed-count');
if (zaehler) zaehler.textContent = gefolgt.size ? `(${gefolgt.size})` : '';
const liste = filter === 'followed' ? all.filter(a => gefolgt.has(a.id)) : all;
g.innerHTML = ''; g.innerHTML = '';
all.forEach(a => { const suche = document.getElementById('artists-search');
const c = makeCard(dispArtistById(a.id, a.name), a.albumCount+' Alben', null, '🎤'); if (suche) suche.value = '';
if (!liste.length) {
g.innerHTML = filter === 'followed'
? '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p><p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>'
: '<div class="empty-state"><div class="icon">🎤</div><p>Keine Künstler</p></div>';
return;
}
// Das Bild kommt direkt aus `coverArt`, das getArtists ohnehin mitliefert (Navidrome loest
// Kuenstlerbilder selbst auf: artist.*, album/artist.*, extern — und cacht sie). Frueher lief
// hier PRO KUENSTLER ein eigener getArtistInfo2-Aufruf nur um die Bild-Adresse zu erfahren:
// bei 200 Kuenstlern 200 API-Aufrufe auf einmal, die der Browser zu sechst abarbeitet, und
// jeder davon konnte Navidrome zu einer externen Abfrage zwingen. Ist coverArt leer, hat der
// Kuenstler schlicht kein Bild — dann sparen wir uns den Versuch ganz.
liste.forEach(a => {
const bild = a.coverArt ? coverUrl(a.coverArt, 300) : null;
const c = makeCard(dispArtistById(a.id, a.name), a.albumCount+' Alben', bild, '🎤');
c.classList.add('artist-card'); c.classList.add('artist-card');
c.onclick = () => navigate('artist-detail', a); c.onclick = () => navigate('artist-detail', a);
g.appendChild(c); g.appendChild(c);
getArtistImage(a.id).then(imgUrl => {
if(!imgUrl) return;
const artEl = c.querySelector('.card-art');
if(!artEl) return;
const overlay = artEl.querySelector('.play-overlay');
const img = document.createElement('img');
img.src = imgUrl; img.loading = 'lazy'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;transition:transform .4s';
artEl.innerHTML = ''; artEl.appendChild(img); if(overlay) artEl.appendChild(overlay);
c.addEventListener('mouseenter', () => img.style.transform = 'scale(1.05)');
c.addEventListener('mouseleave', () => img.style.transform = 'scale(1)');
});
}); });
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; } } catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
} }
/** IDs der markierten Künstler; kurz gecacht, damit das Umschalten der Chips flott bleibt. */
let _gefolgtCache = { ts: 0, ids: new Set() };
/** Nach einem Herz-Klick verwerfen — sonst zeigte der Filter bis zu 30 s den alten Stand. */
export function invalidateGefolgt() { _gefolgtCache = { ts: 0, ids: _gefolgtCache.ids }; }
async function _gefolgteIds() {
if (Date.now() - _gefolgtCache.ts < 30_000) return _gefolgtCache.ids;
try {
const r = await api('getStarred2');
_gefolgtCache = { ts: Date.now(), ids: new Set((r.starred2?.artist || []).map(a => a.id)) };
} catch { /* Filter bleibt beim letzten bekannten Stand */ }
return _gefolgtCache.ids;
}
export function filterArtists(val) {
const q = val.trim().toLowerCase();
document.querySelectorAll('#artists-grid .card').forEach(c => {
const name = (c.querySelector('.card-title')?.textContent || '').toLowerCase();
c.style.display = !q || name.includes(q) ? '' : 'none';
});
}
// ─── ALBUMS ─── // ─── ALBUMS ───
let _albumSort = 'alphabeticalByName'; let _albumSort = 'alphabeticalByName';
+31 -6
View File
@@ -59,6 +59,21 @@ export function refreshCurrentView() {
} }
} }
// Gespeicherte Scrollposition wiederherstellen. Ein einzelner requestAnimationFrame reicht dafuer
// nicht: solange Cover und Listeneintraege noch nachladen, ist scrollHeight kleiner als das Ziel und
// der Browser kappt den gesetzten Wert stillschweigend — man landete dann doch wieder oben. Deshalb
// wird der Wert ueber bis zu ~0,5 s erneut gesetzt, bis er sitzt.
function _restoreScroll(el, y) {
if(!el) return;
if(!y) { el.scrollTop = 0; return; }
let versuche = 0;
const setzen = () => {
el.scrollTop = y;
if(Math.abs(el.scrollTop - y) > 2 && versuche++ < 30) requestAnimationFrame(setzen);
};
requestAnimationFrame(setzen);
}
export function navigate(page, data=null) { export function navigate(page, data=null) {
// Scroll + search memory: remember where each history entry was scrolled AND any open local // Scroll + search memory: remember where each history entry was scrolled AND any open local
// (header) search, so Back/Forward returns you to the same spot with the same query. The global // (header) search, so Back/Forward returns you to the same spot with the same query. The global
@@ -86,7 +101,7 @@ export function navigate(page, data=null) {
// Back/Forward → restore this entry's saved scroll (rAF so the shown page has laid out) and // Back/Forward → restore this entry's saved scroll (rAF so the shown page has laid out) and
// re-open the local (header) search if one was active here. // re-open the local (header) search if one was active here.
const y = S._scrollByIdx[S._navCursor] || 0; const y = S._scrollByIdx[S._navCursor] || 0;
if(_main) requestAnimationFrame(() => { _main.scrollTop = y; }); if(_main) _restoreScroll(_main, y);
const hsq = S._hsByIdx[S._navCursor]; const hsq = S._hsByIdx[S._navCursor];
if(hsq && _hsInput) { _hsInput.value = hsq; onHeaderSearch(hsq); } if(hsq && _hsInput) { _hsInput.value = hsq; onHeaderSearch(hsq); }
} else if(_main) { } else if(_main) {
@@ -94,8 +109,10 @@ export function navigate(page, data=null) {
} }
} }
closeSidebar(); closeSidebar();
// artist-news haengt jetzt an den Favoriten (Knopf "✨ Neu"), nicht mehr an Entdecken —
// deshalb bleibt die Seitenleiste in der Bibliothek stehen und Favoriten bleibt markiert.
if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover'); if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover');
else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library'); else if(['home','artists','albums','starred','playlists','artist-news'].includes(page)) switchNavMode('library');
if(page==='home') loadHomeData(!S._navJumping); // explicit Home click refreshes; back/forward keeps state if(page==='home') loadHomeData(!S._navJumping); // explicit Home click refreshes; back/forward keeps state
if(page==='artists' && !document.getElementById('artists-grid').children.length) loadArtists(); if(page==='artists' && !document.getElementById('artists-grid').children.length) loadArtists();
@@ -108,6 +125,7 @@ export function navigate(page, data=null) {
if(page==='genre-detail' && data) loadGenreDetail(data); if(page==='genre-detail' && data) loadGenreDetail(data);
if(page==='deezer-album-detail' && data) loadDeezerAlbumDetail(data); if(page==='deezer-album-detail' && data) loadDeezerAlbumDetail(data);
if(page==='partial-album-detail' && data) loadPartialAlbumDetail(data); if(page==='partial-album-detail' && data) loadPartialAlbumDetail(data);
if(page==='artist-news') import('./artistNews.js').then(m => m.loadArtistNews());
if(page==='new-releases') loadNewReleases(); if(page==='new-releases') loadNewReleases();
if(page==='playlists') loadPlaylists(); if(page==='playlists') loadPlaylists();
if(page==='playlist-detail' && data) loadPlaylistDetail(data); if(page==='playlist-detail' && data) loadPlaylistDetail(data);
@@ -125,12 +143,19 @@ export function navigate(page, data=null) {
S._navStack = S._navStack.slice(0, S._navCursor + 1); S._navStack = S._navStack.slice(0, S._navCursor + 1);
const safe = data ? JSON.parse(JSON.stringify(data)) : null; const safe = data ? JSON.parse(JSON.stringify(data)) : null;
const last = S._navStack[S._navCursor]; const last = S._navStack[S._navCursor];
if(!last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe)) { const istNeu = !last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe);
S._navStack.push({ page, data: safe }); if(istNeu) S._navStack.push({ page, data: safe });
}
S._navCursor = S._navStack.length - 1; S._navCursor = S._navStack.length - 1;
_navSave(); _navSave();
try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {} // Nur eine WIRKLICH neue Ansicht bekommt einen Browser-Eintrag. Vorher wurde immer gepusht,
// auch wenn der Eintrag wegen der Dublettenpruefung gar nicht in den Stack wanderte: zweimal
// auf denselben Tab geklickt hiess zwei Browser-Eintraege auf dieselbe Ansicht, und jeder davon
// kostete einen Zurueck-Druck, der sichtbar nichts tat. Der allererste Eintrag ersetzt ebenfalls,
// weil main.js die schon bestehende Seite bereits auf nsIdx 0 gesetzt hat.
try {
if(istNeu && S._navStack.length > 1) history.pushState({ nsIdx: S._navCursor }, '', '');
else history.replaceState({ nsIdx: S._navCursor }, '', '');
} catch {}
} }
} }
+107 -8
View File
@@ -1,12 +1,22 @@
import { S } from '../state.js'; import { S } from '../state.js';
import { esc, toast } from '../utils.js'; import { esc, toast, formatGesamtdauer } from '../utils.js';
import { api, apiCached, apiIsCached, coverUrl, romajiLookup } from '../api.js'; import { api, apiCached, apiIsCached, coverUrl, romajiLookup, dispArtistById, getArtistOverride } from '../api.js';
import { makeCard, makeSongItem } from '../ui/components.js'; import { makeCard, makeAlbumCard, makeSongItem, mkSection } from '../ui/components.js';
import { playQueue } from '../player/player.js'; import { playQueue } from '../player/player.js';
import { openEditPlModal } from '../ui/ui.js'; import { openEditPlModal } from '../ui/ui.js';
// Circular — safe (called inside event handlers) // Circular — safe (called inside event handlers)
import { navigate } from './navigation.js'; import { navigate } from './navigation.js';
import { quickPlayPlaylist } from './detail.js'; import { quickPlayPlaylist } from './detail.js';
import { getArtistImage, loadArtists } from './home.js';
// Besitzer einer Playlist, sofern es nicht die eigene ist. Navidrome liefert `owner` und `public`
// in getPlaylists/getPlaylist mit; interessant ist die Angabe nur bei geteilten (oeffentlichen)
// Listen — bei den eigenen waere "von mir" nur Rauschen.
function _besitzer(pl) {
const o = pl?.owner;
if (!o || o === S.cfg.user) return '';
return o;
}
// ─── PLAYLISTS ─── // ─── PLAYLISTS ───
export async function loadPlaylists() { export async function loadPlaylists() {
@@ -19,7 +29,9 @@ export async function loadPlaylists() {
if(!lists.length) { g.innerHTML = '<div class="empty-state"><div class="icon">🎶</div><p>Keine Playlists vorhanden</p></div>'; return; } if(!lists.length) { g.innerHTML = '<div class="empty-state"><div class="icon">🎶</div><p>Keine Playlists vorhanden</p></div>'; return; }
lists.forEach(pl => { lists.forEach(pl => {
const cv = coverUrl(pl.coverArt, 512); const cv = coverUrl(pl.coverArt, 512);
const c = makeCard(pl.name, pl.songCount+' Titel', cv, '🎶'); const von = _besitzer(pl);
const c = makeCard(pl.name, pl.songCount+' Titel' + (von ? ' · von '+von : ''), cv, '🎶');
c.dataset.plOwner = von;
c.dataset.plId = pl.id; c.dataset.plId = pl.id;
c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayPlaylist(pl); }; c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayPlaylist(pl); };
c.querySelector('.card-info').onclick = () => navigate('playlist-detail', pl); c.querySelector('.card-info').onclick = () => navigate('playlist-detail', pl);
@@ -29,7 +41,8 @@ export async function loadPlaylists() {
api('getPlaylist', `&id=${pl.id}`).then(r => { api('getPlaylist', `&id=${pl.id}`).then(r => {
const count = (r.playlist?.entry || []).length; const count = (r.playlist?.entry || []).length;
const card = g.querySelector(`[data-pl-id="${pl.id}"]`); const card = g.querySelector(`[data-pl-id="${pl.id}"]`);
if (card) card.querySelector('.card-sub').textContent = count + ' Titel'; if (card) card.querySelector('.card-sub').textContent =
count + ' Titel' + (card.dataset.plOwner ? ' · von ' + card.dataset.plOwner : '');
}).catch(() => {}); }).catch(() => {});
}); });
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; } } catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
@@ -49,10 +62,13 @@ export async function loadPlaylistDetail(pl) {
const full = r.playlist || {}; const full = r.playlist || {};
S._currentPlaylist = {...pl, ...full, id: pl.id}; S._currentPlaylist = {...pl, ...full, id: pl.id};
const songs = full.entry || []; const songs = full.entry || [];
const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60); const gesamt = songs.reduce((s,t) => s+(t.duration||0), 0);
document.getElementById('playlist-detail-stats').textContent = `${songs.length} Titel · ${totalMin} Min.`; const von = _besitzer({ ...pl, ...full });
document.getElementById('playlist-detail-stats').textContent =
`${songs.length} Titel · ${formatGesamtdauer(gesamt)}` + (von ? ` · von ${von}` : '');
const overviewCard = document.querySelector(`#playlists-grid [data-pl-id="${pl.id}"]`); const overviewCard = document.querySelector(`#playlists-grid [data-pl-id="${pl.id}"]`);
if (overviewCard) overviewCard.querySelector('.card-sub').textContent = songs.length + ' Titel'; if (overviewCard) overviewCard.querySelector('.card-sub').textContent =
songs.length + ' Titel' + (von ? ' · von ' + von : '');
const descEl = document.getElementById('playlist-detail-desc'); const descEl = document.getElementById('playlist-detail-desc');
if(descEl) { descEl.textContent = full.comment||''; descEl.style.display = full.comment ? '' : 'none'; } if(descEl) { descEl.textContent = full.comment||''; descEl.style.display = full.comment ? '' : 'none'; }
document.getElementById('btn-play-playlist').onclick = () => playQueue(songs, 0); document.getElementById('btn-play-playlist').onclick = () => playQueue(songs, 0);
@@ -215,6 +231,10 @@ export async function loadStarred(sort = _starredSort) {
if (searchEl) searchEl.value = ''; if (searchEl) searchEl.value = '';
try { try {
const r = await api('getStarred2'); const r = await api('getStarred2');
// getStarred2 liefert Kuenstler und Alben im SELBEN Paket wie die Songs — hier ist also die
// Uebersicht "wem folge ich" zu haben, ohne einen zusaetzlichen Aufruf.
_renderStarredArtists(r.starred2?.artist || []);
_renderStarredAlbums(r.starred2?.album || []);
let songs = (r.starred2?.song || []).map(s => ({...s, _starred: true})); let songs = (r.starred2?.song || []).map(s => ({...s, _starred: true}));
if (sort === 'newest') songs.sort((a, b) => new Date(b.starred||0) - new Date(a.starred||0)); if (sort === 'newest') songs.sort((a, b) => new Date(b.starred||0) - new Date(a.starred||0));
else if (sort === 'oldest') songs.sort((a, b) => new Date(a.starred||0) - new Date(b.starred||0)); else if (sort === 'oldest') songs.sort((a, b) => new Date(a.starred||0) - new Date(b.starred||0));
@@ -223,10 +243,89 @@ export async function loadStarred(sort = _starredSort) {
else if (sort === 'plays') songs.sort((a, b) => (b.playCount||0) - (a.playCount||0)); else if (sort === 'plays') songs.sort((a, b) => (b.playCount||0) - (a.playCount||0));
el.innerHTML = ''; el.innerHTML = '';
if(!songs.length) { el.innerHTML = '<div class="empty-state"><div class="icon">⭐</div><p>Noch keine Favoriten</p></div>'; return; } if(!songs.length) { el.innerHTML = '<div class="empty-state"><div class="icon">⭐</div><p>Noch keine Favoriten</p></div>'; return; }
const titel = document.getElementById('starred-songs-title');
if (titel) titel.style.display = songs.length ? '' : 'none';
songs.forEach((s,i) => el.appendChild(makeSongItem(s, i, songs, { noArtistLink: true }))); songs.forEach((s,i) => el.appendChild(makeSongItem(s, i, songs, { noArtistLink: true })));
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; } } catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
} }
// Kuenstler, denen man folgt. Die Sortierchips oben gelten fuer die Songliste; hier waere eine
// eigene Sortierung nur Ballast, also alphabetisch.
// Wie viele gezeigt werden, richtet sich nach der Fensterbreite: eine Reihe voll, nicht mehr. Bei
// 200 gefolgten Kuenstlern 200 Karten samt Bildern zu bauen, von denen man vier sieht, waere
// Verschwendung — den Rest erreicht man ueber "Alle anzeigen" (Kuenstlerseite mit Gefolgt-Filter).
const _KARTE = 145 + 12; // .scroll-row .card width + gap
function _passendeAnzahl(wrap) {
const breite = wrap?.clientWidth || document.getElementById('main-content')?.clientWidth || 900;
// Eine Karte weniger, damit "Alle anzeigen" am Ende sichtbar ist statt angeschnitten.
return Math.max(2, Math.floor(breite / _KARTE) - 1);
}
let _starredArtists = [];
function _renderStarredArtists(artists) {
_starredArtists = artists;
// "✨ Neu" fuehrt in die Zeitleiste und steht in der Kopfzeile der Favoriten — dieselbe
// Stelle wie in Android. Ohne gefolgte Kuenstler gaebe es dort nichts zu sehen.
const neuBtn = document.getElementById('starred-news-btn');
if (neuBtn) neuBtn.style.display = artists.length ? '' : 'none';
const wrap = document.getElementById('starred-artists-section');
if (!wrap) return;
wrap.innerHTML = '';
if (!artists.length) return;
const sortiert = [...artists].sort((a,b) => (a.name||'').localeCompare(b.name||''));
const zeigen = _passendeAnzahl(wrap);
const s = mkSection(`Künstler, denen du folgst (${artists.length})`);
const row = document.createElement('div'); row.className = 'scroll-row';
sortiert.slice(0, zeigen).forEach(a => {
const c = makeCard(dispArtistById(a.id, a.name), `${a.albumCount || 0} Alben`, null, '🎤');
c.classList.add('artist-card');
c.onclick = () => navigate('artist-detail', { id: a.id, name: a.name });
row.appendChild(c);
const pin = getArtistOverride(a.id);
const setzen = img => {
if (!img) return;
const art = c.querySelector('.card-art');
if (art) art.innerHTML = `<img src="${img}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\'no-art\'>🎤</div>'">`;
};
if (pin?.image) setzen(pin.image);
else if (a.coverArt) setzen(coverUrl(a.coverArt, 300)); // kein Extra-Aufruf noetig
else getArtistImage(a.id).then(setzen);
});
if (sortiert.length > zeigen) {
const mehr = document.createElement('div');
mehr.className = 'card artist-card starred-more';
mehr.innerHTML = '<div class="card-art"><div class="no-art">→</div></div>'
+ '<div class="card-info"><div class="card-title">Alle anzeigen</div>'
+ `<div class="card-sub">+${sortiert.length - zeigen} weitere</div></div>`;
mehr.onclick = () => { navigate('artists'); loadArtists('followed'); };
row.appendChild(mehr);
}
s.appendChild(row); wrap.appendChild(s);
}
// Bei Groessenaenderung neu aufteilen — sonst bleibt die Zahl auf dem Stand des ersten Aufbaus.
let _resizeTimer = null;
window.addEventListener('resize', () => {
if (!document.getElementById('page-starred')?.classList.contains('active')) return;
clearTimeout(_resizeTimer);
_resizeTimer = setTimeout(() => {
if (_starredArtists.length) _renderStarredArtists(_starredArtists);
}, 200);
});
function _renderStarredAlbums(albums) {
const wrap = document.getElementById('starred-albums-section');
if (!wrap) return;
wrap.innerHTML = '';
if (!albums.length) return;
const s = mkSection(`Gemerkte Alben (${albums.length})`);
const row = document.createElement('div'); row.className = 'scroll-row';
[...albums].sort((a,b) => new Date(b.starred||0) - new Date(a.starred||0))
.forEach(a => row.appendChild(makeAlbumCard(a)));
s.appendChild(row); wrap.appendChild(s);
}
export function filterStarred(val) { export function filterStarred(val) {
const q = val.trim().toLowerCase(); const q = val.trim().toLowerCase();
document.querySelectorAll('#starred-songs .song-item').forEach(item => { document.querySelectorAll('#starred-songs .song-item').forEach(item => {
+9 -2
View File
@@ -1,6 +1,6 @@
import { S } from '../state.js'; import { S } from '../state.js';
import { esc, toast, fmtCount } from '../utils.js'; import { esc, toast, fmtCount } from '../utils.js';
import { api, deezerSearch, deezerLastError, deezerLookupAlbum, fetchRomajiIndex, dispArtistById, getArtistOverride } from '../api.js'; import { api, coverUrl, deezerSearch, deezerLastError, deezerLookupAlbum, fetchRomajiIndex, dispArtistById, getArtistOverride } from '../api.js';
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js'; import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js';
import { playPreview } from '../player/player.js'; import { playPreview } from '../player/player.js';
import { openDlModal } from '../ui/ui.js'; import { openDlModal } from '../ui/ui.js';
@@ -73,6 +73,7 @@ export async function doLocalSearch(q, el) {
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image is the wrong person). // A pin's Deezer picture wins (fixes same-name artists whose Navidrome image is the wrong person).
const _pin = getArtistOverride(a.id); const _pin = getArtistOverride(a.id);
if (_pin?.image) _setArt(_pin.image); if (_pin?.image) _setArt(_pin.image);
else if (a.coverArt) _setArt(coverUrl(a.coverArt, 300)); // kein Extra-Aufruf noetig
else getArtistImage(a.id).then(_setArt); else getArtistImage(a.id).then(_setArt);
}); });
s.appendChild(g); el.appendChild(s); s.appendChild(g); el.appendChild(s);
@@ -206,7 +207,7 @@ export async function loadDeezerAlbum(album) {
<button class="back-btn" style="margin:0" onclick="document.getElementById('global-search-input').dispatchEvent(new Event('input'))"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button> <button class="back-btn" style="margin:0" onclick="document.getElementById('global-search-input').dispatchEvent(new Event('input'))"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
<div style="flex:1"> <div style="flex:1">
<div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div> <div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div>
<div style="font-size:12px;color:var(--text2)">${esc(album.artistName)}</div> <div class="dz-inline-artist${album.artistId ? ' song-link' : ''}"${album.artistId ? ' id="dz-alb-artist"' : ''}>${esc(album.artistName)}</div>
</div> </div>
<button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,deezerData:${JSON.stringify(album).replace(/"/g,'&quot;')}})">⬇ Album laden</button> <button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,deezerData:${JSON.stringify(album).replace(/"/g,'&quot;')}})">⬇ Album laden</button>
</div> </div>
@@ -215,6 +216,12 @@ export async function loadDeezerAlbum(album) {
tracks.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, tracks))); tracks.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, tracks)));
s.appendChild(list); s.appendChild(list);
el.innerHTML = ''; el.appendChild(s); el.innerHTML = ''; el.appendChild(s);
// Deezer hängt an jeden Albumtreffer das Artist-Objekt mit id — ein Sprung zum Künstler
// kostet also keinen zusätzlichen Aufruf.
if (album.artistId) {
s.querySelector('#dz-alb-artist')?.addEventListener('click', () =>
loadDeezerArtistResults(album.artistId, album.artistName, null));
}
} catch(e) { toast('Fehler: ' + e.message); } } catch(e) { toast('Fehler: ' + e.message); }
} }
+158 -8
View File
@@ -20,7 +20,20 @@
#app input,#app textarea,#app [contenteditable=true],#app .selectable, #app input,#app textarea,#app [contenteditable=true],#app .selectable,
#app #np-lyrics-content,#app .lyrics-line,#app .lyrics-plain{-webkit-user-select:text;user-select:text} #app #np-lyrics-content,#app .lyrics-line,#app .lyrics-plain{-webkit-user-select:text;user-select:text}
html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--text);overflow:hidden} html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--text);overflow:hidden}
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--surface2);border-radius:2px} /* Rollbalken: 4 px waren zwar unauffaellig, aber mit der Maus kaum zu treffen — in einer langen
Playlist kam man nicht ans Ende. Jetzt 12 px Greiffläche mit einem schmalen sichtbaren Balken
(durchsichtiger Rand + background-clip), wie bei Spotify. min-height haelt ihn bei sehr langen
Listen greifbar, sonst schrumpft er bei 1900 Titeln auf wenige Pixel. */
::-webkit-scrollbar{width:12px;height:12px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--surface2);border-radius:8px;border:3px solid transparent;background-clip:content-box;min-height:44px}
::-webkit-scrollbar-thumb:hover{background:var(--text3);background-clip:content-box}
::-webkit-scrollbar-thumb:active{background:var(--text2);background-clip:content-box}
::-webkit-scrollbar-corner{background:transparent}
/* Firefox kennt die ::-webkit-Regeln nicht. Dort NUR die Farbe setzen — der Standardbalken ist
ohnehin breit genug zum Greifen, "thin" wuerde ihn wieder verschmaelern. scrollbar-color wird
vererbt; die Ausnahmen (.scroll-row, Lyrics, Chips) setzen weiterhin scrollbar-width:none. */
html{scrollbar-color:var(--surface2) transparent}
/* ── SETUP ── */ /* ── SETUP ── */
html.ns-authed #setup-screen{display:none} html.ns-authed #setup-screen{display:none}
@@ -48,6 +61,10 @@ html.ns-authed #setup-screen{display:none}
/* ── APP LAYOUT ── */ /* ── APP LAYOUT ── */
#app{display:none;height:100vh;flex-direction:column} #app{display:none;height:100vh;flex-direction:column}
.app-body{display:flex;flex:1;overflow:hidden} .app-body{display:flex;flex:1;overflow:hidden}
/* Spalte rechts der Seitenleiste: oben die feste Kopfzeile, darunter der Rollbereich.
Frueher lag die Kopfzeile IM Rollbereich (sticky) — der Rollbalken lief dadurch bis ganz
nach oben neben die Suchleiste, obwohl dort nichts rollt. */
.main-wrap{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden}
/* ── SIDEBAR ── */ /* ── SIDEBAR ── */
.sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;padding-bottom:var(--player-h)} .sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;padding-bottom:var(--player-h)}
@@ -88,7 +105,11 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
.nav-item svg{flex-shrink:0;width:15px;height:15px} .nav-item svg{flex-shrink:0;width:15px;height:15px}
/* ── MAIN ── */ /* ── MAIN ── */
.main{flex:1;overflow-y:auto;overflow-x:hidden;padding-bottom:var(--player-h);background:var(--bg);background-image:radial-gradient(ellipse 80% 40% at 50% 0%,rgba(232,160,32,.04) 0%,transparent 60%)} /* Der Rollbalken gehoert zum Scrollbereich und laeuft ueber dessen ganze Hoehe. Mit
padding-bottom reichte .main bis an den Fensterboden, also bis UNTER die Player-Leiste —
bei 4 px fiel das nicht auf, bei 12 px schon. Deshalb endet der Scrollbereich jetzt per
margin-bottom ueber der Leiste; die Platzreserve fuer den Inhalt ist damit ohnehin ueberfluessig. */
.main{flex:1;overflow-y:auto;overflow-x:hidden;margin-bottom:var(--player-h);padding-bottom:16px;background:var(--bg);background-image:radial-gradient(ellipse 80% 40% at 50% 0%,rgba(232,160,32,.04) 0%,transparent 60%)}
.page{display:none;padding:24px 28px} .page{display:none;padding:24px 28px}
.page.active{display:block} .page.active{display:block}
.page-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;flex-wrap:wrap;gap:10px} .page-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;flex-wrap:wrap;gap:10px}
@@ -274,11 +295,27 @@ button:focus{outline:none}
.album-cover .no-art{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:50px} .album-cover .no-art{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:50px}
.album-type{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);margin-bottom:5px} .album-type{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);margin-bottom:5px}
.album-name{font-size:28px;font-weight:800;letter-spacing:-1px;margin-bottom:5px;line-height:1.1} .album-name{font-size:28px;font-weight:800;letter-spacing:-1px;margin-bottom:5px;line-height:1.1}
.album-artist-name{font-size:14px;color:var(--text2);margin-bottom:10px;cursor:pointer} .album-artist-name{font-size:15px;font-weight:600;color:var(--text);margin-bottom:10px}
.album-artist-name:hover{color:var(--accent)} /* Der Künstler ging unter dem 28px-Albumtitel in --text2 (#8888aa) unter und sah nach nichts
Anklickbarem aus. Jetzt hell und halbfett; wo wirklich ein Ziel dahintersteht, zusätzlich
unterstrichen (lokal: .song-link-Spans, Deezer: .is-link). */
.album-artist-name .song-link,.album-artist-name.is-link{
cursor:pointer;text-decoration:underline;text-decoration-color:var(--text3);
text-underline-offset:3px;transition:color .15s,text-decoration-color .15s}
.album-artist-name .song-link:hover,.album-artist-name.is-link:hover{
color:var(--accent);text-decoration-color:var(--accent)}
.album-stats{font-size:12px;color:var(--text3)} .album-stats{font-size:12px;color:var(--text3)}
/* Inline-Albumkopf in der globalen Suche — gleiche Lesbarkeit auf kleinerem Raum. */
.dz-inline-artist{font-size:13px;font-weight:600;color:var(--text)}
.dz-inline-artist.song-link{text-decoration:underline;text-decoration-color:var(--text3);
text-underline-offset:3px}
.dz-inline-artist.song-link:hover{color:var(--accent);text-decoration-color:var(--accent)}
.playlist-detail-desc{font-size:13px;color:var(--text2);line-height:1.5;margin-top:6px;max-width:480px} .playlist-detail-desc{font-size:13px;color:var(--text2);line-height:1.5;margin-top:6px;max-width:480px}
.album-actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap} .album-actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
/* Markiert: gefuelltes Herz in Akzentfarbe — gilt fuer den Album-Knopf und den Folgen-Knopf
auf der Kuenstlerseite. */
.btn-outline.is-starred,.artist-action-btn.is-starred{color:var(--accent);border-color:var(--accent)}
.artist-follow-btn.is-starred{color:var(--accent);border-color:var(--accent)}
.btn-play-all{display:flex;align-items:center;gap:7px;padding:10px 20px;border-radius:25px;background:var(--accent);color:#000;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:opacity .15s,transform .15s} .btn-play-all{display:flex;align-items:center;gap:7px;padding:10px 20px;border-radius:25px;background:var(--accent);color:#000;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:opacity .15s,transform .15s}
.btn-play-all:hover{opacity:.9;transform:scale(1.02)} .btn-play-all:hover{opacity:.9;transform:scale(1.02)}
.btn-outline{display:flex;align-items:center;gap:7px;padding:10px 18px;border-radius:25px;background:var(--surface);border:1px solid var(--border2);color:var(--text);font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;transition:background .15s} .btn-outline{display:flex;align-items:center;gap:7px;padding:10px 18px;border-radius:25px;background:var(--surface);border:1px solid var(--border2);color:var(--text);font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;transition:background .15s}
@@ -364,7 +401,10 @@ button:focus{outline:none}
/* ── QUEUE PANEL ── */ /* ── QUEUE PANEL ── */
.queue-panel{position:fixed;right:0;top:0;bottom:var(--player-h);width:290px;background:var(--bg2);border-left:1px solid var(--border);transform:translateX(100%);transition:transform .3s;z-index:90;display:flex;flex-direction:column;padding:18px 0} .queue-panel{position:fixed;right:0;top:0;bottom:var(--player-h);width:290px;background:var(--bg2);border-left:1px solid var(--border);transform:translateX(100%);transition:transform .3s;z-index:90;display:flex;flex-direction:column;padding:18px 0}
.queue-panel.open{transform:translateX(0)} .queue-panel.open{transform:translateX(0)}
.queue-title{font-size:15px;font-weight:700;padding:0 18px 14px;border-bottom:1px solid var(--border)} .queue-head{display:flex;align-items:center;gap:8px;padding:0 10px 14px 18px;border-bottom:1px solid var(--border)}
.queue-title{font-size:15px;font-weight:700;flex:1;min-width:0}
.queue-close{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:30px;height:30px;border:none;background:none;color:var(--text2);border-radius:8px;cursor:pointer;transition:background .15s,color .15s}
.queue-close:hover{background:var(--surface);color:var(--text)}
.queue-list{flex:1;overflow-y:auto;padding:6px 10px} .queue-list{flex:1;overflow-y:auto;padding:6px 10px}
.queue-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;cursor:pointer;transition:background .15s} .queue-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;cursor:pointer;transition:background .15s}
.queue-item:hover{background:var(--surface)} .queue-item:hover{background:var(--surface)}
@@ -579,9 +619,9 @@ button:focus{outline:none}
.bottom-nav{display:flex;flex-direction:column;justify-content:flex-start} .bottom-nav{display:flex;flex-direction:column;justify-content:flex-start}
/* player sits above bottom nav */ /* player sits above bottom nav */
.player{bottom:80px} .player{bottom:80px}
/* push content above player + bottom nav */ /* Scrollbereich endet ueber Player UND unterer Leiste — siehe Kommentar bei .main */
.main{padding-bottom:calc(var(--player-h) + 80px + env(safe-area-inset-bottom) + 64px)} .main{margin-bottom:calc(var(--player-h) + 80px + env(safe-area-inset-bottom));padding-bottom:16px}
.page{padding-bottom:calc(var(--player-h) + 80px + env(safe-area-inset-bottom) + 96px)} .page{padding-bottom:24px}
.queue-panel{bottom:calc(var(--player-h) + 80px)} .queue-panel{bottom:calc(var(--player-h) + 80px)}
.preview-bar{bottom:calc(var(--player-h) + 80px)} .preview-bar{bottom:calc(var(--player-h) + 80px)}
.toast-container{bottom:calc(var(--player-h) + 80px + 16px)} .toast-container{bottom:calc(var(--player-h) + 80px + 16px)}
@@ -776,6 +816,17 @@ button:focus{outline:none}
.lyrics-line{font-size:18px;font-weight:600;line-height:1.85;color:rgba(255,255,255,.22);transition:color .3s,transform .3s,opacity .3s;padding:5px 0;cursor:pointer;text-align:center;transform:scale(1);transform-origin:center center;will-change:transform} .lyrics-line{font-size:18px;font-weight:600;line-height:1.85;color:rgba(255,255,255,.22);transition:color .3s,transform .3s,opacity .3s;padding:5px 0;cursor:pointer;text-align:center;transform:scale(1);transform-origin:center center;will-change:transform}
.lyrics-line.past{color:rgba(255,255,255,.45)} .lyrics-line.past{color:rgba(255,255,255,.45)}
.lyrics-line.active{color:#fff;transform:scale(1.04)} .lyrics-line.active{color:#fff;transform:scale(1.04)}
/* Mitlaufen-Schalter: schwebt unten rechts ueber dem Text, nur im Lyrics-Tab und nur bei
synchronen Zeilen (Klasse .hat-sync setzt das Skript). Aus = gedaempft, an = Akzentfarbe. */
.np-follow{display:none;position:absolute;right:22px;bottom:22px;z-index:3;align-items:center;gap:6px;
background:rgba(0,0,0,.42);border:1px solid rgba(255,255,255,.14);color:rgba(255,255,255,.6);
font-family:var(--font);font-size:11px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;
padding:7px 12px;border-radius:18px;cursor:pointer;backdrop-filter:blur(10px);
transition:color .2s,border-color .2s,background .2s}
.np-overlay.lyrics-mode .np-follow.hat-sync{display:flex}
.np-follow:hover{background:rgba(0,0,0,.6);color:rgba(255,255,255,.85)}
.np-follow.active{color:var(--blue);border-color:color-mix(in srgb,var(--blue) 55%,transparent)}
.np-follow svg{pointer-events:none;flex-shrink:0}
.lyrics-plain{font-size:15px;line-height:1.9;color:rgba(255,255,255,.65);white-space:pre-wrap;text-align:center;padding-top:24px} .lyrics-plain{font-size:15px;line-height:1.9;color:rgba(255,255,255,.65);white-space:pre-wrap;text-align:center;padding-top:24px}
.section-count{font-size:11px;font-weight:500;color:var(--text3);background:var(--surface2);padding:2px 8px;border-radius:10px;flex-shrink:0} .section-count{font-size:11px;font-weight:500;color:var(--text3);background:var(--surface2);padding:2px 8px;border-radius:10px;flex-shrink:0}
.album-partial-badge{position:absolute;bottom:5px;left:5px;background:rgba(0,0,0,.72);color:#fff;font-size:9px;font-weight:700;padding:2px 6px;border-radius:4px;pointer-events:none;z-index:2} .album-partial-badge{position:absolute;bottom:5px;left:5px;background:rgba(0,0,0,.72);color:#fff;font-size:9px;font-weight:700;padding:2px 6px;border-radius:4px;pointer-events:none;z-index:2}
@@ -1125,3 +1176,102 @@ button:focus{outline:none}
.abs-vol-slider::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);cursor:pointer} .abs-vol-slider::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);cursor:pointer}
.abs-vol-slider::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--accent);cursor:pointer;border:none} .abs-vol-slider::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--accent);cursor:pointer;border:none}
@media(max-width:768px){.abs-mini-bar{bottom:80px;height:var(--player-h)}} @media(max-width:768px){.abs-mini-bar{bottom:80px;height:var(--player-h)}}
/* ─── MUSIK-DUELL (Vergleich zweier Nutzer) ─────────────────────────────────
Vorher lebte die Ansicht ausschliesslich von Inline-Stilen mit festen Flex-Verhaeltnissen.
Am Handy ging das auf, am Desktop zerfiel es. Jetzt ein Raster: eine Spalte auf schmalen
Schirmen, zwei ab 780 px — die Anzeigetafel bleibt in beiden Faellen ueber die volle Breite. */
.cmp-body{display:grid;grid-template-columns:1fr;gap:26px;align-content:start}
.cmp-wide{grid-column:1/-1}
@media(min-width:780px){
.cmp-body{grid-template-columns:1fr 1fr;gap:26px 30px}
}
.cmp-head{display:flex;align-items:center;justify-content:center;gap:16px;flex-wrap:wrap;margin-bottom:14px}
.cmp-name{font-size:15px;font-weight:800;max-width:38vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cmp-score{font-size:30px;font-weight:900;letter-spacing:-1px;font-variant-numeric:tabular-nums}
.cmp-verdict{text-align:center;font-size:13px;color:var(--text2);margin-bottom:16px}
/* Eine Kategoriezeile: Wert · Name · Wert. Feste Aussenspalten, damit die Zahlen untereinander
stehen und die Mitte den Rest bekommt — genau das fehlte vorher. */
.cmp-cat{display:grid;grid-template-columns:minmax(64px,1fr) auto minmax(64px,1fr);align-items:center;
gap:10px;padding:9px 10px;border-radius:10px}
.cmp-cat+.cmp-cat{margin-top:4px}
.cmp-cat-label{font-size:12px;color:var(--text2);text-transform:uppercase;letter-spacing:.4px;
font-weight:800;white-space:nowrap}
.cmp-val{font-size:15px;font-weight:700;font-variant-numeric:tabular-nums}
.cmp-val.a{text-align:right}
.cmp-val.b{text-align:left}
.cmp-val.win{color:var(--accent)}
.cmp-val.lose{color:var(--text3)}
.cmp-cat.win-a{background:linear-gradient(90deg,rgba(232,160,32,.13),transparent 70%)}
.cmp-cat.win-b{background:linear-gradient(270deg,rgba(232,160,32,.13),transparent 70%)}
/* Tauziehen-Zeile fuer gemeinsame Kuenstler */
.cmp-row{display:grid;grid-template-columns:34px 1fr minmax(90px,1.4fr) 1fr 34px;align-items:center;
gap:8px;padding:6px 0}
.cmp-row-name{text-align:center;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;min-width:0}
.cmp-num{font-size:12px;color:var(--text2);font-weight:700;font-variant-numeric:tabular-nums}
.cmp-bar{height:6px;border-radius:3px;background:var(--accent)}
.cmp-track{display:flex;align-items:center;gap:10px;padding:6px 0;min-width:0}
.cmp-track img{width:40px;height:40px;border-radius:6px;object-fit:cover;flex-shrink:0}
/* ─── NEU VON DEINEN KÜNSTLERN ─────────────────────────────────────────── */
.artist-news-hint{background:rgba(232,160,32,.08);border:1px solid rgba(232,160,32,.28);border-radius:10px;padding:10px 14px;font-size:12px;color:var(--text2);line-height:1.5;margin-bottom:14px}
.artist-news-hint strong{color:var(--text)}
.artist-news-row{display:flex;align-items:center;gap:14px;padding:10px 12px;border-radius:12px;transition:background .15s}
.artist-news-row:hover{background:var(--surface)}
.artist-news-art{width:56px;height:56px;flex-shrink:0;border-radius:8px;overflow:hidden;background:var(--surface2);display:flex;align-items:center;justify-content:center}
.artist-news-art img{width:100%;height:100%;object-fit:cover}
.artist-news-info{flex:1;min-width:0}
.artist-news-title{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.artist-news-sub{font-size:12px;color:var(--text2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.artist-news-artist{cursor:pointer;transition:color .15s}
.artist-news-artist:hover{color:var(--accent);text-decoration:underline}
.artist-news-date{font-size:11px;color:var(--text3);margin-top:3px}
.artist-news-action{flex-shrink:0}
.artist-news-have{font-size:12px;color:var(--text3);white-space:nowrap}
@media(max-width:600px){
.artist-news-art{width:46px;height:46px}
.artist-news-row{gap:10px;padding:8px 6px}
}
/* Dublettenauflösung im Metadaten-Editor: zwei Dateien gegenübergestellt, darunter der Löschknopf */
.dublette-box{margin-top:12px;padding:12px;background:var(--bg2);border:1px solid var(--border2);border-radius:var(--radius-sm);text-align:left}
.dublette-datei{margin-bottom:10px;font-size:12px}
.dublette-datei b{display:block;margin-bottom:2px;color:var(--text2);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
.dublette-pfad{color:var(--text3);word-break:break-all;line-height:1.35}
.dublette-meta{color:var(--text2);margin-top:2px}
.dublette-urteil{margin:8px 0;font-size:12px;line-height:1.4}
.dublette-urteil.ok{color:var(--text2)}
.dublette-urteil.warn{color:var(--danger)}
.dublette-box .btn-danger{width:100%;padding:9px 14px;background:transparent;border:1px solid var(--danger);border-radius:var(--radius-sm);color:var(--danger);font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;transition:background .2s}
.dublette-box .btn-danger:hover:not(:disabled){background:rgba(224,85,85,.12)}
.dublette-box .btn-danger:disabled{opacity:.55;cursor:default}
/* ── Warteschlange bearbeiten ── */
/* Griff und Entfernen-Knopf sitzen am rechten Rand beider Queue-Listen. Am Zeigergeraet
erscheinen sie erst beim Ueberfahren, damit die Liste ruhig bleibt; auf Touch-Geraeten gibt es
kein Ueberfahren, dort stehen sie immer da. */
.queue-item,.np-queue-item{position:relative}
.q-del,.q-grip{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:26px;height:26px;border:none;background:none;color:var(--text3);border-radius:6px;cursor:pointer;opacity:0;transition:opacity .15s,color .15s,background .15s}
.q-grip{cursor:grab;touch-action:none}
.queue-item:hover .q-del,.queue-item:hover .q-grip,
.np-queue-item:hover .q-del,.np-queue-item:hover .q-grip{opacity:1}
.q-del:hover{color:var(--danger);background:rgba(224,85,85,.12)}
.q-grip:hover{color:var(--text)}
@media(hover:none){.q-del,.q-grip{opacity:.65}}
/* Die gezogene Zeile schwebt ueber den anderen; die weichenden gleiten, sie nicht — sonst
haengt sie dem Finger hinterher. */
.q-dragging{z-index:5;background:var(--surface2)!important;box-shadow:0 8px 24px rgba(0,0,0,.45);cursor:grabbing}
.q-dragging .q-grip{cursor:grabbing}
.q-dragging-aktiv [data-qidx]:not(.q-dragging){transition:transform .16s ease}
.q-dragging-aktiv{user-select:none}
/* ── Konzerte deiner Künstler ── */
/* Kalenderblatt statt Cover: ein Konzert hat kein Bild, aber ein Datum — und danach sucht das
Auge in einer Liste, die nach vorn läuft. */
.konzert-datum{flex-shrink:0;width:46px;height:46px;border-radius:10px;background:var(--surface);border:1px solid var(--border2);display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1}
.kd-tag{font-size:17px;font-weight:700;color:var(--text)}
.kd-monat{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--text2);margin-top:2px}
.konzert-ticket{font-size:12px;padding:6px 12px;white-space:nowrap;text-decoration:none;display:inline-block}
.konzert-abgesagt .konzert-datum{opacity:.45}
.konzert-abgesagt .artist-news-title{text-decoration:line-through;color:var(--text2)}
+34
View File
@@ -1,6 +1,40 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Setzt die Cache-Version des Service Workers beim Bauen.
*
* Vorher stand in sw.js ein handgepflegtes `ns-v78`. Wurde es beim Ausliefern vergessen — und das
* passiert zuverlaessig —, blieb der alte Cache bestehen: die Aktivierung loescht nur Caches, deren
* Name vom aktuellen abweicht. Nutzer sahen dann alte Staende und mussten den Browsercache von Hand
* leeren, was man niemandem erklaeren moechte. Die Version kommt jetzt aus der Bauzeit, ist also bei
* jedem Ausliefern neu, und der alte Cache raeumt sich beim ersten Start selbst ab.
*/
function serviceWorkerVersion() {
return {
name: 'sw-cache-version',
closeBundle() {
const pfad = join('dist', 'sw.js');
try {
const inhalt = readFileSync(pfad, 'utf8');
const version = `ns-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}`;
const neu = inhalt.replace(/^const CACHE = '[^']*';/m, `const CACHE = '${version}';`);
if (neu === inhalt) {
this.warn('sw.js: CACHE-Konstante nicht gefunden — Version nicht gesetzt');
return;
}
writeFileSync(pfad, neu);
console.log(` Service-Worker-Cache: ${version}`);
} catch (e) {
this.warn(`sw.js konnte nicht versioniert werden: ${e.message}`);
}
},
};
}
export default defineConfig({ export default defineConfig({
plugins: [serviceWorkerVersion()],
build: { build: {
outDir: 'dist', outDir: 'dist',
assetsDir: 'assets', assetsDir: 'assets',