Compare commits

..
703 Commits
Author SHA1 Message Date
marcandClaude Opus 5 4cbeca268c docs: Web-Aenderung als 3.4 fuehren, Android-Anpassung bleibt 3.3
Die Seitenleiste ist ein eigener, nur im Web ausgelieferter Stand — getrennt von der
Fold-/Flip-Anpassung, zu der das APK 1.27 gehoert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 23:45:43 +02:00
marcandClaude Opus 5 9afd73ae7f feat(web): Playlists und Favoriten als Liste in der Seitenleiste
Statt zweier Knoepfe, die erst eine Uebersichtsseite oeffnen, steht unter den
Navigationsrubriken der Abschnitt "Deine Musik": angepinnt Favoriten und "Neu von
deinen Kuenstlern", darunter die Playlists mit Cover und Titelzahl.

Links und nicht rechts, weil rechts belegt ist: dort faehrt die Warteschlange als
290-px-Panel ein und am Rand sitzt der Rollbalken aus 3.1.

Der Listenteil rollt eigenstaendig (nav ist jetzt eine Flex-Spalte), auf niedrigen
Fenstern rollt als Rueckfall die ganze Leiste. Dieselbe gecachte Abfrage wie die
Uebersichtsseite; nach Anlegen/Umbenennen/Loeschen wird der Cache verworfen und neu
gezeichnet, ein Coverwechsel tauscht nur die betroffene Zeile (gleiche Adresse pl-<id>,
sonst kaeme dasselbe Bild aus dem Browser-Cache).

Die Kachel-Uebersicht bleibt per "Alle" erreichbar, die untere Leiste auf dem Handy
bleibt unveraendert.

Ausserdem: in 3.2 uebersehene Mischstelle nachgezogen — der Shuffle-Knopf der
Playlist-Seite benutzte weiter sort(() => Math.random()-.5). Mein Suchmuster hatte nur
die Schreibweise mit 0.5 erfasst, nicht die mit .5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 19:40:56 +02:00
marcandClaude Opus 5 7cdcc352d6 fix(android): Handy-Layout im Now-Playing unangetastet lassen
Nachtrag zu 6248329: zwei der Aenderungen hatten doch Wirkung auf gewoehnliche Handys.

1. Senkrechte Zentrierung galt fuer alle Anzeigen und verschob den Block auf einem hohen
   Handy um etwa 60 dp nach unten. Jetzt wird nur zentriert, wenn eine der neuen Grenzen
   wirklich greift (Hoehe oder 400-dp-Deckel); solange die Breite das engste Mass ist,
   bleibt es beim alten Aufbau samt 28 dp Abstand oben.
2. Die Schwelle fuer die kompakte Kopfzeile lag bei 400 dp und erfasste damit jedes
   uebliche Handy. Jetzt 356 dp oder kurze Anzeige — also Fold-Deckel und
   Flip-Frontdisplay, nicht aber ein 360- oder 384-dp-Handy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 19:04:38 +02:00
marcandClaude Opus 5 624832959d fix(android): Now-Playing an Fold und Flip anpassen — Deckel, Hoehenmass, Rollbereich
Die Seite hatte keine Breitengrenze: das Cover war fillMaxWidth() mal aspectRatio(1f).
Aufgeklappt sind das ueber 800 dp, womit Titel, Fortschritt und Knoepfe aus dem Bild
geschoben wurden. Das Cover nimmt jetzt das Kleinste aus Breite, Hoehe abzueglich
Restbedarf und 400 dp; Titelblock/Fortschritt/Knoepfe sind auf 480 dp gedeckelt (wie
.np-inner im Web), Liedtext und Warteschlange auf 560 dp.

Auf kurzen Anzeigen (Flip-Frontdisplay, Querformat, kleine Handys) wurde die Knopfreihe
abgeschnitten. Jetzt schrumpfen dort Abstaende, Reiter und Abspielknopf, und die
Player-Seite laesst sich rollen, wenn es weiterhin nicht reicht: der Inhalt ist
mindestens so hoch wie die Seite, passt er, wird zentriert, passt er nicht, waechst er
darueber hinaus. Die Wischgeste zum Schliessen bleibt, weil ein Rollbereich ohne Weg die
Geste nicht verbraucht.

Entschieden wird nach Maszen, nicht nach Gerätenamen: schmal < 400 dp, kurz < 560 dp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 18:47:03 +02:00
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
marcandClaude Opus 5 33b3df609a fix(backend): Live-Mitschnitte aus der Suche ausschliessen
is_live meint nur "laeuft gerade als Stream" — eine Konzert-AUFNAHME kam
ungehindert durch und landete als Albumtrack in der Bibliothek ("Dat Adam - Ufo
Video Days 2015 Live" auf dem echten Kuenstlerkanal, Dauer im Fenster). Der
Variantenfilter bekommt daher eine live-Regel. Sie schaltet sich ab, wenn der
gesuchte Titel selbst live enthaelt; die Pruefung nutzt \blive\b statt includes,
sonst wuerde "deliver" die Regel aushebeln. Drei Tests dazu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:35:44 +02:00
marcandClaude Opus 5 6d8a8a1d5e fix(backend): Neuauflage-Jahreszahl nicht als Pflicht-Keyword fordern
Folgekorrektur zu 02b5905: "… - 2020 Remaster" steht auf der Veroeffentlichung,
aber nie im YouTube-Titel — als Pflicht-Keyword haette die Zahl den richtigen
Upload abgelehnt. Jahreszahlen neben einem Qualifier (remaster/version/edition/
anniversary/mono/stereo) fallen daher wieder raus; ein Titel, der selbst eine
Jahreszahl ist ("1999", "Berlin 2020"), behaelt sie. Gegen 29 Beispieltitel
gemessen verhalten sich 24 exakt wie vorher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:20:44 +02:00
marcandClaude Opus 5 02b590505a fix(backend): Zahlen im Titel sind Pflicht-Keywords, Abkuerzungen erlaubt
"700 Main Street" lud "777 Main Street" eines fremden Artists: buildTitleKeywords
behielt nur die zwei laengsten Woerter (Street, Main), die unterscheidende 700
fiel als zu kurz raus — und Schritt 3 der Kaskade prueft den Kuenstler-Channel
nicht mehr. Zahlen werden jetzt immer als \b<n>\b gefordert. Dazu eine kleine
Abkuerzungstabelle (street/st, saint/st, mount/mt, doctor/dr, versus/vs,
number/no), denn der korrekte Art Track heisst "700 Main st" und scheiterte
bisher an jedem Schritt. Tests in backend/test/ytdlp.test.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:04:00 +02:00
marcandClaude Opus 5 9f0d4c3153 docs(changelog): 2.3 — Pull-Stack, APK-Download-Fix, Alben-Tab-Scrollfix
Fasst die Aenderungen seit 2.2 als Release zusammen: image-basiertes
docker-compose.yml (Bauen wandert nach .dev.yml), der auf GITEA_URL
umgeschriebene APK-Asset-Download und der Alben-Tab-Fix, dazu das
veroeffentlichte versionCode 3. Am Handy bestaetigt: Update-Dialog,
In-Place-Install und die Scrollpositionen funktionieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:36:59 +02:00
marcandClaude Opus 5 e91708bc97 chore(android): versionCode 3 veroeffentlicht (Scroll-Fixes im Release)
APK mit dem Debug-Key vom alten Rechner gebaut (Signatur 1039a00e…, passend zur
installierten App) und als Asset im Release apk-latest ersetzt, Body auf
versionCode=3. Dazu das ausfuehrbare Bit auf gradlew, das im Clone fehlte, und
.idea/ ignoriert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:25:25 +02:00
marcandClaude Opus 5 0029b89de4 fix(android): Alben-Tab behaelt die Scrollposition beim Zurueck
LaunchedEffect(state.albumSort) { scrollToItem(0) } feuerte nicht nur beim
Sortierwechsel, sondern auch beim ersten Compose nach der Rueckkehr aus einem
Album — die im ViewModel gehaltene Position wurde also sofort ueberschrieben.
Das Hochscrollen sitzt jetzt in setAlbumSort() und laeuft nur bei echtem
Wechsel; dazu zwei ungenutzte remember*State-Importe entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:38 +02:00
marcandClaude Opus 5 3e8897b551 fix(backend): APK-Download nutzt den erreichbaren Gitea-Host statt der ROOT_URL
Gitea baut browser_download_url aus seiner eigenen ROOT_URL (hier
192.168.178.153:3000), die vom Backend-Container nicht erreichbar ist. Der
API-Call gelang, der Asset-Download nicht — der stille Fallback auf die nicht
vorhandene lokale Datei meldete dann "Keine APK hinterlegt". Asset- und
Redirect-URLs werden jetzt auf GITEA_URL umgeschrieben; scheitert der Weg
trotzdem, sagen Log und Fehlermeldung welcher der beiden Wege fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:21:31 +02:00
marcandClaude Opus 5 043ec3a108 refactor(docker): docker-compose.yml ist der Pull-Weg, Bauen wandert nach .dev.yml
Namen getauscht: das Standard-Compose zieht jetzt die fertigen Registry-Images
(vorher docker-compose.prod.yml), die Build-Variante heisst docker-compose.dev.yml.
Weniger verwirrend, weil der Produktivfall ohne -f auskommt. Kopfkommentare,
README, CLAUDE.md, .env.example und build-push-images.mjs entsprechend angepasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:15:46 +02:00
marcandClaude Opus 5 62391e4aae feat(docker): pull-based prod stack (docker-compose.prod.yml + Registry-Images)
Produktivsystem baut nicht mehr selbst, sondern zieht die drei fertigen Images
(novasonic-frontend/-backend/-scrobble) aus der Gitea-Registry. Das bestehende
docker-compose.yml bleibt der Build-auf-dem-Host-Weg. Dazu .dockerignore fuers
Frontend-Image (android/, backend/, .git raus) und README/CLAUDE.md aktualisiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:13:46 +02:00
HoneyandClaude Opus 4.8 767d088665 refactor(docker): default compose builds from Dockerfiles (baked deps, cached)
Chosen flow: build on the host from backend/scrobble Dockerfiles instead of
pulling from a registry (no registry / no Docker-on-dev needed). Heavy deps are
baked, so restarts are instant and rebuilds after git pull are cache-fast.
Removed the now-obsolete docker-compose.dev.yml (runtime-install variant); the
optional push-to-registry script stays for a future CI flow. Updated README,
CLAUDE.md, .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 13:34:00 +02:00
HoneyandClaude Opus 4.8 1d8d8143f2 refactor(docker): make image-based the default compose, build variant is .dev
Less confusion: docker-compose.yml is now the default (pulls pre-built images)
and the build-on-host variant is docker-compose.dev.yml. Updated all references
(CLAUDE.md, README, .env.example, build-push-images.mjs, compose header).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 08:03:14 +02:00
HoneyandClaude Opus 4.8 149443e4f7 feat(docker): image-based prod stack (Dockerfiles + prod compose + push script)
Adds a pull-based production option so the host no longer builds/installs deps
on every restart:
- backend/Dockerfile + scrobble/Dockerfile bake the heavy deps (ffmpeg, Deno,
  yt-dlp, node libs). Backend still refreshes yt-dlp on start (non-fatal).
- docker-compose.prod.yml pulls ${REGISTRY}/novasonic-*:${TAG}.
- scripts/build-push-images.mjs (npm run push-images) builds + pushes all three.
- .env.example documents every var incl. REGISTRY/TAG; .env gitignored.

The build-on-host docker-compose.yml stays as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 07:45:17 +02:00
HoneyandClaude Opus 4.8 93ee958434 feat: artist-page scroll/search memory (android) + move APK button to bottom (web)
- Android: the artist page's main list and the "Alle anzeigen" album overlay
  (grid scroll + search query) are hoisted into the Artist/Explore ViewModels,
  so drilling into an album/artist and back keeps position + query. Covers the
  library artist and Deezer (Explore) artist screens.
- Web: the "Android App" download section now sits at the very bottom of
  Settings, below the Navidrome-Server (account) section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 07:42:24 +02:00
HoneyandClaude Opus 4.8 a780f6622b chore(android): versionCode 2 (first apk-latest release upload)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 07:27:06 +02:00
HoneyandClaude Opus 4.8 6da40d9e63 docs(scripts): add .env.release.example template for publish-apk
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 00:52:49 +02:00
HoneyandClaude Opus 4.8 2fd4d978d0 feat(android): in-app updater (backend/Gitea APK), auto-bump versionCode
The app checks {backend}/api/apk/info on launch and, if a newer versionCode
than BuildConfig.VERSION_CODE is offered, shows an update dialog that
downloads {backend}/api/apk into a single wiped-each-time cache file and
hands it to the system installer (FileProvider + REQUEST_INSTALL_PACKAGES).
No APK hoarding; in-place install preserves data (stable signing key needed).

- backend /api/apk/info now returns versionCode (parsed from the release body).
- publish-apk.mjs auto-increments versionCode, sets versionName, and writes
  versionCode/versionName into the Gitea release body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 00:46:11 +02:00
HoneyandClaude Opus 4.8 845f125d76 feat: remember scroll + search position on Back (web + android)
Going into an album/artist and back now restores the scroll position and
keeps the typed query, instead of resetting to the top.

- Web: navigation.js snapshots each history entry's #main-content scroll and
  any open local (header) search, restoring them on Back/Forward; a genuine
  new navigation still starts at the top.
- Android: the Alben/Künstler lists (LibraryViewModel) and the global/local
  search-results lists (ExploreViewModel/SearchViewModel) hoist their
  LazyListState/LazyGridState, so scroll survives a drill-in/back for the
  tab's lifetime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 00:06:46 +02:00
HoneyandClaude Opus 4.8 3ceba8a365 feat(android): partial-album detail gets Play/Shuffle + track 3-dot menu
You can still enjoy an incomplete album: the downloaded-tracks section now has
Abspielen + Zufall buttons (play/shuffle the downloaded songs) and each
downloaded row gets the 3-dot context menu, threaded from both artist screens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 23:58:24 +02:00
HoneyandClaude Opus 4.8 3000dc1622 feat(scripts): one-command APK build + upload to the fixed Gitea release
`npm run publish-apk` builds the APK (assembleDebug by default — the release
build type is unsigned) and uploads it to the apk-latest Gitea release,
replacing the old .apk asset, so the web download button always serves the
latest build. Config via env or a gitignored scripts/.env.release
(GITEA_URL/REPO/TOKEN, APK_GRADLE_TASK). Release title tracks versionName.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 18:23:27 +02:00
HoneyandClaude Opus 4.8 33d24c6ed4 feat(backend): APK download proxies a fixed Gitea release (local fallback)
The /api/apk + /api/apk/info routes now fetch a fixed Gitea release
("apk-latest") server-side over the LAN and stream its .apk asset, falling
back to /data/novasonic.apk. Gitea can stay internal-only — the backend holds
the token, the browser only ever talks to the backend. Follows redirects,
constant ~30 MB (overwrite the one release's asset each build).

env: GITEA_URL, GITEA_REPO, GITEA_TOKEN (private repo only), GITEA_APK_TAG.
docker-compose documents them; unset = local-file fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 18:16:13 +02:00
HoneyandClaude Opus 4.8 908b50bcd9 feat(web): download the Android APK from Settings
Backend serves a staged APK so co-users self-serve the latest build:
- GET /api/apk streams APK_PATH (default /data/novasonic.apk) with the
  android package content-type + attachment disposition; 404 if absent.
- GET /api/apk/info reports availability, size, mtime, and an optional
  version from /data/novasonic.version.txt.
- Settings gets a "🤖 Android App" section that appears only when an APK is
  present and shows version/size/date.

Maintainer updates the APK with:
  docker cp app-release.apk novasonic-backend:/data/novasonic.apk

Android source is already tracked; only build artifacts stay out of git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 18:00:36 +02:00
HoneyandClaude Opus 4.8 7996971ba0 fix(web): search results panel spans full content width
The header search overlay was anchored to the search bar's width, leaving a
dead strip on the right where scrolling hit the background page. Anchor it to
the sticky #mobile-header instead (which already sits right of the sidebar), so
it spans the full main-content width — sidebar stays visible, no dead strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:46:50 +02:00
HoneyandClaude Opus 4.8 026b392657 fix: Japanese/CJK albums vanished from artist discography (web + android)
The album-name normaliser stripped everything but a-z0-9, so any JP/CJK
title collapsed to "". All of an artist's Japanese singles then normalised
identically, de-duplicated into one entry and "matched" each other — e.g.
inabakumori (稲葉曇) showed only 2 of 9 releases. Normalisers now keep
Unicode letters/numbers (\p{L}\p{N}) in:
- web _normAlbum (detail.js)
- android normAlbum (categorizeAlbums), normTrack (partial-album split),
  the enrichPartialCounts helpers, and the Deezer artist-name match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 18:49:59 +02:00
HoneyandClaude Opus 4.8 ed641e999c fix(web): artist hero name no longer clips last letter; fold 2.3 into 2.2
- .artist-hero-name: add padding-right so the heavy/negative-letter-spacing
  final glyph isn't cut by overflow:hidden.
- CHANGELOG: merge the 2.3 batch into a single 2.2 (2026-08-22) section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 00:48:33 +02:00
HoneyandClaude Opus 4.8 67642f8ab8 fix(web): header search dropdown no longer covers the sidebar
Anchor #header-search-overlay to the search bar (position:absolute inside a
relative .header-search-wrap) instead of position:fixed full-viewport width,
so the results drop down at the bar's width and the left sidebar stays visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 00:41:29 +02:00
HoneyandClaude Opus 4.8 b7c96451d8 feat(android): download notifications everywhere + swipe tabs (NowPlaying, Library)
- Downloads started from global search / Bibliothek now poll via the
  app-lifetime NotificationStore, so they show success/failure like the
  importer and artist page did (was a silent toast only).
- Swipe between tabs, synced with the tab bar, matching Erkunden:
  * Now Playing: Player <-> Lyrics <-> Queue (cover's skip-track swipe
    still wins as a child gesture).
  * Bibliothek: Playlists <-> Alben <-> Künstler <-> Offline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 23:19:20 +02:00
HoneyandClaude Opus 4.8 d11de8a69e feat(android): importer 30s preview + partial-album detection
- Importer: each resolved track gets a play/stop button that plays its
  Deezer/Spotify preview via the app-level preview bar (pauses library
  playback, highlights the row). Parity with the web importer.
- Artist "Unvollständig" now works: Deezer's /artist/{id}/albums omits
  nb_tracks, so owned albums never registered as partial. The artist VM
  now resolves the real total for matched albums via a per-album lookup
  (like web), so the partial section + downloaded/missing split populate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 23:10:28 +02:00
HoneyandClaude Opus 4.8 b584159568 fix: reliable concerts (backend events cache) + web importer preview layout
- Backend now caches Ticketmaster events per artist and prefers the larger
  recent result, so a flaky partial/empty response can't shrink a good list;
  every client (web/Android) stays consistent (1h TTL self-heals).
- Web SoundCloud importer preview: add the missing .gtrack-num grid cell so
  the 4-column row no longer squeezes the cover/title (title was wrapping one
  char per line).
- CHANGELOG [2.3] opened for this batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 23:02:56 +02:00
HoneyandClaude Opus 4.8 f56d47800c fix(android): even concert rows — fixed calendar-style date block
The "16. Sep. 2026" date wrapped inside the 52dp column, so rows with/without
a time ended up different heights and the divider/venue misaligned. The date
block is now a fixed 56dp calendar block: big day number, small month/year and
time, each single-line — every row is the same height and lines up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 20:00:49 +02:00
HoneyandClaude Opus 4.8 2208ce77b6 fix(events): show the venue-local concert time consistently
Backend sent the UTC `dateTime` (a 20:00 local show read as 08:00Z), and
Android's ZonedDateTime.parse choked on zone-less values → some rows had a
wrong time, others none at all.

- Backend: prefer localDate+localTime (venue-local, naive) over the UTC instant.
- Android: parse a naive LocalDateTime first, fall back to ZonedDateTime for
  older "…Z" values. Web already renders the naive string as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 19:59:26 +02:00
HoneyandClaude Opus 4.8 f27eaf9c82 fix(android): surface concert-fetch errors instead of swallowing them
Events were fetched with runCatching{}.getOrDefault(emptyList()), so any
network/deserialization failure looked identical to "no concerts". The
backend + Ticketmaster work (verified), so the failure is client-side.

- EventsClient: expectSuccess + timeouts so a non-2xx (e.g. an SSO/HTML
  redirect) becomes a real exception instead of an obscure body() failure.
- ArtistViewModel: capture the failure into eventsError (+ Logcat) rather
  than defaulting to empty.
- ConcertsSheet: show "Konzerte konnten nicht geladen werden" + the reason
  when the fetch failed, vs. "Keine Konzerte angekündigt" when genuinely none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 19:46:16 +02:00
HoneyandClaude Opus 4.8 7b4599c50a feat(artist): minimal icon-only pin button + show all Deezer releases (Android)
- Pin ("Falscher Künstler?") is now a small icon-only ⚙ chip on both web
  and Android — accent-blue when pinned, subtle white otherwise; no more
  text banner over the hero.
- Android artist discography no longer filters to record_type=="album";
  it now shows albums, EPs AND singles (ordered albums→EPs→singles),
  matching Deezer's own artist page. Web already showed all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 19:19:17 +02:00
HoneyandClaude Opus 4.8 934253a036 feat(web): merge Song Importer into Global Suche (one unified field)
The importer no longer has its own sidebar/bottom-nav entry. The Global
Suche field now handles both jobs: plain text runs the Deezer search;
a pasted Spotify/SoundCloud/YouTube link switches into importer mode
(badge + "Importieren" button + preview), matching the Android app and
tidying the left nav.

- New onGlobalInput/tryImporterResolve; importer DOM moved into
  page-discover; old page-spotify-importer removed; routing cleaned up.
- Existing resolvers unchanged (still read the mirrored importer-link-input).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 17:59:44 +02:00
HoneyandClaude Opus 4.8 0a6c81b4a1 docs: tidy CHANGELOG (drop duplicated [0.9] block) + refresh README
- CHANGELOG: remove the accidentally duplicated Changed/Fixed/Investigated
  block that sat orphaned before [0.8]; [0.9] now appears once.
- README: artist-pages bullet now mentions the same-name artist pin
  (Deezer link/id) and that a pinned artist's similar list comes from Deezer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 00:54:19 +02:00
HoneyandClaude Opus 4.8 593c9fdb7b fix(backend): YouTube downloads — install Deno + pull yt-dlp nightly
Downloads were 403ing on two fronts: (1) recent yt-dlp needs a JS runtime to
solve YouTube's signature/nsig challenge (Deno, added in cb7a3d7), and (2) the
current yt-dlp *stable* had a bug that 403'd the android_vr client on every video
(yt-dlp#17456, fixed in nightly). Switch the backend from the stable yt-dlp
binary to nightly via pip (`--pre 'yt-dlp[default]'`). Verified end-to-end incl.
a real frontend download. No cookies / PO-token service / proxy needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 00:40:31 +02:00
HoneyandClaude Opus 4.8 cb7a3d77be fix(backend): install Deno so yt-dlp can solve YouTube's signature/nsig challenge
Recent yt-dlp (which the backend re-pulls latest on every restart) needs an
external JavaScript runtime to solve YouTube's signature/nsig challenge — without
it, "Signature solving failed" → formats get skipped/HTTP 403. Install Deno in the
backend container (+ unzip for its installer) and put it on PATH for the node
process so spawned yt-dlp finds it. No extra container, no account, no proxy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 23:58:22 +02:00
HoneyandClaude Opus 4.8 635ba8d353 revert: drop the YouTube cookies/PO-token/proxy workarounds (back to pre-saga state)
None of it got downloads working on the flagged datacenter IP (web_music media
403, then 429 rate-limiting), so revert the whole lot to keep things clean:
- config.js: remove potProviderUrl + ytdlpProxy
- ytdlp.js: remove potArgs/proxyArgs; player_client back to web_music,default
- novasonic-server.js: remove pot/proxy args from importer resolve
- docker-compose.yml: remove the bgutil-provider service + backend env/plugin/depends_on
- CHANGELOG: drop the [2.2] section

Kept the one genuine fix: a failed direct-link download now reports failure
(moved its note into [2.1]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 20:24:13 +02:00
HoneyandClaude Opus 4.8 31d4a72e62 fix(backend): download via default client (web_music media 403s); add optional proxy
The PO token provider works (web_music gets a token) but web_music's media URLs
still 403 from a datacenter IP. Prefer the default player clients for the
DOWNLOAD (their URLs go through with a PO token), keeping web_music only as a
fallback for Art-Track extraction; the YTM search still leads with web_music.

Also add an optional YTDLP_PROXY (--proxy) — the durable fix if the IP stays
flagged (403/429) even with a PO token. Empty → unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 17:07:34 +02:00
HoneyandClaude Opus 4.8 4daf87b550 docs(changelog): split download fixes into [2.2] (PO token provider + false-success)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:34:15 +02:00
HoneyandClaude Opus 4.8 8fc6a3aaef feat(backend): PO token provider for YouTube downloads; drop the cookies approach
Replace the cookies-file stopgap (needs a throwaway account + manual refresh)
with a self-hosted PO token provider — the cookie-less way past YouTube's
datacenter-IP block (bot check + media 403).

- docker-compose: new bgutil-provider service (brainicism/bgutil-ytdlp-pot-provider)
  + install the bgutil-ytdlp-pot-provider plugin in the backend + POT_PROVIDER_URL
  env + depends_on.
- ytdlp.js / server.js: when POT_PROVIDER_URL is set, pass
  --extractor-args youtubepot-bgutilhttp:base_url=... to the download cascade, the
  YTM search probe and the importer resolve. Empty → omitted (unchanged behaviour).
- Removed the cookiesFile config + --cookies wiring so there's no dead code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:28:32 +02:00
HoneyandClaude Opus 4.8 ef019b9b9d fix(backend): direct-link download failure was reported as success
The direct-URL (sourceUrl) path returned { success: true } even when no audio
file was produced (e.g. YouTube 403 / bot check), so the job was marked
"done/Fertig!" and web + Android both said the track was added. It now returns
{ failed: true } → the server sets status 'error' with a clear message, which
both frontends already render as a failure. Search-cascade path was already
correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:20:52 +02:00
HoneyandClaude Opus 4.8 0990dfadb5 fix(backend): pass YouTube cookies to yt-dlp to beat the datacenter bot check
YouTube now returns "Sign in to confirm you're not a bot" for yt-dlp from
datacenter IPs, blocking downloads, search and link-resolve. When a cookies file
is present (YTDLP_COOKIES, default /data/youtube-cookies.txt) every yt-dlp call
passes --cookies so it authenticates. Checked at call time (drop the file in, no
code change); absent → unchanged behaviour. Applied to the download cascade, the
YTM search probe, and the importer resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:02:26 +02:00
HoneyandClaude Opus 4.8 e82dec7e01 docs(changelog): 2.1 — Musik-Match, Android artist pin, pin/search/similar fixes, player + swipe polish
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 11:45:28 +02:00
HoneyandClaude Opus 4.8 6f455b565f fix(android): smoother Erkunden swipe + top charts no longer vertically centered
- Pre-compose the neighbour page (beyondViewportPageCount = 1) so the first
  swipe doesn't hitch while the heavy LazyColumn builds.
- Drive the TabRow indicator off pagerState.currentPage so it moves with the
  swipe (at the half-way point) instead of only on settle — the lag read as
  "not smooth".
- Wrap each pager page in Box(fillMaxSize) so short content (the Top-Alben
  charts row) sits at the top again, not vertically centered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 22:01:34 +02:00
HoneyandClaude Opus 4.8 7f5d25a03b feat(android): swipe between Erkunden sub-tabs (Bibliothek ↔ Global)
Wrap the Erkunden content in a HorizontalPager so a left/right swipe switches
between the Bibliothek and Global search tabs, two-way synced with the TabRow: a
chip tap animates the pager; a settled swipe updates the selected tab (and stops
any preview, like a tap). Removes the idea from the backlog now that it's built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:52:11 +02:00
HoneyandClaude Opus 4.8 87ee8cd097 fix(android player): seek broke after seamless track transitions (durationMs stuck at 0)
After skipping through already-buffered tracks the player stays READY, so
onPlaybackStateChanged never fires and syncState() isn't re-called; ctrl.duration
was TIME_UNSET(→0) at the transition, leaving state.durationMs stuck at 0. The
seek bar then computed fraction*0 = 0 and snapped playback to the start every time
(or to a stale value → "only 10s"). Restarting the app re-read the real duration,
which is why it recovered.

Fix: durationMs now self-heals — the 500ms ticker refreshes it too, and
resolveDurationMs() falls back to the current song's known duration while
ctrl.duration is still unset. Also record the Erkunden swipe-between-tabs idea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:47:58 +02:00
HoneyandClaude Opus 4.8 803331e807 feat(android): pinned similar artists tap through to library; note Following view idea
Pinned similar artists (Deezer related) were non-clickable on Android because
Deezer artists carry no Navidrome id. On tap, resolve the name against the
library (exact case-insensitive match) and navigate to that Navidrome artist if
present, else a short "not in your library" toast.

Also: extend the CHANGELOG "Artist following" backlog idea with a dedicated
"Following" view (grid of followed artists) + follow/unfollow toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 01:12:47 +02:00
HoneyandClaude Opus 4.8 26de85a140 fix(artist pin): apply pins in search + fix similar artists (web + android)
Two gaps the pin left, on both platforms:

1. Search still showed the wrong same-name artist's photo. The library search
   now prefers the pin's Deezer picture for a pinned artist (web: search.js
   artist cards; android: LibrarySearchContent via SearchViewModel overrides).

2. Similar Artists were "weird" — they come from Last.fm keyed to the (merged/
   wrong) Navidrome artist. When pinned, use Deezer "related" of the pinned
   artist instead (new deezerRelatedArtists / ItunesClient.lookupRelatedArtists).

Also: web Similar Artists now render with profile pictures (avatar chips), like
the Android app, instead of plain text chips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 18:42:15 +02:00
HoneyandClaude Opus 4.8 1ee85482f3 feat(android): artist pin (same-name disambiguation) — parity with web
Android now reads AND writes the shared server-side artist overrides
(/api/artist-overrides), so pins set on web apply here and can be set/removed
in-app — closing the web-only gap.

- ArtistOverride model + ArtistOverrideClient (GET/POST/DELETE on the backend);
  MusicRepository caches the map and exposes get/set/remove.
- ArtistViewModel resolves the pin up front; when pinned it enriches the artist
  page straight from the pinned Deezer id (albums/top-tracks/fans/image) instead
  of the name-guess. Picker: Deezer search candidates + a "paste Deezer link/id"
  route (reliable when /search/artist can't surface a low-profile same-name
  artist), plus clear-pin.
- ArtistDetailScreen: "⚙ Falscher Künstler? / Gepinnt" pill top-right of the hero,
  pinned Deezer picture overrides the hero image, ModalBottomSheet picker.
- ItunesClient.lookupArtist(id) (Deezer-backed) for the link-paste flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 18:28:29 +02:00
HoneyandClaude Opus 4.8 a5e5d414b4 docs: record ideas in the bottom Planned/Backlog, mention Musik-Match in README
Move the share-links / Wrapped / household-leaderboard / ListenBrainz ideas
into the existing "Planned / Backlog" section at the bottom of the CHANGELOG
(not a new top section), and add a short Musik-Match clause to the README
listening-stats feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 01:18:12 +02:00
HoneyandClaude Opus 4.8 e34a934460 docs(changelog): add Ideas / Backlog section (share-links, Wrapped, leaderboard, ListenBrainz)
The CHANGELOG is the home for feature ideas going forward. Records the
share-by-link deep-link idea (PWA on PC / Android app on phone via App Links +
assetlinks.json), plus the already-tracked Wrapped, household leaderboard, and
optional ListenBrainz relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 01:16:09 +02:00
HoneyandClaude Opus 4.8 19f82ad076 fix(android settings): move gap below Musik-Match, not above it
The Column already spaces children 12dp; the extra Spacer(8dp) between
"Meine Statistiken" and "Musik-Match" stacked to ~20dp, making that gap look
too big while Musik-Match↔Speichern stayed tight. Group the two stat buttons
together (12dp) and add the separating space (4dp) before the Save button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 01:08:19 +02:00
HoneyandClaude Opus 4.8 c933847201 feat(android): Head-to-Head "Musik-Match" screen (parity with web)
Android CompareScreen mirroring the web feature: partner picker fed by
GET /scrobble/users, then GET /scrobble/compare?with= → a match-score ring
(Canvas arc), per-user totals, mirrored shared-artist bars, shared tracks,
and each side's unique top artists. Rows navigate to artist/album. Reuses the
one PlayLogClient HttpClient (fetchUsers/fetchCompare). Opened from Settings
"🎧 Musik-Match". Parity matrix updated (Android ✅).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 00:52:33 +02:00
HoneyandClaude Opus 4.8 cbedbba6fc feat(scrobble): Head-to-Head "Musik-Match" — compare two users' listening
Adds GET /scrobble/users (distinct users for the partner picker) and
GET /scrobble/compare?with= to the scrobble service. Taste match = cosine
of the two users' artist-play vectors (0..100); also returns shared artists
(mirrored bars), shared tracks, and each side's unique top artists. Reuses
the existing per-user play-log — no new data capture.

Web UI src/ui/compare.js (overlay from Settings "🎧 Musik-Match"): partner
picker fed by /scrobble/users, match-score ring, per-user totals, shared +
unique lists with clickable artist/album rows. Cosine + shared logic verified
against a hand computation on an in-memory DB. Android pending (parity: 🔜).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 00:46:29 +02:00
HoneyandClaude Opus 4.8 ba2306656e feat(artist pin): pin by Deezer link/ID when search can't surface the artist
Deezer's /search/artist is a ranked, incomplete fuzzy match — low-profile
same-name artists (e.g. Nana id 80239872) never appear in results. Add a
"paste Deezer link or ID" field to the pin picker that looks the artist up
directly via /artist/{id} (new deezerLookupArtist) and pins it. Same reliable
route as the importer direct link vs yt-dlp search.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 00:38:33 +02:00
HoneyandClaude Opus 4.8 5f251d555f fix(artist pin): esc() crashed on numeric Deezer id; move pin button to banner top-right
- esc() now coerces to string first (String(s ?? '')), so passing a number (e.g. the
  Deezer artist id in the pin picker) no longer throws 'replace is not a function'.
- The '⚙ Falscher Künstler?' button moved from under the fan count to the top-right
  of the artist banner (absolute, on the hero). SW cache -> ns-v75.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 00:16:14 +02:00
HoneyandClaude Opus 4.8 02960cf886 feat(artist): pin a local artist to a specific Deezer artist (same-name fix, shared)
Fixes same-name collisions where NovaSonic guessed the wrong Deezer artist (e.g.
'Nana' → a random DJ): fans, top songs, missing albums and the hero image were all
off. A '⚙ Falscher Künstler?' button on the artist page opens a picker (same-name
Deezer candidates with image + fan count); the chosen mapping is stored SERVER-SIDE
(shared across users, like the romaji overrides) in /data/deezer-artist-overrides.json.

Once pinned, the artist page short-circuits the name search to the pinned Deezer id
(drives albums/top-songs/fans) and overrides the hero image with the pinned artist's
Deezer picture (NovaSonic-side, so it works even for tag-merged same-name artists —
Navidrome can't disambiguate them without an MBID). New endpoints
GET/POST/DELETE /api/artist-overrides; api.js load/get/set/remove; loaded at startup.
SW cache -> ns-v74. Needs on-device runtime test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 21:38:42 +02:00
HoneyandClaude Opus 4.8 b788fedec3 fix(download): dedup missed re-downloads when the title contains ' - ' (KH triple/quadruple)
findExistingTrack split the filename at the first ' - ' assuming 'Artist - Title',
so a title with internal dashes ('Kingdom Hearts 2 - Dearly Beloved - Main Theme')
or a garbage/absent artist prefix ('01-01 - …') was mis-parsed → the duplicate went
undetected and got re-downloaded (3-4x). Now match the query title against BOTH the
full stem AND the part after the first ' - ', so titles with dashes and prefix-only
filenames are recognized. Normal 'Artist - Title' files still match; no false
positives; remix-vs-base distinction preserved. Unit-tested on the exact KH case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 17:06:14 +02:00
HoneyandClaude Opus 4.8 75dceba97b fix(editor): 'Deezer' autofill button is Deezer-only again (MusicBrainz has its own button)
Stufe B routed /api/meta/lookup through the combined resolver, so the editor's
'Deezer' button could silently return MusicBrainz data while the status said
'von Deezer'. The editor already has a dedicated 'MusicBrainz' button, so keep the
two explicit and honest: Deezer button = Deezer only. The combined
Deezer→MusicBrainz→clean resolver stays on the DOWNLOAD paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 16:53:25 +02:00
HoneyandClaude Opus 4.8 e61d57425e docs(CLAUDE): document the single-track metadata resolver (Deezer→MusicBrainz→clean fallback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 01:51:54 +02:00
HoneyandClaude Opus 4.8 6d084a7b7a fix(download): stop wrong album/cover from same-titled tracks; add MusicBrainz fallback
Root cause: deezer pickTrackMatch fell back to a blind title-only match, grabbing a
same-titled song by a completely different artist (e.g. 'Memories' by Rab Noakes for
'☆NoAki☆') and applying its album/cover/year. The download's clean fallback existed
but was never reached because the lookup almost always returned *something*.

Stufe A — deezer.js: the title-only fallback now REQUIRES an artist correspondence
(equal, or one name contained in the other after normalization). No match → {} →
the existing clean fallback finally runs (album_artist = song artist, album =
'Singles', cover = source thumbnail, year/genre untouched). Test that encoded the old
blind behaviour replaced; NoAki + loose-match regression tests added.

Stufe B — musicbrainz.js: a rate-limited (~1/s, User-Agent) MusicBrainz recording
lookup with the SAME artist discipline + Cover Art Archive cover. trackmeta.js is the
single resolver (Deezer → MusicBrainz → clean fallback); the three single-track call
sites use it (album batch stays Deezer-only to avoid MB rate-limit slowdowns).

Unit-tested (17/17) + live-verified: NoAki→clean fallback, real track→Deezer, MB finds
real albums when Deezer misses. Backend-only; no frontend/Android change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 01:51:29 +02:00
HoneyandClaude Opus 4.8 16f76a4236 fix(web): show non-Japanese names as-is with Romaji on (e.g. ☆NoAki☆ kept its stars)
With 'show Romaji' enabled, dispArtist/dispArtistById/dispArtistParts/dispTitle
substituted the sort tag for the display name unconditionally. For non-Japanese
names the sort tag is just a sort key (leading symbols/articles stripped, so
'☆NoAki☆' → 'NoAki'), which wrongly replaced the real name. Guard the substitution
on the original being Japanese — mirroring what dispAlbumName already did — so
Romaji only applies to Japanese names and everything else shows unchanged.
SW cache -> ns-v73.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 00:48:22 +02:00
HoneyandClaude Opus 4.8 d195518dfb fix(android settings): remove open delay — seed theme instead of a loading gate
The previous flicker fix gated the whole settings form behind a 'loaded' spinner,
which read as a small lag when opening Settings (it waited for DataStore's first
emit even though it's already warm). Instead, expose the applied theme id
(NsColors.themeId, set in applyNsTheme) and seed the ViewModel's initial state with
it, so the theme picker no longer jumps from the default — no gate, no spinner, no
delay. Remaining text fields fill within a frame from the warm DataStore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 00:40:38 +02:00
HoneyandClaude Opus 4.8 711a084686 fix(android): scrobble respects 1/3 & 2/3 again; no settings flicker on open
Scrobble timing: at onMediaItemTransition ctrl.duration is still TIME_UNSET (→0),
so getScrobbleDelayMs's third/two_thirds modes hit the 15s floor and scrobbled way
too early (both Navidrome + our play-log). Use the song's KNOWN duration
(song.duration), falling back to the player only if absent. Web was unaffected
(it already used the song's duration).

Settings flicker: the ViewModel started with default state (theme = Dark Ocean)
and rendered a frame before DataStore emitted the real values, so the theme picker
visibly jumped. Gate the form on a 'loaded' flag set after the first emit; show a
spinner until then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 00:24:55 +02:00
HoneyandClaude Opus 4.8 0c7132a7f2 feat(scrobble stats): genre ranking (Top-Genres) — web + android
Play-log now stores each play's genre (from the song tags; web scrobbleLog +
android PlayLogClient capture song.genre). The stats endpoint returns topGenres
(GROUP BY lower(genre), case-insensitive, blanks ignored); web + android render a
Top-Genres section. Schema migrates via ALTER TABLE. Old plays have no genre
(fills going forward, like cover art). SW cache -> ns-v72.

Android compileDebugKotlin + web build green; genre grouping + null-ignore unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 01:35:00 +02:00
HoneyandClaude Opus 4.8 249b693936 feat(android auto): tapping a song plays its whole list from that song
Browse songs now carry their context in the mediaId (songctx:<contextId>|<songId>).
A new onSetMediaItems override expands a single tapped song into its full list
(Favoriten / album / playlist) and returns it with the tapped index as the start
position, so playback continues through the rest instead of stopping after one song.
onAddMediaItems shares the same resolver; onGetItem resolves context songs too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 01:25:38 +02:00
HoneyandClaude Opus 4.8 d35fb01306 fix(android auto): tapping a song (e.g. in Favoriten) did nothing — re-attach stream URI
Android Auto strips a MediaItem's URI (localConfiguration) when a browse-tree item
crosses the process boundary, so onAddMediaItems received the tapped song without a
playable URI and the else branch passed it through unchanged → no playback. Rebuild
the item with the stream URI resolved from its mediaId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 20:22:09 +02:00
HoneyandClaude Opus 4.8 1d98b273e7 fix(web player + stats modal): no auto-resume after user pause; stable stats modal
Player: opening the PWA no longer auto-plays when the user had paused. Pausing via
the media notification while backgrounded routes through togglePlay, which now sets
S._userPaused; _onForeground only auto-resumes an INVOLUNTARY background stop
(_wasPlayingBeforeHide && !_userPaused). playCurrent clears the flag on a new track.
Involuntary-stop/freeze recovery on foreground is preserved.

Stats modal: no more small→big flash when switching period/tab — the body keeps a
stable min-height and retains its current content until the new data arrives (only
the first open shows 'Lade…'); a per-request token drops out-of-order responses.
SW cache -> ns-v71.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 18:06:18 +02:00
HoneyandClaude Opus 4.8 3129333f9a feat(scrobble stats): navigator lower bound, clickable rows, android button to top
- Navigator can't step before logging began: the endpoint returns firstPlayedAt
  (MIN(played_at) over all the user's plays, unfiltered by window); web + android
  disable the back arrow once the shown year/month is at/before that first play.
- Top artists/tracks/albums are now clickable → open the artist / album page (web
  navigates and closes the overlay; android navigates via nav routes). Tracks open
  their album.
- Android: moved the 'Meine Statistiken' button to the top of Settings (was at the
  bottom near logout). SW cache -> ns-v70.

Android compileDebugKotlin + web build green; firstPlayedAt verified server-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 21:50:27 +02:00
HoneyandClaude Opus 4.8 86eb893f40 feat(scrobble): time-travel navigator (year/month) + fix stats cover art
Navigator (web + android): browse Alles / Jahr / Monat with a ‹ › stepper — see
top plays for a specific month or year (e.g. March 2024). Steps into the future
are disabled. Uses the existing /scrobble/stats endpoint with computed from/to +
bucket (month for year view, day for month view). All windows verified.

Cover art fix: the app resolves covers from the Subsonic **coverArt token**, not
the raw entity id — so stats covers (which used albumId/songId) never loaded.
Now the play-log stores each play's coverArt (web + android capture), the stats
endpoint returns it for top tracks/albums, and both clients render coverUrl(coverArt)
with an id fallback. Old plays lack coverArt (blank until re-listened); artist
images still use artistId (may need getArtistInfo2 later if blank). Schema migrates
existing DBs via ALTER TABLE. SW cache -> ns-v69.

Android compileDebugKotlin + web build green; server windows + cover storage unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 13:11:34 +02:00
HoneyandClaude Opus 4.8 14c0dea7e4 fix(scrobble): 'Alles' (all-time) stats returned nothing — from=0 mapped to now
The client sends from=0 for the all-time range, but the string "0" is truthy, so it
was routed through _toEpochSec which maps 0 -> now, producing an empty window. Parse
'from' directly (0/absent/invalid -> all-time) instead. Affects web + Android.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:55:29 +02:00
HoneyandClaude Opus 4.8 561014c83f docs: v2.0 — document the scrobble / listening-stats system
- CHANGELOG: new [2.0] entry (own timestamped play-log service, stats view on
  web + android with cover art, remix-dedup fix, user-select UI fix, node 20->22);
  Platform Parity Matrix gains play-log / stats / stats-cover rows (+ Wrapped 🔜).
- README: Listening stats feature, scrobble/ in the project tree, SCROBBLE_URL
  env + scrobble container table, port 3710 + SSO-exclusion note in setup.
- CLAUDE.md: new 'Listening stats / scrobble system' section (isolation rationale,
  node:sqlite gotchas, ping.view multi-user auth, capture point, cover resolution,
  SSO deploy gotcha).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 19:33:46 +02:00
HoneyandClaude Opus 4.8 906b3b48cb ui(scrobble): label the totals tile 'Plays' instead of 'Wiedergaben' (web + android)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 19:29:55 +02:00
HoneyandClaude Opus 4.8 fff6f9e4da feat(scrobble): Android stats screen + cover art in stats (web + android)
Phase 4 (part B): Android statistics screen consuming GET /scrobble/stats —
tiles (plays + listening time), day/month/year play series, top artists/tracks/
albums, 24h listening clock, period tabs; opened from a Settings button
("Meine Statistiken"). New StatsViewModel + StatsScreen + ScrobbleModels;
PlayLogClient.fetchStats() (tolerant JSON decode, null on any failure);
repo.fetchScrobbleStats(); nav route + SettingsScreen(onOpenStats).

Cover art everywhere: top artists show a circular artist image, tracks/albums
a square cover — resolved client-side from Navidrome via artistId/albumId/songId
(no coverArt knowledge in the scrobble service). Endpoint now also returns
albumId for top tracks so track covers resolve reliably. Web stats list gets the
same thumbnails. SW cache -> ns-v67.

Android compileDebugKotlin + web build green. Needs on-device runtime test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 18:41:27 +02:00
HoneyandClaude Opus 4.8 cc1ec1653f fix(ui): stop accidental text-selection of UI chrome when dragging near controls
Dragging the mouse near a field edge (Global Suche, the importer, etc.) started
a text selection of the surrounding page titles/labels instead of feeling like an
app. Disable user-select on the #app shell; re-enable it for real text fields
(input/textarea/contenteditable) and copyable content (lyrics). Setup screen is
outside #app and unaffected. SW cache -> ns-v66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 17:40:18 +02:00
HoneyandClaude Opus 4.8 26880016e9 feat(scrobble): web statistics view (Phase 3 UI)
Opens from Settings ("Meine Statistiken") as a self-contained modal overlay
(no router changes). Fetches GET /scrobble/stats and renders: totals tiles
(plays + listening time), a per-bucket play series, top artists/tracks/albums
with mini bars, and a 24h listening clock. Period tabs (all / this year /
last 30 days) switch from/to + bucket. Vanilla inline SVG-free bars, no chart
lib. Reuses .modal-overlay.open + design tokens; sends local tz offset for
day bucketing. Graceful empty/error/unconfigured states. SW cache -> ns-v65.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 17:17:14 +02:00
HoneyandClaude Opus 4.8 e782b75bd9 feat(scrobble): /scrobble/stats aggregation endpoint (Phase 3 backend)
Per-user listening stats via on-the-fly GROUP BY strftime (no rollup tables):
totals (plays + seconds), top artists/albums/tracks, a day/month/year time
series, and a 24h listening clock. Local-day bucketing via a tz offset.
Params exactly matched per query (node:sqlite throws on extra named params);
from defaults to all-time (avoiding the _toEpochSec(0)->now trap). Unit-verified.

Next: the web statistics view. Wrapped endpoint is Phase 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 17:09:42 +02:00
HoneyandClaude Opus 4.8 eb55184118 fix(download): remix/live/acoustic/instrumental no longer skipped as duplicate of base track
findExistingTrack normalized titles by stripping ALL bracketed content, so
"Therapy (Remix)" collapsed to "Therapy" and was skipped because the base
track existed. Keep brackets that contain a version marker (remix/live/acoustic/
instrumental/vip/flip/edit/mix/…) so distinct recordings are recognized as new;
still strip noise brackets (feat. X, Official Video). Verified via Deezer that the
remix track carries the marker in its title. Unit-tested both directions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 17:06:00 +02:00
HoneyandClaude Opus 4.8 71190488ca feat(scrobble): own timestamped play-log service (stats/Wrapped foundation)
Phase 1+2 of a self-hosted, multi-user listening archive — the basis for
day/month/year stats and a yearly "Wrapped". Runs IN ADDITION to the existing
Navidrome/Last.fm scrobble; never replaces it.

Service (isolated, own container):
- scrobble/scrobble-server.js — node:sqlite play-log (own volume), routes
  POST /scrobble/plays (idempotent by uuid), GET /scrobble/history (archive),
  DELETE /scrobble/plays?user= (test cleanup), GET /scrobble/health.
  Multi-user auth via Navidrome ping.view (cached) -> user not spoofable.
- docker-compose: novasonic-scrobble service (node:22, port 3710) + volume;
  backend bumped node:20->22 (EOL); SCROBBLE_URL plumbing (entrypoint, .env.example).

Capture (both platforms, offline-safe queue, idempotent batch flush):
- Fires at the existing scrobbleMode counted-play moment — consistent with
  Last.fm, respects the user's setting (no separate 50%/4min rule).
- Web: src/player/scrobbleLog.js (localStorage queue) + subsonicAuthParams()
  in api.js + hooks in player.js + flush init in main.js; S.cfg.scrobbleUrl.
- Android: data/PlayLogClient.kt (filesDir queue, reused Ktor client) +
  repo.logPlay/flushPlayLog + PlayerController hook + NovaSonicApp flush +
  scrobbleUrl in PrefsStore/SettingsViewModel/SettingsScreen (with test button).

Plan + design decisions documented in docs/scrobble-plan.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 22:54:25 +02:00
HoneyandClaude Opus 4.8 e69fbaf75e fix(android): back focus lost after tabbing out — re-assert overlay back callbacks on resume
With NowPlaying (or an ABS player) open, backgrounding and resuming the app made the next back
press pop the NavHost invisibly to Home before dismissing the overlay. Returning from the
background re-registers the NavController's own OnBackPressedCallback on top of the dispatcher,
stealing back-priority from the overlays.

AppNavigation now bumps a resumeTick on Lifecycle.Event.ON_RESUME and keys every overlay
DisposableEffect (NowPlaying, ABS player, ABS item, Erkunden overlay) on resumeTick +
currentBackEntry, so each overlay re-adds its callback above the NavController after a resume or
navigation. Needs on-device verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 10:00:04 +02:00
HoneyandClaude Opus 4.8 17bb527e1d fix: download notification opened the wrong song (same-name album)
The finished-download notification resolved the album by name via search3, then fell back to
a same-named album by a DIFFERENT artist (or the first result) when the exact artist match
wasn't found — e.g. right after download, before Navidrome rescans. Many artists share an album
title ("I LIKE IT!"), so the notification navigated to a completely different song.

- Web (notify.js) + Android (AppNavigation): require the artist to match when known (fuzzy for
  multi-artist / album-artist drift); drop the name-only and first-result fallbacks. If nothing
  matches, show "wird evtl. gerade gescannt" instead of opening a wrong album.

The download itself was correct all along (verified: it grabbed UPSAHL's official "I LIKE IT!"
visualizer) — only the notification's navigation was wrong. SW ns-v63.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 02:30:53 +02:00
HoneyandClaude Opus 4.8 1e206e8df3 fix(web): 'Unknown Artist' download from artist page + rename itunes*→deezer* identifiers
Fix (the reported bug):
- Deezer's /artist/{id}/albums omits the artist object, so discography album cards had an
  empty artist name → the download modal fell back to the literal "Unknown Artist", which
  poisoned the yt-dlp search ("Unknown Artist <album>") and its artist/channel filter, failing
  with "Nicht eindeutig gefunden".
- deezerLookupArtistAlbums now backfills the known artist name (we're on that artist's page).
- openDlModal now defaults album cards to an ALBUM download (was always Track), so a one-click
  download no longer grabs an album as a single track named after the album.

Rename (requested cleanup, dependency-checked so nothing breaks):
- All frontend itunes* identifiers → deezer* (functions, the ItunesItem-shape field keys
  itunesData/itunesArtistId/itunesFanCount/itunesId, the itunes-album-detail route + DOM ids,
  the badge-itunes CSS class). The backend reads none of these fields, so the boundary is safe.
- Verified: build passes; route/DOM-id/CSS producer↔consumer all consistent; only the factual
  "Apple's iTunes /search 403" comments keep the historical name. SW ns-v62.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 00:31:09 +02:00
HoneyandClaude Opus 4.8 bc87a04da5 docs(changelog): note the 'Unknown Artist' download failure from artist-page album cards
Empty artist name (Deezer /artist/albums omits the artist object) + openDlModal defaulting to
type=track for album cards + downloads.js 'Unknown Artist' literal → yt-dlp searches
'Unknown Artist <album>' and fails. Documented as a Known Bug; no code change yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 00:19:23 +02:00
HoneyandClaude Opus 4.8 96861bea06 docs(web): unify the artist / album-artist separator hints in the editor
Both fields already accept ; and , (the backend normalizer splits on •·;,). The hints now
read uniformly — "mehrere mit ; oder , trennen" — and explain the actual difference: artist
is split into individual artists, album-artist stays one combined credit. SW ns-v61.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 00:10:04 +02:00
HoneyandClaude Opus 4.8 e4f030c98d fix: artist page resolved "Flay!" to the far more popular "K.FLAY"
The Deezer artist match required only that every query word appear in the candidate name, so
"Flay!" (query word: flay) matched "K.FLAY" (words: k, flay). Sorted by fan count, K.FLAY
(54,822 fans) buried the real "Flay!" (39 fans) — the page showed K.FLAY's fans + top tracks.

Now prefer an EXACT normalized-name match ("flay" == "flay", not "kflay"), falling back to the
loose match only when there's no exact one. Web (detail.js) + Android (ArtistViewModel).
Verified against Deezer: exact match selects the real Flay! (id 12242284, whose top tracks
are actually Flay!). SW ns-v60.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 23:53:42 +02:00
HoneyandClaude Opus 4.8 b3cf01dd3b fix: duplicate rows in artist "Top Songs" when a track exists twice in the library
Navidrome can hold the same track twice (downloaded as a single AND as an album track), so
getTopSongs returned two identical rows ("PIXELATED KISSES" x2). Collapse the top-songs list
by title+album for display (keeps the highest play count) on web + Android. SW ns-v59.

Note: this is a display fix — the underlying duplicate files remain a library-cleanup matter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 17:48:02 +02:00
HoneyandClaude Opus 4.8 fbee695aad fix: album shown 2-3x in "In Library" on the artist page
Deezer returns the same album as several variants (single/EP/album/regional/explicit), and
each variant matched the same Navidrome album, so one owned album rendered 2-3 times
("PIXELATED KISSES" x3).

- Web (detail.js): collapse matched entries to one per owned Navidrome album and one per
  not-owned title before classifying (also avoids duplicate track-count fetches).
- Android (categorizeAlbums): distinctBy(collectionId) missed variants with different ids;
  now also skip an owned album already classified.
SW ns-v58.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 17:41:33 +02:00
HoneyandClaude Opus 4.8 cc4d3b72d2 chore: bump SW to ns-v57 after reverting the artist-image fallback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 17:21:31 +02:00
Honey e059c358b3 Revert "feat: Deezer artist photo fallback for local artists with no Navidrome image"
This reverts commit 569af968c5.
2026-07-31 17:20:45 +02:00
HoneyandClaude Opus 4.8 569af968c5 feat: Deezer artist photo fallback for local artists with no Navidrome image
Niche local artists (e.g. N!GHT) whose Navidrome metadata agents have no image showed a
blank hero. Now, when Navidrome has no artist image, the hero falls back to the matched
Deezer artist's photo (Navidrome image → Deezer photo → album cover), on web + Android.

- Web (detail.js): the Deezer-photo fallback previously ran only for non-local artists;
  now it runs whenever the Navidrome image is absent, local or not.
- Android: ArtistViewModel captures the matched Deezer artist's photo (deezerImage);
  ArtistDetailScreen uses it as the hero fallback.
SW ns-v56.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 17:07:53 +02:00
HoneyandClaude Opus 4.8 640e475c51 fix: owned albums wrongly shown as "Unvollständig 1/0" on artist page
Deezer's /artist/{id}/albums list omits nb_tracks, so the album total read as 0 and every
owned album (e.g. N!GHT's "Moonlight"/"Yume") was classified as incomplete "1 / 0 Tracks".

- Web (detail.js): resolve the real Deezer track count via the album endpoint for MATCHED
  (owned) albums only — a couple of calls, not the whole discography — and treat an unknown
  total as complete. Genuine partials still work when the true count is known.
- Android (categorizeAlbums): mark partial only when a known total exceeds what you own;
  unknown total counts as complete (never a false "1 / 0").
- CHANGELOG: documented the fix; corrected the Android lyrics known bug (auto-scroll follows
  playback but isn't smooth — it jumps rather than glides). SW ns-v55.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 17:05:14 +02:00
HoneyandClaude Opus 4.8 5528947a93 feat: fans on local Navidrome artists (android) + harmonize web fan layout
- Android local artist page (ArtistDetailScreen/ArtistViewModel) now resolves the Deezer
  artist (fan-sorted, so same-name collisions pick the popular one) and shows "<n> Fans"
  under the name — parity with the web local artist page.
- Also migrated ArtistViewModel's preview to the app-level PreviewController: the local
  artist page had its OWN uncontrollable MediaPlayer preview, which had the same
  "keeps playing after navigating away" bug. Now every preview uses the global bar.
- Web: the artist-hero fan line was laid out in a row next to the name (flex without
  column direction) and used an odd tinted color — now stacked cleanly under the name in
  a muted white. SW ns-v54.
- CHANGELOG: documented the above + added a Known Bug (Android synced-lyrics auto-scroll
  still doesn't follow playback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 16:31:55 +02:00
HoneyandClaude Opus 4.8 9167bfaf0e feat(android): global 30s-preview control bar + artist fan count
Preview bar:
- 30s previews used to keep playing with no control once you left the album/artist/Explore
  screen they started on. Moved the preview player from ExploreViewModel to an app-level
  PreviewController, and added a global PreviewBar (above the mini player, on every screen)
  to pause/resume/stop it. Starting real library playback cancels the preview.

Artist fan count (parity with web):
- ItunesItem carries fanCount (Deezer nb_fan); shown on search artist cards and the artist
  page ("559.542 Fans" / "1,2 Mio. Fans"). Combined with the existing nb_fan sort this makes
  same-name collisions (5x "Joji") resolve to the right artist on Android too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 16:24:46 +02:00
HoneyandClaude Opus 4.8 c92ac3d719 fix(web): cleaner German fan-count format (559.542 instead of "559,5 Tsd.")
fmtCount now shows the full grouped number below a million (toLocaleString de-DE) and only
compacts millions ("1,2 Mio."). The old "Tsd." form read awkwardly on the artist hero. SW ns-v53.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 16:16:03 +02:00
HoneyandClaude Opus 4.8 065d2f7319 feat: disambiguate same-name artists by Deezer fan count + show fans
Deezer's artist search doesn't rank by popularity, so a name collision (5 artists named
"Joji") returned a niche one first (62 fans) instead of the real Joji (559k) — the artist
page then loaded the wrong discography and owned songs didn't show as downloaded.

- api.js: _dzArtist carries fanCount (nb_fan); artist search sorts by fanCount desc.
- search.js: artist cards show the fan count as subtitle; fan count passed to the artist page.
- detail.js: pick the most-popular name match (not the first); for local Navidrome artists
  with a real collision (>=2 same-name candidates), tie-break by SONG OVERLAP — prefer the
  Deezer candidate whose top tracks actually overlap the library (each artist has different
  songs, so this nails the right person). Costs a few calls only on a collision.
- Artist hero shows "<n> Fans" (immediately for global-search artists, after match for local).
- Android: DzArtist.nb_fan + /search/artist sorted by nb_fan so the right artist is first.
- Also: web global-search "unavailable" message now says Deezer, not Apple. SW ns-v52.

Verified live against Deezer: both fan-sort and overlap select the real Joji (id 5078097).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 16:11:45 +02:00
HoneyandClaude Opus 4.8 1e69831dd8 feat: move the whole metadata/search stack from iTunes to Deezer (1.3)
Apple's iTunes /search returns 403 from server/datacenter IPs, which had broken global
search, charts, album/artist/single downloads and the metadata-editor autofill in
production. Everything now sources metadata from Deezer (free, no key, more requests,
returns explicit versions + real artist images + durations). Web + Android share one
source, so an artist resolves to one consistent name everywhere. Deezer results are
mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged.

Backend:
- New backend/lib/deezer.js: lookupTrackMeta, fetchAlbumTracks, searchAlbumTracks,
  fetchArtistAlbums (+ pure pickTrackMatch/mapAlbumTrack for tests).
- metadata.js drops the iTunes lookups; novasonic-server download paths (track, album,
  artist, playlist enrich, spotify artwork) + autofill now use Deezer.
- Artist download uses Deezer discography (real albums only), not the iTunes album search.
- Editor autofill endpoint /api/itunes/lookup -> /api/meta/lookup; removed the dead
  iTunes /search proxy and the Apple charts RSS proxy.
- backend/test/deezer.test.js: unit tests for numbering, mixed-artist album-artist
  stability, explicit flags, the Japanese-title match guard (npm test; RUN_LIVE=1 for live).

Web:
- api.js search/lookups already on Deezer; charts now use Deezer /chart. Artist cards
  show the real Deezer photo. Visible "iTunes"/"Apple Music" labels -> "Deezer"
  (search badge, charts badge, preview bar, editor button, artist heading). SW ns-v51.

Android:
- New DeezerModels.kt with Dz* models + mappers to ItunesItem/ItunesRssEntry.
- ItunesClient is Deezer-backed (search = tracks+albums+artists in parallel; album/artist
  lookups; /chart charts), same method signatures. Visible strings -> Deezer.

Docs: README, CLAUDE.md and CHANGELOG (1.3) updated; Platform Parity Matrix reflects the
shared Deezer source + Android non-local artist page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 12:48:33 +02:00
HoneyandClaude Opus 4.8 a65613b5a1 fix(web): Deezer artist images + rename the search label
- Search artist cards now use the Deezer artist picture directly (Deezer artists carry
  a photo, unlike iTunes entities) instead of a null image + an extra album lookup;
  the lookup stays only as a fallback when no picture is present.
- Non-local (Deezer) artist pages: fetch the Deezer artist picture (/artist/{id}) for
  the hero photo, which was blank because getArtistImage only runs for Navidrome artists.
- Label "iTunes Künstler" → "Deezer Künstler".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 22:01:59 +02:00
HoneyandClaude Opus 4.8 ad433c4dbc fix(backend): Deezer proxy route crashed the whole server on Express 5
`app.get('/api/deezer/*', …)` — a bare "*" — throws "Missing parameter name" in
Express 5's path-to-regexp at startup, so the server never came up and EVERY endpoint
(abs-proxy, romaji-overrides, deezer, …) failed with a CORS/ERR_FAILED in the browser.
Use a RegExp route instead, which registers cleanly on both Express 4 and 5. Verified
against express 5.2.1: bare "*" crashes, the regex is OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 21:54:36 +02:00
HoneyandClaude Opus 4.8 68a9cdd7d1 feat(web): switch the Apple/Erkunden search to Deezer
Apple's iTunes /search returns 403 from server IPs, killing the Erkunden search. Deezer
is free, needs no key, and is actually better: it returns explicit versions, artist
images, track durations and 30s previews.

- Backend: /api/deezer/* proxy (CORS-safe); album download accepts a `tracks` array in
  the request body (Deezer albums send their tracks directly, pre-mapped).
- api.js: itunesSearch / itunesLookupAlbum / itunesLookupArtistSongs|Albums now hit
  Deezer and map results to the existing ItunesItem shape, so the UI + download flow are
  unchanged. Function names kept.
- downloads.js: album downloads fetch the Deezer album's tracks and send them in the body.

iTunes /lookup (by ID) and the charts RSS proxy stay. Known follow-ups: artist-type
download still calls iTunes /search server-side; the metadata-editor iTunes autofill;
and the Android client — all still on iTunes for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 20:54:10 +02:00
HoneyandClaude Opus 4.8 ccf704754a fix(web): proxy Apple charts (CORS) + clear message when Apple search is blocked
Confirmed via curl from a clean IP: Apple's iTunes /search returns 403 (blocked
server-side, likely datacenter-IP anti-scraping) while /lookup?id= still returns 200.
So the Erkunden Apple search fails through no fault of ours.

- Charts: the "most-played" RSS (rss.applemarketingtools.com) was fetched directly
  from the browser and hit CORS; proxy it via /api/itunes-charts/:kind.
- Error visibility: itunesSearch records the last outcome (HTTP status / network);
  the global-search view now shows "Apple-Suche gerade nicht verfügbar" with the
  reason (403 = Apple blocks the API, not you) instead of a blank "no results".
- iTunes proxy now forwards the upstream status code instead of always 200.

Deezer (free, no key, ~50 req/5s) is the planned search fallback; MusicBrainz is too
rate-limited (1/s) for interactive search.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 20:32:08 +02:00
HoneyandClaude Opus 4.8 9c8b4775ea fix: use the US iTunes store everywhere for consistent artist naming
Search (web api.js) was JP-first, the download lookups were JP-first, and the Android
client was already US — so the same artist resolved inconsistently: sometimes kanji,
sometimes katakana, sometimes romaji, depending on which path and which store matched.

Switch everything to the US store:
- api.js itunesSearch → country=us (one fetch, matches the Android client)
- metadata.js lookupItunesTrackMeta → US only (drops the JP fetch: consistent names +
  halves the download's iTunes calls, which also eases the rate limit)
- searchItunesAlbumTracks → country=us
- remove the JP-katakana guards in the album/artist download paths (redundant now, and
  they wrongly romanized genuine Japanese artists like 羽岡佳 → "Kei Haneoka")

Titles still come from the source, so a Japanese title the user picked stays Japanese;
only iTunes-derived fields (album_artist, genre, year, artwork) follow the US store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 10:37:07 +02:00
HoneyandClaude Opus 4.8 d98978a6c7 feat(web): refresh the current view after deleting a song or playlist
Deletes left the removed item visible because the list loaders are cached (getPlaylists
~3 min): deletePlaylist navigated to a still-cached list, and deleteSong only reloaded
playlists. Add refreshCurrentView() — drops the API cache and re-runs the active nav
entry's loader (playlists, playlist/album/artist detail, starred, home, …). deleteSong
calls it after the delete+rescan delay; deletePlaylist invalidates getPlaylists before
navigating. Bump SW cache to ns-v46.

Also record the post-1.2 work in the 1.2 changelog (importer Enter, search single-song
+ radio, NowPlaying order, official-audio artist-metadata match, JP-katakana album
artist fix, year back-fill, delete refresh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 23:27:29 +02:00
HoneyandClaude Opus 4.8 966b318d9e fix(backend): back-fill the release year from iTunes on single-track downloads
embedMetadata already writes date/track/totaltracks/disc, but the single-track path's
"album already known" branch back-filled genre/total/disc/duration/track# from iTunes
and omitted the year — so a source track without a year got no `date` tag written.
Add !meta.year to the fetch trigger and merge genreMeta.year, so the release year is
set at download time like the other fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 23:15:41 +02:00
HoneyandClaude Opus 4.8 72b612f5ff fix(backend): don't tag Western artists with the JP store's katakana album-artist
The iTunes lookup is JP-first (for correct Japanese titles/artwork), but the Japanese
store returns katakana-localized names for Western artists — so DJ Snake's single got
album_artist / folder "DJスネイク • アルーナジョージ" while the title/artist stayed English.

- lookupItunesTrackMeta: take the NAMES (album, album-artist, track, artist) from the US
  store when the search isn't Japanese; keep JP names for actual Japanese songs.
- album & artist download paths: if iTunes' collectionArtistName is Japanese but the
  request artist isn't, keep the request's English name for the tag and folder.

Already-downloaded files aren't rewritten — re-download or fix via the editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:54:15 +02:00
Honey 8cd3e9ac23 Revert "fix(backend): cache the iTunes proxy to avoid Apple rate-limiting the search"
This reverts commit a3c8b1afd1.
2026-07-26 22:42:46 +02:00
HoneyandClaude Opus 4.8 a3c8b1afd1 fix(backend): cache the iTunes proxy to avoid Apple rate-limiting the search
The web Apple-Music search proxies through /api/itunes (browsers can't call
itunes.apple.com — no CORS). The proxy forwarded every request uncached, so heavy
download activity (each download does several album/track lookups) plus client
searches blew past Apple's ~20 req/min/IP limit and the search started returning
nothing. Add a 30-min in-memory cache keyed by the target URL; only genuine 200
responses (with the resultCount signature) are cached, never throttle/error pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:39:21 +02:00
HoneyandClaude Opus 4.8 04a29b684d fix(backend): match official audio by yt-dlp artist metadata, not just channel name
The strict cascade steps required the YouTube channel name to contain the artist, so
official uploads on label channels (BANGTANTV, HYBE LABELS, …) were rejected and the
no-channel fallback grabbed a fan reupload instead (observed: BTS "Pied Piper").

Add yt-dlp's music `artist` field as an OR alternative to the channel check in the
strict steps (two --match-filter args = OR). A metadata dump of "BTS Pied Piper"
confirmed only the official upload has artist=BTS (fan/live/lyric reuploads: NA), and
it's the #1 result — so it now passes strict step 2. channel_is_verified was rejected
as a signal: lyric videos and fan MVs also run on verified channels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:19:09 +02:00
HoneyandClaude Opus 4.8 f097dddc64 feat(web): Enter in the importer field starts the import
Pressing Enter in the importer link input now triggers resolveImporterLink()
(same as the Importieren button). Bump SW cache to ns-v45.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:11:44 +02:00
HoneyandClaude Opus 4.8 1e242796fe feat(web+android): search play queues only the tapped song (radio fills the rest)
Playing a song from search results queued the whole result list. Now it plays only
the clicked song; Radio mode, if on, fills the rest — closer to Spotify. Both
platforms already trigger a radio top-up on the first track (web playQueue →
fillRadioQueue; Android onMediaItemTransition → checkRadioRefill), so no extra wiring
is needed. Album/playlist play still queues the full list. Bump SW cache to ns-v44.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:00:58 +02:00
HoneyandClaude Opus 4.8 14fd5119f2 fix(android): NowPlaying shows album above artists, matching the web player
Reordered the NowPlaying track info to title → album → artists (was title → artists
→ album), so the Android layout matches the PC/web player.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:09:27 +02:00
HoneyandClaude Opus 4.8 f21174bd2c docs: changelog 1.2 + CLAUDE.md notes (web router, album_artist derivation)
CHANGELOG: 1.2 entry for the post-1.1 work — web forward/back navigation, album
detail album-artist, metadata autofill "&" normalization, Android Erkunden back
re-assert + directional tab transitions, and the backend mixed-artist / album_artist
download fixes.

CLAUDE.md: album/artist download album_artist comes from iTunes collectionArtistName
(not the request/lead-single artist); client-side History router applies nsIdx on
popstate for both directions and must never pushState in the back path.

README left unchanged — the post-1.1 changes are fixes/polish, not new features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:29:40 +02:00
HoneyandClaude Opus 4.8 b1a32f5130 feat(android): directional tab transitions in the bottom nav
Tab switches slid in a fixed direction (and sometimes flipped between push/pop feel,
so the direction felt random). Now the slide follows the target tab's position in the
bottom bar: tapping a tab to the right slides in from the right, a tab to the left
slides in from the left — applied across all four enter/exit/popEnter/popExit
transitions so it's consistent regardless of how Compose classifies the move. Detail
screen push/pop transitions are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:24:42 +02:00
HoneyandClaude Opus 4.8 179e0eb902 fix(web): center NowPlaying star vertically with the whole track-info block
The favorite star lived inside .np-title-row, so it sat pinned to the title line
rather than centered against the title + album + artist block. Move it out to be a
flex sibling of .np-text inside .np-info (align-items:center), so it centers on the
full text block. Bump SW cache to ns-v43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 01:42:09 +02:00
HoneyandClaude Opus 4.8 e78ce43019 fix(backend): artist download also derives album_artist from iTunes album artist
Apply the same collectionArtistName derivation to the per-album loop of the artist
download, so a collaboration/soundtrack in an artist's discography is not tagged with
the searched artist. Each track still gets its own iTunes artistName; album_artist
stays the iTunes album-level artist (consistent, no Navidrome splitting).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:07:40 +02:00
HoneyandClaude Opus 4.8 48a13fbccb fix(backend): album_artist from iTunes collectionArtistName, not the lead single's artist
The album download tagged album_artist with the request's `artist`, which can be a
single track's performer — the lead single "TAKEDOWN" is by TWICE, so a whole
KPop-Demon-Hunters soundtrack got album_artist=TWICE on every track. Derive the album
artist from the iTunes tracks' collectionArtistName (the album-level artist, identical
across the album) and fall back to the request artist only if iTunes omits it. This is
the documented intent (mainArtist = collectionArtistName when available) and keeps
album_artist consistent so Navidrome doesn't split the album.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:02:35 +02:00
HoneyandClaude Opus 4.8 4d44a476a5 docs: note per-track artist search for mixed-artist album downloads
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:42:50 +02:00
HoneyandClaude Opus 4.8 bdf2867bdd fix(backend): album downloads search by per-track artist, not the album artist
downloadAlbumTracks passed each track's artistName as `artist` but no `rawArtist`,
so in ytdlp.js the YTM/YouTube search query and the channel filter used
`mainArtist = albumArtist || artist` — always the album-artist blob. On mixed-artist
soundtracks that matched nothing: e.g. TWICE's "Strategy" and MeloMance's "Love,
Maybe" were searched as "KPop Demon Hunters Cast, HUNTR/X & Saja Boys <title>" with
`channel ~= KPop`, and failed with "Nicht eindeutig gefunden".

Pass rawArtist = track.artistName so the search + channel filter use the actual
performer ("TWICE Strategy", channel ~= TWICE). The album_artist tag still comes from
`albumArtist`, so album grouping is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:42:20 +02:00
HoneyandClaude Opus 4.8 e8b82c177f fix(web): album detail shows the album artist, not the first track's artist
loadAlbumDetail overwrote the header artist with parseArtists(songs[0]) — the first
track's artist. On soundtracks/compilations that is wrong (e.g. "KPop Demon Hunters"
showed "TWICE" from track 1 "TAKEDOWN"). Use r.album (the getAlbum response, which
carries the album's own album-artist tags in .artists / .artist) so it matches the
Android app: "KPop Demon Hunters Cast • HUNTR/X • Saja Boys".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:28:39 +02:00
HoneyandClaude Opus 4.8 ea98d81146 chore(web): bump service-worker cache to ns-v42
Forces installed PWAs to pull the new build (metadata "&" split + forward
navigation fixes) instead of serving stale cached assets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:54:36 +02:00
HoneyandClaude Opus 4.8 79100c5321 fix(web): browser/mouse Forward navigation now works (not only Back)
The popstate listener always called _navGoBack(), but both Back and Forward fire
popstate — so Forward was treated as Back and never restored the page you came from.
_navGoBack also pushState()'d on every back, which wiped the browser's forward stack
entirely.

Route all history through the History API: navigate() pushes a state (nsIdx) only on
genuine new navigation; popstate now applies whichever entry the browser moved to via
_navOnPopState(state.nsIdx), covering Back AND Forward with no pushState in the
handler. In-app back buttons / Backspace go through history.back() so there's a single
path and the browser stack stays in sync. NowPlaying overlays still close first on a
back press.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:49:40 +02:00
HoneyandClaude Opus 4.8 3f57e2ae26 fix(web): metadata autofill normalizes trailing " & " in multi-artist lists
Apple Music / MusicBrainz render multi-artist credits as "A, B, C & D" — the last
artist is joined with " & " instead of the list separator, so it stayed glued to the
previous one (e.g. "REI AMI & KPop Demon Hunters Cast" became a single artist that
Navidrome's ";" split could not break apart).

_fmtArtists now also rewrites " & " to the list separator, but only when the string
is already a list (contains ; , • ·), so real band names like "Simon & Garfunkel"
stay intact. The iTunes and MusicBrainz autofill results are now run through
_fmtArtists too (previously set raw), so the artist field comes out ";"-separated
and matches the Navidrome Tags.artist.Split config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:41:53 +02:00
HoneyandClaude Opus 4.8 315b8aaacc fix(android): re-assert Erkunden overlay back priority on every navigation
The root-level overlay back callback was keyed only on the overlay state, so after an
album drill-down or tab switch the NavHost could re-register its own back callback
above ours and regain priority — one back press then popped Erkunden → Home instead
of dismissing the overlay ("sometimes" bug). Key the DisposableEffect on
currentBackEntry as well, so the callback is removed and re-added (bumped to the top
of the OnBackPressedDispatcher) after every navigation while an overlay is open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:56:10 +02:00
HoneyandClaude Opus 4.8 c5649ac22f docs: record 1.1 hard-won knowledge in CLAUDE.md; refresh README
CLAUDE.md:
- Download pipeline: leading aesthetic characters in artist names must be trimmed
  before the iTunes lookup (a missed match cascades into a wrong-duration YouTube
  grab), and an album tag is always written (defaulting to Singles) so Navidrome
  and the metadata editor agree.
- New Android section covering the traps this release cost the most time on: a
  BackHandler inside a NavHost destination loses back priority (overlay back must
  be registered at the app root), overlay state must live in the ViewModel rather
  than remember/rememberSaveable, bottomBar and ModalBottomSheet inset handling,
  reusing the Ktor client, global optimistic StarState, and iTunes artists having
  no artwork.

README: offline downloads also cover single songs and work without a server
connection; StarState added to the data/ listing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:15:21 +02:00
HoneyandClaude Opus 4.8 a80ac3deb0 docs: changelog for 1.1
Polish release covering Android navigation and offline resilience, download
accuracy for stylised artist names, and the lrclib lyrics-throttling fix.

Also updates the parity matrix (single-song offline downloads, player bar on all
screens) and corrects the Windows media-tile known bug — the two-element gapless
handoff was kept for skip speed, not reverted as the entry claimed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:13:36 +02:00
HoneyandClaude Opus 4.8 4503bbe54e fix(android): uniform icon-only action buttons in playlist detail
Since the offline-download button was added, four buttons shared one row while Play
and Shuffle also carried text labels — the labels got squeezed out and the buttons
looked compressed. All four are now equal-width and icon-only (play, shuffle, edit,
download); Play stays filled as the primary action, the rest tint accent when
active/done. Icons carry contentDescriptions for accessibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:21:41 +02:00
HoneyandClaude Opus 4.8 9270d31608 fix(android): playbar clears system nav bar on detail screens; Erkunden all-albums survives drill-down
- On detail screens (album/artist/playlist) the MiniPlayer was the bottom-most bar
  with no NavigationBar to absorb the system-navigation inset, so it sat behind the
  phone's gesture/nav bar. Pad the bar with navigationBarsPadding() when the tab bar
  is absent.
- The Erkunden (iTunes) artist "show all albums" view used local remember state, so
  drilling into an album and back reset it to the main artist view. Hoist the open
  section into ExploreViewModel.artistAllAlbums so it survives both nav-route and
  overlay round-trips, and wire its back into the root overlay-back handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:21:01 +02:00
HoneyandClaude Opus 4.8 e50b4dc93c fix(android): Erkunden (Apple) overlay back no longer jumps to Home
The iTunes artist / album detail in the Erkunden tab are in-screen overlays on the
NavHost "erkunden" destination, not real routes. Their BackHandler lost back priority
to the NavHost, so one back press popped Erkunden → Home instead of dismissing the
overlay (artist→search, album→artist).

Handle the overlay's back at the app root via OnBackPressedCallback (the same proven
mechanism as the NowPlaying overlay): ExploreScreen reports its current overlay close
action up through onOverlayBackChange, and AppNavigation registers a root-level back
callback for it. Also covers an album opened straight from search (no artist), which
had no back handling at all. Removes the temporary nav-debug strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:11:52 +02:00
HoneyandClaude Opus 4.8 b07212fdce chore(android): TEMP nav back-stack debug strip (diagnosing back→home bug)
Shows the live NavController back stack at the top of the screen so we can see
whether detail screens are being pushed correctly or the back button isn't being
handled. To be reverted once the back-navigation issue is diagnosed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:10:50 +02:00
HoneyandClaude Opus 4.8 419f80568c feat(android): playbar everywhere, song-search context menu, offline tab loads offline
- MiniPlayer now shows on every screen (album/artist/playlist detail), not just
  top-level tabs; the tab bar stays top-level-only.
- Local library song search results get the 3-dot context menu (add to playlist,
  favorite, queue, download, …) via onShowContextMenu.
- Offline tab is fed by the local Room DB and no longer waits on the online library:
  it stays usable while the library is loading or the server is unreachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:21:02 +02:00
HoneyandClaude Opus 4.8 03046cd631 fix(android): show artist images in Apple-Music search (Erkunden)
iTunes artist entities carry no artwork, so search results rendered as bare grey
circles. Mirror the web PWA: after showing results, fetch one album per artist and
borrow its cover (entity=album, limit=1), applied only if the user is still viewing
the same artist set. Also give ArtistCard a Person-icon fallback instead of an empty
grey dot, and fall back to the artist id for Subsonic (library) artist covers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:04:48 +02:00
HoneyandClaude Opus 4.8 03bc736725 fix(android): iTunes download sheet opens fully so the download button is reachable
The "was herunterladen" sheet (track/album/artist + Herunterladen) opened only
half-expanded, hiding the download button behind the system nav bar until you
dragged it up. skipPartiallyExpanded=true opens it fully (button already clears
the nav bar via navigationBarsPadding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:46:44 +02:00
HoneyandClaude Opus 4.8 abb4047966 fix(android): full-height song context sheet + styled playlist-picker input
- Song context sheet now opens fully expanded (skipPartiallyExpanded) so the
  "Herunterladen" option at the bottom is reachable without an extra drag-up.
- The "new playlist" field in the playlist picker now matches the app's search
  bars: rounded 12dp, filled surface, leading icon, NsAccent/NsOnSurface3 borders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:42:31 +02:00
HoneyandClaude Opus 4.8 17a28b3011 feat(playlists): show playlist cover in the "add to playlist" picker (web + Android)
The picker listed playlists with a generic list icon; now each row shows the
playlist's cover art (falling back to the icon when there's none), so you can
tell them apart at a glance when adding a song.

- web (modals.js): 34px cover thumbnail per row.
- android (PlaylistPickerSheet): 40dp AsyncImage cover, coverUrl passed from AppNavigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 23:21:18 +02:00
HoneyandClaude Opus 4.8 1775ec5b9b fix(search): trim leading aesthetic punctuation in artist lookups
Artists like ".diedlonely" (leading dot, †, ✝, _ …) weren't surfaced by iTunes,
so the download's metadata lookup fell through to a titleOnly match — grabbing a
same-titled track by another artist (e.g. the "In the Bleak Midwinter" Christmas
carol: Holiday genre, 176s single) and steering the YouTube search to the wrong
(slowed) version.

- backend/metadata.js: strip leading non-alphanumeric from the search artist and
  the first-word comparison so the real artist matches (and the right album/genre/
  duration are used).
- android ExploreViewModel: strip leading symbols from the search term so the
  Explore tab finds these artists.

Keeps the original name for display/storage — only the search/compare is trimmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:34:44 +02:00
HoneyandClaude Opus 4.8 d50e22b62d perf: stop per-cover HttpClient churn (Android) + scroll-arrow reflow (web)
Android: MusicRepository.coverArtUrl/streamUrl built a fresh SubsonicClient
per call — each spins up (and never closes) a Ktor HttpClient. That ran for
every cover on every recomposition, causing heavy GC/alloc churn (and a leak)
that showed up as scroll/tab lag. Reuse the cached ClientHolder client for URL
building instead.

Web: the home scroll-row arrows called getBoundingClientRect on every scroll
event (layout thrashing). Split it: only toggle arrow visibility on scroll
(rAF-throttled); do the getBoundingClientRect vertical-centering only on
resize/content change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:02:11 +02:00
HoneyandClaude Opus 4.8 ef25ac3100 fix(android): use the heart icon for favorite in Now Playing (was a star)
Every other favorite toggle uses Favorite/FavoriteBorder (heart); Now Playing
was the odd one out with Star/StarBorder. Make it a heart everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:53:56 +02:00
HoneyandClaude Opus 4.8 ec4d5d0572 fix(android): instant star/favorite feedback everywhere
Tapping the star only fired the API and never updated any UI state, so the
icon didn't change (no feedback). Add a global reactive StarState with
optimistic overrides: the star flips instantly across every screen showing
that song (SongRow lists, Now Playing, context sheet), the API runs in the
background, and the override reverts if the call fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:30:17 +02:00
HoneyandClaude Opus 4.8 8741c2fb88 fix(android): back from album returns to the "all albums" view, not the artist page
The artist screen's "show all albums" overlay state was plain remember(), so
navigating into an album and pressing back reset it — dumping you on the
collapsed artist page instead of the list you came from. Persist which section
is open via rememberSaveable (a String? title) so it survives the round-trip,
and re-derive the overlay entries from the (surviving) categorized albums.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:35:01 +02:00
HoneyandClaude Opus 4.8 445a7bf583 fix(backend): consistent album for no-album downloads + tolerant editor resolve
Two-part fix for "[Unknown Album]" vs "Singles" mismatch that broke the
metadata editor (File not found):

- Download: always write an album tag, defaulting to "Singles" so it matches
  the on-disk folder (album || 'Singles'). No-album tracks no longer show as
  "[Unknown Album]" while living in a "Singles" folder. (Future downloads.)
- Editor resolve: Subsonic getSong returns a *virtual* tag-built path (e.g.
  "[Unknown Album]/Title.mp3" with raw "|"), not the physical file ("Singles/
  Artist - Title_YTPMV.mp3"). Add findExistingTrack as a final fallback, and
  make its normalizer map the sanitized chars (/ \ : * ? " < > | and _) to a
  space so a title tag matches the sanitized filename. (Fixes existing tracks.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:33:20 +02:00
HoneyandClaude Opus 4.8 63a7260c94 fix(lyrics): stop hammering lrclib (caused IP throttling → no lyrics)
Rapid skips + lyrics prefetch fired the full lrclib cascade many times over,
so lrclib throttled the IP and requests timed out (canceled) on both web and
Android. Reduce the request volume:

- Web: de-dupe concurrent fetches per song (prefetch + tab-open now share one
  cascade); bump the per-request timeout 6s → 8s.
- Android: per-song Mutex so prefetch + on-open load don't both run the cascade;
  send a proper User-Agent (lrclib asks for one and treats it better); debounce
  the prefetch (1.5s, cancelled on the next skip) instead of firing immediately
  on every track change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:12:01 +02:00
HoneyandClaude Opus 4.8 ddb9418603 feat(web): send a "now playing" scrobble at track start
Ping Navidrome with scrobble?submission=false when a track starts so
getNowPlaying reflects it instantly (feeds e.g. a Discord rich-presence
plugin / other clients' Now Playing). Independent of the existing delayed
"played" scrobble used for play counts and Last.fm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:58:30 +02:00
HoneyandClaude Opus 4.8 7f7d7740e4 docs: plan artist following + new-release/concert notifications
Follow artists and get notified on new releases (iTunes/MusicBrainz/Last.fm)
and nearby concerts (reusing the existing Ticketmaster integration), surfaced
in the in-app notification bell, with a download action from the alert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:47:01 +02:00
HoneyandClaude Opus 4.8 17a38e9c64 docs: plan Discover Mixes (auto-download, temporary) in backlog
Themed/random discovery mixes (Rock/Charts/Random) that auto-download via the
yt-dlp backend, auto-delete after a configurable retention time, with an option
to keep tracks in the permanent library. Captures sources, retention, keep flow,
and open questions for later implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:42:54 +02:00
HoneyandClaude Opus 4.8 7cba39dfaf fix(web): NowPlaying showed a stale cover after rapid skips
The NP overlay only refreshed its cover while open, so skipping with it closed
left the <img> on the last-opened song; reopening briefly showed that old cover
until the current one loaded. Now the NP cover element is kept pointed at the
current song even while closed (loads in the background), so opening it always
shows the right artwork.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 11:32:19 +02:00
HoneyandClaude Opus 4.8 46e252b494 perf(web): instant skips via gapless handoff to a pre-buffered element
Reintroduce the two-element handoff, this time as a skip-speed optimization
(not for the OS tile). A second <audio> element pre-buffers the next track;
on a forward skip playCurrent hands playback over to it instead of reloading
the source, so playback starts immediately instead of ~1s. Covers are
pre-warmed in parallel so the new artwork is ready too.

Listeners are bound to both elements once (handlers guard with el !== S.audio).
Safety: handoff only when the standby is buffered (readyState >= 2); otherwise
falls back to the unchanged source-swap path, and a failed handoff play()
recovers with a fresh source load. Web-only; the native bridge path is untouched.

Note: this does not change the OS media-tile flicker (a separate, MSE-only fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 11:17:16 +02:00
Honey be73f717ac Revert "experiment(web): drop separate preload <audio> element (single-element like Navidrome)"
This reverts commit f252586398.
2026-06-26 21:50:06 +02:00
Honey e1ec1f3660 Revert "polish(web): warm next track via fetch to shrink media-tile gap"
This reverts commit 6131eea9ec.
2026-06-26 21:00:44 +02:00
HoneyandClaude Opus 4.8 6131eea9ec polish(web): warm next track via fetch to shrink media-tile gap
Removing the second <audio> element fixed most of the OS media-tile flicker,
but skips now load the next track fresh → a brief gap where Windows shows the
generic site entry. Prefetch the next track's first ~4MB with fetch() (not a
media element, so no Chromium session flip) to warm the HTTP cache + TLS
connection; the src-swap then starts faster and the gap shrinks. Abortable on
queue change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:50:44 +02:00
HoneyandClaude Opus 4.8 f252586398 experiment(web): drop separate preload <audio> element (single-element like Navidrome)
Navidrome's player (react-jinke fork) uses one persistent <audio> element and
does not flicker the OS media tile, so the source-swap itself isn't the cause.
Our one structural difference was a second buffering <audio> (_preAudio) for
preload. A second media element seems to make Chromium hand the OS media
session over on skip → Windows tile flicker. This drops it; we rely on the main
element's preload="auto" + browser HTTP cache. Cover art is still prewarmed.

Trade-off: next-track start may be marginally slower. Easy to revert if it
doesn't fix the flicker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:01:38 +02:00
HoneyandClaude Opus 4.8 bed007258a docs: record desktop media-tile flicker as a known limitation
The two-element gapless handoff was reverted (switching <audio> elements
still ends/starts the OS media session, so Windows rebuilds the tile).
Correct the changelog claim and add a Known Bugs entry; the real fix is MSE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:46:35 +02:00
Honey 420c84d191 Revert "feat(web): gapless audio handoff to fix desktop media-tile flicker"
This reverts commit 98d7c84269.
2026-06-26 17:45:40 +02:00
HoneyandClaude Opus 4.8 98d7c84269 feat(web): gapless audio handoff to fix desktop media-tile flicker
Add a second <audio> element; S.audio always points at the active one and
listeners are bound to both once (handlers guard with el !== S.audio).
preloadNext buffers the next track into the standby element, and playCurrent
hands playback over to it on skip instead of swapping the source — so the
active element never empties and Windows keeps the media tile (also makes
forward skips instant/gapless).

Safety: handoff only when the standby is buffered (readyState >= 2); falls
back to the unchanged source-swap path otherwise, if there's no second
element, or if handoff play() rejects. Web-only — the native bridge path is
untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:13:22 +02:00
HoneyandClaude Opus 4.8 687650518e perf(web): defer lyrics prefetch so it doesn't slow playback start
prefetchLyrics now fires ~1.5s after a song change instead of immediately,
so the lrclib requests no longer compete with audio-stream buffering when a
new track starts. Timer is cleared on rapid song changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:56:11 +02:00
HoneyandClaude Opus 4.8 d65fbcd11e feat(web): scroll-row arrows + stable desktop media tile
Home rows get hover left/right arrows that only show for the scrollable
direction (enhanceScrollRow + initScrollRows, called from startApp);
visibility recomputed on scroll/resize/content-load.

Fix the OS media tile flicker when skipping on desktop PWA: mutate the
existing MediaMetadata in place instead of assigning a new one each track
(the replacement made Windows tear down and rebuild the tile). Action
handlers are now set once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:43:43 +02:00
HoneyandClaude Opus 4.8 7498e45b16 feat(web): desktop keyboard shortcuts
Space = play/pause, ←/→ = seek 5s, Shift+←/→ = prev/next, ↑/↓ = volume.
Handler lives in player.js (where _pos/_dur/seekToMs are in scope), wired
from startApp via initKeyboardShortcuts(). Ignored while typing in inputs,
and yields Space to focused buttons/links so it never double-fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:21:36 +02:00
HoneyandClaude Opus 4.8 ffa4fb35ea chore(pwa): desktop install polish for manifest
Add PNG icons (192/512) for a crisp taskbar/Start-menu icon, drop the
portrait orientation lock so the window resizes freely on desktop, and add
a stable app id. Installs as a standalone desktop window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:59:58 +02:00
HoneyandClaude Opus 4.8 58dacbabc3 fix(web): refresh Home on explicit Home click
loadHomeData now takes a force flag; navigating to Home via a click
(not back/forward) invalidates the home rows' API cache so they re-fetch
and the random row re-randomizes — matching the Android re-tap behavior.

CHANGELOG: note the Home-refresh fix under 1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:19:38 +02:00
HoneyandClaude Opus 4.8 211b8ec6c2 feat(lyrics): prefetch on play + smooth measured-position autoscroll
Warm the lrclib cache the moment a track starts (Android PlayerController
onMediaItemTransition; web onNpSongChange) so opening the Lyrics tab is
instant. Extract a DOM-free fetch helper on web for reuse.

Rewrite Android lyrics autoscroll to center on the active line's measured
position in a LazyColumn instead of a fixed line-height estimate — removes
stutter and the downward drift on wrapped lines; active line sits at a
~40%-from-top reading anchor.

CHANGELOG: fold into 1.0; drop the now-fixed lyrics known-issue note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:59:51 +02:00
HoneyandClaude Opus 4.8 7a5197b776 docs: mark 1.0 — first stable release
Promote the 0.10 changelog entry to 1.0; feature-complete across web and
Android. Android versionName was already 1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:02:51 +02:00
HoneyandClaude Opus 4.8 73fcfae30f feat(android): offline downloads — albums, playlists, detail screen & playback
Download albums/playlists to private device storage (Room + WorkManager,
HttpURLConnection streaming). New Offline tab lists groups with progress;
tapping a group opens a detail screen (cover-art rows, play/shuffle, per-song
delete from device). Downloaded tracks play from disk on every screen.
Album/playlist cards show a downloaded badge; download button resumes missing
songs. Song storage is dedup'd by ID and reference-counted on delete.

Fixes: Android 15 download crash (dropped banned DATA_SYNC foreground service);
offline detail flash-back (initial empty state no longer mistaken for deleted).

Docs: CHANGELOG 0.10 + parity matrix; condensed README Features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:06:43 +02:00
HoneyandClaude Sonnet 4.6 5a2ab54bbb docs: merge 0.10 back into 0.9, single release entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:45:01 +02:00
HoneyandClaude Sonnet 4.6 ac8ce6b7d8 docs: add 0.10 changelog entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:24:59 +02:00
HoneyandClaude Sonnet 4.6 e246382caf fix(ui): star button sits right next to track info, not far right
- Sidebar: replace ♫ emoji with new equalizer SVG icon (icon.svg)
- Mobile header: remove duplicate logo+name next to search bar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 20:41:15 +02:00
HoneyandClaude Sonnet 4.6 c802ef99d7 fix(ui): revert vite base /app/ — broke CSS/JS asset loading
Keep nginx /app/ alias for favicon resolution only.
CSS/JS stay at root-relative paths via location /.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 20:35:12 +02:00
HoneyandClaude Sonnet 4.6 e326ee6350 feat(ui): new equalizer logo, favicon link, fix favicon path
- icon.svg / icon-192/512.png: blue→purple equalizer gradient design
- nginx.conf: add /app/ alias so reverse-proxy prefix resolves assets
- vite.config.js: base /app/ + %BASE_URL% in favicon links so path
  resolves correctly whether app is served at / or /app/
- ic_launcher.xml: same equalizer design for Android app icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 19:50:30 +02:00
HoneyandClaude Sonnet 4.6 8e81404ab6 fix(ui): new equalizer logo, favicon link, fix favicon path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 19:37:15 +02:00
HoneyandClaude Sonnet 4.6 d4ebcf0447 fix(ui): fix shuffle badge shift, regenerate PNG icons, fix favicon path
- styles.css: remove overflow:hidden from .player-info-wrap — it was
  clipping the player-status-row and shifting the shuffle badge
- index.html: use absolute /icon.svg path for favicon; add PNG fallback
  for browsers without SVG favicon support
- icon-192.png / icon-512.png: regenerated from new equalizer SVG design

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:24:28 +02:00
HoneyandClaude Sonnet 4.6 db81595b02 fix(ui): star button sits right next to track info, not far right
Wrap player-info + star in .player-info-wrap (flex, align-items:center)
so the star is immediately to the right of the text block and centered
vertically with it, instead of floating to the far end of the 1fr column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:12:20 +02:00
HoneyandClaude Sonnet 4.6 3d6b74b873 feat(ui): new equalizer logo, favicon link, fix star vertical alignment
- icon.svg: replaced music-note with 5-bar equalizer waveform (gold
  gradient on dark bg) — works well as favicon and app icon at all sizes
- index.html: add <link rel="icon"> so browsers pick up the favicon;
  move #btn-star-current out of the title row and into player-track so
  player-track's align-items:center centers it with the full info block
- styles.css: add flex:1 to .player-info base rule; remove stale mobile
  overrides that assumed the star was inside player-info (position:absolute,
  padding-right hack)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 21:38:40 +02:00
Honey 9aa1947568 Revert "fix(backend): keep source artist name as-is, no iTunes override"
This reverts commit 1616d98fa6.
2026-06-21 16:44:26 +02:00
HoneyandClaude Sonnet 4.6 1616d98fa6 fix(backend): keep source artist name as-is, no iTunes override
Artist name from Spotify/source is stored directly in folder, filename,
and tags without being replaced by the iTunes artistName. iTunes is used
only for supplemental metadata (genre, year, cover, duration, trackNum,
albumArtist/collectionArtistName). Sort tags still receive romaji via
romajiForStorage() in ytdlp.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 16:43:15 +02:00
HoneyandClaude Sonnet 4.6 ff79428683 fix(backend): fix-artist-sort uses kanji for folder/filename, romaji only for sort tags
Consistent with point 7: original (kanji) names in folder, filename,
and main tags — romaji only in ARTISTSORT/ALBUMARTISTSORT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 16:05:21 +02:00
HoneyandClaude Sonnet 4.6 38d21cf2e0 fix(backend): remove title keyword filter for Japanese tracks
Japanese Art Tracks on YTM/YouTube are often romanized ("Souiunatsu"
for "そういう夏") — the Japanese keyword filter in --match-filter always
rejected the correct video. Channel + duration are sufficient quality
gates for JP tracks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 14:10:08 +02:00
HoneyandClaude Sonnet 4.6 d5b088a658 fix(backend): remove album suffix from YTM search query
Album words ("KAGUYA Music", "OST" etc.) appended to the search query
caused wrong or no results — "Kei Haneoka 確信 KAGUYA Music" would miss
tracks the plain "Kei Haneoka 確信" query finds immediately.
Artist + cleanTitle is sufficient for both JP and Western tracks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 13:50:37 +02:00
HoneyandClaude Sonnet 4.6 fa4c954ab9 feat(0.9): romaji sort-tags only, robust dupe detection, Mittelweg no-duration, autofill all fields
- Japanese tracks stored with original names in folder/filename/tags; romaji only in sort tags
- findExistingTrack: case-insensitive, searches all album folders under artist, strips release suffix
- --max-filesize 30M + 2x duration cap on all yt-dlp calls
- iTunes collectionArtistName captured as album_artist; fixes compilation album splitting
- album_artist tag uses mainArtist (not rawArtist) so all tracks in one album are consistent
- No-duration cascade: steps 5-8 (title-only/no-filter) removed — fail cleanly like with-duration
- Autofill iTunes: fills title, artist, albumArtist, discNum (was album/genre/year/track/total only)
- Autofill MusicBrainz: fills albumArtist, discNum (was missing both)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 13:26:11 +02:00
HoneyandClaude Opus 4.8 8be3a515e6 docs(changelog): autofill should fill every resolved field, not a subset
Both iTunes/MusicBrainz autofill buttons currently fill only partial form
fields; they should populate title, artist, album-artist, album, genre, year,
track, disc, total, cover — backend endpoints must return those fields too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:03:47 +02:00
HoneyandClaude Opus 4.8 a57e18198f docs(changelog): correct metadata-editor autofill bug description
Artists are stored ;-separated (feat./ft. normalized away at download), and
both lookups already use the first artist. The real gap is the strict match
failing for multi-artist tracks (no fallback) plus iTunes autofill not filling
the artist/album-artist fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:00:46 +02:00
HoneyandClaude Opus 4.8 6e7a18302a docs(changelog): note duplicate check is case-sensitive (filename FS match)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:58:19 +02:00
HoneyandClaude Opus 4.8 8b6b4b0839 docs(changelog): track playlist re-download (dupe drift) + no-duration wrong-cover bugs
Duplicate check matches exact computed path only, so iTunes/romaji/trackNum/
album drift between runs re-downloads existing tracks. The no-duration cascade
branch lacks Mittelweg protection and grabs wrong covers (Yorushika 車窓 →
Piano Echoes Shasou).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:52:58 +02:00
HoneyandClaude Opus 4.8 f3fa68928c docs(changelog): track metadata-editor autofill bugs (album artist + multi-artist split)
iTunes/MusicBrainz autofill don't fill the Album-Artist field; multi-artist
lookup splitting is inconsistent across the two endpoints (neither handles
/, feat., ft.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:49:59 +02:00
HoneyandClaude Opus 4.8 feaa8ee61f docs(changelog): track known backend download/metadata bugs
Duplicate detection (filename-only), missing download size cap (52MB wrong
video), and uncaptured iTunes album artist (collectionArtistName) affecting
Navidrome grouping/sorting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:45:36 +02:00
HoneyandClaude Opus 4.8 4c85fec4ca docs: add CLAUDE.md with project conventions and download-pipeline knowledge
Captures the rules and hard-won details from recent work: no mocked frontend
data, two-part versioning, yt-dlp-only download cascade + Mittelweg rule, file
naming, the YTM-search limitation for obscure tracks, and edit/delete file
resolution. Loaded automatically by Claude Code at session start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:48:29 +02:00
HoneyandClaude Opus 4.8 0fe2de5084 docs(changelog): release 0.8 + switch to two-part MAJOR.MINOR versioning
- Promote 0.7.1 → 0.8 with this session's work (Mittelweg cascade, web_music
  client, clean filename, duration-filter + metadata-editor fixes, squid removal)
- Drop the patch component from all historical versions (0.7.0 → 0.7, etc.)
- Add Metadata editor and Song delete rows to the parity matrix (Web only)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:35:21 +02:00
HoneyandClaude Opus 4.8 a870f50c87 feat(backend): Mittelweg cascade — never download wrong song on no duration match
When a track's duration is known but nothing matches the duration window, the
cascade no longer falls back to blind title-only/no-filter grabs (which pulled
same-titled videos by other uploaders, e.g. a "recognize" fan clip). Instead it
relaxes only the duration constraint while still REQUIRING the artist channel +
title. If nothing matches, the download produces no file and the job now fails
cleanly with "Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen"
(previously it falsely reported success with no file).

Single, album and playlist paths all surface the not-found state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:13:59 +02:00
HoneyandClaude Opus 4.8 cf6e75b312 fix(backend): restore YTM Songs filter param for cleaner song search
Re-add the &params= Songs-shelf filter (the documented way to song-search
YouTube Music) so results exclude videos/playlists/clips. Verified neutral
for the ambiguous-artist case (yt-dlp's search can't surface that track
regardless) but cleaner for normal tracks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:51:43 +02:00
HoneyandClaude Opus 4.8 1e8e5e8874 fix(backend): strip release suffix from filename too (01 - Artist - Title)
The filename used the raw title, so "recognize - Single" leaked into the
filename ("01 - MINA - recognize - Single.mp3"). Now the cleaned title drives
both the filename and the embedded title tag, giving "01 - MINA - recognize.mp3"
(Track - Artist - Title). The album folder keeps the full album name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:29:14 +02:00
HoneyandClaude Opus 4.8 902243586b fix(backend): match files when Navidrome stem desyncs by release suffix
After the embedded-title change (recognize vs "recognize - Single"),
Navidrome's title-derived stem can drop the "- Single"/"- EP" suffix the
on-disk filename still carries, so _findFileByStem matched neither exact nor
suffix and every fallback failed -> "File not found on disk" on edit/delete.
Now also match on the structured prefix in both directions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:39:35 +02:00
HoneyandClaude Opus 4.8 77bbb18c8d chore(backend): include resolved path in delete-song "file not found" error
The delete-song endpoint returned a bare "File not found on disk" with no
context, making path-resolution mismatches impossible to diagnose. Now
reports the Navidrome path, music root, and searched filename — matching the
detail the edit-metadata endpoint already provides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:32:46 +02:00
HoneyandClaude Opus 4.8 e93c0c7ab1 fix(backend): drop YTM Songs filter param — it dropped the exact track
The &params= Songs-tab filter re-ranked YTM results toward a same-named
artist's discography and excluded the official track (qNffDItRUeo) entirely,
even logged-out. The default blended search (what the browser shows) ranks
the exact track first. Removing the param so yt-dlp gets the same result set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:15:12 +02:00
HoneyandClaude Opus 4.8 c4e4c8c090 fix(backend): use space (not dash) between artist and title in search query
"MINA - recognize" skewed YTM ranking toward a same-named artist's full
discography, so the actual track (qNffDItRUeo) never appeared in results.
"MINA recognize" — matching how users type in the YTM search box — ranks
the exact official track first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:06:00 +02:00
HoneyandClaude Opus 4.8 c34123af38 fix(backend): apply web_music client to YTM search extraction too
The YTM search (getYtmVideoUrls) ran without the web_music client, so yt-dlp
parsed the search page as a generic YouTube search and returned unrelated
videos instead of the native Songs shelf — the actual artist track (e.g.
qNffDItRUeo for MINA - recognize) never appeared in results. Apply the same
player_client=web_music,default arg to the search step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:48:26 +02:00
HoneyandClaude Opus 4.8 9014659112 fix(backend): use web_music player client for YTM track access
yt-dlp's android_vr player client returns "This video is not available" for
many YouTube Music Art Track / topic uploads (e.g. MINA - recognize) that
play fine in the browser. Switch to player_client=web_music with default
fallback so these tracks become downloadable, fixing the cascade falling
through to wrong YouTube videos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:46:53 +02:00
HoneyandClaude Sonnet 4.6 ce4577e8f4 fix(backend): strip release suffix from title metadata tag
searchTitle (e.g. "recognize") is now passed to embedMetadata instead of
the raw title ("recognize - Single"), so the ID3 title tag matches the
actual song name. Album tag stays unchanged ("recognize - Single").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:44:13 +02:00
HoneyandClaude Sonnet 4.6 bb0be52f46 fix(backend): fix getYtmVideoUrls always returning empty + remove unsupported ytmsearch5
[youtube:tab] in stderr fires for all YTM search page processing, not just
artist channel expansion, causing channelExpanded=true for every search and
returning [] — bypassing all YTM cascade steps entirely. Fix: only check
stdout for /browse/UC (actual channel URL in results). Remove ytmsearch5:
scheme which is not supported by this yt-dlp version. Revert pool to 10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:32:22 +02:00
HoneyandClaude Sonnet 4.6 3c54850cca fix(backend): add ytmsearch5 step + expand YTM pool to 20 for better coverage
ytmsearch5 (yt-dlp native YTM search) added as step 2 — returns different
result ordering than the manual YTM URL, recovering songs that the URL
approach ranks too low. YTM pool expanded from 10 → 20 results to reduce
fallthrough to YouTube.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:25:37 +02:00
HoneyandClaude Sonnet 4.6 4804023a98 fix(backend): don't abort cascade on yt-dlp non-zero exit (e.g. video not available)
spawnYtdlp now always resolves so the hasAudio() cascade continues when a
single YTM result is geo-blocked or unavailable. Error is still logged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:23:59 +02:00
HoneyandClaude Sonnet 4.6 88d3e336c8 fix(backend): use searchTitle in YTM URL (was using raw title with release suffix)
YTM search used `title` directly, so "recognize - Single" was searched on
YouTube Music instead of "recognize" — causing no YTM results and falling
through to the less reliable YouTube search cascade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:19:43 +02:00
HoneyandClaude Sonnet 4.6 d5bc887f31 fix(backend): tighten duration filter margin from ±15% to ±10% (min 10s)
Matches spotdl behaviour; still covers YouTube intros/outros while
reducing false positives from similarly-lengthed wrong songs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:07:19 +02:00
HoneyandClaude Sonnet 4.6 4cbfa35655 fix(backend): normalize iTunes lookup across all download paths
- Track path: always fetch iTunes when durationMs/trackNum missing (not just genre)
- Both paths: strip release suffixes from title before iTunes lookup
- Log "Album:" instead of "Album aus Spotify:" (also fires for iTunes global search)
- Log duration when fetched from iTunes so it's visible in job log

Ensures all importers have consistent yt-dlp search filters (duration filter
now always runs when iTunes finds the track).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:00:32 +02:00
HoneyandClaude Sonnet 4.6 86d3999628 fix(download): pass Spotify durationMs through to yt-dlp duration filter
- downloads.js: use durationMs (Spotify) before trackTimeMillis (iTunes)
- server: read durationMs from top-level body, not only from tracks[0]
- server: also grab durationMs + trackNum from iTunes in the Spotify path

Without durationMs the duration filter never ran, allowing a 58s fan video
to pass all text filters and be downloaded instead of the actual song.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:55:17 +02:00
HoneyandClaude Sonnet 4.6 5f257bf451 fix(backend): generalize release-suffix stripping from search title
Handles all Spotify trailing suffixes: Single, EP, Remaster, Deluxe Edition,
Bonus Tracks, B-Sides, bare years (2024), Official Music Single, etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:44:06 +02:00
HoneyandClaude Sonnet 4.6 bff652a415 fix(backend): strip release-type suffixes from YTM search title + extend stopwords
- "recognize - Single" title → search uses "recognize" (strips "- Single/EP/Album/…")
- Adds Single/EP/Remaster/Deluxe/… to titleKws STOPWORDS so they never become
  mandatory title-match filters (no YouTube video title contains "Single")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:42:07 +02:00
HoneyandClaude Sonnet 4.6 d1b107ae71 docs: add CHANGELOG v0.7.1 (squid removal + ytdlp search fix)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:36:42 +02:00
HoneyandClaude Sonnet 4.6 ecb7721e3b fix(backend): only append album to YTM query for Japanese titles
Western tracks now use artist + title only (matches spotdl behaviour).
Album suffix was causing noise for short English titles like singles
where album name = title + "Single".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:35:32 +02:00
HoneyandClaude Sonnet 4.6 319a3804f3 fix(backend): strip title-duplicate and generic words from album search suffix
Album "recognize - Single" produced query "MINA - recognize recognize Single"
because "recognize" appeared in both title and album name. Now filters album
words already present in the title, plus generic words (Single, EP, Remaster…).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:29:39 +02:00
HoneyandClaude Sonnet 4.6 f1bdd811ca chore: remove squid.wtf (Qobuz) backend — yt-dlp only
Deletes backend/lib/squid.js; strips all references from downloader.js,
config.js, novasonic-server.js, docker-compose.yml, .env.example, README,
and CHANGELOG. Default DOWNLOAD_BACKEND is now ytdlp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:59:30 +02:00
HoneyandClaude Sonnet 4.6 925c173c15 chore: stage previously uncommitted screen changes (ArtistDetail, ItunesArtist, NowPlaying)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:52:42 +02:00
Honey 85252e24df fix: unresolved reference 'albums' after rename to albumsByNewest/albumsByName 2026-06-18 20:21:36 +02:00
HoneyandClaude Sonnet 4.6 7030f029b7 docs: finalize CHANGELOG v0.7.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:15:11 +02:00
HoneyandClaude Sonnet 4.6 93c5fb481d feat(android): add description field to playlist edit dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:13:19 +02:00
HoneyandClaude Sonnet 4.6 43cf11465e fix: playlist edit — cover upload in pencil dialog, remove cover-remove button
Android: camera hero button removed; cover upload moved into edit dialog.
Web: trash icon removed from cover actions in edit modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:12:27 +02:00
HoneyandClaude Sonnet 4.6 6a1bf33f31 fix(android): album Neueste now uses Navidrome type=newest (add-date), not year sort
Loads albumsByNewest (type=newest) and albumsByName (alphabetical) in parallel.
Neueste → Navidrome add-date order; A-Z → alphabetical; Älteste → client-side by year asc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:09:32 +02:00
HoneyandClaude Sonnet 4.6 9ffd071c58 feat(android): artist fast-scroll index bar with letter bubble
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:06:44 +02:00
HoneyandClaude Sonnet 4.6 dd484df636 fix(android): album sort — scroll to top on change, remove Künstler, default Neueste
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:05:00 +02:00
HoneyandClaude Sonnet 4.6 6be7981b8a feat(android): playlist mgmt, album sort, home hero parity, notification fix
- Playlist: create (FAB + dialog), rename, delete, remove songs, cover upload/remove
- Library Albums tab: sort chips (Neueste/Älteste/A-Z/Künstler) matching StarredScreen
- ABS notification: shows current chapter title; updates live without re-buffering
- Home hero: pool now matches web (frequent(5) ∪ recentlyPlayed(5)); label MEISTGESPIELT / ZULETZT GEHÖRT
- Home: added Zuletzt gehört section (type=recent) matching web layout
- Notifications: fixed delivery bug — polling moved to NotificationStore app-lifetime scope so notifications arrive even after user navigates away from ImporterScreen
- NotificationStore.startPoll() extended with navType/navAlbum/navArtist/navId params
- Removed defunct AbsPlayerService.kt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 19:04:36 +02:00
HoneyandClaude Sonnet 4.6 eacce487df fix: lyrics scroll — center active line only, remove drift phase
The 'drift to next line' animation moved the text while the user was
still reading/singing the current line, making it feel out of sync.
Now matches the web behavior: smooth-scroll to center the active line
when it changes, nothing more.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 01:39:51 +02:00
HoneyandClaude Sonnet 4.6 ec9c0add9e feat: notification nav + ABS search + HTML desc fix + brighter NowPlaying bg
- Notification sheet: add 'Zum Album →' / 'Öffnen →' button; tapping searches
  Navidrome and navigates to the matching album/playlist
- AppNotification: add navType/navAlbum/navArtist/navId fields (mirrors web notify.js)
- ImporterViewModel: store resolved album/artist/type in notification after download
- ABS Android: search bar with 400ms debounce, server-side search via /search endpoint
- AbsClient: add searchLibraryItems() for library-specific search
- AbsScreen/abs.js: strip HTML tags from book descriptions (<p> tags no longer visible)
- NowPlayingScreen: vibrant palette swatch + lighter scrim (0.15) + wider gradient
  for more visible dominant color background

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 01:38:58 +02:00
HoneyandClaude Sonnet 4.6 72feac41bb fix: ABS skip library list + nav label overflow + NowPlaying top bar + dominant color bg
- ABS: new AbsBooksScreen loads books directly on tab open; library pills shown only when >1 library — no more two-step library→books navigation
- Nav: shorten "Hörbücher"→"Bücher", "Einstellungen"→"Einst.", reduce label font to 9sp with maxLines=1 to prevent label wrapping
- NowPlaying: increase top padding so the tab bar sits lower on screen (statusBarTop + 16dp, min 52dp)
- NowPlaying: replace blurred-cover background with dominant-color gradient extracted via Palette from cover art; animates on track change; falls back to dark color

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 00:23:43 +02:00
HoneyandClaude Sonnet 4.6 820a75f40b feat: ABS full playback on Android — chapters, speed, sleep timer, progress sync
- AbsClient: add session API (startSession, closeSession, getProgress, saveProgress, getItemChapters) and data classes (AbsChapter, AbsAudioTrack, AbsPlaySession, AbsProgress)
- AbsPlayerController (new): standalone ExoPlayer controller for ABS audio; tracks global time across multi-track audiobooks, syncs progress to ABS every 30 s, chapter navigation, speed control, sleep timer
- AbsScreen: book detail screen with cover/meta/progress/chapter list/play button; AbsNowPlayingScreen overlay (progress bar, prev/+30s/play/-30s/next controls, speed picker, sleep timer, chapter list with auto-scroll)
- AppNavigation: create AbsPlayerController, wire up book detail and player overlay, hardware back button handling for all ABS sub-screens
- PrefsStore + SettingsViewModel + SettingsScreen: absChapterTrackMode toggle — progress bar shows current chapter length instead of full book
- README: update ABS section to cover both web and Android features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 00:15:30 +02:00
HoneyandClaude Sonnet 4.6 3280ffee37 docs: trim README to core features only; fix: remove duplicate save button in Android settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:58:15 +02:00
HoneyandClaude Sonnet 4.6 ba16f88355 docs: update README — accurate feature list + Android native app description
Removed:
- "WebView wrapper" — Android is native Jetpack Compose, not a WebView
- "Artwork pre-warming / pre-buffer on cold start" — not implemented this way
- ABS chapters/speed/sleep/progress on Android — web only for now

Added:
- Lyrics section: lrclib.net, fallback chain, in-memory cache, synced scroll
- Android: last played song in MiniPlayer across restarts
- Android: ABS library browser tab (hidden until configured)
- Android: no login flash on startup
- Settings: connection status dots, save button top+bottom, matched web order
- Multi-artist support in NowPlaying
- Artist cards with images in search
- Genre browsing
- ABS web vs Android split into separate entries
- Accurate Android project structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:56:02 +02:00
HoneyandClaude Sonnet 4.6 613d5156fa fix: settings order now matches web — Theme → Bibliothek → ABS → Discover APIs → Navidrome
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:54:11 +02:00
HoneyandClaude Sonnet 4.6 a0d3c7a592 feat: ABS integration, last played song, settings reorder + conn dots
Settings:
- Reordered to match web: Navidrome → Bibliothek → Theme → Audiobookshelf → Discover APIs
- Save button now appears at top AND bottom for easy access
- Audiobookshelf section: Server URL + API Key (masked) + green/red status dot with Test button
- NovaSonic Backend section: same green/red conn dot + Test button
- Connection status uses ConnStatus sealed class (Idle/Checking/Ok/Err)

Audiobookshelf:
- New AbsClient.kt: Ktor HTTP client, proxies through NovaSonic backend or direct with Bearer token
- New AbsScreen.kt: library list → book list with covers, author, duration
- Hörbücher tab appears in bottom nav only when ABS URL + API key are both set
- absUrl + absApiKey persisted in DataStore

Last played song:
- PlayerController saves song to DataStore on every media item transition
- MiniPlayer shows last song (dimmed, single play button) when no active queue
- Tapping play fetches the song from Navidrome and resumes
- LastPlayedSong stored in DataStore (id, title, artist, coverArt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:49:36 +02:00
HoneyandClaude Sonnet 4.6 f7012dab49 fix: login flash, album name casing, lrclib duration mismatch
- Android: show nothing while DataStore loads credentials (null initial
  value) to prevent login screen flash on startup when already logged in
- Web: dispAlbumName now only uses sortName as romaji for Japanese titles;
  English albums with lowercase ALBUMSORT tags now display correct casing
- lrclib: add no-duration fallback exact match to find songs where local
  file duration differs from lrclib entry (e.g. Sanctuary Opening 5:58 vs 4:24)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 22:50:11 +02:00
HoneyandClaude Sonnet 4.6 0272b9e99f chore: remove NetEase lyrics fallback from Android and web
NetEase returned wrong/unsynced results. lrclib with exact + fuzzy
search is sufficient; no third-party Chinese platform needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:48:46 +02:00
HoneyandClaude Sonnet 4.6 eb5b0ea360 fix: lrclib rate limit — exact first, then 2 parallel searches; check HTTP 200
3 simultaneous requests were hitting lrclib rate limiter.
Now: exact match alone → if no content, 2 parallel searches.
Also check response.status === 200 before parsing to avoid treating
404/429 error bodies as valid empty lyrics responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:46:09 +02:00
HoneyandClaude Sonnet 4.6 d7f8b301ab perf: parallel lrclib requests + in-memory cache for lyrics
- Android + web: fire exact/artist-search/title-search in parallel so
  total wait = slowest single request instead of sum of all three
- Android: HashMap cache keyed by artist|title, cleared on app restart
- Web: Map cache (session lifetime), cache hit is instant re-open
- Web: extract _showLyricsResult helper, safeJson wrapper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:26:07 +02:00
HoneyandClaude Sonnet 4.6 d0df01a76d fix: lyrics faster + title-only search fallback for metadata mismatches
- Android: add 6s HttpTimeout to LrcLibClient, wrap each step in withTimeoutOrNull
- Both: add title-only lrclib search (step 3) so "Sanctuary (Opening)" is found
  even when artist tag differs ("Utada" vs "Hikaru Utada")
- Web: reduce all timeouts 8s → 5s, extract bestMatch helper

Search order: lrclib exact → lrclib artist+title → lrclib title-only → NetEase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:20:47 +02:00
HoneyandClaude Sonnet 4.6 71667d1a03 feat: add NetEase as lyrics fallback after lrclib (Android + web)
Search order: lrclib exact → lrclib fuzzy search → NetEase.
NetEase has strong coverage for Asian/Japanese music that lrclib misses.
Duration-matched candidate selection on all three steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:50:16 +02:00
HoneyandClaude Sonnet 4.6 8b9bfd3098 fix: restore direct lrclib lyrics search with fuzzy fallback (both platforms)
Navidrome lyrics plugin does not implement getLyricsBySongId for synced
lyrics — removed all plugin calls. Both Android and web now query
lrclib.net/api/get (exact match) with /api/search fallback for metadata
mismatches (e.g. artist tagged differently in Navidrome vs lrclib).
Also fixes Android lyrics scroll tween<Int> type inference error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:45:32 +02:00
HoneyandClaude Sonnet 4.6 7e20062995 feat: Android Auto, radio mode, lyrics overhaul + web lyrics cleanup
Android:
- fix: Android Auto credentials never set on MusicService → artwork/stream null
- feat: Android Auto tabs reduced to Zuletzt gespielt, Favoriten, Playlists
- feat: radio mode (getSimilarSongs2 → getRandomSongs fallback, <5 songs refill)
- fix: lyrics now use Navidrome getLyricsBySongId plugin exclusively
- fix: lyrics scroll rewritten with Column+ScrollState absolute animateScrollTo
- fix: lyrics recomposition lag — extracted LyricsLineItem + key(idx) per line
- chore: remove dead LrcLibClient (direct lrclib bypassed Navidrome plugin)

Web:
- fix: lyrics now use Navidrome getLyricsBySongId only, removed direct lrclib fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:30:20 +02:00
HoneyandClaude Sonnet 4.6 8c8f03503b fix: multi-artist click in NowPlaying — individual composables per artist
Replace AnnotatedString/ClickableText (offset-based click was unreliable)
with a horizontally-scrollable Row of individual Text composables. Each
artist has its own explicit clickable modifier bound directly to its ID.

ID de-duplication: if Navidrome copies the primary artistId to all entries
in the artists array, only the first keeps the ID; others render as
non-navigable text (dimmed NsAccent) instead of silently going to wrong page.

Fallback: split song.artist by ';' when artists array is absent/single,
matching the web app's parseArtists() logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:44:59 +02:00
HoneyandClaude Sonnet 4.6 55eef2f831 feat: randomized home greetings (web PWA)
Same pool of music-themed phrases as Android — picks randomly each load.
With username: "Ready for some beats, Marc" — without: "Pick something great"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:38:21 +02:00
HoneyandClaude Sonnet 4.6 f15c005a3c feat: multi-artist support in NowPlaying (OpenSubsonic artists array)
- Song model: add SongArtist{id,name} + artists: List<SongArtist>
  Navidrome sends this via OpenSubsonic extension — previously ignored
- NowPlaying: if song.artists has >1 entry, render each name as an
  inline AnnotatedString with ClickableText, separated by ' · ';
  every artist navigates to their own page with their real ID
  Falls back to single artist/artistId entry when array is absent
- AppNavigation: onArtistClick now receives artistId: String instead
  of a fixed closure, enabling per-artist navigation from the sheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:37:08 +02:00
HoneyandClaude Sonnet 4.6 0e651e8be3 feat: randomized home greetings (music-themed, EN)
Replace static time-of-day greetings (Guten Morgen/Tag/Abend) with a random
pick from a pool of 10-12 music-themed phrases each time HomeScreen is composed.
With username: "Ready for some beats, Marc" — without: "What's on the playlist today?"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:28:44 +02:00
HoneyandClaude Sonnet 4.6 3855207530 feat: artist cards with image in search + fix action bar overflow
Search: replace SmallArtistChip (text pill) with ArtistCard showing
circular profile image (72dp) + name below; works for both Navidrome
artists (uses coverArt) and iTunes artists (uses artworkUrl100 if present)

ArtistDetailScreen: convert action bar Row → LazyRow so Play / Shuffle /
Konzerte pills sit comfortably with horizontal scroll instead of cramming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:24:18 +02:00
HoneyandClaude Sonnet 4.6 13e9ec9d9a feat: Konzerte button on artist page (Android)
- EventModels: ConcertEvent / ConcertVenue / ConcertOffer (Kotlinx Serializable)
- EventsClient: Ktor client fetching backendUrl/api/events/{artist}
- ArtistViewModel: fetches events in parallel with iTunes data on load;
  openEventsSheet / dismissEventsSheet
- ConcertsSheet: ModalBottomSheet with event rows (date, venue, city,
  Tickets → button that opens browser, Ausverkauft state)
- ArtistDetailScreen: Konzerte pill in action bar showing event count,
  tapping opens ConcertsSheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:17:55 +02:00
HoneyandClaude Sonnet 4.6 b5aa484c20 fix: push NowPlaying cover and controls down by 28dp
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 23:35:59 +02:00
HoneyandClaude Sonnet 4.6 42c12cbd3a fix: genre loading spinner + NowPlaying back button priority
GenreViewModel: GenreUiState(genres = it) left isLoading=true (default),
so genres loaded but spinner never cleared. Added isLoading=false explicitly.

AppNavigation: replace BackHandler + navController.enableOnBackPressed with a
DisposableEffect that re-adds an OnBackPressedCallback each time NowPlaying
opens. Since callbacks are LIFO, a freshly-added callback always beats the
BackHandler NavHost registers internally, regardless of composition order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:49:40 +02:00
HoneyandClaude Sonnet 4.6 f01fc71f54 feat: back navigation, NowPlaying fix, home refresh, genre top-6
- ExploreScreen: add BackHandlers for state-based overlays (partialAlbum →
  albumTracks → artistDetail hierarchy) so system back navigates correctly
- AppNavigation: disable NavController's OnBackPressedCallback while NowPlaying
  is open so our BackHandler always intercepts first (fixes tab-switch-then-back bug)
- Home tab: tapping Home while already on Home triggers vm.load() refresh via refreshKey
- HomeViewModel: load all genres sorted by songCount desc (removed take(12) cap)
- HomeScreen: show only top-6 genres + 'Alle anzeigen' button with total count
- New GenreListScreen + GenreViewModel: full scrollable genre list at 'genres' route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:40:48 +02:00
HoneyandClaude Sonnet 4.6 afae5d94df feat: partial album split view + artist page full-screen + Bibliothek iTunes integration
- Partial album detail: tapping incomplete albums now shows downloaded songs
  (normal playback) above and missing iTunes tracks (30s preview + download)
  below — on both Android and web PWA
- Android: ItunesArtistScreen now renders full-screen above the tab/search bar
- Android: ArtistDetailScreen (Bibliothek) now loads iTunes data in parallel —
  adds Weltweit populär section and In Library/Unvollständig/Nicht heruntergeladen
  album sections identical to the Erkunden artist page
- Fix: artist no longer appears in its own Similar Artists row
- Fix: duplicate album entries deduplicated by collectionId in categorizeAlbums

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:54:32 +02:00
HoneyandClaude Sonnet 4.6 b25e162806 fix: safe area and UI polish pass
- Enable dark navigation headers in Tab navigator (fixes status bar overlap
  on all 5 tab screens automatically)
- Use useSafeAreaInsets for AlbumDetail/ArtistDetail back buttons
- Use useSafeAreaInsets for NowPlaying header and bottom padding
- Remove redundant paddingTop from Home heading and Login content
- Cleaner tab bar (height 60, better label spacing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:22:02 +02:00
HoneyandClaude Sonnet 4.6 98394bab75 fix: spread DarkTheme to include fonts in NavigationContainer theme
React Navigation v7 requires theme.fonts (medium, regular, bold, heavy).
Providing a partial theme without fonts caused BottomTabItem to crash
with "Cannot read property 'medium' of undefined".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:45:27 +02:00
HoneyandClaude Sonnet 4.6 4027827312 fix: add babel-preset-expo devDependency and babel.config.js
Without this, Metro fails to bundle because the preset is not found.
Verified: npx expo export --platform android now bundles 1093 modules clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:04:35 +02:00
HoneyandClaude Sonnet 4.6 db80f41018 fix: upgrade to react-native-track-player 5.0.0-alpha0 (Media3/New Arch)
- RNTP 4.1.2 used ExoPlayer 2.x which is incompatible with RN 0.85.3
- RNTP 5.0.0-alpha0 uses androidx.media3 and supports New Architecture
- Remove newArchEnabled:false from app.json (New Arch is default)
- Remove Kotlin null safety patch (no longer needed)
- Remove patch-package (no more patches required)
- Fix getState() -> getPlaybackState() API rename in player.js
- Add babel.config.js with babel-preset-expo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:53:02 +02:00
HoneyandClaude Sonnet 4.6 1856452d8f fix: remove react-native-reanimated and react-native-worklets
These packages require New Architecture (Fabric), which conflicts with
react-native-track-player requiring Old Architecture (newArchEnabled: false).
Neither of these packages was imported anywhere in our code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 11:30:42 +02:00
Honey a48093d6d8 fix: disable New Architecture for react-native-track-player compatibility 2026-06-10 10:19:29 +02:00
Honey a65a3eeb5b fix: add error boundary, harden player setup, catch init errors 2026-06-10 09:51:42 +02:00
Honey 2327778136 fix: move gesture-handler and TrackPlayer init to index.js, fix Dimensions usage 2026-06-10 09:21:27 +02:00
Honey 45ec25d257 fix: patch react-native-track-player for Kotlin 2.x null safety 2026-06-10 01:23:20 +02:00
Honey fac3ab670d fix: align dependencies to Expo SDK 56 2026-06-10 01:16:22 +02:00
Honey 68912522f8 fix: correct eas.json buildType value 2026-06-10 01:10:51 +02:00
Honey 3c9602f861 chore: add eas.json for cloud builds 2026-06-10 01:08:16 +02:00
Honey 131097550a fix: remove invalid track-player config plugin, fix adaptive icon path 2026-06-10 00:28:48 +02:00
HoneyandClaude Sonnet 4.6 1b96706a1c feat: add Expo mobile app (mobile/)
Full React Native app using react-native-track-player for native
audio — fixes background freeze, autoplay stopping, stuck play
button, and enables Android Auto in future. Includes:

- Navidrome API layer (ported from src/api.js)
- Zustand global store with SecureStore config persistence
- react-native-track-player with background playback service
- Bottom tab navigation: Home, Artists, Albums, Search, Settings
- Screens: Home, Library (Artists/Albums), ArtistDetail, AlbumDetail,
  Search, NowPlaying, Login/Settings
- Persistent PlayerBar with progress indicator
- Romaji/sort-tag display support carried over from web app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:19:06 +02:00
HoneyandClaude Sonnet 4.6 59adee60d5 fix: invalidate sort caches after metadata save, remove sort-debug logs
After saving sort tags via the metadata editor, invalidate _artistSortMap
and the apiCached getArtists entry so the updated Navidrome sortName values
are picked up on the next page visit without a hard refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:34:12 +02:00
Honey 85f8d758a5 debug: full JSON dump of song/album + artist sort map diagnostics 2026-06-08 23:23:41 +02:00
HoneyandClaude Sonnet 4.6 9e6e81bc22 fix: auto-populate artist sort map on album page — no Artists page visit needed
ensureArtistSortMap() fetches getArtists once per session (cached) and
populates _artistSortMap automatically. Called at start of loadAlbumDetail
when showRomaji is on, so artist sort names are always available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:07:02 +02:00
Honey 6851c37e15 fix: remove non-existent sortArtist/artistSort checks from dispArtist — Navidrome only exposes sortName on artist objects 2026-06-08 23:04:46 +02:00
HoneyandClaude Sonnet 4.6 b4ece2db85 fix: use r.album.sortName for album name — Navidrome only exposes sortName on album objects
Song objects carry sortName for title only, no album/artist sort fields.
Album sort is only available on the album object itself (r.album.sortName).
Updated loadAlbumDetail to use the fresh API response album, not the nav album.
Simplified dispAlbum to not check non-existent song sort fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:00:16 +02:00
Honey 228f58d6d5 debug: log all sort keys from Navidrome song and album response 2026-06-08 22:55:03 +02:00
Honey 44ef685922 debug: log sort fields from Navidrome album/song response 2026-06-08 22:50:41 +02:00
HoneyandClaude Sonnet 4.6 e3987a6376 feat: album names use Navidrome sortName tag when showRomaji is on
dispAlbumName now takes the full album object and reads album.sortName
directly — no kuromoji fallback, no backend translation.
dispAlbum (song context) also drops the kuromoji fallback, keeping only
the sortAlbum/albumSort file tag.

Override list → Navidrome sort tag → raw Japanese name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:42:53 +02:00
HoneyandClaude Sonnet 4.6 4c3ec5726c fix: dispArtistById prefers Navidrome sortName over kuromoji translation
Added _artistSortMap populated from getArtists (artist listing) and
getArtist (artist detail) responses. dispArtistById now checks the
Navidrome sort tag first, falls back to kuromoji only when missing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:35:40 +02:00
HoneyandClaude Sonnet 4.6 da68afe0e0 fix: remove romaji album name from player bar and now-playing overlay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:30:25 +02:00
HoneyandClaude Sonnet 4.6 8f7f1e2635 fix: artist hero appears instantly; album names skip romaji conversion
Hero now only waits for getArtist (fast local Navidrome) before revealing.
getArtistImage (Last.fm lookup) was the 2-4s bottleneck when uncached —
it now resolves async and patches in the artist photo after the hero is
already visible.

Album card, album detail header, and home hero now display the raw album
name from Navidrome instead of converting to romaji when showRomaji is on.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:23:35 +02:00
HoneyandClaude Sonnet 4.6 28489f722a docs: rewrite README with current features, structure and env vars
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:56:54 +02:00
HoneyandClaude Sonnet 4.6 fc2c5de1fb fix: pass TICKETMASTER_API_KEY env var to backend container
Was missing from docker-compose environment list so the key was never
visible to the Node.js process even when set in .env.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:49:39 +02:00
HoneyandClaude Sonnet 4.6 d47a3404b3 debug: add /api/events-debug/:artist endpoint to inspect raw Ticketmaster response
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:06:10 +02:00
HoneyandClaude Sonnet 4.6 7fe755379b fix(events): fix Ticketmaster URL encoding + add error logging
Colons in startDateTime were being percent-encoded which Ticketmaster
may reject. Also log errors/status so backend logs reveal the cause
when no events appear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:57:26 +02:00
HoneyandClaude Sonnet 4.6 2c9d3a9dde perf: cache Ticketmaster event results for 30 min per artist
Avoids burning API quota on repeat visits to the same artist page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:52:50 +02:00
HoneyandClaude Sonnet 4.6 adcbb2e883 feat: replace Bandsintown with Ticketmaster for concert events
Backend: swap /api/events/:artist to use Ticketmaster Discovery API
(startDateTime=now, classificationName=music, size=20, sort by date).
Maps TM response to the same {datetime, venue, offers} shape the modal
expects. Returns [] if TICKETMASTER_API_KEY is not set.

Frontend: remove all debug console.logs from events.js; drop redundant
client-side date filter (TM already filters by startDateTime server-side).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:51:51 +02:00
HoneyandClaude Sonnet 4.6 def625a622 chore: remove unused SPOTIFY_CLIENT_ID/SECRET from .env.example
The Spotify importer resolves links without OAuth credentials — these
variables were never read by any code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:47:30 +02:00
HoneyandClaude Sonnet 4.6 972c6b9722 docs: add TICKETMASTER_API_KEY to .env.example
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:43:03 +02:00
HoneyandClaude Sonnet 4.6 c8e3f5b641 perf: show artist hero after 2 fast calls instead of waiting for all 6
Split the allSettled into two phases: await imgP+albumP first (both are
fast — getArtistImage hits S.artistImgCache when navigating from the
listing, getArtist is a local Navidrome call), then reveal the hero.
The slow calls (getArtistInfo2 / Last.fm bio, iTunes search, events)
keep running in parallel and are only awaited when rendering the content
sections below the hero — so the user sees the hero card immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:42:03 +02:00
HoneyandClaude Sonnet 4.6 1f9495f806 fix(android): persist artists array in localStorage so multi-artist splits survive cold start
The queue serialization was saving artist as a plain string but not the
artists array. On cold start the app restores from localStorage, so
parseArtists had no array to work with and fell back to splitting the
artist string by semicolons — "Maduk Veela" has none, so it returned
one combined entry. PWA always re-fetches fresh API data so the array
was always present. Adding artists:[] to both song and queue entries
fixes artist navigation after cold start on Android.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:31:08 +02:00
HoneyandClaude Sonnet 4.6 60e89fe3b6 fix(android): instant cold-start resume + play button icon flicker
WebBridge: skip setMediaItems/prepare when the track is already buffered
from the cold-start pre-warm, so resuming the last song is instant (~0s)
instead of re-buffering from scratch (~10s).

Player: show pause icon during buffering (isPlaying=false, isBuffering=true)
so the button doesn't flash back to "play" between tracks. Also optimistically
set _nativePlaying=true and call setPlayIcon immediately on playCurrent so the
icon updates before the native callback round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:08:56 +02:00
HoneyandClaude Sonnet 4.6 76efa393ea fix(android): keep skip buttons visible in notification
Returning RESULT_ERROR_NOT_SUPPORTED from onPlayerCommandRequest caused
Media3 to mark the skip commands as failed and hide the buttons. Now
returns RESULT_SUCCESS so the notification keeps them enabled, while
ForwardingPlayer no-ops seekToNext/Previous so ExoPlayer never tries to
advance its single-item queue — JS still owns all queue navigation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:06:33 +02:00
HoneyandClaude Sonnet 4.6 128437821a fix: instant OS notification update + real audio preload for gapless playback
- Call updateMediaSession immediately on track change so skip buttons and
  cover art in the OS notification bar update without waiting for audio load
- Replace link[rel=prefetch] (no-op for audio streams) with a shadow Audio
  element (preload=auto) that actually buffers the next track
- Start preloading 60s before track end instead of 30s for more buffer margin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:57:52 +02:00
HoneyandClaude Sonnet 4.6 f04d87254b feat: concerts button always visible, shows 'Keine Konzerte angekündigt' when empty
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:26:46 +02:00
HoneyandClaude Sonnet 4.6 4bb131b219 debug: add console logs to fetchEvents to diagnose empty results
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:26:18 +02:00
HoneyandClaude Sonnet 4.6 98c964ecae fix: proxy Bandsintown events through backend to bypass 403, always show ticket link with sold-out badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:23:00 +02:00
HoneyandClaude Sonnet 4.6 914297651e feat: artist concerts via Bandsintown — button + modal with ticket links
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:09:52 +02:00
HoneyandClaude Sonnet 4.6 6bbebcc602 fix: increase mobile artist hero height to 320px
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:00:52 +02:00
HoneyandClaude Sonnet 4.6 f808db7eae feat: remove color tint overlay from artist hero
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 00:59:44 +02:00
HoneyandClaude Sonnet 4.6 32bde3ce87 feat: mobile artist hero — full-bleed image with name, no pfp square
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 00:51:49 +02:00
HoneyandClaude Sonnet 4.6 c3d0360946 fix: hero reveal — set bg inside pfp onload so cache is warm, no state machine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:51:13 +02:00
HoneyandClaude Sonnet 4.6 55cfaf0392 fix: hero reveals on image load only, color tint non-blocking CSS fade
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:39:06 +02:00
HoneyandClaude Sonnet 4.6 28de177381 fix: hero waits for pfp + bg + color before revealing — no more incremental flicker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:36:31 +02:00
HoneyandClaude Sonnet 4.6 0a3c72e2fd fix: remove expandArtistBio from main.js imports and window binding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:34:28 +02:00
HoneyandClaude Sonnet 4.6 f91176da2a feat: artist bio always shows full text, remove Mehr lesen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:29:24 +02:00
HoneyandClaude Sonnet 4.6 b436e39f40 fix: hero non-blocking — reveals on pfp load, tint fades in via CSS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:28:59 +02:00
HoneyandClaude Sonnet 4.6 42fe5d802b fix: hero fades in complete — no more incremental load flicker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:19:37 +02:00
HoneyandClaude Sonnet 4.6 e2964829bd feat: artist hero — dominant color tint + artist photo as full background
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:15:48 +02:00
HoneyandClaude Sonnet 4.6 4131907ebd fix: hide pfp initial when photo loads, brighter album art background
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:43:33 +02:00
HoneyandClaude Sonnet 4.6 0ac7fc5383 fix: artist hero — large square pfp on left, brighter background
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:39:45 +02:00
HoneyandClaude Sonnet 4.6 64aec78a43 feat: artist hero redesign — album art banner + circular pfp on left
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:31:54 +02:00
HoneyandClaude Sonnet 4.6 79a4aa352f fix: artist hero desktop uses blurred backdrop + natural-proportion portrait
Single object-position can never work well for portrait photos in a ~6:1 wide
banner — we'd only see ~11% of the image height no matter the value.

Desktop now uses two layers:
  1. artist-hero-bg: same image, blurred + darkened, fills full banner width
  2. artist-hero-img: sharp portrait anchored to the right at natural proportions
     (height: 100%, width: auto — no cropping, full face visible)

Mobile unchanged (cover + center center was already working great).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:11:24 +02:00
HoneyandClaude Sonnet 4.6 ce0697e00a fix: restore center center on mobile artist hero (was breaking phone view)
Only override object-position on desktop (>=769px) to center 30%.
Mobile keeps center center which was already working great.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:07:23 +02:00
HoneyandClaude Sonnet 4.6 aa20eec29d fix: artist hero uses <img> with responsive object-position for better crop
Switching from background-image (inline JS, single position for all sizes)
to an <img> element lets CSS media queries apply different object-position:
- Mobile: center top (portrait images show face in upper portion)
- Desktop: center 30% (wide banner, lower anchor avoids showing only hair)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:05:47 +02:00
HoneyandClaude Sonnet 4.6 106fbf54bf refactor: reorder settings sections — important ones first, login last
Theme → Bibliothek (+ Romaji) → Audiobookshelf → Discover APIs → Navidrome Server
Also merged the standalone Romaji Korrekturen row into the Bibliothek section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:59:10 +02:00
HoneyandClaude Sonnet 4.6 e3c435420c fix: artist hero banner shows face/upper body on desktop
background-position: center crops to mid-body on wide desktop banners.
Shifting to center 20% anchors the crop in the upper-third of the image
where faces typically are, matching the phone experience.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:57:22 +02:00
HoneyandClaude Sonnet 4.6 920e385a8f fix: break static circular dep in notify.js that caused 'Failed to fetch' after ui split
notify.js statically imported navigate from views/views.js, creating a new
circular chain: navigation.js → ui/ui.js → settings.js → downloads.js →
notify.js → views/views.js → navigation.js. This chain did not exist in the
old monolithic ui.js and caused module initialisation failures in some browsers.

Fix: make navigate a lazy dynamic import inside _notifNavigate() body, which
is the only call site. Also replace unnecessary dynamic import('../utils.js')
calls with the already-statically-imported toast function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 19:49:29 +02:00
HoneyandClaude Sonnet 4.6 6391857fda feat: configurable scrobble timing (15s / 30s / 1/3 / 2/3 / end)
Settings dropdown: 15s (default), 30s, 1/3 of duration, 2/3 of duration, song end.
Duration-based modes use s.duration from Navidrome metadata (always available).
'end' mode skips the timer and fires the scrobble in onEnded (web) / onQueueEnded (native).
Skipping a song never scrobbles in any mode — _cancelScrobble() still runs on skip/pause.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 17:53:02 +02:00
HoneyandClaude Sonnet 4.6 acb15d8c00 fix: hero card shows romaji for album name and artist
loadHeroCard() now pre-fetches the romaji index (same pattern as loadAlbumRow)
and uses dispAlbumName()/dispArtistById() instead of raw album.name/artist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 14:05:10 +02:00
HoneyandClaude Sonnet 4.6 0663b1c923 refactor: split ui/ui.js into 5 focused modules
settings.js  — settings modal, romaji overrides, sidebar, theme, nav mode
modals.js    — context menu, playlist-add modal, confirm dialog, edit-playlist modal
metadata.js  — song info/edit modal, MusicBrainz/iTunes autofill, saveMetadata, deleteSong
notify.js    — notification panel, bell badge, _addNotif, _notifNavigate
downloads.js — download modal, Spotify download, jobs panel, startArtistSortFix
ui.js        — barrel re-export; all external imports unchanged

player.js left unsplit: shared private state (_nativePlaying, _playGen, _queueGen, etc.)
spans every section — splitting would require exporting internal state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 14:00:38 +02:00
HoneyandClaude Sonnet 4.6 2a2e5ea495 refactor: split views/views.js into 6 focused modules
navigation.js  — navigate(), history, closeHeaderSearch, _navGoBack
home.js        — home page, genres, album rows, loadArtists/loadAlbums, getArtistImage
detail.js      — artist/album/iTunes detail pages, quickPlay helpers, expandArtistBio
playlists.js   — playlists, playlist detail, reorder, starred, random
search.js      — local/header/global search, iTunes helpers, s_goArtist, playerBarGoX
discover.js    — new releases, recommendations (Last.fm + iTunes fallback)
views.js       — barrel re-export; all external imports unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 13:50:36 +02:00
HoneyandClaude Sonnet 4.6 308a27d293 refactor: restructure src into player/views/ui subfolders; fix notification skip buttons
- Move player.js + nowplaying.js → src/player/
- Move views.js + spotify/soundcloud/youtube/abs → src/views/
- Move ui.js + components.js → src/ui/
- Update all import paths (static + lazy) across every file
- MusicService: wrap ExoPlayer in ForwardingPlayer that always advertises
  next/prev commands so the notification skip buttons stay visible in radio
  mode even when the JS queue has only one native track

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 13:12:27 +02:00
HoneyandClaude Sonnet 4.6 d8908c69e9 feat: add session-level API cache — skip spinners on home/albums/artists/playlists revisit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:45:22 +02:00
HoneyandClaude Sonnet 4.6 08ca8303f3 chore: remove unused _syncNpDirect import; add express to backend/package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 01:21:41 +02:00
HoneyandClaude Sonnet 4.6 45cb62198e fix: album cards use romaji index for album name display via _albumRomajiMap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 01:10:38 +02:00
Honey 52f5385988 fix: import dispAlbum in views.js 2026-06-06 01:03:56 +02:00
HoneyandClaude Sonnet 4.6 eb47a42f15 fix: album detail header re-renders with sort tags after songs load; search artist uses dispArtistById
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 00:56:33 +02:00
HoneyandClaude Sonnet 4.6 25d80eb559 feat: use file sort tags (sortAlbum/sortArtist/sortName) as display override when romaji is on
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 00:51:22 +02:00
HoneyandClaude Sonnet 4.6 961511b3d0 fix: apply romaji disp functions in _restoreLastPlayed and album detail header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 00:44:28 +02:00
HoneyandClaude Sonnet 4.6 8704a17b4f fix: romaji display race on reload; iTunes search uses original Japanese name
- Await romaji overrides before restoring player bar so disp* functions
  have the cache ready on first render after reload
- dispArtistParts now carries originalName so artist clicks pass the
  Japanese name to Navidrome/iTunes search while displaying romaji

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 00:09:13 +02:00
HoneyandClaude Sonnet 4.6 ccdcda74ec fix: apply romaji overrides to album cards on home/albums/artist views
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 23:43:27 +02:00
HoneyandClaude Sonnet 4.6 01407ef3f1 feat: update sort-tag labels to reflect Romaji / English support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:08:50 +02:00
HoneyandClaude Sonnet 4.6 bb927e8120 fix: continue lrclib attempts if first response has no lyrics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:01:33 +02:00
Honey 570eb803c2 Kanji → korrektes Romaji für Sort-Tags -> Deleted 2026-06-05 11:50:28 +02:00
HoneyandClaude Sonnet 4.6 89241a4fe6 fix: update romaji overrides hint text — accurate description with line break
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 10:48:35 +02:00
HoneyandClaude Sonnet 4.6 36a0b38eca feat: server-side romaji overrides (shared list); preload ExoPlayer from SharedPreferences on cold start
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 10:47:25 +02:00
HoneyandClaude Sonnet 4.6 b924f01b2a fix: ignore onQueueEnded before onReady fires — prevents stale end-of-song event from advancing queue on app reopen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 01:33:16 +02:00
HoneyandClaude Sonnet 4.6 21d55d8ab0 fix: suppress spurious onQueueEnded after reconnect; handle STATE_ENDED on reopen; add isEnded to snapshot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 01:03:19 +02:00
HoneyandClaude Sonnet 4.6 1277913ac1 fix: apply romaji overrides in dispArtist/dispAlbum/dispTitle display functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 00:50:06 +02:00
HoneyandClaude Sonnet 4.6 a8e54b3a41 feat: add --copy mode to reorganize-music.sh for safe migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 00:40:52 +02:00
HoneyandClaude Sonnet 4.6 59120fd7b6 feat: rename song file artist segment when applying romaji override fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 00:29:21 +02:00
HoneyandClaude Sonnet 4.6 ff3bb782d3 fix: romaji override delete button (JSON.stringify breaks HTML attr); fix file stem search for en-dash track prefixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 00:26:39 +02:00
HoneyandClaude Sonnet 4.6 7496c28663 fix: surface first error detail in fix-artist-sort job message for debugging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 00:17:48 +02:00
HoneyandClaude Sonnet 4.6 ac474b1760 fix: correct onReady sync logic for single-track model; remove stale _setSkipping call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 00:15:39 +02:00
HoneyandClaude Sonnet 4.6 3252706dc2 fix: replace non-existent MediaSession.Callback skip overrides with onPlayerCommandRequest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 00:07:14 +02:00
HoneyandClaude Sonnet 4.6 d539a43307 refactor: single-track ExoPlayer model — JS owns queue entirely
Root cause of all Android player bugs: two competing queue managers.
ExoPlayer held all tracks and auto-advanced with its own index while JS
tracked S.qIdx separately. seekToIndex() is async via runOnMain, so
skips would interleave with ExoPlayer's own advances — causing desync,
wrong-song display, and skips that silently did nothing.

Fix: ExoPlayer always holds exactly ONE MediaItem. JS is the single
source of truth for queue order, shuffle, and repeat.

JS changes (player.js):
- playQueue/playCurrent/nextSong/prevSong/toggleShuffle: replace
  setQueue(allTracks, idx) + seekToIndex with setQueue([single], 0)
- _pushQueueIfBridge: send only current track
- nativeInsertAt: no-op (radio songs land in S.queue; JS picks them up)
- fillRadioQueue: remove addToQueue native call
- onTrackChanged: simplified to just schedule scrobble + radio top-up
- onPlaybackStateChanged: remove index-reconciliation block
- onPositionUpdate: remove reconciliation tick (no queue drift possible)
- onQueueEnded: call onEnded() — JS drives what plays next
- toggleRepeat: setRepeatMode('one') only for repeat=one, else 'off'
- Add onNotificationNext / onNotificationPrev to NSPlayer
- Remove _skipping/_skipTimer/_reconTick/_setSkipping (all dead code)

Android changes:
- MusicService: add onSkipToNext/onSkipToPrevious to StateListener;
  add MediaSession.Callback to intercept notification skip buttons and
  delegate them to JS instead of letting ExoPlayer handle them
- MainActivity: implement new StateListener methods → callJs()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:57:57 +02:00
Honey 85d8e8eae7 fix: soften cover box-shadow in dynamic-color mode so it doesn't bleed on colorful bg 2026-06-04 23:15:52 +02:00
Honey 2012251e58 fix: hide np-bg layers with display:none in dynamic-color mode to remove GPU black boxes 2026-06-04 23:14:14 +02:00
Honey 491d58875c fix: ::before was covering dynamic cover color — use background:inherit 2026-06-04 23:10:52 +02:00
Honey 47d530a11d fix: prevent horizontal scroll in romaji overrides modal 2026-06-04 23:09:03 +02:00
Honey e5076f47d9 fix: clicking a specific song always starts at that position, bypasses shuffle 2026-06-04 23:06:58 +02:00
Honey 4e6f8782ca fix: artist page song clicks bypass shuffle — play exact song in order 2026-06-04 23:06:01 +02:00
Honey 64c107da0b refactor: redesign ABS settings row to match backend status style 2026-06-04 22:56:45 +02:00
Honey d94dadf309 refactor: move romaji overrides into dedicated modal, fix placeholders 2026-06-04 22:50:17 +02:00
Honey 963ef4cbb0 chore: move fix-artist-separators.sh into scripts/ 2026-06-04 22:38:53 +02:00
HoneyandClaude Sonnet 4.6 28428aa362 feat: add reorganize-music.sh to migrate genre folders to Artist/Album structure
Reads ffprobe metadata from every audio file and places it into:
  {dest}/{AlbumArtist}/{Album}/{NN - Artist - Title.ext}
Exactly mirrors the sanitize(), folder and filename rules in
backend/lib/ytdlp.js. Dry-run by default; --move to execute, --clean
to remove empty source dirs afterwards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:37:00 +02:00
HoneyandClaude Sonnet 4.6 d777301c20 feat: batch fix artist sort tags + rename folder when override is added
- edit-metadata: use albumArtistSort (when provided) for artist folder naming
  instead of auto-converted romajiAlbumArtist — ensures manual overrides sent
  by the download pipeline also place files in the correct folder
- Add POST /api/fix-artist-sort endpoint: searches Navidrome for all songs
  where artist or albumArtist matches the kanji, re-writes ARTISTSORT +
  ALBUMARTISTSORT via ffmpeg (-map_metadata 0 preserves all other existing
  tags), moves each file to the correct artist folder, cleans up empty old
  dirs, triggers Navidrome rescan — returns a job ID for progress tracking
- addRomajiOverrideUI: after saving a new override, calls fix-artist-sort and
  shows a job card with live progress

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:33:12 +02:00
HoneyandClaude Sonnet 4.6 0a2736e893 fix: build error in nextSong + apply romaji overrides to downloads
- Fix missing closing brace for `if (ni >= 0...)` in nextSong — the removed
  `else { _nsBridge('next') }` left the inner if-block unclosed, causing
  vite/rollup to reject player.js as invalid JS
- Apply romaji override map to iTunes (executeDownload) and Spotify
  (executeSpotifyDownload) download payloads — sends artistSort/albumArtistSort/
  titleSort/albumSort fields so the backend writes correct sort tags to the
  downloaded file and can organize into the right artist folder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:33:49 +02:00
HoneyandClaude Sonnet 4.6 8c3a23ad14 feat: romaji override list — manual kanji→romaji corrections for sort tags
Adds a maintained list in Settings where you can map kanji artist/album/title
strings to their correct romaji reading (e.g. 照井順政 → Terui Yoshimasa).
Overrides are applied last (highest priority) when the edit-metadata modal
opens, so saving the song writes the correct sort tags permanently to the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:17:13 +02:00
HoneyandClaude Sonnet 4.6 db7a515ee4 fix: stop ExoPlayer shuffle conflict causing end-of-queue desync
When reaching the last shuffled song, the UI changed but audio kept playing
the same song. Root cause: ExoPlayer still had its own shuffle mode on (set
in onReady), so its auto-advance and _nsBridge('next') picked random indices
that conflicted with the pre-shuffled JS array.

- setShuffleMode always false: we own the playback order via pre-shuffled
  JS array, ExoPlayer must advance sequentially through it
- Remove _nsBridge('next') fallback: never let ExoPlayer decide the next
  song autonomously. End of queue with no repeat = let ExoPlayer stop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:13:45 +02:00
HoneyandClaude Sonnet 4.6 ef40795a9c fix: ::before backdrop was intercepting all clicks in NP overlay
The pseudo-element added for the flicker fix had position:absolute;inset:0
with no pointer-events:none, so it sat over the entire overlay and consumed
every click — queue items, lyrics lines, buttons. Add pointer-events:none.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:11:52 +02:00
HoneyandClaude Sonnet 4.6 c21f3be696 fix: fetch real playlist song counts from getPlaylist in background
getPlaylists returns stale/wrong songCount from Navidrome's index.
After rendering the grid, fire one getPlaylist request per playlist
in parallel and update each card's subtitle as responses arrive.
Counts are visually corrected within a second of the page loading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:06:34 +02:00
HoneyandClaude Sonnet 4.6 1d5f4a620d fix: NP flicker, fully-random shuffle, playlist count backpatch
NP overlay flicker (stronger fix):
- Remove contain:layout style paint (causes layer issues on some Android WebView)
- Add ::before pseudo-element with solid background so no frame can be transparent
- Add -webkit-transform prefixes for both open and closed states

Shuffle fully random:
- playQueue no longer pins startIdx when shuffle is on
- All songs shuffled into a completely random order, S.qIdx=0 always
- toggleShuffle mid-queue still pins current song first (Spotify toggle behavior)

Playlist count wrong in overview:
- Navidrome's getPlaylists returns stale/wrong songCount in its index
- Add data-pl-id to each playlist card in loadPlaylists
- After loadPlaylistDetail gets the accurate entry count, backpatch the
  matching overview card's subtitle so the grid shows the real song count

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:03:54 +02:00
HoneyandClaude Sonnet 4.6 3c9a8e892a fix: prevent NP overlay from flickering through to content below on Android WebView
Replace will-change:transform with translate3d(0,100%,0) / translate3d(0,0,0).
will-change triggers a layer-creation/destruction cycle on WebView — when the
player bar DOM updates during a skip, the compositor can briefly expose the
layer's clear back buffer, flashing the content below through the overlay.
translate3d pins the element to a stable GPU layer from the start.
Also add contain:layout style paint to isolate the overlay's paint context
from external repaints, and -webkit-backface-visibility for older WebView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:56:28 +02:00
HoneyandClaude Sonnet 4.6 0552b3f5d0 fix: Spotify-style shuffle — fixed queue order, not random-each-skip
- playQueue: unified shuffle for both web and Android — pre-shuffles the
  songs array with the tapped song pinned first, saves _origQueue for restore.
  Passes S.qIdx (0 when shuffled) to NSBridge.setQueue instead of startIdx.
- _computeNextIdx: removed the random-pick shuffle branch entirely.
  Since the queue is already shuffled, sequential traversal IS the shuffle order.
- toggleShuffle: removed !HAS_BRIDGE guard so queue reorder runs on Android too.
  Re-sends reordered queue to ExoPlayer so native order stays in sync with JS.
  Removed setShuffleMode call (we manage order ourselves via pre-shuffled array).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:51:42 +02:00
HoneyandClaude Sonnet 4.6 1f1a5cd3a6 fix: startup speed, image retry, shuffle random start, genres, search badge, playlist refresh
- Remove await on fetchRomajiIndex() at startup so navigation is not blocked
  by the romaji index network call (was the main cause of 30s Android startup)
- Add window._imgRetry() with 3 retries (1.5s/3s/4.5s backoff) for all card/
  song/cover images that fail to load; falls back to emoji only after exhausting retries
- playQueue with HAS_BRIDGE + shuffle: pick a random startIdx so album/playlist
  playback starts at a random track, not always index 0
- Reduce mobile genre grid limit from 12 to 6
- Fix "Global Suche" badge text: was "iTunes Store + Local", now "iTunes Store"
- deleteSong: always refresh loadPlaylists() unconditionally (not just when
  page is active) with a 3s delay so song counts are current when navigating back

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:38:15 +02:00
HoneyandClaude Sonnet 4.6 6dc47a2c77 fix: suppress stale-snapshot NP flicker during skip window on Android
Guard both snapshot-reconciliation paths (_skipping window can return the
old index before ExoPlayer commits the new one):
- onPlaybackStateChanged: skip the safety-net snapshot read while _skipping
- onPositionUpdate: reset _reconTick (defer recon) while _skipping
- _setSkipping: always reset _reconTick on every new skip/seek

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 01:46:23 +02:00
HoneyandClaude Sonnet 4.6 63ff8920bf fix: repair Japanese song download pipeline
Five separate root causes fixed:

1. buildTitleKeywords (ytdlp.js): For Japanese titles \w strips all
   kana/kanji leaving no keywords. Now uses the first whitespace-
   delimited segment as a literal regex keyword so --match-filter
   title ~= requires the YTM video to contain the Japanese text.

2. Tokenizer wait (ytdlp.js): If kuromoji hasn't finished loading when
   a Japanese download starts, romajiForStorage returns the original
   Japanese text unchanged — producing Japanese folder/filenames and
   skipping sort tags. Now waits up to 30 s for the tokenizer.

3. titleOnlyMatch guard (metadata.js): iTunes titleOnlyMatch (no artist
   check) was used as a fallback for all searches. For Japanese
   titles/artists this matched wrong US songs with identical titles
   (common in anime OSTs). Now titleOnlyMatch is skipped when the
   search title or artist contains Japanese characters.

4. squid.js rawArtist: rawArtist was not in squid's signature and
   never forwarded to embedMetadata, so Qobuz-downloaded J-Pop tracks
   stored the iTunes-resolved English name as artist instead of the
   Japanese original. Fixed by accepting and forwarding rawArtist.

5. squid.js romaji paths: Folder and filename used raw artist/title
   with no romaji conversion, creating Japanese-named paths
   inconsistent with the yt-dlp path. Now uses romajiForStorage same
   as ytdlp.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 01:40:46 +02:00
HoneyandClaude Sonnet 4.6 52451f5086 fix: resolve Japanese-path songs stored as romaji files
When Navidrome has a Japanese path in its DB but the file was saved with
a romaji filename by the download pipeline, the existing fallback chain
compared the Japanese title stem against romaji filenames and always
failed. Added _findFileByStemOrRomaji which converts the stem via
romajiForStorage before the final recursive search, covering all three
endpoints: edit-metadata, delete-song, song-sorttags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 01:30:04 +02:00
HoneyandClaude Sonnet 4.6 07b8114cae fix: reconcile native state on app foreground; refresh playlist after delete
- Call reconcileNativeState() on visibilitychange→visible so the player UI
  updates immediately when the user opens the app after a background auto-advance,
  before deferred evaluateJavascript callbacks have had a chance to fire
- After deleteSong() succeeds, refresh the active playlist view (grid or detail)
  with a 2s delay to let Navidrome's background scan complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 01:01:56 +02:00
HoneyandClaude Sonnet 4.6 a2714dddaa feat: delete song from disk via edit-metadata modal
- /api/delete-song endpoint: resolves path via same fallback chain as
  edit-metadata, validates file is inside music root + is an audio file,
  deletes it, then prunes the album folder if empty and the artist folder
  if that too becomes empty — no recursive rm, depth strictly limited to
  two levels above the file so no accidental mass-deletes
- Safety: path.resolve() guard ensures no path-traversal outside music root;
  rmdirSync (non-recursive) fails if dir is non-empty as extra safety net
- Frontend: delete button (red outline, trash icon) at bottom of edit mode
  with confirmation dialog showing title + folder-cleanup warning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:36:31 +02:00
HoneyandClaude Sonnet 4.6 0788c801fa fix: eliminate NP overlay stale-song on autoplay and cover-load delay
Three improvements:
- openNowPlaying(): reads NSBridge.getState() (always-fresh @Volatile snapshot)
  via ID search to reconcile S.qIdx before render; fixes NP showing old song
  when deferred evaluateJavascript callbacks haven't fired yet after backgrounding
- onPositionUpdate(): periodic check (~every 2s) detects track mismatch via
  snapshot ID; catches missed/delayed onTrackChanged when NP is open
- updatePlayerUI(): pre-warms next song's 1024px cover so auto-advance NP
  swap is instant from browser cache instead of a 1-second load delay
- styles: add backface-visibility:hidden + isolation:isolate to .np-overlay
  to reduce GPU composite flicker on Android WebView on manual skip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 01:30:55 +02:00
HoneyandClaude Sonnet 4.6 050aff6dbe feat: prev-in-shuffle retraces actual play order via history stack
Added a play history stack (_playHist) that records S.qIdx before each
forward advance (manual next, auto-advance via onTrackChanged). prevSong
now pops from this stack instead of blindly doing S.qIdx--, so pressing
back during shuffle always returns to the song that was actually playing
before, regardless of where shuffle jumped. History clears on new queue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 01:10:06 +02:00
HoneyandClaude Sonnet 4.6 2d303083a0 fix: NP overlay still shows stale song on auto-advance
Two fixes:
1. Remove snapshot read from openNowPlaying() — the MediaController snapshot
   can show the old song at the old index (both still valid entries in the
   queue), so the ID check does not detect staleness; after the onTrackChanged
   fix S.qIdx is always authoritative so there is no need to read the snapshot
2. Call syncNowPlaying() explicitly in onTrackChanged so the NP overlay is
   refreshed immediately when the song changes while it is already open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 01:04:16 +02:00
HoneyandClaude Sonnet 4.6 ba28518d8a fix: NP overlay shows stale song on Android auto-advance
openNowPlaying() was blindly trusting the MediaController snapshot index,
which can lag one track behind ExoPlayer on auto-advance. This overwrote
the already-correct S.qIdx that onTrackChanged had just set, causing the
NP overlay to render the previous song while the mini-player showed the
right one. Now only accept the snapshot index when the song ID at that
position matches — stale snapshots are silently ignored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:41:01 +02:00
HoneyandClaude Sonnet 4.6 9ab3dab508 fix: solid cover-color NP background via canvas extraction
Replace saturated blur filter with actual dominant-color extraction:
fetch cover as blob (avoids canvas CORS taint), draw on 24x24 canvas,
find most vivid pixel, darken to ~28% for a moody solid background.
Blurred bg layers hidden when mode is active. Transitions smoothly
between songs via CSS transition on background.
Renamed setting label to "Einfarbiger Player-Hintergrund".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:36:32 +02:00
HoneyandClaude Sonnet 4.6 20545f71be chore: add Android project files to version control
Adds build config, Gradle wrapper, resources, and gitignore.
Kotlin sources, manifests and app build.gradle were already tracked.
Excludes build output, .gradle cache, .idea, local.properties, and release APK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:34:40 +02:00
HoneyandClaude Sonnet 4.6 9d81cdde31 feat: dynamic NP cover color, taller settings modal, fix Android song sync
- Now Playing: add "Dynamische Hintergrundfarbe" toggle in Theme settings;
  when on, np-bg uses higher saturation/brightness so the cover color really
  shows through the blurred background
- Settings modal: extend to bottom nav on mobile (was leaving ~74px dead space
  for the player bar which the overlay already covers at z-index 200)
- Android: fix onTrackChanged always staying on the first song — the snapshot
  from MediaController can lag behind ExoPlayer on auto-advance, causing the
  stale currentIndex to overwrite the correct p.index; now only advance to
  a higher live index (for rapid-skip catch-up) and use p.trackId directly
  instead of the potentially-stale snapshot id for the queue-mismatch guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:28:06 +02:00
HoneyandClaude Sonnet 4.6 c405465f5c fix: wire _findFileByStem into both file resolution fallback chains
Handles "File not found" for songs with non-standard folder/artist names
(e.g. artist separator • or year-only album folders) by doing a deep
recursive title-stem search as a last resort after rebase and dir scan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:17:41 +02:00
HoneyandClaude Sonnet 4.6 8989b15767 fix: pass id-based iTunes lookup requests through to proxy
/api/itunes/lookup was returning 400 for any request without artist+title,
including id-based lookups (?id=...&entity=album) used by the artist page.
Use next() to fall through to the /:endpoint proxy for those requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:07:12 +02:00
HoneyandClaude Sonnet 4.6 2df5a54985 fix: prevent 9-second downloads caused by short iTunes duration match
When iTunes enrichment returns a jingle/preview as the first result (e.g.
trackTimeMillis=9000), the yt-dlp duration filter becomes impossible
(duration > 30 & duration < 24), all filtered steps fail, and the final
unfiltered fallback downloads whatever YouTube returns first — often a
short clip.

Fix 1: spotify.js — only copy iTunes trackTimeMillis to durationMs when
it is > 30 seconds (genuine song, not a jingle or preview).

Fix 2: ytdlp.js — only enter the duration-filtered search branch when
durationMs > 30 000ms, so a corrupted short value silently falls back to
the unfiltered search path which still finds the correct full song.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:06:33 +02:00
HoneyandClaude Sonnet 4.6 3db71c24a4 fix: restore playlist membership after metadata edit renames/moves file
When a file is renamed or moved to a new artist/album folder during
metadata edit, Navidrome assigns a new song ID, breaking all playlist
entries that referenced the old ID.

Before the edit: collect which playlists contain the song.
After rename + rescan: poll Navidrome until the new song appears,
then update each affected playlist (add new ID, remove old index).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:01:50 +02:00
HoneyandClaude Sonnet 4.6 fce53e409d fix: find renamed files by searching original Navidrome directory
When Navidrome has a stale index (e.g. file was renamed from
'01 - Idea 10.mp3' to '01 - Gibran Alcocer - Idea 10.mp3'),
_findFileInDir now falls back to the original file's directory
and matches by track-number prefix ('01 - ') so the renamed file
is found even without a Navidrome rescan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:44:58 +02:00
HoneyandClaude Sonnet 4.6 6fa9751fb6 fix: log music root in file-not-found error for diagnosis
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:14:46 +02:00
HoneyandClaude Sonnet 4.6 4f83055edd fix: also write totalTracks, discNum, discTotal for Spotify imports
Spotify enrichment only copies album/trackNum/duration from iTunes.
Now also fetch totalTracks, discNum, discTotal from iTunes for:
- alreadyEnriched playlist tracks
- single track downloads where album came from Spotify

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:00:25 +02:00
HoneyandClaude Sonnet 4.6 78024181f0 fix: always write English genre for all download paths
1. JP genre mapping table in metadata.js — when only JP iTunes has the song
   (niche Japanese artists), map localized JP genre names (アニメ, ポップ, etc.)
   to English equivalents so files never get Japanese characters in genre tag.

2. Spotify playlist import (alreadyEnriched path) — was skipping iTunes lookup
   entirely, leaving genre empty. Now does a lightweight genre-only lookup.

3. Single track download with Spotify album — was skipping iTunes when album
   already known from Spotify. Now also fetches genre from iTunes when missing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 22:58:08 +02:00
HoneyandClaude Sonnet 4.6 2ae439bfd2 fix: resolve Navidrome path mismatch across Docker containers for metadata edit
When Navidrome and the backend run in separate containers, Navidrome returns
absolute paths rooted at its own mount (e.g. /opt/navidrome/music) which
don't exist in the backend container (mounted at /music).

Add three-stage fallback:
1. _rebasePath: strip leading path components and try under CONFIG.musicPath
2. _findFileRecursive: exact filename search under CONFIG.musicPath
3. _findFileInDir: fuzzy search in album dir by track-number prefix or title stem
   (handles files renamed to include artist in the filename)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:48:05 +02:00
HoneyandClaude Sonnet 4.6 760d8d5e1f fix: use US iTunes genre to avoid Japanese-localized genre names
JP store returns localized genres (e.g. イージーリスニング instead of Easy Listening).
Fetch JP and US in parallel, use JP match for title/album/artwork but always
take genre from US result so genre is always in English/Western characters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:14:43 +02:00
HoneyandClaude Sonnet 4.6 67f4be272a fix: ie-totaltracks element id + JP-strict/US-fallback iTunes matching
Prevent false Japanese matches by never using JP title-only fallback.
Strategy: JP strict → US strict → US title-only so niche Western tracks
still resolve but same-English-title Japanese songs don't pollute results.

Also fix JS error in autofillItunes: ie-total → ie-totaltracks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:15:04 +02:00
Honey 21159ad2c5 fix: iTunes route order (lookup before proxy); stable cover art URLs for browser caching 2026-05-27 18:01:09 +02:00
Honey 09cec18b33 fix: strip punctuation in normalizeTitle so comma variants match (e.g. 'Mr. Lawrence' vs 'Mr, Lawrence') 2026-05-27 17:49:31 +02:00
Honey 9821334cc8 fix: split multi-artist string before iTunes search query in lookupItunesTrackMeta 2026-05-27 17:40:14 +02:00
Honey 3837783225 fix: iTunes JP-first with US fallback; romaji filter in playlist/starred search 2026-05-27 17:16:57 +02:00
Honey a9463917e3 fix: use iTunes country=jp globally — returns Japanese original names, identical for non-Japanese music 2026-05-27 15:21:11 +02:00
Honey 8f14d013b3 fix: skip iTunes album/duration enrichment for Japanese Spotify tracks to prevent wrong-song downloads 2026-05-27 15:15:45 +02:00
Honey b5ffb1e73d fix: non-blocking romaji fetch in artist detail — prevent blank page on slow index load 2026-05-27 15:08:11 +02:00
Honey 27acede41d fix: fetch romaji index in loadArtistDetail before dispArtistById 2026-05-27 14:02:42 +02:00
HoneyandClaude Sonnet 4.6 c8ccb80f2f fix: header search overlay clipping and mobile fit
- Change overlay from position:absolute to position:fixed so it isn't
  clipped by main-content's overflow-y:auto
- Set overlay top dynamically from header.offsetHeight so it always
  anchors exactly below the sticky header
- Responsive max-height: accounts for player bar + bottom nav on mobile
- Hide logo text on ≤359px screens to keep search pill wide enough

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:04:34 +02:00
HoneyandClaude Sonnet 4.6 36f968881b feat: persistent inline header search with overlay results
Replaces the hidden toggle-based mobile search bar with a permanently
visible pill-shaped search input in the header row, next to the logo.

- Search is always visible — no button click required
- Typing shows a floating overlay with library results (artists, albums,
  songs) without navigating away from the current page
- Clearing the input or navigating to any page closes the overlay silently
- The ✕ clear button appears only while text is present
- navigate() now calls closeHeaderSearch() so results disappear cleanly
  when tapping any result or bottom nav tab
- Old toggleMobileSearch / closeMobileSearch kept as no-op stubs for safety

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 12:55:26 +02:00
HoneyandClaude Sonnet 4.6 b7afa19f70 feat: romaji artist names, Apple Music autofill, romaji index invalidation
- Artist grid and detail header now show romaji names when toggle is on.
  Builds a Map<artistId, artistRomaji> from the existing song romaji index
  so no extra API call is needed.

- Apple Music autofill button added to edit modal next to MusicBrainz.
  Reuses the existing lookupItunesTrackMeta backend function via a new
  GET /api/itunes/lookup endpoint — fills album, year, genre, track#,
  and cover art.

- Romaji index is invalidated after every successful download so newly
  added songs appear with correct sort tags without requiring a page reload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 12:26:51 +02:00
HoneyandClaude Sonnet 4.6 212491aa89 fix: convert katakana proper nouns (e.g. ヨルシカ) to romaji in sort tags
When kuromoji marks a katakana token as reading '*' (unknown word — common
for band names like ヨルシカ), the code fell back to the surface_form which
is still katakana. _hiraToRomaji can't handle katakana so it passed through
unchanged, causing sort tags to show ヨルシカ instead of Yorushika.

Fix: if the surface_form contains katakana and reading is *, convert it
mechanically via _kataToHira + _hiraToRomaji — katakana-to-romaji is always
unambiguous and needs no dictionary.

Also add a Japanese-char guard in the frontend when applying romaji index
values, so stale cache entries can never overwrite correct values with Japanese.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:52:35 +02:00
HoneyandClaude Sonnet 4.6 e6d85e63a8 fix: clear sort tag fields synchronously before async fill
Sort tag fields were not reset when opening a new song's edit modal,
so values from the previously opened song persisted until the async
romaji index fetch completed (or indefinitely if it found no entry).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:40:16 +02:00
HoneyandClaude Sonnet 4.6 fb605b0b31 fix: ignore Japanese sort tags from file in edit modal
File sort tags containing Japanese characters are incorrect (sort tags
should be romaji/ASCII). Ignore them and keep the romaji index value
instead so the edit modal always shows the correct romaji pre-fill.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:39:49 +02:00
HoneyandClaude Sonnet 4.6 c73665af58 fix: load sort tags regardless of romaji toggle state
romajiLookup returned null when the index wasn't loaded (only loaded when
showRomaji is ON). Now fetchRomajiIndex is called on-demand in the edit modal
so sort tags pre-fill from the index works even when the toggle is OFF.
Actual file tags from /api/song-sorttags still overwrite as the final step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:14:51 +02:00
HoneyandClaude Sonnet 4.6 255ea1969c feat: load actual file sort tags in edit modal + remove settings hints
- backend: new GET /api/song-sorttags reads TITLESORT/ARTISTSORT/ALBUMARTISTSORT/
  ALBUMSORT directly from the file via ffprobe
- ui.js: pre-fill sort fields from romaji index, then overwrite with real file
  tags from the backend (shows actual values even if manually set)
- index.html: remove redundant hint texts in settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:42:34 +02:00
HoneyandClaude Sonnet 4.6 5dda1a7101 fix: iTunes Japanese artist lookup + sort tag editing in info modal
- views.js: skip ASCII word-match when artist name is non-ASCII so Japanese
  artists (e.g. ヨルシカ) resolve correctly to an iTunes artist ID
- index.html + styles.css: collapsible Sort-Tags section in edit modal
- ui.js: pre-fill sort fields from romaji index; include in save body
- novasonic-server.js: accept manual titleSort/artistSort/albumArtistSort/albumSort
  overrides; fall back to auto-computed romaji when not provided

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:49:47 +02:00
HoneyandClaude Sonnet 4.6 1c0d3eb4cc feat: remove Künstler sort pill from albums tab
Artists have their own dedicated tab so sorting albums by artist is redundant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:16:25 +02:00
HoneyandClaude Sonnet 4.6 5130ae24a9 feat: remove artist subtitle from album cards
Artist has its own tab so the redundant name below each album card isn't needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:15:01 +02:00
Honey e67ccaf52d Changed Settings beschreibung 2026-05-25 19:13:15 +02:00
HoneyandClaude Sonnet 4.6 6a28ba786c fix: fetch romaji index on startup when showRomaji is enabled
On page reload the saved cfg restored showRomaji=true but _romajiMap was
never fetched, so all display helpers fell back to Japanese text. Await
fetchRomajiIndex before the first navigate when the setting is on.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:12:27 +02:00
HoneyandClaude Sonnet 4.6 40efa207bb fix: move albumQuerySuffix before ytmQuery to resolve temporal dead zone crash
albumQuerySuffix and related consts (escRe, JP_CHARS, variantFilter, titleKws,
artistChannelPart) were declared after the ytmQuery/query lines that reference them,
causing ReferenceError on every download. Moves the block to before its first use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:20:15 +02:00
HoneyandClaude Sonnet 4.6 1812500523 fix: YTM search for Japanese tracks - channel filter + album query context
- Skip channel filter when artistFirstWord is non-ASCII (Japanese kanji/kana):
  YouTube channels are named in romaji/English, not kanji — the filter was
  rejecting all valid results and causing 6+ extra cascade steps
- Add albumQuerySuffix: when title is Japanese (no keyword match) OR has only
  one romaji keyword, include the album's first two English words in the search
  query so YTM ranks the official OST/album version above fan covers

Fixes: 照井順政 channel filter rejecting all YTM results
Fixes: "Souiunatsu" matching covers with romaji subtitle instead of official OST

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 16:12:27 +02:00
HoneyandClaude Sonnet 4.6 1aff19e319 feat: keep Japanese in main tags, romaji in sort tags + frontend toggle
- Downloads now store original Japanese in TITLE/ARTIST/ALBUM tags
  so Apple Music, iTunes, YTM etc. all see the authentic original text
- Sort tags (TITLESORT/ARTISTSORT/ALBUMSORT) written with romaji for
  apps that use them for sorting/display
- iTunes-resolved English artist name used for ARTISTSORT and folder names
  when available (more accurate than kuromoji for known artists)
- rawArtist parameter added to downloadTrack so YTM search query uses
  the original Japanese channel name (fixes wrong song downloads)
- spotify.js: artistName preserved as original; itunesArtistName stored
  separately so the Spotify importer shows Japanese titles
- romaji index now includes titleRomaji/artistRomaji/albumRomaji fields
- Frontend: showRomaji setting (Settings > Bibliothek) — when on, player
  bar, now-playing, queue, and song lists all show romaji via index lookup
- refreshDisplayMode() updates all live UI when the toggle is saved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:58:53 +02:00
HoneyandClaude Sonnet 4.6 f1eabf7099 fix: playlist matching for romaji-stored Japanese tracks + punctuation cleanup
- Clean Japanese punctuation (、。・) in romajiForStorage so it doesn't pass through
- Add POST /api/romaji-convert endpoint for batch title conversion
- Add romajiConvert() to api.js
- Add third fallback in _createNdPlaylist: convert Japanese title to romaji and
  search Navidrome with it (for songs already downloaded with romaji tags)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 01:20:37 +02:00
HoneyandClaude Sonnet 4.6 c7bebd5691 fix: correct artist names and prevent wrong song downloads for Japanese tracks
- spotify.js: stop overwriting trackName with iTunes English title —
  yt-dlp must search with original Japanese title to find the right song
  (was downloading English covers e.g. Ghost In A Flower instead of 幽霊花)
- metadata.js: add title-only fallback in lookupItunesTrackMeta so tracks
  are found even when kuromoji gives wrong artist romanization (e.g.
  Terui Jun Sei vs iTunes Yoshimasa Terui); also return itunesArtistName
- novasonic-server.js: use itunesArtistName from iTunes lookup as the
  stored artist when available (single-track and playlist download paths)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:39:26 +02:00
HoneyandClaude Sonnet 4.6 392da7e021 fix: romaji for album names + Japanese playlist track matching
- ytdlp.js + novasonic-server.js: apply romajiForStorage to album tag
  and folder/rename paths (was only applied to artist/title before)
- ui.js: _createNdPlaylist falls back to romaji index for Japanese titles
  since Navidrome search3 doesn't index Japanese text properly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:36:16 +02:00
HoneyandClaude Sonnet 4.6 88533b8835 fix: verify iTunes artist name matches query before using result
iTunes ranks by popularity, so searching "WRLD Tunes" returned Juice WRLD
first. Now fetches 5 results and picks the first where all query words
appear in the iTunes artist name, preventing false-positive matches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:36:46 +02:00
HoneyandClaude Sonnet 4.6 36b09856ed fix: use iTunes names instead of kuromoji for Spotify Japanese tracks
Search iTunes with original Japanese names (accurate) rather than
pre-converting with kuromoji (inaccurate for uncommon names/readings).
iTunes returns the correct English artist name and title directly.
Also run iTunes enrichment for single-track Spotify imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:13:34 +02:00
HoneyandClaude Sonnet 4.6 446c583f7f feat: normalize Japanese artist/title names to romaji at storage time
- Add romajiForStorage() to romaji.js — smart word spacing + Title Case
- ytdlp.js: apply to folder names, filenames, and embedded tags
- novasonic-server.js: apply in edit-metadata endpoint and Spotify resolve
- spotify.js: update track artist/name from iTunes canonical English names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:22:22 +02:00
HoneyandClaude Sonnet 4.6 c03463b66b feat: move now-playing star inline with title
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:17:54 +02:00
HoneyandClaude Sonnet 4.6 b63d93114a feat: show track number in iTunes album track list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:46:30 +02:00
HoneyandClaude Sonnet 4.6 5d7ef60159 docs: update artist separator list in README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:32:53 +02:00
HoneyandClaude Sonnet 4.6 1f13374438 fix: remove & as artist separator
Separators: • · ; , only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:32:19 +02:00
HoneyandClaude Sonnet 4.6 f03c6dfabd fix: remove space and / as artist separators, keep &
Separators: • · ; , & — space and / removed to avoid breaking multi-word artist names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:31:52 +02:00
HoneyandClaude Sonnet 4.6 e5bb2bf2d3 feat: accept space, &, / as artist separators in metadata editor
- Space as fallback separator on save when no explicit separator present
  (Artist1 Artist2 → Artist1;Artist2)
- & and / added to explicit separator set everywhere
- navidromeArtist (download path) also handles /

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:30:18 +02:00
Honey 8a60ab2b98 Revert "feat: accept & and / as artist separators in metadata editor"
This reverts commit 64232a39fe.
2026-05-24 12:25:15 +02:00
HoneyandClaude Sonnet 4.6 64232a39fe feat: accept & and / as artist separators in metadata editor
_fmtArtists and _normalizeArtists now split on & and / in addition to
• · ; , — so "Arriez & King Dif" typed in the field normalizes correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:24:47 +02:00
HoneyandClaude Sonnet 4.6 0823c1ca86 fix: split on & when deriving artist folder name
"Arriez & King Dif" was used as-is for the folder; now splits on , ; &
so only the first artist is used: "Arriez".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 23:45:40 +02:00
HoneyandClaude Sonnet 4.6 11a31f85aa fix: populate albumArtist in metadata editor via getAlbum fallback
getSong often omits albumArtist; getAlbum always returns album.artist.
Enrich the song object before showing the edit form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:50:22 +02:00
HoneyandClaude Sonnet 4.6 c6990cfd7b feat: remove album from song subtitle; add Go to Album in context menu
Album name no longer shown inline next to the artist — navigate via
right-click > Zum Album instead, available everywhere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:47:43 +02:00
HoneyandClaude Sonnet 4.6 5093ce1f9d docs: update README — importer sources, metadata editor, file conventions, project structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:45:14 +02:00
HoneyandClaude Sonnet 4.6 4842c89c4e feat: artist separator normalization + separator hints in metadata editor
- Normalize •, ·, ;, , separators on save (both edit and download paths)
- Fix ndSong scope bug (was inaccessible outside try block in edit-metadata)
- Apply _normalizeArtists to artist/albumArtist before ffmpeg tags and file rename
- Storage format: Artist A;Artist B (no space, Navidrome/MusicBrainz style)
- Display format: Artist A; Artist B (space, via _fmtArtists in frontend)
- Show separator hint below label in metadata editor fields
- Remove hover effect on player bar song title

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:43:51 +02:00
HoneyandClaude Sonnet 4.6 7ba5b8ce9a feat: artist in filename, hide album=title subtitle, remove title hover
Naming convention (download + edit-metadata):
  Before: {01 - }Title.ext
  After:  {01 - }Artist1, Artist2 - Title.ext
- ytdlp.js: builds fileBase with artist prefix; findExistingTrack checks
  both new and old format so existing files aren't re-downloaded
- metadata.js: findExistingTrack accepts optional safeArtist param
- novasonic-server.js: file rename on title edit now includes artist
  (falls back to Navidrome's stored artist if not provided in body)

Song list subtitle: hide album name when album === title (singles where
the album is named after the track appear to duplicate the title)

Player bar: removed .player-title:hover underline/colour effect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:33:35 +02:00
HoneyandClaude Sonnet 4.6 2dd3d7c7e5 fix: refresh edit form fields when getSong returns after edit mode opened
When the user opens the edit modal before the getSong async call returns,
the form fields are populated from the initial song object (from the song
list) which may omit albumArtist and other fields that only getSong
returns. Now, when getSong resolves and edit mode is already visible, all
form fields are updated to the authoritative Navidrome data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:28:28 +02:00
HoneyandClaude Sonnet 4.6 5c69a8137c feat: rename file and move to correct folder on metadata edit
After saving metadata with ffmpeg:
1. Title change → rename the mp3 in place to "{01 - }?{sanitized title}.ext"
   (same convention as the downloader uses)
2. Album-artist or album change → compute new {artistFirstPart}/{album}
   directory, move the file there (create dirs as needed)
3. Old album dir is deleted if empty after the move; old artist dir
   is deleted too if it also becomes empty

When only the album name changes (no artist change) the old in-place
folder-rename behaviour is preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:12:50 +02:00
HoneyandClaude Sonnet 4.6 17414ca888 fix: iTunes artist match and MusicBrainz first-artist-only query
iTunes: split artist by [\s,;/] not just space so "VXLLAIN, iGRES"
yields first word "vxllain" (no trailing comma) and matches correctly.

MusicBrainz: extract first artist from the full credit string (split on
,;&•) and use `artistname` field — MB stores credits like "VXLLAIN feat.
iGRES & ENXK" which never matched the comma-joined full string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:00:58 +02:00
HoneyandClaude Sonnet 4.6 ae48ed4129 feat: YouTube/YT Music importer + album artist in view mode
- Info modal view now shows Album-Künstler row from Navidrome albumArtist
- Song Importer detects youtube.com / youtu.be URLs (red badge)
- New /api/youtube/resolve endpoint: yt-dlp --dump-json for single
  videos and playlists (up to 50 entries), enriched with iTunes lookup
  for proper album/year/genre/cover on single tracks
- New src/youtube.js renders preview card and delegates download to
  executeSpotifyDownload (which already handles sourceUrl → direct yt-dlp)
- Importer hint updated to list YouTube as a supported source

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:55:37 +02:00
HoneyandClaude Sonnet 4.6 3df1990d41 feat: add album artist field to metadata editor
Adds an editable Album-Künstler input to the info modal. Populated from
Navidrome's albumArtist field on open; saved as album_artist tag via
ffmpeg. Explicit value takes precedence; falls back to deriving from
artist (first semicolon segment) when artist is changed without an
explicit albumArtist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:50:45 +02:00
HoneyandClaude Sonnet 4.6 4da1fb25e2 fix: OG scrape fills missing artist even when JSON-LD found track name
JSON-LD could set track_name but return empty byArtist, making the OG
block (guarded by `if not track_name`) never run — leaving artist blank.
Now OG runs whenever either name or artist is missing, and only fills
fields that are still empty so JSON-LD name isn't overwritten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:46:50 +02:00
HoneyandClaude Sonnet 4.6 c052d6e913 fix: remove click-outside-to-close on info/metadata modal
The backdrop click handler was too easy to trigger accidentally,
especially when editing fields. Close button handles dismissal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:36:18 +02:00
HoneyandClaude Sonnet 4.6 e05daf3824 remove: lyrics downloading (fetchLyrics, .lrc files, uslt tag embedding)
Removes LRCLIB lookup, .lrc sidecar file writing, and lyrics/uslt
metadata embedding from all downloads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:34:59 +02:00
HoneyandClaude Sonnet 4.6 52ba73506f fix: SC album defaults to title, filter [Unknown Album] placeholder, fallback file search
- SC resolve: strip yt-dlp placeholder albums (starting with '['), use track title as album for singles
- edit-metadata: if Navidrome path stale, search music root recursively for file by name
- Both single and playlist SC track handling updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:31:04 +02:00
HoneyandClaude Sonnet 4.6 98ad8ed579 fix: OG scrape fallback — parse JSON-LD first, fix artist field index
JSON-LD byArtist gives the correct artist list. As secondary fallback,
OG desc format is 'Artist · Song · Year' not 'Song · Artist · Album',
so use desc_parts[0] not desc_parts[1] for artist.
Also handle both attribute orderings in meta tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:27:54 +02:00
HoneyandClaude Sonnet 4.6 d785a4c09a fix: SC metadata fields, albumartist & separator, importer clear, folder rename on album edit
- SC tracks: use artworkUrl/album/genre fields (not iTunes artworkUrl100/collectionName/primaryGenreName)
- Playlist handler: same SC field fallbacks for artwork, album, genre, year
- ytdlp.js: convert & to , in album_artist tag so Navidrome albumartist.Split works
- Importer: clear both previews on any URL input change, not just on clear
- edit-metadata: rename album folder on disk when album name changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:19:42 +02:00
HoneyandClaude Sonnet 4.6 62fefbb5e7 fix: import scDlSingle and scDlTrack in main.js to prevent runtime ReferenceError
Missing imports caused Object.assign(window,...) to throw before navigate
was ever assigned, breaking all onclick nav buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:56:16 +02:00
HoneyandClaude Sonnet 4.6 0e8eb311af fix: circular import causing navigate undefined; unified importer input
- Remove top-level ui.js import from soundcloud.js (broke views.js→soundcloud.js→ui.js→views.js cycle)
- Use window.executeSpotifyDownload() instead to avoid the circular dep
- Merge Spotify + SoundCloud into one unified URL input that auto-detects
  the service and colors the badge/button accordingly
- resolveImporterLink() routes to the correct handler based on URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:39:21 +02:00
HoneyandClaude Sonnet 4.6 ee65183555 feat: SoundCloud downloader + rename page to Song Importer
- New src/soundcloud.js: resolves SoundCloud URLs via yt-dlp --dump-single-json,
  renders track/playlist preview with per-track download buttons
- Backend POST /api/soundcloud/resolve: extracts metadata (title, artist, genre,
  artwork, duration) directly from SoundCloud — no iTunes lookup needed
- ytdlp.js: sourceUrl param — if set, downloads directly from URL instead of
  ytsearch cascade (used for SoundCloud tracks)
- Spotify importer page renamed to Song Importer, now contains both
  Spotify and SoundCloud sections in one view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:35:48 +02:00
HoneyandClaude Sonnet 4.6 e4cdbe44e7 fix: preserve existing cover art when editing metadata without new image
-map 0:v? copies the embedded cover stream from the original file when
no new artwork is supplied, preventing it from being silently stripped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:28:04 +02:00
HoneyandClaude Sonnet 4.6 c9fe1da1b0 fix: raise JSON body limit to 10mb for cover art upload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:00:48 +02:00
HoneyandClaude Sonnet 4.6 5559857c15 feat: metadata editor with MusicBrainz autofill + cover art upload
- Info modal gets an edit pencil button that switches to edit form
- Editable fields: title, artist, album, genre, year, track#, disc#, total tracks
- Cover art: upload your own image or auto-fetch from Cover Art Archive via MusicBrainz
- MusicBrainz button queries /api/musicbrainz/lookup and autofills all fields
- Save POSTs to /api/edit-metadata which rewrites tags via ffmpeg and triggers Navidrome rescan
- Backend: GET /api/musicbrainz/lookup + POST /api/edit-metadata endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:22:11 +02:00
HoneyandClaude Sonnet 4.6 1a44f8bd73 feat: show newest albums first on home screen, recently played below
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:56:21 +02:00
HoneyandClaude Sonnet 4.6 70fd299637 fix: script splits on / (no spaces needed), exceptions for Au/Ra & HUNTR/X
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:03:30 +02:00
HoneyandClaude Sonnet 4.6 3a8b879abf feat: use ; as artist separator in downloads, add & handling
navidromeArtist() now writes ; instead of / so Navidrome splits
correctly with the updated Tags.Artist.Split config.
Also handles & (e.g. "Artist A & Artist B" → "Artist A;Artist B").

Add fix-artist-separators.sh — one-time Debian script to update
existing library files from " / " to ";" in artist tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:26:19 +02:00
HoneyandClaude Sonnet 4.6 a6e6af0333 fix: use parseArtists() on album detail page — stops Au/Ra splitting on /
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:13:02 +02:00
HoneyandClaude Sonnet 4.6 467ee8a0fe fix: consistent artist parsing across player, NP overlay, and song list
Extract parseArtists() helper — uses Navidrome's resolved artists array
when it has multiple entries, otherwise splits artist string on ';' only.
Removes old split on / that broke Au/Ra, HUNTR/X etc everywhere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:08:33 +02:00
HoneyandClaude Sonnet 4.6 7b4e221881 fix: use Navidrome's resolved artists array instead of splitting on /
Manual split on / broke artist names like HUNTR/X and Au/Ra.
Now uses s.artists (OpenSubsonic array with correct IDs per artist)
and falls back to s.artist as a single unsplit link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:54:30 +02:00
HoneyandClaude Sonnet 4.6 4f83eebe79 feat: sort genre pills by total play count via backend
Page loads instantly with song-count order, then silently re-sorts
once the backend aggregates play counts across all songs. Result cached
20 min server-side to avoid repeated full-library fetches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 12:02:25 +02:00
HoneyandClaude Sonnet 4.6 790f02e5b4 feat: show 24 genre pills on desktop, 12 on mobile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 11:54:31 +02:00
HoneyandClaude Sonnet 4.6 6894e8a94a feat: Spotify-style shuffle — pre-shuffle queue, each song plays once
Shuffle now shuffles the queue array once (current song first, rest
randomised behind it) instead of picking a random index on every skip.
Toggling shuffle off restores the original queue order. Removed the
'once' repeat state added in the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:31:50 +02:00
HoneyandClaude Sonnet 4.6 a8f56a9bc6 fix: genre shuffle/play no longer navigates away from genre page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:48:14 +02:00
HoneyandClaude Sonnet 4.6 89aeeb7421 fix: radio fill lock prevents concurrent fills; random fallback when similar songs exhausted
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:15:55 +02:00
HoneyandClaude Sonnet 4.6 b9a5c6468f refactor: dedup buildTrackList, extract topsong row helper, split romaji to lib
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:36:06 +02:00
HoneyandClaude Sonnet 4.6 84f1fd006d docs: update README with hero card, genre browser, scrobbling, play count, queue fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:29:36 +02:00
HoneyandClaude Sonnet 4.6 79b878c546 fix: stay on queue tab when selecting song from NP queue view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:27:53 +02:00
HoneyandClaude Sonnet 4.6 da185e1d96 fix: hero label shows source (Meistgespielt/Zuletzt gehört); play count column always visible when enabled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 09:53:43 +02:00
HoneyandClaude Sonnet 4.6 3128b06b24 fix: 15s scrobble, play count clarity, star always visible, all-songs actions
- Scrobble timer: 12s → 15s
- Play count moved after action buttons, shown as "23×" (distinct from duration)
- Song star/menu buttons always visible (removed opacity:0 hover-only rule)
- Artist all-songs page: added star + 3-dots to every row (same as top songs)
- Hero card: picks from full recent+frequent pool (was capped at first 5 = all frequent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 09:47:06 +02:00
HoneyandClaude Sonnet 4.6 7e07fef7ba feat: timer scrobble (12s), artist topsong star+menu, play count toggle
Scrobbling:
- Replace end-of-track scrobble with 12s timer approach
- Android: schedule on onTrackChanged, cancel on pause
- Web: schedule in playCurrent, cancel in togglePlay pause + onEnded
- Skip/new track resets the timer automatically (new song ID)

Artist top songs:
- Add star button and 3-dots context menu to every top song row
- Topsong grid expanded to 5 columns to fit action buttons

Settings — play count toggle:
- New "Bibliothek" section with toggle switch
- When on: shows play count in album/playlist song rows
- Artist page always shows play count regardless of toggle
- Persisted in ns_cfg localStorage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 09:35:26 +02:00
HoneyandClaude Sonnet 4.6 f79817410b feat: scrobbling, genre grid redesign with detail view + play button
- Scrobble to Navidrome on auto-advance (Android) and track end (web)
  so type=recent and type=frequent are accurate for the hero card
- Genre section: pill chips → grid cards with colored left bars
- Top 15 genres shown by default (sorted by song count), expand toggle
- Genre card: click → genre detail page with full song list
- Genre card: hover play button → shuffle-play without navigating
- Genre detail page reuses album-detail layout (back btn, stats, song list)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 09:18:56 +02:00
HoneyandClaude Sonnet 4.6 d3a1bf6a82 feat: home hero+genres, fix Android queue desync via song ID verification
Home view:
- Add hero card (random pick from recent+frequent combined pool)
- Add genre browser chips (sorted by song count, tap = shuffle-play)

Android sync fix:
- Add currentId to getState() snapshot so JS can verify song identity
- onReady: reset stale localStorage queue when native song ID mismatches
- onTrackChanged: reject stale old-queue callbacks by ID verification
- onPlaybackStateChanged safety net: ID-guard prevents wrong-queue index corruption
- connectToService: call updateStateSnapshot() before onReady so getState() is never empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 09:06:47 +02:00
HoneyandClaude Sonnet 4.6 377a6954e3 fix: radio mode uses getSimilarSongs2 for artist/genre-aware playback
- Dedup now checks only the last 50 queued songs instead of the entire
  queue, so the radio doesn't stall on small libraries once every similar
  song has been seen
- If every candidate is a recent duplicate (tiny library), allow up to 5
  repeats rather than returning empty and letting the queue run dry
- Add second _queueGen guard after the getRandomSongs fallback await

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:45:16 +02:00
HoneyandClaude Sonnet 4.6 9fe8656a21 fix: resolve queue desync causing player bar / NP to show wrong song
Root cause: addToQueue was sent to native twice for every radio top-up.
fillRadioQueue() already sends addToQueue internally, but onTrackChanged
and onQueueEnded also re-sent the same songs in their .then() callbacks,
causing native to accumulate duplicates. With native having double the
songs JS has, native indices no longer map to the correct JS queue entries.

Also fixed: playQueue called fillRadioQueue before setQueue was dispatched,
so addToQueue could land in the old native queue before setQueue wiped it.

Changes:
- fillRadioQueue: add _queueGen guard — bail if playQueue() replaced the
  queue while the network request was in flight
- playQueue: increment _queueGen; move fillRadioQueue() inside the rAF
  after setQueue so addToQueue always lands in the correct native queue
- onTrackChanged: remove .then() that re-sent addToQueue (double-send fix)
- onQueueEnded: remove addToQueue re-send, keep only _nsBridge('play')
- onPlaybackStateChanged: add safety-net sync when isPlaying transitions
  to true — catches any remaining edge cases where onTrackChanged missed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:02:42 +02:00
HoneyandClaude Sonnet 4.6 f97e31178c fix: revert MusicService debounce — it was causing the desync, not fixing it
The 120ms debounce on JS-initiated transitions (seek/setQueue) delayed
updateStateSnapshot(), leaving a stale window where getState() returned
the old index. If an AUTO callback fired during that window and the JS
onTrackChanged handler called getState(), it would read the stale index
and overwrite the correct player bar state with the wrong song.

Fix: always fire onTrackChanged immediately so updateStateSnapshot() runs
synchronously. The JS-side getState() reconciliation (from the previous
commit) then reliably gets the settled native index every time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:51:15 +02:00
HoneyandClaude Sonnet 4.6 52079536b6 fix: eliminate notification/playerbar song desync on Android
Two-pronged fix for the state drift between the system notification
(ground truth) and the WebView player bar:

JS (player.js): onTrackChanged now reconciles the incoming index with a
synchronous NSBridge.getState() call before applying it. Stale callbacks
from earlier rapid skips immediately correct themselves to the live
settled index instead of overwriting the correct state.

Kotlin (MusicService.kt): debounce JS-initiated transitions (seek /
setQueue) by 120 ms so rapid skip sequences produce one onTrackChanged
notification at the final index rather than one per skip step. Auto-advance
transitions are still fired immediately since JS doesn't initiate those.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:38:25 +02:00
HoneyandClaude Sonnet 4.6 0874fd4f9f feat: radio mode uses getSimilarSongs2 for artist/genre-aware playback
Replaces getRandomSongs with getSimilarSongs2 seeded from the current track.
Falls back to random if Navidrome returns nothing (no Last.fm config or unknown song).
Deduplicates against songs already in the queue.
Compatible with AudioMuse-AI plugin if added later — same endpoint, better results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:06:17 +02:00
HoneyandClaude Sonnet 4.6 2d0ee021e6 fix: sync track state before opening NP on notification tap; remove clickable title from player bar
- MainActivity: emit onTrackChanged + openNowPlaying in one JS tick on notification tap
  to prevent race where stale song appeared in NP overlay and bottom bar
- index.html: remove onclick/cursor from player-title so only album/artist are clickable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:58:49 +02:00
HoneyandClaude Sonnet 4.6 084c1728cd fix: prevent lyrics line reflow on active — remove font-weight change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 19:18:54 +02:00
HoneyandClaude Sonnet 4.6 7894790436 tweak: lyrics no wrap + reduce active line glow/scale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 19:16:34 +02:00
HoneyandClaude Sonnet 4.6 e4cc93d362 fix: restore LRCLIB fallback for synced lyrics; fix np-drag-handle null crash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 19:03:42 +02:00
HoneyandClaude Sonnet 4.6 654cde6796 refactor: remove LRCLIB fallback — handled by navidrome-lyrics-plugin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 18:54:45 +02:00
HoneyandClaude Sonnet 4.6 4b2f277ff7 feat: persist volume level in localStorage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:39:56 +02:00
HoneyandClaude Sonnet 4.6 4f152028f4 feat: auto-close preview when main playback starts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:10:08 +02:00
HoneyandClaude Sonnet 4.6 9f134883d9 tweak: widen PC progress bar controls from 420px to 520px
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:06:15 +02:00
HoneyandClaude Sonnet 4.6 d99145cce6 tweak: increase album/artist gap in player bar to 4px
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:05:44 +02:00
HoneyandClaude Sonnet 4.6 2553534cc8 tweak: keep player bar static during swipe-up; improve title/album/artist spacing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:03:54 +02:00
HoneyandClaude Sonnet 4.6 32036b7312 fix: collapse status-badge row when inactive so player-info centers correctly
.player-status-row had min-height:16px + margin-top:3px = 19px of permanent
invisible space, making player-info ~63px tall on desktop. Centered in 112px
this pushed the title to ~25px from top instead of ~34px.

Switched badges from opacity:0 (still in layout) to display:none, removed
min-height — row collapses to 0 when no badge is active so player-info
is only as tall as the visible title+album+artist rows (~44px), and
align-items:center on player-track places them at the true bar midpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:59:56 +02:00
HoneyandClaude Sonnet 4.6 bc068551de fix: desktop player-track stretch + mobile padding breathing room
Desktop: player-track had align-self:center (from grid align-items:center),
collapsing it to 52px content height. align-items:center inside it then
centered against 52px, not the full 112px bar — text appeared at top.
align-self:stretch forces player-track to fill the bar height so internal
centering works correctly.

Mobile: padding:14px 12px was too symmetric — art filled the 46px content
area exactly and touched the progress strip. Changed to padding:6px 12px 14px
(6px top breathing room, 14px bottom for progress strip, ~54px content area).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:56:16 +02:00
HoneyandClaude Sonnet 4.6 a5048abd2e fix: center player bar content vertically; use theme accent for artist
padding:0 12px 14px meant zero top padding, so align-items:center placed
content in the top 60px of the 74px bar — visually 7px above center.
Changed to padding:14px 12px (symmetric) so art+text center at exactly
37px, the visual midpoint of the bar. Progress strip stays absolute at bottom.

Artist color changed from hardcoded gold to var(--accent) in both the
bottom bar and Now Playing UI so it follows the active theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:50:24 +02:00
HoneyandClaude Sonnet 4.6 ea293a498f fix: tighten player bar line-height; accent color for artist in bar + NP
- line-height:1.2 on title and artist rows keeps the text block within
  the 46px art circle so it no longer overflows above/below
- Artist name uses accent color (gold) in both the bottom bar and the
  Now Playing UI, making it immediately distinguishable from the album

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:39:28 +02:00
HoneyandClaude Sonnet 4.6 494361191a fix(android): debounce onReady; pre-warm artwork before playback starts
Song restart: onPageFinished can fire multiple times mid-session (hash/state
changes in WebView). Added 3s debounce so onReady is not re-delivered
spuriously while a song is playing.

Notification artwork: warmArtworkCache was called from onTimelineChanged
where player.currentMediaItemIndex hadn't updated yet (lags until prepare()),
so it warmed the wrong tracks. WebBridge.setQueue now calls
MusicService.publicWarmArtwork(startIndex) immediately after setMediaItems
and before prepare()/play(), giving the bitmap loader a head start with
the correct index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:27:39 +02:00
HoneyandClaude Sonnet 4.6 ac4f09c8b8 fix: never rebuild native queue in onReady while ExoPlayer is playing
When onReady fires during MediaController reconnect (fragment navigation,
activity resume), calling setQueue restarts the current song from pos 0.
Guard: if native isPlaying=true, only re-sync JS UI — never touch the queue.
Only push a new queue when native is idle/empty (cold start).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:22:27 +02:00
HoneyandClaude Sonnet 4.6 0e05740df7 feat: show title/album/artist order in bottom bar and now playing UI
- Bottom bar: player-info is now a centered flex column so text block
  aligns properly against the art circle
- Now playing: split artist·album line into album (above) + artist (below)
  matching the bottom bar layout; removed np-album-sep reference
- Removed stale np-album-sep style.display call from player.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:20:04 +02:00
HoneyandClaude Sonnet 4.6 4c4dee4e55 fix: remove player-album-sep references after HTML restructure
Removing the separator span from the HTML broke updatePlayerUI which still
called getElementById('player-album-sep').style — null crash on every
song play. Removed both occurrences since album now has its own line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:03:11 +02:00
HoneyandClaude Sonnet 4.6 aa8d58d01f fix: null guard in hideCtxMenu and scroll/touchmove listeners
hideCtxMenu now guards against null #ctx-menu element. scroll/touchmove
listeners only fire when menu is actually visible, preventing spurious
calls on every scroll event.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:00:23 +02:00
HoneyandClaude Sonnet 4.6 96e3cb0451 fix: prevent stacked reorder mode from multiple 'Neu anordnen' clicks
Added _reorderCtx.active flag — plReorderEnter bails early if already
active, preventing duplicate handles and event listeners from stacking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:54:36 +02:00
HoneyandClaude Sonnet 4.6 92edaa9da4 feat: show album above artist in bottom bar; close ctx menu on scroll
- Player bar sub-line now shows album (clickable) on first row, artist below
- Context menu now closes on scroll and touchmove (was staying open on mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:03:20 +02:00
HoneyandClaude Sonnet 4.6 47cf3d13e5 fix: use pointerdown+pointerType to block touch seeks on bottom bar
mousedown does not expose the input source — replaced with pointerdown
which carries pointerType='touch', allowing noTouch=true to reliably
skip touch input without affecting mouse/stylus on PC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:39:02 +02:00
HoneyandClaude Sonnet 4.6 162bf530a2 fix: block synthetic mousedown on bottom progress bar from touch taps
noTouch=true now also adds a touchstart preventDefault listener so the
browser does not fire synthetic mousedown/click after a tap, which was
still triggering seeks in the PWA despite touch events being disabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:35:43 +02:00
HoneyandClaude Sonnet 4.6 e47450b20d tweak: reduce swipe-up overlay speed from 2.5x to 1.5x for smoother feel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:32:00 +02:00
HoneyandClaude Sonnet 4.6 eeb822bbb2 fix: suppress login screen flash on load when already authenticated
Adds a synchronous inline script that checks localStorage before first
paint and sets html.ns-authed, hiding #setup-screen via CSS immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:28:32 +02:00
HoneyandClaude Sonnet 4.6 43312be65d fix: remove overlay cap during swipe-up so NP slides smoothly without stopping
The Math.min(160) cap on 'lift' meant the overlay froze after 160px of drag
(at ~70% down the screen) while the finger kept moving — the felt 'stop'.

Now: player bar caps at its own height (once it slides off, no more movement
needed), overlay tracks the finger with no cap at 2.5× speed so ~360px of
upward drag fully reveals the now-playing screen. Release animation (from
the previous fix) then carries it smoothly to position 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:16:32 +02:00
HoneyandClaude Sonnet 4.6 b0fb1b200d fix: smooth NP swipe-open animation; live playing highlight in song lists
Swipe animation:
- _resetDrag(true) now reads the overlay's current drag position and starts
  the CSS transition from there instead of resetting to translateY(100%) first.
  That reset was causing the visible stutter (overlay jumped off-screen then
  slid back up). Now the overlay continues seamlessly from wherever the finger
  released to translateY(0).

Playing highlight:
- makeSongItem: adds data-song-id attribute to every .song-item element.
- updatePlayerUI: calls _updatePlayingHighlights() after every track change.
  This toggles .playing on all visible song items in any open list/playlist/
  favorites view so the highlighted row always matches what's actually playing,
  without requiring the user to navigate away and back.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:10:03 +02:00
HoneyandClaude Sonnet 4.6 0fbb1385f1 fix: remove blue tap highlight and focus ring globally in WebView
-webkit-tap-highlight-color was only on 2 specific elements; every
div/song-item/card still showed a blue flash on tap in Android WebView.
*:focus{outline:none} replaces the button-only rule and covers clickable
divs as well.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:44:40 +02:00
HoneyandClaude Sonnet 4.6 4856f0ca57 fix: bottom bar no touch seek; lyrics seek on Android; smoother NP animation
- initProgressScrub: pass noTouch=true for the bottom mini-bar so touch
  events are never registered on it; only mouse events remain (desktop-only).
  The now-playing scrubber is unchanged and keeps full touch support.
- Export seekToMs(ms) from player.js; use it in the lyrics click handler
  instead of S.audio.currentTime which has no effect when HAS_BRIDGE
  (native ExoPlayer handles playback, not the HTML audio element).
- styles.css: add will-change:transform to .player and .np-overlay to keep
  them on GPU compositor layers during the swipe gesture; tweak NP open
  transition to cubic-bezier(.4,0,.2,1) for a snappier feel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:42:58 +02:00
HoneyandClaude Sonnet 4.6 9d8a5a22bb fix: button focus ring, playlist reorder mode, edit modal mobile, backdrop-filter
- button:focus{outline:none} — removes the blue tap-highlight box in WebView
- Playlist reorder: replaced always-on drag handles with explicit 'Neu anordnen'
  mode. Handles only appear after tapping the button; a sticky Speichern/Abbrechen
  bar appears above the list. Touch drag is now fully supported. Context-menu
  (long-press) is blocked while reorder mode is active to prevent conflicts.
- styles.css: .pl-drag-handle always visible when pl-draggable (no hover needed)
- Edit modal footer: wrap buttons on small screens so Save doesn't overflow
- Edit modal overlay: remove backdrop-filter:blur — causes WebView compositor
  glitch where 3/4 of the modal disappears after returning from background

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:40:11 +02:00
HoneyandClaude Sonnet 4.6 68bec67880 fix: sync queue insert/append to native Android player
Both 'Als Nächstes einfügen' and 'Am Ende der Queue' updated S.queue in JS
but never called NSBridge, so the native ExoPlayer ignored the change and
kept playing the original next item.

- Export nativeInsertAt(song, position) from player.js
- Call it in showCtxMenu with the exact splice index for insert-next
  and -1 (append) for add-to-end

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:27:25 +02:00
HoneyandClaude Sonnet 4.6 e893b7cc12 fix: radio mode appends songs without restarting; favorites/song row mobile fixes
- fillRadioQueue: use addToQueue instead of setQueue so toggling radio mode
  no longer resets position to 0 on the current song
- loadStarred: pass noArtistLink/noAlbumLink to prevent accidental navigation
  taps on mobile (matches playlist behavior)
- styles.css: add pointer:coarse alongside hover:none so song-actions are
  visible on Android WebView which may report hover:hover
- components.js: remove inline download button from song row; keep only star
  and 3-dots (download stays accessible via context menu)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:25:26 +02:00
HoneyandClaude Sonnet 4.6 1b7c05f66d feat: notification 'Öffnen' routes to album for track/album downloads
- Single track and album downloads now navigate to the album page
- Artist downloads continue to navigate to the artist page
- Button label changes to 'Zum Album →' or 'Zum Künstler →' accordingly
- album name stored in job metadata so it survives until poll completes
- Search uses exact album+artist match, falls back to album-name-only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:16:15 +02:00
HoneyandClaude Sonnet 4.6 ba382aa6d9 fix: cover art caching, skip desync, notification tap opens now playing
- api.js: cache coverUrl() per id+size so WebView HTTP cache can work;
  previously each call generated a new random salt/token → unique URL →
  no cache hits → images re-fetched on every render

- player.js: replace _nsBridge('next')/'prev' with explicit seekToIndex
  in nextSong/prevSong so native always jumps to the exact same index JS
  computed; 'next'/'prev' relied on native's internal pointer being in sync
  which caused 'UI shows new song but audio plays old one' when they drifted

- MusicService: set setSessionActivity PendingIntent on MediaSession so
  tapping the playback notification opens the app

- MainActivity: handle open_now_playing extra in onCreate (cold start)
  and onNewIntent (app already running) to show the Now Playing screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:12:29 +02:00
HoneyandClaude Sonnet 4.6 66099c12f0 fix: add kuromoji to backend container npm install
The backend uses NODE_PATH=/deps/node_modules with its own npm install
step — not the root package.json. Added kuromoji there and reverted
the incorrect root package.json change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:48:13 +02:00
HoneyandClaude Sonnet 4.6 5bcb8b09a8 fix: romaji index routes moved after app init + kuromoji in root package.json
- app.get/delete for /api/romaji-index were placed before const app = express()
  causing ReferenceError on startup — moved into the ROUTES section
- kuromoji moved from backend/package.json to root package.json so Docker
  picks it up during the existing npm install step
- Fixed dicPath resolution to use path.dirname(require.resolve(...))

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:26:46 +02:00
HoneyandClaude Sonnet 4.6 aa239f8e6a feat: romaji index for Japanese kanji song search
Backend:
- Add /api/romaji-index endpoint using kuromoji morphological analyzer
- Fetches all Navidrome songs, converts kanji → hiragana readings → romaji
- Only indexes songs with Japanese characters; caches for 20 min
- /api/romaji-index DELETE clears cache (force rebuild)

Frontend:
- fetchRomajiIndex() in api.js: lazy-fetches and caches the index
- doLocalSearch: when query is romaji (ASCII), queries romaji index in
  parallel with Navidrome search3 and merges+deduplicates results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:15:07 +02:00
HoneyandClaude Sonnet 4.6 369a5e82b3 fix: remove duplicate search bar from PC sidebar
The magnifier icon in the top-right already opens search.
Having a second search box in the sidebar was redundant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:08:37 +02:00
HoneyandClaude Sonnet 4.6 bd8919fb39 fix: prevent accidental seek on tap in mobile progress bar
Touch scrubbing now requires ≥8px horizontal drag before committing.
A plain tap on the progress bar is ignored — no unintentional position jump.
Also removed redundant onclick="seekTo()" since _initBarScrub already
handles mouse seeking via mousedown/mousemove/mouseup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:05:55 +02:00
HoneyandClaude Sonnet 4.6 a47b2be177 feat: romaji search for Japanese songs via wanakana
When search input is pure ASCII (romaji), converts it to hiragana in
parallel using wanakana and merges both result sets. Deduplicates by ID
so no double entries. Helps find artists/songs tagged with hiragana in
sort fields (e.g. 'inabakumori' → 'いなばくもり').

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:03:42 +02:00
HoneyandClaude Sonnet 4.6 27eb209945 feat: three-dot context menu on song rows + search shows songs as rows
- Add ⋮ button to every song-item triggering the context menu (same as right-click)
- Show song-actions always visible on touch devices via @media (hover:none)
- Search results: songs now render as makeSongItem rows instead of cards
  (artists/albums remain as horizontal card rows)
- Fix Android app opening system settings on every launch after notification
  permission denial — now only redirects to settings once using notif_settings_shown flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:58:59 +02:00
HoneyandClaude Sonnet 4.6 7bec106350 fix: fetch YTM URLs once and reuse across cascade steps
Each tryYtmSearch() call made a fresh HTTP request, so YouTube could
return different result orderings between steps. The correct video
(right title, wrong channel name) appeared in step 1 but was gone by
step 3 — a worse video with matching keywords slipped through instead.

Now fetch 10 YTM URLs once upfront and filter the same list with
progressively relaxed rules. Also saves 2-4 extra YTM requests per
download.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:39:07 +02:00
HoneyandClaude Sonnet 4.6 c037ee623d fix: preload hi-res NP cover on track change so overlay opens instantly
_crossfadeNpBg only preloaded the 1024px cover when the NP overlay was
already open. Opening it fresh showed the old cover for ~0.5s while the
new image downloaded. Preloading in updatePlayerUI caches it immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:49:57 +02:00
HoneyandClaude Sonnet 4.6 30ef5300cf fix: sync native track index before opening Now Playing overlay
On Android, onTrackChanged fires asynchronously so opening the overlay
quickly after a track change could show the previous song. Query
NSBridge.getState() synchronously at open time to get the real index.
Same flash can also occur on web — openNowPlaying now always renders
from current S.qIdx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:49:05 +02:00
HoneyandClaude Sonnet 4.6 6d0d1b28dc fix: widen sleep picker to 150px so custom input gets proper space
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:37:20 +02:00
HoneyandClaude Sonnet 4.6 6fcf3179be fix: 2-line layout for sleep timer custom row (label above, input+OK below)
Picker is only ~90px wide; single-line label+input+OK was too cramped.
Now stacks vertically so input gets full row width minus the OK button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:30:45 +02:00
HoneyandClaude Sonnet 4.6 ee6df1e1fe fix: prevent music canplay callback from interrupting ABS playback start
When absPlayItem() paused S.audio, the stale canplay listener from the
last playCurrent() call still had a matching _playGen value and would
call S.audio.play() — triggering absInterrupt() which immediately paused
the ABS audio. Fix: export cancelMusicAutoplay() that bumps _playGen
before pausing, invalidating any pending canplay/guard callbacks.

Also shrink OK button and widen time input in sleep timer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:21:29 +02:00
HoneyandClaude Sonnet 4.6 f5343efd37 fix: add OK button back to audiobook sleep timer custom input
Mobile needs the button since there's no keyboard Enter key in all cases.
Button is compact and styled to match the accent color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:13:31 +02:00
HoneyandClaude Sonnet 4.6 cf88d87791 fix: simplify audiobook sleep timer custom input; update README
- Remove OK button and expand-on-click from sleep timer custom row
- Always show label + plain number input (no spinners, Enter to confirm)
- Update README with all current features (Spotify importer, ABS, lyrics, filters, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:04:12 +02:00
Honey 757c7045ba fix: normalize title comparison in iTunes lookup to handle Spotify dash-variants vs iTunes paren-variants 2026-05-14 18:53:06 +02:00
Honey 55370ad760 feat: show iTunes enrichment status indicator in Spotify importer 2026-05-14 18:01:34 +02:00
Honey 11dec04fea fix: improve Spotify importer metadata — copy iTunes durationMs, skip redundant backend lookup when frontend already enriched track 2026-05-14 17:58:36 +02:00
Honey e78df7bdb9 feat: add search filter bar to playlist detail and favorites views 2026-05-13 13:58:08 +02:00
Honey d5470a6440 fix: add Alben to mobile bottom nav; match sidebar order to mobile (Playlists before Favoriten) 2026-05-13 13:01:56 +02:00
Honey 47f1605965 feat: add sort filter chips to albums and favorites views 2026-05-13 12:53:34 +02:00
Honey 751cc05b89 fix: reset progress bar to 0:00 immediately on track change on web too 2026-05-13 12:46:58 +02:00
Honey 10e3e0eb1d fix: reset progress bar to 0:00 immediately on track skip/play in bridge mode 2026-05-13 12:45:55 +02:00
Honey a1a4416bb6 fix: restart skip grace period on isPlaying:true to suppress multi-fire buffering during seek 2026-05-13 12:44:27 +02:00
HoneyandClaude Sonnet 4.6 d87abd2e9e fix: suppress transient pause during seek in bridge mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:46:12 +02:00
HoneyandClaude Sonnet 4.6 74a452c037 fix: use playlist/album artworkUrl as fallback when track has no cover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:41:01 +02:00
HoneyandClaude Sonnet 4.6 ee05998840 fix: suppress transient isPlaying:false during native skip transitions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 09:42:07 +02:00
HoneyandClaude Sonnet 4.6 1e61d35018 fix: use isPlaying() in syncNowPlaying to handle bridge/native mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 02:00:26 +02:00
HoneyandClaude Sonnet 4.6 0c5fe53f13 fix: inject iTunes previewUrl into Spotify importer track items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:56:14 +02:00
HoneyandClaude Sonnet 4.6 c8f82ceba5 fix: scrape Spotify OG tags as fallback for single track resolution
song_info returns GenericError for unauthenticated track lookups.
Fall back to scraping og:title/og:description/og:image from the public
Spotify track page — description format is "Song · Artist · Album · Year".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:48:37 +02:00
HoneyandClaude Sonnet 4.6 4f52e7b883 fix: album_info yields pages (flatten), track items use flat format; track GenericError handling
- Album: flatten generator pages with list comprehension, parse flat track.{name,artists,...} directly
- Track: try both URI and URL, report GenericError clearly instead of crashing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:44:56 +02:00
HoneyandClaude Sonnet 4.6 b74d0da294 fix: song_info needs URI not URL; album_info returns generator of track items
- Track: pass spotify:track:ID URI instead of full https:// URL
- Album: Public().album_info() yields track items (generator); metadata from PublicAlbum

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:40:31 +02:00
HoneyandClaude Sonnet 4.6 a401c7a607 fix: use Public().song_info() for tracks, Public().album_info() for albums
Both are the correct unauthenticated API methods on the Public class.
Song class requires a logged-in session and has no public equivalent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:36:33 +02:00
HoneyandClaude Sonnet 4.6 76785a4eaa debug: wrap Song init in try-except, log Public methods before any Song attempts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:21:19 +02:00
HoneyandClaude Sonnet 4.6 5d0c5ec6eb fix: album track wrapping + try multiple Song() call patterns for tracks
- Album: wrap item.get('track') as {itemV2: ...} so _parse_track navigates trackUnion correctly
- Track: try get_song_info(url) then get_song_info(); log Song/Public methods on failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:15:43 +02:00
HoneyandClaude Sonnet 4.6 bbe4412f42 chore: remove unused showSetupError from ui.js (duplicate of _showSetupError in main.js)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:07:49 +02:00
HoneyandClaude Sonnet 4.6 67185b85e9 fix: track needs Public() client; fix album partner API structure parsing
- Song() requires a client instance as first arg; pass Public() for unauthenticated access
- Album response is data.albumUnion.{name,artists,coverArt,tracks.items} not flat keys
- Add per-field debug logging for track and album responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:01:35 +02:00
HoneyandClaude Sonnet 4.6 6bedd3ed28 fix: proxy iTunes API through backend to resolve CORS errors
Frontend was calling itunes.apple.com directly; browsers block it (no CORS headers).
Added /api/itunes/:endpoint proxy on the backend and updated all itunesSearch/Lookup
calls in api.js to route through it when backendUrl is configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:23:40 +02:00
HoneyandClaude Sonnet 4.6 7b5928f809 fix: parse spotapi playlist response structure (data.playlistV2.content.items)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:17:03 +02:00
HoneyandClaude Sonnet 4.6 575ca385fd debug: log spotapi playlist page structure to diagnose 0-track response
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:13:17 +02:00
HoneyandClaude Sonnet 4.6 ddc6cb8cb2 fix: detect spotapi exports at runtime, lazy-import per type
PublicSong doesn't exist in the installed spotapi version. Now discovers
available class names at startup and picks the right one dynamically.
Missing track/album class no longer breaks playlist resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:11:06 +02:00
HoneyandClaude Sonnet 4.6 4ec441af57 fix: switch to spotapi (api-partner.spotify.com) to avoid rate limits
api.spotify.com/v1/ aggressively rate-limits server IPs regardless of
TLS fingerprint or token. SpotipyFree was already hitting the correct
endpoint (api-partner.spotify.com) but crashed on KeyError: 'ownerV2'
due to its own outdated field parsing — not a network block.

spotapi uses the same internal GraphQL API (Spotify's web player API)
which is on a separate rate-limit bucket from the public REST endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:02:28 +02:00
HoneyandClaude Sonnet 4.6 d4c41a9f92 fix: enforce Spotify rate limit cooldown server- and client-side
Server tracks _spRateLimitUntil and rejects retries instantly without
hitting Spotify, returning the remaining wait time. Retrying before the
window expired was resetting Spotify's timer (19s → 7s → 48s).

Frontend now shows a live countdown with a note not to retry early.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:22:10 +02:00
HoneyandClaude Sonnet 4.6 5265aa1f33 fix: replace SpotipyFree with curl_cffi, remove dead Node.js TOTP code
SpotipyFree crashes with KeyError: 'ownerV2' — Spotify changed their
internal API response format and the library is out of date.

curl_cffi presents a real Chrome TLS fingerprint so open.spotify.com/api/
server-time responds (plain Node.js TLS is filtered by Spotify's WAF).
Node.js TOTP fallback always failed on this for the same reason — removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:17:15 +02:00
HoneyandClaude Sonnet 4.6 750089fc8e revert: remove proxy support, restore SpotipyFree-only resolver
IP is not actually blocked (api.spotify.com returns 401, not 403/timeout).
Previous rate limits were temporary. Reverting to the clean state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:04:10 +02:00
HoneyandClaude Sonnet 4.6 207e241600 fix: add SPOTIFY_PROXY support to bypass server IP ban
Spotify blocks API calls from server IPs (open.spotify.com/api/* times
out, api.spotify.com/v1/ returns 429). Setting SPOTIFY_PROXY routes all
Spotify calls through curl_cffi via that proxy, bypassing the IP ban.

Without proxy, SpotipyFree is still tried (works on non-banned IPs).
A clear tip is printed if SpotipyFree fails without a proxy configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 19:45:18 +02:00
HoneyandClaude Sonnet 4.6 7f7bedc15e fix: remove rate-limited REST fallback, explicitly install SpotipyFree
The curl_cffi fallback in spotify_resolve.py was silently catching
SpotipyFree ImportErrors and hitting api.spotify.com/v1/ directly —
the same rate-limited endpoint. Removed it entirely.

Also explicitly pip-install SpotipyFree before TzurSoffer's package
so the import is always available regardless of dependency resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:38:23 +02:00
HoneyandClaude Sonnet 4.6 0f1e6ff531 refactor: remove dead code from backend and lib
- Remove unused spotifyClientId/spotifyClientSecret from config
- Remove AUDIO_EXTS from metadata exports (internal-only)
- Remove tryYtdlp wrapper in ytdlp.js (direct spawnYtdlp calls)
- Remove _spArtistName wrapper in server (direct _spAllArtists calls)
- Fix stale comment: primary resolver is SpotipyFree, not curl_cffi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:18:50 +02:00
HoneyandClaude Sonnet 4.6 c36dbb3c94 fix: use SpotipyFree as primary resolver (SpotAPI + Chrome TLS fingerprint)
SpotipyFree wraps SpotAPI which uses tls_client to impersonate Chrome's
TLS fingerprint and hits Spotify's internal endpoints. This is exactly
what TzurSoffer's spotdl fork uses. Removes the complex GQL hash
extraction (which couldn't find hashes in lazy-loaded webpack chunks).
curl_cffi + TOTP kept as fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:09:33 +02:00
HoneyandClaude Sonnet 4.6 06b78be199 fix: full stderr logging per-line, scan embed page for GQL hash
- Log each Python stderr line separately (no 500-char truncation) so
  we can see exactly what's happening in the bundle scan
- Try embed page (open.spotify.com/embed/...) first for GQL hashes —
  focused app, fewer bundles, GQL chunks directly referenced
- Suppress noisy bundle-fetch errors to keep logs readable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:26:51 +02:00
HoneyandClaude Sonnet 4.6 46eb8c826f fix: scan resource page JS for GQL hash (Spotify lazy-loads feature chunks)
fetchPlaylist/getAlbum/getTrack GraphQL hashes are not in homepage bundles
— they live in lazy-loaded chunks that only appear on the playlist/album/track
page. Fetch the specific page HTML to find the extra JS bundles, then scan
those for the persisted query hash. Also improved hash regex (no semicolon
restriction, wider window, fallback nearest-hex search).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:45:32 +02:00
HoneyandClaude Sonnet 4.6 e87d48d6b0 fix: use api-partner.spotify.com GraphQL instead of rate-limited REST API
api.spotify.com/v1 is IP-banned for 24h. Switch to Spotify's internal
partner API (api-partner.spotify.com/pathfinder/v1/query) which uses
separate infrastructure and rate limits. Persisted query hashes are
extracted dynamically from JS bundles alongside the TOTP secret.
Falls back to REST only if GQL hash not found or partner API fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:35:44 +02:00
HoneyandClaude Sonnet 4.6 8bf481a2da fix: replace spotdl CLI with direct curl_cffi Chrome TLS impersonation
spotdl save hangs for 60s initializing its full pipeline. Rewrite
spotify_resolve.py to use curl_cffi (already installed) which
impersonates Chrome's TLS fingerprint directly. TOTP token fetched
in Python too. RATE_LIMITED propagates without TOTP fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:21:32 +02:00
HoneyandClaude Sonnet 4.6 b53910ed45 fix: switch backend to node:20-slim (Debian) for glibc compatibility
tls-client (used by SpotAPI/spotdl) bundles a native .so that requires
libresolv.so.2 from glibc. Alpine uses musl libc which doesn't provide
this — causing spotdl to crash on import. Debian-based image has glibc.
Also install yt-dlp via direct download (latest) instead of apk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:07:50 +02:00
HoneyandClaude Sonnet 4.6 eacd68aea7 fix: remove Spotify credential requirement — no API keys needed
spotdl (SpotAPI) works without developer credentials. Remove the
health-check warning, credential error UI, and Client Credentials
fallback that were blocking the importer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:01:35 +02:00
HoneyandClaude Sonnet 4.6 9b197e119a fix: strip ?si= from Spotify URL, improve spotdl error logging
spotdl doesn't handle tracking params in URLs. Also separate stdout/stderr
in Python script and always try parsing stdout before checking exit code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:55:41 +02:00
HoneyandClaude Sonnet 4.6 3c85deab40 feat: use spotdl (TzurSoffer fork) as primary Spotify resolver
Replaces rate-limited api.spotify.com calls with spotdl's SpotAPI
approach, which uses Spotify's internal browser endpoints — no
developer credentials needed, no api.spotify.com rate limits.
TOTP anonymous token kept as automatic fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 14:49:19 +02:00
HoneyandClaude Sonnet 4.6 8cc6653898 fix: add Spotify web-player headers to API calls, pace paginated requests
Without app-platform/spotify-app-version headers, requests look like
bot traffic and get rate-limited aggressively. Add 300ms delay between
playlist track pages to avoid burst throttling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 00:08:44 +02:00
HoneyandClaude Sonnet 4.6 859f4a458a fix: fail fast on Spotify 429 instead of retrying into timeout
Retrying with wait times (23s + 60s + 60s) exceeded the 120s global
timeout and showed a generic timeout error. Now returns immediately
with a RATE_LIMITED error and shows the user how long to wait.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 00:05:41 +02:00
HoneyandClaude Sonnet 4.6 fbb00baa11 feat: TOTP-based anonymous Spotify token (no developer credentials needed)
Dynamically extracts Spotify's TOTP secret from their web-player JS bundle,
computes a 6-digit HMAC-SHA1 code synced to open.spotify.com/api/server-time,
and requests a token from open.spotify.com/api/token — completely bypassing
the rate-limited Client Credentials token. Client Credentials kept as fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:12:29 +02:00
HoneyandClaude Sonnet 4.6 61271bff27 fix: use anonymous clienttoken as primary Spotify auth, Client Credentials as fallback
Developer Client ID is 24h rate-banned. clienttoken.spotify.com uses
Spotify's own web-player client ID (separate rate-limit bucket).
Also: fast-fail when Retry-After > 60s and invalidate the cached token
so the next request immediately fetches a fresh one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:59:55 +02:00
HoneyandClaude Sonnet 4.6 3633b4c5fa fix: handle 429 rate-limit within timeout window
- Cap per-retry wait at 15s (Spotify's Retry-After can be very long)
- Increase global resolve timeout from 25s to 120s so retries can complete
- Log rate-limit waits for visibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:15:24 +02:00
HoneyandClaude Sonnet 4.6 bb52717c95 fix: force HTTP/1.1 via ALPNProtocols to prevent h2 framing mismatch
api.spotify.com negotiates HTTP/2 via TLS ALPN but https.request uses
HTTP/1.1 framing — the protocol mismatch silently hangs the connection.
wget on Alpine doesn't advertise h2 so it works fine. ALPNProtocols:
['http/1.1'] forces HTTP/1.1 on both ends.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:01:12 +02:00
HoneyandClaude Sonnet 4.6 d2cb1bed68 fix: add User-Agent, dual timeout, done-guard, and response logging to _fetchTimeout
- User-Agent header (Spotify may throttle anonymous requests)
- req.setTimeout + socket.setTimeout for belt-and-suspenders
- done() guard prevents double-resolve/reject
- [sp-http] log shows status code of each Spotify response
  so logs reveal whether the hang is before or after HTTP headers arrive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:50:18 +02:00
HoneyandClaude Sonnet 4.6 f4766acc74 fix: switch Spotify HTTP to https module with socket.setTimeout for OS-level timeout
undici cannot abort a TCP connect in progress even with AbortController.
Node's https.request + socket.setTimeout fires at the OS socket level
and aborts within ms even when the TCP SYN is silently dropped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:45:10 +02:00
HoneyandClaude Sonnet 4.6 d2620519aa fix: replace AbortSignal.timeout with AbortController+setTimeout for reliable fetch timeouts
undici (Node.js native fetch) silently ignores AbortSignal.timeout when
TCP connect is blocked. Using AbortController driven by Node's setTimeout
guarantees the abort fires and the hanging request is cancelled within 10s.
Adds [spotify] token ok log to confirm when credentials work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:43:31 +02:00
HoneyandClaude Sonnet 4.6 76271c1e54 fix: 25s backend timeout + 30s frontend timeout on Spotify resolve
Extracts resolve logic into _resolveSpotify() and wraps it in
Promise.race with a 25s timer so the backend always sends a response
even if undici's AbortSignal.timeout silently fails on blocked sockets.
Also reduces frontend timeout from 90s to 30s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:39:07 +02:00
HoneyandClaude Sonnet 4.6 7d38fdf601 fix: add 90s timeout to Spotify resolve fetch, prevent infinite spinner
Frontend fetch to /api/spotify/resolve had no timeout — if the backend
stalled (Spotify API slow or unreachable), the spinner would never stop.
Also caps the health check at 5s so loadSpotifyImporter can't hang.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:32:38 +02:00
HoneyandClaude Sonnet 4.6 f7e4059f2d fix: friendly error for Spotify editorial playlist 403
Backend returns {error:'EDITORIAL_PLAYLIST'} on 403 playlist routes.
Frontend shows a step-by-step workaround card instead of a red error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:22:56 +02:00
HoneyandClaude Sonnet 4.6 468827571a feat: switch Spotify auth to official Client Credentials Flow
Replaces the broken open.spotify.com/get_access_token scrape with
accounts.spotify.com/api/token (Basic auth, client_credentials grant).
Adds SPOTIFY_CLIENT_ID/SECRET to config, docker-compose, and .env.example.
Health endpoint now reports spotify.configured; frontend disables the
importer and shows a setup guide when credentials are missing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:57:01 +02:00
Honey 7c1c444bf2 fix: 429 retry, safety counter, local file filter in Spotify pagination 2026-05-09 20:58:16 +02:00
Honey b7f09058a9 fix: use sp_t cookie trick for Spotify anonymous token (spotdl approach) 2026-05-09 20:55:00 +02:00
Honey 6c4caebc69 fix: regex accessToken directly from embed HTML instead of brittle JSON path 2026-05-09 20:17:37 +02:00
Honey 60c74a60a2 fix: get Spotify token from embed page instead of blocked get_access_token endpoint 2026-05-09 20:02:18 +02:00
Honey f218b66ad2 fix: replace Spotify embed scraper with anonymous token + real API
Uses open.spotify.com/get_access_token for an anonymous bearer token,
then paginates through the full Spotify API — removes the 100-track
embed limit for playlists and albums.
2026-05-09 19:58:07 +02:00
Honey 7dd22d9639 fix: apply square curve to volume slider so low positions are genuinely quiet 2026-05-09 17:12:37 +02:00
Honey e2c28926b2 feat: redesign sleep timer custom input as clickable row with inline edit 2026-05-09 17:10:35 +02:00
Honey b4c12a2940 feat: add custom minutes input to audiobook sleep timer 2026-05-09 16:25:06 +02:00
Honey 5ee6614ac9 fix: lower default audio volume from 50% to 30% 2026-05-09 16:21:09 +02:00
Honey 2feb00be62 fix: don't auto-resume audio on tab/app focus if user explicitly paused 2026-05-09 16:17:50 +02:00
HoneyandClaude Sonnet 4.6 8e752fa892 fix: player bar instant update + stop music on app close
player.js: move NSBridge.setQueue() into requestAnimationFrame so the
updatePlayerUI DOM changes are committed and painted before the blocking
@JavascriptInterface call. setQueue serializes a large JSON payload and
blocks the JS/Blink main thread while Java parses it — deferring it one
frame ensures the player bar visually updates immediately on tap.

MusicService: onTaskRemoved now calls player.stop() + stopSelf() so
swiping the app from recents stops playback instead of leaving it running
in the background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:00:18 +02:00
HoneyandClaude Sonnet 4.6 7084fc4118 fix: player bar freezes on manual song start in bridge mode
- playQueue: update UI immediately before NSBridge.setQueue so the bar
  reflects the new song without waiting for onTrackChanged
- onTrackChanged: remove index guard that blocked UI refresh when
  S.qIdx was already set; always call updatePlayerUI/onNpSongChange
- nextSong/prevSong: optimistic UI update before calling native next/prev
- _computeNextIdx: extracted helper for shuffle/repeat/linear logic
- _lastPositionMs: new variable for prev() 3s threshold in bridge mode
- updatePlayerUI/_syncNpDirect: guard img.src assignment to prevent
  redundant reloads and flicker on double update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 13:04:33 +02:00
HoneyandClaude Sonnet 4.6 cf2921c56f fix: cover art on auto-advance + native back-button overlay routing
Issue 1 (MusicService): wrap DefaultHttpDataSource in CacheBitmapLoader so
Media3 notification artwork is fetched once and served from memory on every
subsequent auto-advance. warmArtworkCache() pre-loads current+next 3 tracks
on both onMediaItemTransition and onTimelineChanged (queue replace via setQueue).
Adds explicit media3-datasource dependency.

Issue 2 (back button): replace deprecated onKeyDown with OnBackPressedCallback.
WebBridge.setOverlayState(bool) tracks whether JS has an overlay open;
consumeBackPress() is called first — if true, MainActivity delegates to
window.NSPlayer.handleBack() which closes the topmost layer. Falls back to
webView.goBack() then moveTaskToBack(true) so the MusicService binding survives.
JS side: _syncOverlayState() + MutationObserver keep the native flag current;
handleBack covers NP overlay, queue panel, action sheet, all modals, sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:11:09 +02:00
HoneyandClaude Sonnet 4.6 76ecfe2268 feat: wire NSBridge fully + HD artwork + disable watchdog in wrapper mode
player.js
- Replace `_native()` function with `const HAS_BRIDGE` (evaluated once at
  module load so bridge detection is rock-solid)
- Add `buildTrackList()` — maps S.queue to NSBridge Track array with 1024px
  artworkUrl so lock-screen cover is crisp on high-DPR displays
- Add `_pushQueueIfBridge()` — called by `fillRadioQueue()` so radio top-ups
  are pushed to ExoPlayer in real time (fixes autoplay gap after radio refill)
- `playQueue`: route through `NSBridge.setQueue` when bridge present; old
  `S.audio` path retained for browser/PWA fallback
- `playCurrent`: `NSBridge.seekToIndex + play` in bridge mode
- `nextSong / prevSong / togglePlay / onEnded`: delegate to bridge, skip local
  S.qIdx manipulation (native sends onTrackChanged callback instead)
- `seekTo / _initBarScrub`: use `_lastDurationMs` for ms-accurate seeks
- `onTrackChanged`: guard with `p.index !== S.qIdx` to avoid redundant redraws
- `updateMediaSession`: artwork sizes 96/300/512 → 256/512/1024
- Player-bar cover: 100 → 256px; NP cover/bg: 400 → 1024px;
  queue items: 60 → 128px; preload hint sizes: 96/300/512 → 256/512/1024

main.js
- Wrap watchdog setInterval in `typeof window.NSBridge === 'undefined'` guard;
  the foreground service keeps ExoPlayer alive — JS watchdog not needed there

views.js
- Album detail / playlist detail covers: 300 → 512px
- Playlist grid cards: 200 → 512px
- Search result song cards: 200 → 256px
- Artist top-songs / all-songs thumbnails: 60 → 128px
- Partial album art in _renderArtistAlbums: 300 → 512px

components.js
- makeAlbumCard: 200 → 512px
- makeSongItem: 60 → 128px

nowplaying.js
- NP queue items: 60 → 128px
- NP action sheet thumb: 80 → 256px

sw.js
- Bump CACHE to ns-v41 so stale small-cover entries are evicted on next load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 10:33:43 +02:00
HoneyandClaude Sonnet 4.6 0ee2a62adb fix: ensure foreground service + notification permission on Android 14
- startService() before bindService() so Android 14 allows foreground
  promotion (bind-only no longer satisfies the "started from foreground"
  requirement since API 34)
- smarter POST_NOTIFICATIONS flow: re-ask once, then redirect to app
  notification settings instead of silently doing nothing after denial
- track first-ask via SharedPreferences so the settings redirect only
  triggers on a second-or-later launch after denial

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:42:11 +02:00
HoneyandClaude Sonnet 4.6 0e17d2e5db feat: native Android audio via Media3 ExoPlayer + full NSBridge JS adapter
- Replace legacy MediaBrowserServiceCompat/MediaSessionCompat with
  Media3 MediaSessionService (MusicService.kt) + ExoPlayer set to
  WAKE_MODE_NETWORK — holds WakeLock + WifiLock through Doze so audio
  plays uninterrupted on locked screen
- Media3 manages the notification; no manual NotificationCompat.Builder
  → no disappear/reappear flicker when skipping tracks
- Full bidirectional bridge:
    JS → native: window.NSBridge.* (WebBridge.kt, 13 methods)
    Native → JS: window.NSPlayer.* callbacks (onReady, onTrackChanged,
      onPositionUpdate, onPlaybackStateChanged, onQueueEnded, onError)
- player.js: progressive enhancement — all playback routes through
  NSBridge when running inside the wrapper, falls back to S.audio on
  plain PWA/browser; removes all window.Android.* calls
- main.js: guard _onForeground mediaSession playbackState setter behind
  !window.NSBridge so native Media3 session is never stomped
- ProGuard rules updated to keep WebBridge @JavascriptInterface methods
  and Media3 classes
- bridge-test.html: manual integration test page for every bridge method

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 01:14:28 +02:00
HoneyandClaude Sonnet 4.6 1ede8727a2 fix: arm canplay before play(), reset on any error, remove stale MediaSession on restore
- playCurrent(): arm canplay listener BEFORE calling play() so it's never missed
  when play() hangs (common on locked Android — promise neither resolves nor rejects)
- playCurrent(): on ANY play() failure reset audio element (src + load()) so the
  audio engine starts fresh and canplay fires reliably; previously only AbortError
  triggered the reset — NotAllowedError (audio focus lost on lock screen) did not
- playCurrent(): single _armCanplay() helper replaces two separate guard/listener
  setups; re-arms after failure in case canplay already fired before the error
- _restoreLastPlayed(): remove updateMediaSession() call — pre-setting stale song
  metadata caused Samsung to treat the first actual playCurrent() as a new session
  (old song metadata → new song metadata while playing = notification close/reopen)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 18:55:10 +02:00
HoneyandClaude Sonnet 4.6 d355129134 fix: lock screen audio + notification — audioSession once, deferred metadata, SW artwork cache
- Set navigator.audioSession.type='playback' only once (guarded flag) — repeated
  reassignment was resetting the OS audio session and triggering Doze network cuts
- Defer updateMediaSession() until after play() resolves, not before audio.src swap —
  new MediaMetadata during HAVE_NOTHING state caused Samsung to close/reopen notification
- Fix silent bug: updatePlayerUI(s, true) now actually skips MediaSession (added param)
- 10s safety guards now attempt play() instead of just resetting _trackChanging flag
- SW v40: cache-first for getCoverArt with token-stripped keys — artwork served from
  cache even when Doze blocks network, survives session restarts with new auth tokens
- Pre-warm next track artwork 2s after song starts (earlier than 30s-remaining trigger)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 18:37:23 +02:00
HoneyandClaude Sonnet 4.6 ec13124d99 fix: generation counter prevents stale _reset() from clobbering active transition
When skipping rapidly, old playCurrent() closures had _reset() callbacks
that could fire _trackChanging=false while a newer transition was still
in progress — breaking both notification guard and background recovery.

Each playCurrent() now captures a generation number (++_playGen).
_reset() is a no-op if _playGen has advanced (newer call took over).
canplay and play().catch handlers also check the generation before acting.

This makes all stale callbacks safe regardless of how quickly the user skips.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 00:38:55 +02:00
HoneyandClaude Sonnet 4.6 b941672a77 fix: keep _trackChanging=true through entire recovery window (lock screen)
When play() fails on locked screen, Android may revoke audio focus and
fire a spurious pause event. Previously we reset _trackChanging=false
right in the catch, leaving a window for that pause event to set
playbackState='paused' and dismiss the Samsung cover screen notification.

Now _trackChanging stays true for the ENTIRE recovery (initial fail ->
canplay retry -> success/fail), with a 10s Kima-style safety net as the
only exit. This means:
- AbortError: reload src+load(), _trackChanging stays true, canplay retries
- NotAllowed/network: same — _trackChanging stays true, canplay retries
- If canplay fires and play() succeeds: play event resets _trackChanging
- If canplay fires and play() fails: _reset() is called
- If canplay never fires (Doze): 10s timeout resets _trackChanging

Net effect: Samsung lock screen notification never sees playbackState='paused'
during the recovery window, so it stays open instead of dismissing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 00:24:11 +02:00
HoneyandClaude Sonnet 4.6 688348545d fix: clean rewrite of audio recovery matching Kima Hub design
playCurrent():
- Remove canplay listener from before play() — it caused a race where
  both the listener and the initial play() fired simultaneously
- canplay is now only registered in the catch (failure path only),
  eliminating the double-play race entirely
- AbortError: reload src+load() with _trackChanging=true (OS interruption
  recovery); canplay fires once buffered and retries play()
- Other errors: reset _trackChanging, let canplay retry (not throttled
  by Android background timers unlike setTimeout)
- Add 10s safety net to reset _trackChanging if Doze kills everything
  (matches Kima Hub's reload failsafe)

main.js error handler:
- Skip when _trackChanging=true (playCurrent already recovering) to
  prevent conflicting dual recovery attempts
- _onForeground: reset _trackChanging on play() failure so the flag
  can't get stuck when foreground recovery also fails

index.html:
- Add crossorigin=anonymous: Navidrome and NovaSonic backend both send
  Access-Control-Allow-Origin:* so this is safe, and it enables proper
  cross-context HTTP caching for cover art and audio responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 23:34:28 +02:00
HoneyandClaude Sonnet 4.6 0344dcdcb6 fix: preload=auto, artwork pre-warm, AbortError reload (matching Kima Hub)
- Add preload="auto" playsinline to <audio> element: browser starts
  buffering as soon as src is set, not only when play() is called —
  critical for background where play() has no data to work with
- Fix AbortError handling: instead of silently returning (was swallowing
  audio element bad-state errors), reload src + load() like Kima Hub
  so the already-registered canplay listener retries play() once buffered
- Pre-warm next song's cover art in _preloadNext() using _cachedCoverUrl
  so the lock screen notification never cold-starts artwork on first skip
- Move _coverCache to top of file so _preloadNext and updateMediaSession
  share the same stable token URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 23:09:46 +02:00
HoneyandClaude Sonnet 4.6 a0c6d09c41 fix: stable notification artwork + canplay retry for background audio
- Cache cover art URLs per song so the same auth token is reused across
  metadata updates; Samsung/Chrome can cache the artwork image and the
  notification no longer visually reloads on every skip
- Add canplay listener as background-safe play() retry: the audio
  element fires canplay from its internal state machine (not throttled
  like setTimeout by Android Doze), so play() is retried as soon as
  the browser has buffered enough data
- Set navigator.audioSession.type='playback' on each _tryPlay attempt
  (not just on play event) so the OS audio session claim happens before
  Chrome requests the play grant
- Guard _onForeground playbackState assignment with !_trackChanging to
  prevent visibilitychange/pageshow from stomping 'playing' to 'paused'
  during an in-progress track transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 22:49:35 +02:00
HoneyandClaude Sonnet 4.6 3f5a5a4c43 fix: Samsung notification stays alive; retry play() on background rejection
- Set playbackState='playing' explicitly before MediaMetadata swap so
  Samsung One UI never sees a gap where state is ambiguous during skip
- Add retry loop (500ms→1s→2s→4s→8s) in playCurrent() catch — handles
  Android Doze rejecting play() in background; stops retrying if the
  song changes mid-flight (AbortError)
- Fix error handler: set _trackChanging=true before audio.load() so
  the load()'s pause event can't slip past the Samsung notification guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 22:29:30 +02:00
HoneyandClaude Sonnet 4.6 33dc07ed5d fix: audioSession playback type, exponential backoff, pageshow recovery
- Set navigator.audioSession.type = 'playback' on first play — tells Android
  OS this is a music app, keeping background network alive through Doze mode
- Replace flat 2s error retry with exponential backoff (1s/2s/4s/8s)
- Add pageshow event alongside visibilitychange for Samsung/iOS PWA reliability
- Attempt audio resume on foreground if OS killed playback while backgrounded
- Switch preload from hidden audio element to link rel=prefetch (lighter,
  avoids triggering two-audio-stream detection on Android)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:34:23 +02:00
HoneyandClaude Sonnet 4.6 502427d02c fix: preload next track 30s early to survive Android Doze network gap
When the current song ends, Android's battery optimization briefly kills
background network access. By starting the next song's stream request while
the current track is still playing (and Chrome holds an active audio session),
the buffer is ready before the gap hits. The same URL is reused by the main
element so Chrome can serve from its in-memory buffer.

Also reset _trackChanging on play() failure to avoid permanently suppressing
pause events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:40:25 +02:00
HoneyandClaude Sonnet 4.6 89e169b70a fix: prevent notification close on skip and background audio stall
- playCurrent: set _trackChanging flag and update media session metadata
  before changing src, so the pause event fired by src-swap does not
  set playbackState='paused' and dismiss the Samsung cover screen notification
- Remove stalled->load() handler that silently paused audio with no recovery
- Error handler now retries regardless of paused state (load() causes paused)
- Add .catch() to audio.play() in playCurrent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:23:51 +02:00
Honey f2c77f975d fix: volume styling and mini bar switch bug 2026-05-03 17:32:05 +02:00
Honey 7744ae85db fix: Scrub lag fix and Volume control for Audiobooks 2026-05-03 17:20:02 +02:00
Honey 4b07203082 fix: Player switching 2026-05-03 17:10:03 +02:00
Honey 0d8b8b6f5d fix: Bug fixes Scrub lag Chapter track last chapter Mini bar stays over music Resume ABS doesn't stop music 2026-05-03 16:47:17 +02:00
Honey eb4d6fcbf9 fix: CROS fix, Mini Bar fix switches Audiobook and Music Player, Chapter Track mode added, Delted Playlist mode from Audiobooks 2026-05-03 15:14:19 +02:00
Honey 8f126562e7 update: Playlist mode toggle und Mini player bar für Audiobücher
fix: Music starts → ABS pauses: ABS starts → Music pauses
2026-05-03 15:04:30 +02:00
Honey 88999d30ca fix: Backend path error fix 2026-05-03 14:55:36 +02:00
Honey 0276894ed9 fix: Backend Proxy and Buttons Audiobookshelf 2026-05-03 14:51:47 +02:00
Honey 28f5b83751 fix. Cors Fix and Audiobookshelf Setting Design Fix 2026-05-03 14:49:08 +02:00
HoneyandClaude Sonnet 4.6 d70f649b90 fix: ABS proxy via backend to bypass CORS + settings button layout
- Add /api/abs-proxy/* endpoint in the backend that forwards all ABS API
  calls server-side (X-ABS-URL + X-ABS-Token headers). Browser never
  touches ABS directly for API calls — no CORS issue.
- abs.js routes through proxy when backendUrl is set; falls back to direct
  if no backend (requires user to configure CORS on ABS server)
- Audio streaming still uses direct <audio src> (HTML media bypasses CORS)
- Settings: status text above buttons, buttons full-width flex row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 14:44:16 +02:00
HoneyandClaude Sonnet 4.6 7c6343e28e fix: ABS API key auth + mobile bottom-nav Hörbücher button
- Replace username/password login with API key (Bearer token) auth
  - New absVerifyApiKey() verifies via GET /api/me instead of POST /login
  - Settings shows single API Key field with hint (Profil → API Token in ABS)
  - API key never pre-filled in settings for security
- Add "Bücher" button to mobile bottom-nav (bnav-library), hidden until connected
- _showAbsNavItem() helper keeps sidebar + bnav in sync on connect/disconnect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 14:41:18 +02:00
HoneyandClaude Sonnet 4.6 c17b7ea11a feat: Audiobookshelf integration — browse, stream, chapters, sleep timer
- src/abs.js: full ABS API client + player engine
  - Login/token auth, library browsing, search
  - Playback session API (multi-file book support via startOffset)
  - Progress sync to ABS every 30s and on pause
  - Chapter navigation (prev/next/seek-to), ±10s / ±30s skip
  - Speed control (0.75–2×), sleep timer with countdown
- index.html: sidebar nav, audiobooks/detail pages, full-screen player overlay,
  ABS settings section (URL + credentials + library picker)
- state.js: abs + absPlayer state objects
- views.js: navigate() handles 'audiobooks' and 'abs-book-detail'
- styles.css: book grid, detail page, player overlay, pickers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 14:35:00 +02:00
HoneyandClaude Sonnet 4.6 719d4d9afd fix: standard ALTCHA PoW + lower default volume to 65%
Replace custom PoW scheme with standard ALTCHA: SHA-256(salt+number)
solver, base64-encoded {algorithm,challenge,number,salt,signature} verify
payload. Add verbose logging to diagnose future failures.
Lower default audio volume from 0.8 to 0.65.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 13:57:47 +02:00
HoneyandClaude Sonnet 4.6 941a486913 fix: strip only Unicode symbols from artist name for ytsearch queries
Using the full artist name with Unicode symbols (e.g. ☆NoAki☆) in ytsearch
caused YouTube to return 0-1 completely irrelevant results, letting wrong songs
fall through to the no-filter fallback. Strip only \p{S} (Unicode symbol chars
like ☆ ★ ♥) but keep ASCII punctuation — so N!GHT stays "N!GHT" (important for
disambiguation) while ☆NoAki☆ becomes "NoAki" (searchable).
YTM URL still uses the original artist name with all chars intact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 12:42:06 +02:00
HoneyandClaude Sonnet 4.6 e858b02df7 fix: use original artist name in YTM search and add strict channel filter
Use mainArtist (with special chars) for ytsearch queries so e.g. "N!GHT - Yume"
searches correctly instead of "NGHT - Yume" which YouTube misreads as "night"
and returns ambience videos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 11:50:19 +02:00
HoneyandClaude Sonnet 4.6 03fb45b986 fix: use original artist name in YTM search and add strict channel filter
- YTM URL now uses unstripped artist name (e.g. "N!GHT" instead of "NGHT") for
  better search ranking on YouTube Music
- Steps 1-2 of cascade now include a soft channel ~= filter using the artist's
  first word, preventing wrong-artist songs with matching titles from being
  downloaded (e.g. a different "Moonlight" for N!GHT)
- Steps 3+ drop the channel filter so label-hosted tracks still get found

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 02:25:28 +02:00
HoneyandClaude Sonnet 4.6 7b92cf7444 fix: reduce playlist creation wait time after Spotify import
Cap Navidrome scan poll from 3 min to 60s, shrink post-scan buffer from 5s to 3s, and error fallback from 30s to 20s — playlist now appears sooner after downloads complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 02:11:04 +02:00
HoneyandClaude Sonnet 4.6 8f8c38fcf0 fix: word-boundary variant filter; reattempt ALTCHA verify payload structure
- ytdlp.js: use \b word boundaries in all variant filter regexes so that
  partial matches like "nightcoreals" containing "nightcore" no longer
  falsely reject official videos; also fixes sped regression (!~= \bsped\b
  instead of the too-narrow "sped up" pattern)
- squid.js: restructure verify payload to mirror challenge response layout
  { parameters, solution: { number, derivedKey }, signature }

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 01:48:29 +02:00
HoneyandClaude Sonnet 4.6 b9692e6f08 fix: include ALTCHA signature in verify payload; case-insensitive variant filter
- squid.js: add top-level signature from challenge response to verify payload
  (server added signature validation, causing HTTP 400 without it)
- ytdlp.js: switch variant filter from !*= to !~= (?i) so Remix/REMIX/remix
  are all rejected, not just lowercase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 01:27:10 +02:00
HoneyandClaude Sonnet 4.6 598c108214 fix: detect YTM artist channel expansion in getYtmVideoUrls; revert PWA MediaSession changes
- ytdlp.js: watch for [youtube:tab] in yt-dlp output during flat-playlist step;
  return [] when channel expansion is detected so cascade falls through to ytsearch
- Revert player.js / main.js / state.js PWA _changingTrack changes (not effective)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 01:11:36 +02:00
Honey a5959b19b8 fix: pwa changes 2026-05-02 21:27:57 +02:00
HoneyandClaude Sonnet 4.6 89abc81875 fix: unwrap ALTCHA challenge .parameters wrapper before solving PoW
The /api/altcha/challenge response is { parameters: { nonce, salt, ... } }.
Destructuring the top level left nonce/salt/keyPrefix as undefined, causing
Buffer.from(undefined) crash on every squid.wtf download attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:55:26 +02:00
HoneyandClaude Sonnet 4.6 7aad4989f3 fix: pre-filter YTM search results to skip artist channel expansion
--match-filter 'webpage_url !*= /browse/' was applied to individual videos
AFTER yt-dlp expanded an artist channel into its uploads playlist — by then
each video had a /watch?v= URL, so the filter never fired.

New approach: run yt-dlp --flat-playlist --print webpage_url first to get
the raw search result URLs, filter /browse/ entries in Node.js, then pass
only the actual watch?v= video URLs to yt-dlp for download with --max-downloads 1.
The channel is never expanded; we get the real song from the search results.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:51:21 +02:00
HoneyandClaude Sonnet 4.6 ac8d26bf9d feat: split backend into lib/ modules + add squid.wtf (Qobuz) download backend
- backend/lib/config.js  — CONFIG from env (adds DOWNLOAD_BACKEND, SQUID_QUALITY)
- backend/lib/jobs.js    — jobs Map, updateJob(), log()
- backend/lib/metadata.js — iTunes, artwork, lyrics, sanitize helpers
- backend/lib/ytdlp.js   — yt-dlp download cascade + shared embedMetadata()
- backend/lib/squid.js   — ALTCHA PoW solver, Qobuz search/download via squid.wtf
- backend/lib/downloader.js — backend selector: ytdlp | squid | auto (squid-first + fallback)
- novasonic-server.js thinned to Express routes only, imports from lib/
- docker-compose: mount full backend/ dir, install deps outside read-only volume
- .env.example: document DOWNLOAD_BACKEND and SQUID_QUALITY

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:44:26 +02:00
HoneyandClaude Sonnet 4.6 2d71fdce6a fix: skip artist channel results, live notification list update
- Add --match-filter 'webpage_url !*= /browse/' to baseArgs so yt-dlp
  skips YTM artist channel pages returned as search results (previously
  caused recursive channel-upload fetches that wasted time)
- Call _renderNotifList() in _addNotif so playlist-created notifications
  appear immediately without needing a page reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:59:04 +02:00
HoneyandClaude Sonnet 4.6 f831e1fd8f fix: use exact expected filename instead of newest-file heuristic
Parallel downloads to the same album dir (e.g. two tracks from same
album) both resolved to the last-written file, causing wrong metadata
to be embedded. Now each download targets its known output path
(trackPrefix + safeTitle + audioFmt) directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:25:51 +02:00
HoneyandClaude Sonnet 4.6 3962a90b0d fix: delete cover.jpg and leftover webm/m4a after ffmpeg embed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:09:12 +02:00
HoneyandClaude Sonnet 4.6 252d2d3071 feat: download playlist tracks in parallel batches (MAX_PARALLEL)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:03:29 +02:00
HoneyandClaude Sonnet 4.6 c1a740240a fix: normalize diacritics in playlist artist matching
Use Unicode property escapes to strip only symbols (☆, !, etc.)
while keeping Unicode letters (ø, ä, é) — fixes "Kløn" becoming
"Kln" instead of staying "Kløn" in the search query.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:54:50 +02:00
HoneyandClaude Sonnet 4.6 afcbf81547 fix: strip parens from keywords, top-10 search, clean search query
- buildTitleKeywords: strip parenthetical tags before picking words so
  "Eleven Night (Jersey Club)" → keywords ["Eleven","Night"] instead of
  ["Eleven","Jersey"] — "Jersey" was never in the YT video title
- Cascade: add top-10 steps (3+4) with duration still active before
  dropping duration filter, so "Whispers" (position 6-10) can be found
  without falling through to the unfiltered last resort
- search query: strip non-ASCII chars from artist name so "☆NoAki☆"
  becomes "NoAki" in the query → cleaner YTM search ranking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:49:53 +02:00
HoneyandClaude Sonnet 4.6 51d2ea257f fix: use top-2 title keywords for precise match-filter
Single-word filter was too loose: "Echoes of Eternity" produced only
"Eternity" which matched unrelated tracks. Now the top 2 longest words
are used so the filter becomes e.g.
  title ~= (?i)Eternity & title ~= (?i)Echoes?
  title ~= (?i)Amethyst & title ~= (?i)Flowers?
Both words must appear — drastically reduces false matches.
Trailing-s stays optional (Whispers? matches Whisper/Whispers).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:10:05 +02:00
HoneyandClaude Sonnet 4.6 5e7e700d6a fix: rewrite _createNdPlaylist to remove problematic Unicode regex
The normD regex used actual Unicode combining chars (U+0300-U+036F)
in the source which some build tools mangle, causing a SyntaxError at
runtime and silently killing playlist creation. Replaced with normA
that strips non-word/non-space chars via plain ASCII regex, which is
safe and achieves the same loose artist-first-word comparison.
Also fixed race condition in _pollAndCreatePlaylist — save lastStatus
before job is auto-deleted so the done check still works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:07:26 +02:00
HoneyandClaude Sonnet 4.6 2d1c9029c5 fix: add YTM 'Songs only' filter param to search URL
Without &params=EgWKAQII..., yt-dlp gets a mixed feed of videos,
channels and playlists alongside tracks — same query that shows the
correct song first in the browser "Titel" tab returned nursery rhyme
videos and unrelated channels. This param is YTM's internal 'Songs'
filter (equivalent to clicking the Titel tab).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 15:38:35 +02:00
HoneyandClaude Sonnet 4.6 fed3df7e36 fix: normalize diacritics in playlist artist matching
normD strips accents/special chars before comparing artist first-word
so "Kløn" (iTunes) matches "Klon" (embedded tag) → "klon" === "klon"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 15:33:01 +02:00
HoneyandClaude Sonnet 4.6 971cadd8fa fix: match-filter trailing-s optional + playlist search fallback for special chars
- buildTitleKeywords: append '?' to keywords ending in 's' so regex
  "Whispers?" matches both "Whisper" (YouTube title) and "Whispers"
  (iTunes title) — fixes NoAki Whispers downloading wrong track
- _createNdPlaylist: if artist+title search returns no title match,
  retry with title-only query so tracks with special chars in artist
  name (e.g. "Kløn") are still found in Navidrome

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 14:57:51 +02:00
212 changed files with 39013 additions and 3617 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# Frontend build context (root Dockerfile) — keep it to what `vite build` needs.
.git
node_modules
dist
android
docs
scripts
backend
scrobble
.env
*.log
+33 -10
View File
@@ -1,13 +1,36 @@
# Copy to .env and fill in your values
# Copy to .env next to your docker-compose file and fill in. cp .env.example .env
# Used by docker-compose.yml (zieht fertige Images) und docker-compose.dev.yml (baut lokal).
# REGISTRY/TAG unten braucht nur der Pull-Weg (docker-compose.yml).
# Pre-fills the URLs on the setup screen
NAVIDROME_URL=https://music.yourdomain.com
BACKEND_URL=https://novasonic-api.yourdomain.com
# ── URLs pre-filled on the frontend setup screen ─────────────────────────────
NAVIDROME_URL=https://music.example.com
BACKEND_URL=https://backend.example.com
SCROBBLE_URL=https://scrobble.example.com
# Absolute path to your Navidrome music library on the host
# This exact path is used inside the container — no subdirectory is added
MUSIC_PATH=/srv/navidrome/music
AUDIO_FMT=mp3 # mp3 | flac | opus | best
AUDIO_QUALITY=0 # 0 = best quality
# ── Library / downloads (backend) ────────────────────────────────────────────
# Absolute music path — mounted 1:1 into the backend (no remapping), same value inside the container.
MUSIC_PATH=/opt/navidrome/music
AUDIO_FMT=mp3
AUDIO_QUALITY=0
MAX_PARALLEL=2
DOWNLOAD_BACKEND=ytdlp
TICKETMASTER_API_KEY=
# ── Android APK download button + in-app updater ─────────────────────────────
# The backend proxies the fixed Gitea release ("apk-latest") over the LAN, so Gitea can stay internal.
GITEA_URL=http://192.168.2.51:3000 # Gitea-Basis, die der BACKEND-CONTAINER erreicht.
# Muss nicht Giteas eigene ROOT_URL sein — das Backend baut
# die Asset-URL auf diesen Host um (Test aus dem Container:
# curl -sI $GITEA_URL/api/v1/version).
GITEA_REPO=Docker/NovaSonic
GITEA_TOKEN= # read token (only needed for a private repo)
GITEA_APK_TAG=apk-latest
# ── Fertige Images (nur docker-compose.yml) ──────────────────────────────────
# REGISTRY = where `npm run push-images` pushed to and where the host pulls from.
# Gitea container registry (lowercase owner): gitea.honeyy.duckdns.org/docker
# Docker Hub: docker.io/youruser
# Plain-HTTP internal registries need the host's Docker daemon to allow it
# (/etc/docker/daemon.json → {"insecure-registries":["192.168.2.51:3000"]}).
REGISTRY=gitea.honeyy.duckdns.org/docker
TAG=latest
+3
View File
@@ -2,3 +2,6 @@
*.log
node_modules/
dist/
scripts/.env.release
.env
.idea/
+1518
View File
File diff suppressed because it is too large. Load diff
+251
View File
@@ -0,0 +1,251 @@
# CLAUDE.md
Guidance for Claude Code when working in this repository.
## What this is
NovaSonic — a self-hosted music streaming **PWA** (Vite + vanilla ES modules) and
**Android app** (Jetpack Compose + Media3/ExoPlayer) for Navidrome / Subsonic servers,
plus a **Node.js/Express backend** that downloads tracks via yt-dlp and proxies APIs.
## Critical rules
- **Never hardcode or mock frontend data.** All library data comes from the Navidrome
(Subsonic) API. No placeholder songs/albums/artists, ever.
- **Don't commit or push unless asked.** When asked, push to the Gitea remote.
- **Two-part versioning** `MAJOR.MINOR` (e.g. `0.8`) — no patch component. Each release
bumps the minor; features and fixes go in the same release. See `CHANGELOG.md`.
- Keep the `CHANGELOG.md` Platform Parity Matrix accurate when adding cross-platform features.
## Layout
- `src/` — web PWA (vanilla ES modules). State in `src/state.js` (the `S` object);
Subsonic/Deezer API in `src/api.js`; views in `src/views/`, UI in `src/ui/`.
- **Client-side router** (`src/views/navigation.js`, History API): `navigate()` pushStates `{nsIdx}`
only on genuine new navigation; `popstate` applies the target entry's `nsIdx` for **both** back and
forward. Never `pushState` inside the `popstate`/back path — it wipes the browser's forward stack
(that was the "forward doesn't work" bug). In-app back buttons call `history.back()` (one path).
- `backend/novasonic-server.js` — Express API (downloads, metadata edit/delete, events/ABS proxy).
- `backend/lib/` — `ytdlp.js` (download + cascade + ffmpeg tagging), `downloader.js`,
`deezer.js` (Deezer metadata: track/album/artist lookups + matching),
`metadata.js` (artwork, file resolution helpers, duplicate detection), `config.js`, `jobs.js`, `romaji.js`.
- `scrobble/scrobble-server.js` — **separate** listening-stats service (own container + DB), see below.
- `android/` — native Kotlin app (separate; backend-dependent web features like the
metadata editor are **not** in the Android app).
## Commands
```bash
npm run dev # Vite dev server (HMR)
npm run build # production build → dist/
npm run preview # preview the production build
```
Der Stack läuft in Docker. **`docker-compose.yml` ist der Normalfall und zieht fertige Images**
(`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}`) — der Server baut nichts:
`docker compose pull && docker compose up -d`. Ein einfacher Restart ist `docker compose restart
novasonic-backend` (das Backend zieht bei jedem Start das neueste yt-dlp nach).
Gepusht wird von der Dev-Maschine mit `npm run push-images` (`scripts/build-push-images.mjs`, liest
`REGISTRY`/`TAG` aus `.env`); ohne Node tut es direkt `docker build -t $REGISTRY/<name>:latest
<context>` + `docker push` mit den Kontexten `.`, `backend/`, `scrobble/`. Registry ist die
Gitea-Registry (`…/docker`, Owner klein geschrieben).
**`docker-compose.dev.yml`** ist die Variante, die stattdessen lokal aus den Dockerfiles baut
(`backend/Dockerfile` + `scrobble/Dockerfile`), ohne Registry: `docker compose -f
docker-compose.dev.yml up -d --build`. Die schweren Deps (ffmpeg, Deno, yt-dlp, node libs) sind in
den Images gebacken, deshalb sind **Restarts sofort da** und Rebuilds nach `git pull` cache-schnell.
On the deploy host the music lives at `/opt/navidrome/music`.
## Download pipeline (hard-won knowledge)
- Downloads are **yt-dlp only** (the old squid.wtf/Qobuz backend was removed).
- **Metadata source = Deezer, not iTunes** (as of 1.3). Apple's iTunes `/search` returns **403 from
server/datacenter IPs**, which broke search + downloads + charts + editor autofill in production.
All metadata now comes from Deezer (`backend/lib/deezer.js` server-side; `_deezerBase()` in
`src/api.js`; Android `ItunesClient` is Deezer-backed). Free, no key, more requests, returns
explicit versions + real artist images + durations. Deezer generally gives one consistent name per
artist, so the old JP/US-store inconsistency (Western artists as katakana "DJスネイク" on some tracks,
kanji on others) is gone. Titles come from the source, so a Japanese title the user picked stays
Japanese; only Deezer-derived fields (album\_artist, genre, year, artwork) follow Deezer. The
Deezer results are mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged.
`lookupTrackMeta`/`fetchAlbumTracks`/`searchAlbumTracks`/`fetchArtistAlbums` replace the old
`lookupItunesTrackMeta`/`fetchItunesTracks`/`searchItunesAlbumTracks`. Editor autofill endpoint:
`/api/meta/lookup` (was `/api/itunes/lookup`). Matching logic (`pickTrackMatch`) is unit-tested in
`backend/test/deezer.test.js` (`npm test`).
- **File naming:** `{NN - }Artist - Title.ext` with the release suffix stripped from the
title (no `- Single`/`- EP`). The **album folder keeps the full album name**. Original names
are used for folder, filename, and all main tags (artist/title/album/album\_artist) — even
for Japanese tracks. Romaji goes **only** into sort tags (TITLESORT / ARTISTSORT / etc.).
- **Search cascade** (`ytdlp.js`): YTM search first (`web_music` player client, Songs filter
param), then regular YouTube; each candidate filtered by duration (±10%, min 10s),
title keyword, artist channel, and variant exclusions (remix/cover/sped/…).
- **Official audio via `artist` metadata (not just channel).** The strict steps accept a candidate
if the channel name matches the artist **OR** yt-dlp's music `artist` field does. YouTube "Art
Tracks" (label-provided official audio) carry `artist=<Artist>` even when the channel is
`BANGTANTV`/`HYBE LABELS`/… (no artist name). Confirmed on "BTS Pied Piper": only the official
upload had `artist=BTS`; every fan/live/lyric reupload had `artist=NA`. `channel_is_verified` was
rejected as a signal — lyric videos and fan MVs also run on verified channels. Implemented as two
`--match-filter` args (yt-dlp OR's them) in `mf()`.
- **"Mittelweg" rule** (with AND without duration): when nothing matches in the filtered steps,
relax only the duration constraint while **still requiring the artist channel**. If nothing
matches, download **nothing** and fail cleanly with "Nicht eindeutig gefunden — bitte
Direktlink im Importer nutzen". Never fall back to a blind title-only/no-filter grab — it
pulled same-titled covers by other uploaders, and wrong Piano Echoes for Japanese tracks.
- **Numbers in a title are always required keywords.** `buildTitleKeywords` kept only the two
LONGEST words, and numbers are short — so "700 Main Street" was filtered on Street+Main only and
the cascade happily saved **"777 Main Street" by a different artist** (step 3 drops the channel
check, so nothing else caught it). Numeric tokens are now emitted as `\b<n>\b` in addition to the
two longest words. Same fix carries a small abbreviation map (street/st, saint/st, mount/mt,
doctor/dr, versus/vs, number/no): uploaders write "700 Main St" where the release says "Street",
which had rejected the correct official Art Track in every step. One exception: a **re-release year
next to a qualifier** ("… - 2020 Remaster") is dropped again — it sits on the release but never in
the YouTube title, so requiring it would reject the correct upload. A title that IS a year ("1999",
"Berlin 2020") keeps it. Unit-tested in `backend/test/ytdlp.test.js`.
- **Live recordings are excluded by title, not by `is_live`.** `!is_live` only means "not a running
stream" — a concert RECORDING passes it. On the artist's own channel nothing else distinguishes it
from the studio track, which is how "Dat Adam - Ufo Video Days 2015 Live" (channel `Dat Adam`,
209 s, title contains UFO) got saved as the album track. `buildVariantFilter` therefore carries a
`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`
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×
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
(`.diedlonely`, `†…`). `deezer.js` strips leading non-alphanumerics (`stripLeadSym`) before the
Deezer lookup and in `firstWord`. Without it the lookup misses, falls through to a title-only
match, and returns an unrelated same-titled track — whose wrong duration then *also* misleads the
YouTube duration filter (observed: a 96 s track resolved to a 176 s slowed version). The same trim
runs client-side in Android's `ExploreViewModel.search()`.
- **Mixed-artist albums search per track.** `downloadAlbumTracks` passes `rawArtist = track.artistName`
so the YTM/YouTube search query and the channel filter use each track's *own* performer, not the
album artist. `ytdlp.js` derives both from `rawArtist || mainArtist`, and `mainArtist = albumArtist`,
so without this every track on a soundtrack searched the album-artist blob + its channel filter and
matched nothing (e.g. "Strategy" = TWICE, "Love, Maybe" = MeloMance failed). The `album_artist` tag
still uses `albumArtist`, so album grouping is unaffected.
- **Album/artist download `album_artist`** comes from the Deezer tracks' `collectionArtistName`
(= the album-level `album.artist`, identical across the album), derived in `novasonic-server.js` —
**not** the request's `artist`, which can be the lead single's performer (a KPop soundtrack got
tagged `album_artist=TWICE` from track 1 "TAKEDOWN"). Falls back to the request artist only when
Deezer omits it.
- **Always write an `album` tag.** `ytdlp.js` defaults it to `Singles` when no album is known.
Previously no tag was written at all, so Navidrome showed its own "Singles" fallback while the
metadata editor showed "[Unknown Album]" — the two never matched and file resolution failed.
- **Duplicate detection** (`metadata.js findExistingTrack`): searches case-insensitively across
all album folders under the matching artist folder; compares normalized artist+title ignoring
track prefix and release/variant suffix. Handles DJ OKAWARI vs DJ Okawari and "- Single" drift.
- **Single-track metadata resolver = `backend/lib/trackmeta.js`** (Deezer → MusicBrainz → clean
fallback). Deezer's `pickTrackMatch` requires an **artist correspondence** (equal, or one name
contained in the other after stripping non-alphanumerics) — the old blind *title-only* fallback
grabbed a same-titled song by a different artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and
applied its wrong album/cover/year. On no match it returns `{}`; then `musicbrainz.js` (rate-limited
~1/s, `User-Agent` required, Cover Art Archive for covers, **same** artist discipline) is tried; if
that also misses, the download's built-in clean fallback runs (**album_artist = song artist, album =
"Singles", cover = source thumbnail, year/genre left untouched**). Only the three *single-track*
call sites use `trackmeta`; the album batch stays Deezer-only (avoids MB's rate limit). The clean
fallback always existed but was unreachable while the match was too greedy. Matching is unit-tested
(`deezer.test.js`, `musicbrainz.test.js`).
- **Known limitation:** yt-dlp's YTM/YouTube *search* can't surface very obscure Art Tracks by
ambiguously-named artists (e.g. "MINA") — the browser finds them, yt-dlp's API extraction
doesn't (confirmed across params/clients/deno). For those, the **direct link in the importer**
(`sourceUrl` path) is the reliable route.
- **File resolution for edit/delete:** Navidrome's stored path may use a different mount root
or a stale stem. `metadata.js` rebases the path and falls back to recursive/stem matching
(tolerant of release-suffix mismatch). A ghost Navidrome entry needs a full library rescan.
- **Album splitting:** prevented by consistent `album_artist` tag. All tracks in a download
share the same `mainArtist` value (= Deezer `collectionArtistName` when available, else
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
## Listening stats / scrobble system (v2.0)
- **Why separate:** Navidrome stores only a play *count* + *last* play date (no timestamps), so
per-day/month/year stats and a "Wrapped" are impossible from it. NovaSonic *is* the player, so it
records every counted play into its **own** timestamped, multi-user log.
- **Own container `novasonic-scrobble`** (`scrobble/scrobble-server.js`) — deliberately isolated from
the download backend: the backend is heavy (yt-dlp/ffmpeg) and restarts often; an archive needs
uptime + data integrity. Runs on `node:22-slim` with the **built-in `node:sqlite`** (run with
`--experimental-sqlite`; no native build). DB `/data/novasonic-plays.db` in its own volume.
- **Routes:** `POST /scrobble/plays` (batch, **idempotent by uuid**), `GET /scrobble/history`,
`GET /scrobble/stats` (totals, top artists/albums/tracks, day/month/year series, 24h clock),
`DELETE /scrobble/plays?user=` (test cleanup), `GET /scrobble/health`.
- **Multi-user auth:** every request carries the same Subsonic token creds (`u`, `t`, `s`) the app
already uses; the service verifies them via Navidrome **`ping.view`** (cached 5 min) → the stored
`user` is real, not spoofable. `node:sqlite` **throws on extra named params**, so each query gets
exactly the params its SQL references. `from` defaults to all-time (do **not** route `0` through
`_toEpochSec`, which maps `0`→now).
- **Denormalized on purpose:** title/artist/album are stored per play so the archive survives even if
the song is later deleted from Navidrome. Deleting a song does **not** touch the play-log.
- **Capture point (Web + Android):** `logPlay()` fires at the **existing** counted-scrobble moment
(respects the user's `scrobbleMode`, consistent with Last.fm) — **not** a separate 50%/4-min rule.
It runs **in addition to** the Navidrome/Last.fm scrobble, never replacing it. Web:
`src/player/scrobbleLog.js` (localStorage queue) + `subsonicAuthParams()` in `api.js`. Android:
`data/PlayLogClient.kt` (JSON-file queue, **one reused Ktor client**) + `repo.logPlay/flushPlayLog`
+ hook in `PlayerController.scheduleScrobble`. Both queue offline and flush as an **idempotent**
batch (server dedups by uuid) on start / when connectivity returns.
- **Stats view:** Web `src/ui/stats.js` (overlay from Settings), Android `ui/screen/StatsScreen.kt` +
`StatsViewModel.kt` (from a Settings button). **Cover art is resolved client-side** from Navidrome
(`coverUrl`/`coverArtUrl` via `artistId`/`albumId`/`songId`) — the scrobble service stays
cover-agnostic; the stats endpoint just returns the IDs.
- **Config:** `SCROBBLE_URL` env → `window.NS_SCROBBLE_URL` (entrypoint) → `S.cfg.scrobbleUrl` (web) /
`scrobbleUrl` pref (Android), mirroring `backendUrl`. Port **3710**.
- **Deploy gotcha:** the scrobble subdomain **must be excluded from any SSO / forward-auth gateway**
(like the download backend) — it self-authenticates via `ping.view`, and a gateway in front
302-redirects the browser's API calls to the login portal (breaks `fetch` → "nicht erreichbar").
- **Planned:** `/scrobble/wrapped` (yearly story) + optional ListenBrainz relay. See
`docs/scrobble-plan.md`.
## Android offline system
- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
- **Database:** Room DB `novasonic_offline.db` (version 1) with three tables: `downloaded_tracks` (track metadata + local paths + state), `download_groups` (album/playlist/song group + progress), `group_track_cross_refs` (many-to-many). See [OfflineEntities.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt) and [OfflineDao.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt).
- **OfflineRepository** ([OfflineRepository.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt)): holds hot `StateFlow` maps (`_audioMap`, `_coverMap`) keyed by songId/coverArtId for O(1) lookups in `MusicRepository.streamUrl()`. Call `enqueueGroup()` to schedule a download.
- **DownloadWorker** ([DownloadWorker.kt](android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt)): `CoroutineWorker` using plain `HttpURLConnection` streaming to `.tmp` → rename. **No `setExpedited()` and no `setForeground()`** — `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned on Android 15 (targetSdk=35) and crashes the app; downloads are silent background work.
- **Playback:** `MusicRepository.streamUrl()` checks `offlineRepo.localAudioPath(id)` first; if the file exists it returns a `file://` URI. This applies to every play path — album, playlist, Offline tab — no special offline mode needed.
- **Offline tab:** Library → tab 2 (OFFLINE). Driven by `OfflineViewModel`. Play button on a completed group calls `offlineVm.songsForGroup()` (JOIN query → `DownloadedTrackEntity.toSong()`) then `player.playSongs()`.
## Android navigation, state & insets (hard-won)
- **A `BackHandler` inside a NavHost destination loses back priority to the NavHost.** The Erkunden
(Apple) artist/album are in-screen *overlays* on the `erkunden` destination, not routes — their
`BackHandler` never fired, so one back press popped the whole tab to Home. Fix: the overlay reports
its close action upward via `onOverlayBackChange`, and `AppNavigation` registers a **root-level**
`OnBackPressedCallback` for it (the same mechanism the NowPlaying overlay always used successfully).
Real nav routes (`album/{id}`, `artist/{id}`) are unaffected — `popBackStack()` is correct there.
- **Re-assert root-level back callbacks on `ON_RESUME`, not just on navigation.** Returning from the
background re-registers the NavController's own `OnBackPressedCallback` on **top** of the dispatcher,
which steals back-priority from the NowPlaying/ABS/Erkunden overlays — back then pops the NavHost
(invisibly, to Home) before dismissing the visible overlay. Fix in `AppNavigation`: a `resumeTick`
(`LifecycleEventEffect(ON_RESUME)`) plus `currentBackEntry` are keys on every overlay's
`DisposableEffect`, so each re-adds (bumps itself above the NavController) after a resume or a nav.
- **Overlay state belongs in the ViewModel, not `remember`.** Anything an overlay shows (e.g. the
artist's "all albums" section) is destroyed when the user drills into an album, because the overlay
leaves composition. `rememberSaveable` does **not** save you — it only survives for real nav
destinations. Hoist it into the ViewModel (see `ExploreViewModel.artistAllAlbums`).
- **`bottomBar` insets:** the tab `NavigationBar` absorbs the system-navigation inset. Detail screens
have no tab bar, so the MiniPlayer needs `navigationBarsPadding()` itself — otherwise it sits behind
the phone's gesture/navigation bar.
- **`ModalBottomSheet`:** use `rememberModalBottomSheetState(skipPartiallyExpanded = true)` *and*
`navigationBarsPadding()` on the content, or the bottom-most action (e.g. "Herunterladen") opens
hidden behind the navigation bar and needs an extra drag-up.
- **Reuse the Ktor client.** `MusicRepository.coverArtUrl()/streamUrl()` must go through
`ClientHolder.get(creds)`. Constructing a `SubsonicClient` per call spins up (and never closes) an
`HttpClient` for *every cover art request* — this was the cause of visible scroll/tab-switch lag.
- **Star/favorite is optimistic and global** via `data/StarState.kt`. Toggle through it so every icon
(song rows, NowPlaying, context sheet) flips instantly and rolls back if the API call fails.
- **Deezer artists carry a real photo** (`picture_*`), so artist search cards show it directly — no
album-cover borrowing needed. (The old iTunes artist entities had no `artworkUrl100`, which is why
the `entity=album&limit=1` album-cover fallback exists; it now only runs if a picture is missing.)
## UI / references
When referencing files in chat, use clickable markdown links, not backticks:
`[ytdlp.js](backend/lib/ytdlp.js)`.
+147 -57
View File
@@ -1,89 +1,188 @@
# NovaSonic
A self-hosted music streaming PWA for Navidrome / Subsonic-compatible servers.
Built with Vite + vanilla ES modules, served via nginx in Docker.
A self-hosted music streaming PWA and Android app for Navidrome / Subsonic-compatible servers.
Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlayer (Android).
---
## Features
- Stream music from your Navidrome / Subsonic library
- Browse artists, albums, playlists, starred tracks and random picks
- Queue management with shuffle, repeat (all / once) and radio mode (endless random)
- Now Playing overlay with swipe-to-dismiss and lock screen media controls (Android/iOS)
- iTunes / Last.fm integration — charts, global search, 30s previews, recommendations
- Download tracks from YouTube/SoundCloud via yt-dlp directly into your music library
- Duplicate detection — skips tracks that already exist on the server
- Edit playlists (name, description, cover art, public toggle)
- PWA — installable, offline-capable via service worker
- Dark theme, mobile-first responsive layout
- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
- **Artist pages** — Hero card, bio / similar artists / top songs, Ticketmaster events, and a Deezer cross-reference showing owned vs. missing tracks. Same-name artists can be **pinned** (Deezer search or a pasted Deezer link/id) to fix the wrong-discography and wrong-photo problem; a pinned artist's similar list comes from Deezer.
- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with Deezer metadata enrichment and duplicate detection.
- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save.
- **Offline** (Android) — Download albums, playlists, or single songs to private storage; an Offline tab to play and manage them (works without a server connection); downloaded tracks play from disk on every screen.
- **Listening stats** — A separate, self-hosted scrobble service records **timestamped plays** (multi-user; Navidrome only keeps a play *count*). A statistics view (Web + Android, from Settings) shows totals, top artists/tracks/albums with cover art, a day/month/year play series, and a 24h listening clock, plus a **Musik-Match** that compares two users' taste. Runs *in addition to* Navidrome/Last.fm scrobbling.
- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync.
Web is an installable PWA (themes, German UI); Android is native Jetpack Compose + Media3/ExoPlayer with a mini player, media/ABS notifications, and Android Auto. See [CHANGELOG.md](CHANGELOG.md) for the full web/Android parity matrix.
---
## Project Structure
```
Music_App/
├── src/ # ES modules (Vite entry)
NovaSonic/
├── src/
│ ├── player/
│ │ ├── player.js # Playback, queue, transport, media session, preload
│ │ ├── nowplaying.js # Now Playing overlay + synced lyrics
│ │ └── scrobbleLog.js # Play-log capture + offline queue → scrobble service
│ ├── views/
│ │ ├── detail.js # Artist / album detail pages
│ │ ├── views.js # Navigation, page loaders, search
│ │ ├── events.js # Ticketmaster concert events
│ │ ├── home.js # Home page
│ │ ├── search.js # Search, artist/album navigation
│ │ ├── playlists.js # Playlist management
│ │ ├── spotify.js # Spotify importer
│ │ ├── youtube.js # YouTube importer
│ │ ├── soundcloud.js # SoundCloud importer
│ │ ├── navigation.js # Client-side router
│ │ └── abs.js # Audiobookshelf integration
│ ├── ui/
│ │ ├── ui.js # Modals, sidebar, context menu
│ │ ├── components.js # Song rows, cards, shared UI
│ │ ├── downloads.js # Download modal + job tracker
│ │ ├── metadata.js # Metadata editor
│ │ ├── modals.js # Song info, events modals
│ │ ├── notify.js # Toast notifications
│ │ ├── stats.js # Listening statistics view (overlay)
│ │ └── settings.js # Settings page
│ ├── state.js # Shared mutable state (S object)
│ ├── utils.js # md5, esc, formatDur, toast
│ ├── api.js # Subsonic API, Navidrome REST, iTunes API
│ ├── player.js # Playback, transport, queue, media session, preview
│ ├── views.js # Navigation, page loaders, card makers, search
│ ├── ui.js # Modals, overlays, sidebar, settings, context menu
│ ├── pwa.js # Service worker registration + update banner
│ ├── utils.js # Helpers (md5, esc, formatDur, toast)
│ ├── api.js # Subsonic API, Deezer API, cover art
│ └── main.js # App init, audio wiring, window globals
├── public/ # Static assets (copied as-is to dist/)
├── backend/
│ ├── novasonic-server.js # Express API: downloads, metadata, events proxy, ABS proxy
│ └── lib/
│ ├── config.js # Env var → config object
│ ├── jobs.js # In-memory download job store
│ ├── deezer.js # Deezer metadata: track/album/artist lookups
│ ├── metadata.js # Artwork, filename helpers, duplicate detection
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
│ └── romaji.js # Japanese → romaji search index
├── scrobble/ # Isolated listening-stats service (own container + DB)
│ └── scrobble-server.js # node:sqlite play-log: ingest / history / stats / health
├── android/ # Native Android app (Jetpack Compose + Media3)
│ └── app/src/main/kotlin/com/novasonic/app/
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient (Deezer-backed)
│ ├── data/ # PrefsStore (DataStore), MusicRepository, StarState
│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads)
│ ├── player/ # PlayerController, MusicService (Media3)
│ ├── ui/
│ │ ├── screen/ # HomeScreen, NowPlayingScreen, AbsScreen, SettingsScreen, …
│ │ ├── component/ # MiniPlayer, SongRow, SongContextSheet, …
│ │ └── theme/ # Color, Type, Theme
│ └── viewmodel/ # ViewModel per screen
├── public/
│ ├── sw.js # Service worker
│ ├── manifest.json # PWA manifest
│ ├── icon.svg # App icon
│ └── config.js # Runtime config placeholder (generated by Docker)
├── backend/
│ └── novasonic-server.js # Node.js download backend (yt-dlp + ffmpeg)
├── index.html # App shell + Vite entry point
├── styles.css # All styles
│ └── config.js # Runtime config (generated by Docker entrypoint)
├── index.html
├── styles.css
├── vite.config.js
├── Dockerfile # Multi-stage: Node build → nginx serve
├── docker-compose.yml
├── Dockerfile
├── docker-compose.yml # Standard: zieht die fertigen Images aus der Registry
├── docker-compose.dev.yml # Variante: baut lokal aus den Dockerfiles
├── nginx.conf
└── docker-entrypoint.sh # Injects NS_SERVER_URL / NS_BACKEND_URL at startup
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL / SCROBBLE_URL at runtime
```
---
## Docker Setup
## Setup
### Frontend only
### 1. Clone and configure
```bash
git clone <repo>
cd NovaSonic
cp .env.example .env
```
Edit `.env`:
```env
NAVIDROME_URL=https://music.yourdomain.com
BACKEND_URL=https://novasonic-api.yourdomain.com
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # listening-stats service
MUSIC_PATH=/srv/navidrome/music
AUDIO_FMT=mp3 # mp3 | flac | opus | best
AUDIO_QUALITY=0 # 0 = best (yt-dlp)
MAX_PARALLEL=2
TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
```
### 2. Start
```bash
docker compose pull
docker compose up -d
```
Environment variables (set in `docker-compose.yml` or via `-e`):
Zieht die drei fertigen Images aus der Registry — nichts wird auf dem Server gebaut. Dafür in der
`.env` `REGISTRY` (und optional `TAG`) setzen; die Images heißen
`$REGISTRY/novasonic-frontend|-backend|-scrobble`. Neue Stände lädt man von der Dev-Maschine mit
`npm run push-images` hoch (bzw. `docker build -t $REGISTRY/<name>:latest <context> && docker push …`
— Kontexte: `.` (frontend), `backend/`, `scrobble/`).
**Selbst bauen statt ziehen** (keine Registry nötig):
```bash
docker compose -f docker-compose.dev.yml up -d --build
```
Baut lokal aus den Dockerfiles; die Deps stecken in den Images, also sind Restarts sofort da und
Rebuilds nach einem `git pull` dank Layer-Cache schnell.
Frontend on port 8080, backend on port 3700, scrobble service on port 3710. Route each through your reverse proxy. **Important:** the scrobble subdomain must *not* sit behind an SSO / forward-auth gateway — it authenticates itself via Navidrome (`ping.view`), and a gateway in front redirects the app's API calls to a login page.
### 3. First launch
1. Enter your Navidrome server URL, username, and password
2. Optionally add a Last.fm API key in Settings for bios and recommendations
3. Optionally connect Audiobookshelf in Settings
4. Optionally add the NovaSonic backend URL in Settings to enable downloads
5. Optionally add the NovaSonic scrobble URL in Settings to record listening stats
---
## Environment Variables
### Frontend container
| Variable | Description |
|---|---|
| `NS_SERVER_URL` | Pre-fills the Navidrome server URL on the login screen |
| `NS_BACKEND_URL` | Pre-fills the download backend URL |
| `NAVIDROME_URL` | Pre-fills the Navidrome server URL on the login screen |
| `BACKEND_URL` | Pre-fills the download backend URL on the login screen |
| `SCROBBLE_URL` | Pre-fills the listening-stats (scrobble) service URL on the login screen |
### With download backend
The backend requires `yt-dlp` and `ffmpeg`. A separate `docker-compose.yml` lives in `backend/`.
```bash
cd backend
docker compose up -d
```
Environment variables for the backend:
### Backend container
| Variable | Default | Description |
|---|---|---|
| `PORT` | `3700` | Port the backend listens on |
| `MUSIC_PATH` | `/music` | Path to your Navidrome music folder (mount as volume) |
| `AUDIO_FMT` | `mp3` | Output format (`mp3`, `flac`, `opus`, …) |
| `MUSIC_PATH` | `/music` | Absolute path to your Navidrome music folder |
| `AUDIO_FMT` | `mp3` | Download format (`mp3`, `flac`, `opus`, `best`) |
| `AUDIO_QUALITY` | `0` | yt-dlp audio quality (0 = best) |
| `MAX_PARALLEL` | `2` | Maximum concurrent downloads |
| `MAX_PARALLEL` | `2` | Max concurrent download jobs |
| `TICKETMASTER_API_KEY` | — | Free key from [developer.ticketmaster.com](https://developer.ticketmaster.com) |
### Scrobble container
| Variable | Default | Description |
|---|---|---|
| `PORT` | `3710` | Port the scrobble service listens on |
| `DATA_PATH` | `/data` | Where the play-log SQLite DB is stored (own volume) |
| `NAVIDROME_URL` | — | Used to verify user credentials via `ping.view` (multi-user auth) |
---
@@ -95,12 +194,3 @@ npm run dev # Vite dev server with HMR
npm run build # Production build → dist/
npm run preview # Preview the production build locally
```
---
## First Launch
1. Open the app in a browser
2. Enter your Navidrome server URL, username and password
3. Optionally add a Last.fm API key (Settings) for recommendations and similar artists
4. Optionally add the backend URL (Settings) to enable yt-dlp downloads
+12
View File
@@ -0,0 +1,12 @@
# Build output
.gradle/
build/
app/build/
app/release/
# Local config (contains SDK path and server URL — never commit)
local.properties
# IDE
.idea/
*.iml
+134
View File
@@ -0,0 +1,134 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
android {
namespace = "com.novasonic.app"
compileSdk = 35
defaultConfig {
applicationId = "com.novasonic.app"
minSdk = 26
targetSdk = 35
versionCode = 30
versionName = "1.27"
// 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 {
compose = true
buildConfig = true
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
}
dependencies {
// Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.splashscreen)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.icons)
debugImplementation(libs.compose.ui.tooling)
// Navigation
implementation(libs.navigation.compose)
// Lifecycle / ViewModel
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
// Media3
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.media3.common)
implementation(libs.media3.datasource)
implementation(libs.media3.ui)
// Ktor
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.content.neg)
implementation(libs.ktor.serialization.json)
implementation(libs.ktor.client.logging)
// Kotlinx
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlin.reflect)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// WorkManager
implementation(libs.work.runtime.ktx)
// DataStore
implementation(libs.datastore.preferences)
// Coil
implementation(libs.coil.compose)
// Palette (dominant cover color extraction in NowPlaying)
implementation("androidx.palette:palette-ktx:1.0.0")
// MediaStyle for ABS notification (already transitive via media3-session, made explicit)
implementation("androidx.media:media:1.7.0")
// Google Fonts (Outfit)
implementation(libs.compose.ui.google.fonts)
}
+14
View File
@@ -0,0 +1,14 @@
# Keep @JavascriptInterface methods — called by WebView via reflection, stripped otherwise.
-keepclassmembers class com.novasonic.app.WebBridge {
@android.webkit.JavascriptInterface <methods>;
}
# Keep Media3 session / exoplayer classes used by reflection / service binding.
-keep class androidx.media3.** { *; }
-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.**
+80
View File
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- In-app updater: install a downloaded APK (user still confirms the install) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:name=".NovaSonicApp"
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.NovaSonic"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
android:largeHeap="true">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</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>
<service
android:name=".player.MusicService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService"/>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<!-- FileProvider — hands the downloaded update APK to the system installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
<!-- Android Auto -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
</application>
</manifest>
@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NSBridge Test</title>
<style>
body { font-family: monospace; background: #111; color: #eee; padding: 16px; }
h2 { color: #8af; margin: 24px 0 8px; }
button { background: #222; color: #8af; border: 1px solid #444; border-radius: 6px;
padding: 8px 14px; margin: 4px; cursor: pointer; font-family: monospace; }
button:hover { background: #333; }
#log { background: #000; border: 1px solid #333; border-radius: 6px; padding: 12px;
height: 220px; overflow-y: auto; font-size: 12px; white-space: pre-wrap; }
input { background: #222; color: #eee; border: 1px solid #444; border-radius: 4px;
padding: 6px 10px; font-family: monospace; width: 280px; }
.ok { color: #8f8; }
.err { color: #f88; }
.evt { color: #fa8; }
</style>
</head>
<body>
<h1>NovaSonic Bridge Test</h1>
<p>Load this page as a local asset to verify the NSBridge surface before connecting the real PWA.</p>
<div id="log">Waiting for bridge...\n</div>
<h2>Queue</h2>
<button onclick="testSetQueue()">setQueue (2 tracks, autoPlay=true)</button>
<button onclick="testAddToQueue()">addToQueue (append)</button>
<button onclick="cmd('removeFromQueue', 1)">removeFromQueue(1)</button>
<button onclick="cmd('clearQueue')">clearQueue</button>
<h2>Transport</h2>
<button onclick="cmd('play')">play</button>
<button onclick="cmd('pause')">pause</button>
<button onclick="cmd('togglePlay')">togglePlay</button>
<button onclick="cmd('next')">next</button>
<button onclick="cmd('prev')">prev</button>
<button onclick="testSeek()">seekTo(30000)</button>
<button onclick="cmd('seekToIndex', 0, 0)">seekToIndex(0,0)</button>
<h2>Settings</h2>
<button onclick="cmd('setRepeatMode','all')">repeat all</button>
<button onclick="cmd('setRepeatMode','one')">repeat one</button>
<button onclick="cmd('setRepeatMode','off')">repeat off</button>
<button onclick="cmd('setShuffleMode',true)">shuffle on</button>
<button onclick="cmd('setShuffleMode',false)">shuffle off</button>
<button onclick="cmd('setVolume',0.5)">volume 50%</button>
<button onclick="cmd('setPlaybackSpeed',1.5)">speed 1.5x</button>
<button onclick="cmd('setPlaybackSpeed',1.0)">speed 1.0x</button>
<h2>State</h2>
<button onclick="showState()">getState()</button>
<h2>NSPlayer callbacks (incoming from native)</h2>
<p style="color:#aaa;font-size:12px">These fire automatically when native state changes.</p>
<script>
const log = document.getElementById('log');
function print(msg, cls) {
const line = document.createElement('span');
line.className = cls || '';
line.textContent = new Date().toISOString().slice(11,23) + ' ' + msg + '\n';
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
// ── Bridge availability check ─────────────────────────────────────────────
function hasBridge() {
if (!window.NSBridge) { print('❌ window.NSBridge not found — not running inside NovaSonic app', 'err'); return false; }
return true;
}
// ── Generic command helper ────────────────────────────────────────────────
function cmd(method, ...args) {
if (!hasBridge()) return;
try {
const result = window.NSBridge[method](...args);
print(`→ NSBridge.${method}(${args.map(a=>JSON.stringify(a)).join(', ')})${result !== undefined ? ' = ' + result : ''}`, 'ok');
} catch(e) {
print(`❌ NSBridge.${method}: ${e}`, 'err');
}
}
// ── Queue helpers ─────────────────────────────────────────────────────────
const DEMO_TRACKS = [
{
id: "track-1",
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
title: "SoundHelix Song 1",
artist: "T. Schürger",
album: "Demo Album",
artworkUrl: null,
durationMs: 372000
},
{
id: "track-2",
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3",
title: "SoundHelix Song 2",
artist: "T. Schürger",
album: "Demo Album",
artworkUrl: null,
durationMs: 390000
}
];
function testSetQueue() {
if (!hasBridge()) return;
try {
window.NSBridge.setQueue(JSON.stringify(DEMO_TRACKS), 0, true);
print('→ NSBridge.setQueue([2 tracks], startIndex=0, autoPlay=true)', 'ok');
} catch(e) { print('❌ setQueue: ' + e, 'err'); }
}
function testAddToQueue() {
if (!hasBridge()) return;
const track = { id: "track-3", url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3",
title: "SoundHelix Song 3", artist: "T. Schürger", album: null, artworkUrl: null, durationMs: 0 };
try {
window.NSBridge.addToQueue(JSON.stringify(track), -1);
print('→ NSBridge.addToQueue(track-3, position=-1 [append])', 'ok');
} catch(e) { print('❌ addToQueue: ' + e, 'err'); }
}
function testSeek() { cmd('seekTo', 30000); }
function showState() {
if (!hasBridge()) return;
try {
const raw = window.NSBridge.getState();
print('← getState() = ' + raw, 'ok');
} catch(e) { print('❌ getState: ' + e, 'err'); }
}
// ── NSPlayer callbacks (Native → JS) ─────────────────────────────────────
window.NSPlayer = {
onReady() {
print('← NSPlayer.onReady()', 'evt');
},
onPlaybackStateChanged(stateJson) {
try {
const s = JSON.parse(stateJson);
print(`← NSPlayer.onPlaybackStateChanged isPlaying=${s.isPlaying} isBuffering=${s.isBuffering}`, 'evt');
} catch(e) { print('← NSPlayer.onPlaybackStateChanged (parse error): ' + stateJson, 'err'); }
},
onTrackChanged(payloadJson) {
try {
const p = JSON.parse(payloadJson);
print(`← NSPlayer.onTrackChanged index=${p.index} trackId=${p.trackId} reason=${p.reason}`, 'evt');
} catch(e) { print('← NSPlayer.onTrackChanged (parse error): ' + payloadJson, 'err'); }
},
onPositionUpdate(positionMs, durationMs) {
// High-frequency — only log every 5 s to keep the log readable
if (!window._lastPosPrint || Date.now() - window._lastPosPrint > 5000) {
window._lastPosPrint = Date.now();
print(`← NSPlayer.onPositionUpdate pos=${positionMs}ms dur=${durationMs}ms`, 'evt');
}
},
onQueueEnded() {
print('← NSPlayer.onQueueEnded()', 'evt');
},
onError(message) {
print('← NSPlayer.onError: ' + message, 'err');
},
onMediaButtonAction(action) {
print('← NSPlayer.onMediaButtonAction: ' + action, 'evt');
}
};
// ── Init ──────────────────────────────────────────────────────────────────
window.addEventListener('load', () => {
if (window.NSBridge) {
print('✓ window.NSBridge detected', 'ok');
} else {
print('ℹ window.NSBridge not found — run inside NovaSonic app for full test', 'err');
print(' (Callback handlers are registered; native can still fire them)', '');
}
});
</script>
</body>
</html>
@@ -0,0 +1,71 @@
package com.novasonic.app
import android.app.Application
import com.novasonic.app.data.MusicRepository
import com.novasonic.app.offline.OfflineRepository
import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.player.PlayerController
import com.novasonic.app.player.PreviewController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class NovaSonicApp : Application() {
lateinit var repository: MusicRepository
private set
lateinit var playerController: PlayerController
private set
lateinit var absPlayerController: AbsPlayerController
private set
lateinit var offlineRepository: OfflineRepository
private set
// 30s preview player — app-level so its control bar survives navigation (see AppNavigation).
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() {
super.onCreate()
cacheNachUpdateLeeren()
offlineRepository = OfflineRepository(applicationContext) // must be first
repository = MusicRepository(applicationContext)
playerController = PlayerController(applicationContext, repository)
playerController.connect()
absPlayerController = AbsPlayerController(applicationContext)
// Angefangene Downloads weiterlaufen lassen (grosse Playlists brauchen viele Laeufe).
offlineRepository.setzeUnvollstaendigeFort()
// Flush any plays queued while offline to the scrobble service.
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
runCatching { repository.flushPlayLog() }
}
}
}
// Convenience extension to access app-level singletons from any Activity/Composable context.
val android.content.Context.app: NovaSonicApp
get() = applicationContext as NovaSonicApp
@@ -0,0 +1,205 @@
package com.novasonic.app.api
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.client.statement.HttpResponse
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class AbsLibrary(
val id: String = "",
val name: String = "",
val mediaType: String = "book",
)
@Serializable
data class AbsLibrariesResponse(val libraries: List<AbsLibrary> = emptyList())
@Serializable
data class AbsMediaMetadata(
val title: String? = null,
val authorName: String? = null,
val description: String? = null,
)
@Serializable
data class AbsMedia(
val metadata: AbsMediaMetadata = AbsMediaMetadata(),
val duration: Double = 0.0,
val numFiles: Int = 0,
val chapters: List<AbsChapter> = emptyList(),
)
@Serializable
data class AbsLibraryItem(
val id: String = "",
val media: AbsMedia = AbsMedia(),
)
@Serializable
data class AbsItemsResponse(
val results: List<AbsLibraryItem> = emptyList(),
val total: Int = 0,
)
@Serializable
data class AbsSearchBookResult(
val libraryItem: AbsLibraryItem = AbsLibraryItem(),
)
@Serializable
data class AbsSearchResponse(
val book: List<AbsSearchBookResult> = emptyList(),
)
@Serializable
data class AbsChapter(
val id: Int = 0,
val start: Double = 0.0,
val end: Double = 0.0,
val title: String = "",
)
@Serializable
data class AbsAudioTrack(
val index: Int = 0,
val startOffset: Double = 0.0,
val duration: Double = 0.0,
val contentUrl: String = "",
val mimeType: String = "audio/mpeg",
)
@Serializable
data class AbsPlaySession(
val id: String = "",
val audioTracks: List<AbsAudioTrack> = emptyList(),
val chapters: List<AbsChapter> = emptyList(),
val currentTime: Double = 0.0,
)
@Serializable
data class AbsProgress(
val currentTime: Double = 0.0,
val duration: Double = 0.0,
val progress: Double = 0.0,
val isFinished: Boolean = false,
)
class AbsClient(
val absUrl: String,
val apiKey: String,
private val backendUrl: String = "",
) {
private val http = HttpClient(Android) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; isLenient = true }) }
install(HttpTimeout) { requestTimeoutMillis = 30_000; connectTimeoutMillis = 8_000 }
}
private suspend fun apiGet(path: String): HttpResponse =
if (backendUrl.isNotBlank()) {
http.get("$backendUrl/api/abs-proxy$path") {
header("X-ABS-URL", absUrl)
header("X-ABS-Token", apiKey)
}
} else {
http.get("$absUrl$path") {
header("Authorization", "Bearer $apiKey")
}
}
private suspend fun apiPost(path: String, jsonBody: String): HttpResponse =
if (backendUrl.isNotBlank()) {
http.post("$backendUrl/api/abs-proxy$path") {
header("X-ABS-URL", absUrl)
header("X-ABS-Token", apiKey)
contentType(ContentType.Application.Json)
setBody(jsonBody)
}
} else {
http.post("$absUrl$path") {
header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(jsonBody)
}
}
private suspend fun apiPatch(path: String, jsonBody: String): HttpResponse =
if (backendUrl.isNotBlank()) {
http.patch("$backendUrl/api/abs-proxy$path") {
header("X-ABS-URL", absUrl)
header("X-ABS-Token", apiKey)
contentType(ContentType.Application.Json)
setBody(jsonBody)
}
} else {
http.patch("$absUrl$path") {
header("Authorization", "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(jsonBody)
}
}
suspend fun ping(): Boolean = runCatching { apiGet("/api/me").status == HttpStatusCode.OK }.getOrDefault(false)
suspend fun getLibraries(): List<AbsLibrary> = runCatching {
apiGet("/api/libraries").body<AbsLibrariesResponse>().libraries
}.getOrDefault(emptyList())
suspend fun getLibraryItems(libId: String, page: Int = 0): AbsItemsResponse = runCatching {
apiGet("/api/libraries/$libId/items?expanded=1&limit=50&page=$page&sort=media.metadata.title&asc=1")
.body<AbsItemsResponse>()
}.getOrDefault(AbsItemsResponse())
suspend fun searchLibraryItems(libId: String, query: String): List<AbsLibraryItem> = runCatching {
val q = java.net.URLEncoder.encode(query.trim(), "UTF-8")
apiGet("/api/libraries/$libId/search?q=$q&limit=40")
.body<AbsSearchResponse>().book.map { it.libraryItem }
}.getOrDefault(emptyList())
suspend fun getItemChapters(itemId: String): List<AbsChapter> = runCatching {
apiGet("/api/items/$itemId?expanded=1").body<AbsLibraryItem>().media.chapters
}.getOrDefault(emptyList())
suspend fun startSession(itemId: String): AbsPlaySession = runCatching {
apiPost(
"/api/items/$itemId/play",
"""{"deviceInfo":{"clientName":"NovaSonic","clientVersion":"1.0"},"supportedMimeTypes":["audio/mpeg","audio/mp4","audio/aac","audio/ogg","audio/flac","audio/m4b"],"mediaPlayer":"html5","forceDirectPlay":true,"forceTranscode":false}"""
).body<AbsPlaySession>()
}.getOrThrow()
suspend fun closeSession(sessionId: String) {
runCatching { apiPost("/api/session/$sessionId/close", "{}") }
}
suspend fun getProgress(itemId: String): AbsProgress? = runCatching {
apiGet("/api/me/progress/$itemId").body<AbsProgress>()
}.getOrNull()
suspend fun saveProgress(itemId: String, currentTime: Double, duration: Double) {
val progress = if (duration > 0) (currentTime / duration).coerceAtMost(1.0) else 0.0
val finished = progress >= 0.99
runCatching {
apiPatch(
"/api/me/progress/$itemId",
"""{"currentTime":$currentTime,"duration":$duration,"progress":$progress,"isFinished":$finished,"lastUpdate":${System.currentTimeMillis()}}"""
)
}
}
fun coverUrl(itemId: String): String =
"$absUrl/api/items/$itemId/cover?token=${java.net.URLEncoder.encode(apiKey, "UTF-8")}"
fun audioTrackUrl(contentUrl: String): String {
val sep = if (contentUrl.contains('?')) "&" else "?"
return "$absUrl$contentUrl${sep}token=${java.net.URLEncoder.encode(apiKey, "UTF-8")}"
}
fun close() = http.close()
}
@@ -0,0 +1,59 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.ArtistOverride
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.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* Reads/writes the shared artist-pin overrides on the download backend
* (GET/POST /api/artist-overrides, DELETE /api/artist-overrides/:id).
* The pins fix same-name artist collisions; see [ArtistOverride].
*/
class ArtistOverrideClient(private val backendUrl: String) {
private val http = HttpClient(Android) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; coerceInputValues = true }) }
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
}
private fun base() = backendUrl.trimEnd('/')
@Serializable
private data class SetReq(
val ndArtistId: String,
val deezerArtistId: String,
val name: String,
val image: String,
val fanCount: Long?,
)
/** Full map { ndArtistId -> override }. */
suspend fun loadAll(): Map<String, ArtistOverride> =
http.get("${base()}/api/artist-overrides").body()
/** Upsert a pin. Returns true on 2xx. */
suspend fun set(ndArtistId: String, ov: ArtistOverride): Boolean {
val resp: HttpResponse = http.post("${base()}/api/artist-overrides") {
contentType(ContentType.Application.Json)
setBody(SetReq(ndArtistId, ov.deezerArtistId, ov.name, ov.image, ov.fanCount))
}
return resp.status.isSuccess()
}
/** Remove a pin. Returns true on 2xx. */
suspend fun remove(ndArtistId: String): Boolean {
val resp: HttpResponse = http.delete("${base()}/api/artist-overrides/${ndArtistId.encodeURLPath()}")
return resp.status.isSuccess()
}
fun close() = http.close()
}
@@ -0,0 +1,31 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.ConcertEvent
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.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
object EventsClient {
private val http = HttpClient(Android) {
// Surface non-2xx as a real exception (e.g. an SSO redirect / HTML login page) instead of
// letting .body() fail obscurely — the ViewModel turns it into a visible "load failed".
expectSuccess = true
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 10_000 }
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
}
}
suspend fun fetchEvents(backendUrl: String, artistName: String): List<ConcertEvent> {
if (backendUrl.isBlank()) return emptyList()
val base = backendUrl.trimEnd('/')
val encoded = java.net.URLEncoder.encode(artistName, "UTF-8")
return http.get("$base/api/events/$encoded").body()
}
}
@@ -0,0 +1,72 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.*
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.http.isSuccess
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
/** Talks to the NovaSonic download/importer backend (Express server, default :3700). */
class ImporterClient(private val backendUrl: String) {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
}
install(HttpTimeout) {
requestTimeoutMillis = 40_000
connectTimeoutMillis = 12_000
socketTimeoutMillis = 40_000
}
}
private fun base() = backendUrl.trimEnd('/')
companion object {
/** Detect which importer endpoint to hit from a pasted URL. */
fun platformFor(url: String): String? = when {
url.contains("spotify.com") -> "spotify"
url.contains("soundcloud.com") -> "soundcloud"
url.contains("youtube.com") ||
url.contains("youtu.be") -> "youtube"
else -> null
}
}
suspend fun resolve(platform: String, url: String): ImportResolveResponse =
http.post("${base()}/api/$platform/resolve") {
contentType(ContentType.Application.Json)
setBody(ResolveRequest(url))
}.body()
suspend fun download(request: DownloadRequest) {
http.post("${base()}/api/download") {
contentType(ContentType.Application.Json)
setBody(request)
}
}
suspend fun status(jobId: String): DownloadStatus =
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()
}
@@ -0,0 +1,114 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.json.Json
/**
* Metadata/search client. Backed by **Deezer** (api.deezer.com) — Apple's iTunes /search
* returns 403 from datacenter IPs, and Deezer keeps the phone + web PWA on the *same* source
* (identical naming + explicit versions). Deezer responses are mapped to the legacy ItunesItem
* shape (see DeezerModels.kt) so the screens/view models did not change. The public method names
* are kept for the same reason.
*/
object ItunesClient {
private const val BASE = "https://api.deezer.com"
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any)
}
}
/**
* Combined search. Deezer splits by type, so `entity` "album" hits /search/album; anything else
* runs tracks + albums + artists in parallel and merges them (the view model filters by wrapperType).
*/
suspend fun search(
query: String,
entity: String = "musicTrack,album,musicArtist",
limit: Int = 30,
country: String = "us",
): ItunesSearchResponse {
if (entity == "album") {
val albums: DzList<DzAlbum> = http.get("$BASE/search/album") {
parameter("q", query); parameter("limit", limit)
}.body()
return ItunesSearchResponse(results = albums.data.map { it.toItunesItem() })
}
return coroutineScope {
val tracksD = async { runCatching { http.get("$BASE/search") { parameter("q", query); parameter("limit", limit) }.body<DzList<DzTrack>>().data }.getOrDefault(emptyList()) }
val albumsD = async { runCatching { http.get("$BASE/search/album") { parameter("q", query); parameter("limit", 12) }.body<DzList<DzAlbum>>().data }.getOrDefault(emptyList()) }
val artistsD = async { runCatching { http.get("$BASE/search/artist") { parameter("q", query); parameter("limit", 8) }.body<DzList<DzArtist>>().data }.getOrDefault(emptyList()) }
// Deezer doesn't rank artists by popularity — sort by nb_fan so a name collision
// (5× "Joji") resolves to the artist the user means (real Joji has 559k fans, not 62).
val results = artistsD.await().sortedByDescending { it.nb_fan ?: 0 }.map { it.toItunesItem() } +
albumsD.await().map { it.toItunesItem() } +
tracksD.await().map { it.toItunesItem() }
ItunesSearchResponse(results = results)
}
}
/** All tracks of a Deezer album, in the ItunesItem song shape. */
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
val album: DzAlbum = http.get("$BASE/album/$collectionId").body()
return album.tracks?.data.orEmpty().mapIndexed { i, t -> t.toAlbumTrackItem(album, i) }
}
/** A Deezer artist's full discography — albums, EPs AND singles, matching what Deezer's own
* artist page shows. Ordered albums → EPs → singles (Deezer returns newest-first within each). */
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
val albums: DzList<DzAlbum> = http.get("$BASE/artist/$artistId/albums") {
parameter("limit", 200)
}.body()
val rank = { rt: String? -> when (rt) { "album" -> 0; "ep" -> 1; "single" -> 2; else -> 3 } }
return albums.data.sortedBy { rank(it.record_type) }.map { it.toItunesItem() }
}
/**
* A single Deezer artist by id (name / picture / fan count), as an ItunesItem. The reliable
* route when /search/artist can't surface a low-profile same-name artist — used by the
* artist-pin "paste Deezer link/id" flow. Null on a bad id / error.
*/
suspend fun lookupArtist(artistId: Long): ItunesItem? {
val a = runCatching { http.get("$BASE/artist/$artistId").body<DzArtist>() }.getOrNull()
return if (a?.id != null) a.toItunesItem() else null
}
/** Top tracks for a Deezer artist. */
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
val tracks: DzList<DzTrack> = http.get("$BASE/artist/$artistId/top") {
parameter("limit", limit)
}.body()
return tracks.data.map { it.toItunesItem() }
}
/**
* Related Deezer artists (real pictures + fan counts). Used for the "Similar Artists" row when
* a pin is active — Last.fm's similar list is keyed to the (possibly merged/wrong) Navidrome
* artist, so it goes "weird"; Deezer's related are correct for the pinned artist.
*/
suspend fun lookupRelatedArtists(artistId: Long, limit: Int = 12): List<ItunesItem> {
val artists: DzList<DzArtist> = http.get("$BASE/artist/$artistId/related") {
parameter("limit", limit)
}.body()
return artists.data.map { it.toItunesItem() }
}
/** "Neu & Beliebt" — Deezer's global album chart, mapped to the RSS-entry shape. */
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
val albums: DzList<DzAlbum> = http.get("$BASE/chart/0/albums") {
parameter("limit", limit)
}.body()
return albums.data.map { it.toRssEntry() }
}
}
@@ -0,0 +1,156 @@
package com.novasonic.app.api
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.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class LrcLibResponse(
val id: Long = 0,
val trackName: String = "",
val artistName: String = "",
val albumName: String = "",
val duration: Double = 0.0,
val instrumental: Boolean = false,
val plainLyrics: String? = null,
val syncedLyrics: String? = null,
)
data class LrcLine(val timeMs: Long, val text: String)
fun parseLrc(lrc: String): List<LrcLine> {
val regex = Regex("""\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)""")
return lrc.lines()
.mapNotNull { line ->
regex.find(line.trim())?.let { m ->
val mm = m.groupValues[1].toLong()
val ss = m.groupValues[2].toLong()
val frac = m.groupValues[3]
val ms = mm * 60_000 + ss * 1_000 +
if (frac.length == 2) frac.toLong() * 10 else frac.toLong()
val text = m.groupValues[4].trim()
if (text.isNotEmpty()) LrcLine(ms, text) else null
}
}
.sortedBy { it.timeMs }
}
object LrcLibClient {
private val client = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; isLenient = true })
}
install(HttpTimeout) {
requestTimeoutMillis = 8_000
connectTimeoutMillis = 5_000
socketTimeoutMillis = 8_000
}
// lrclib asks clients to identify themselves; a proper UA also avoids being throttled.
install(UserAgent) { agent = "NovaSonic/1.0 (+https://gitea.honeyy.duckdns.org/Docker/NovaSonic)" }
}
private val cache = HashMap<String, LrcLibResponse?>()
// Per-song lock so a prefetch + an on-open load don't both fire the whole cascade (→ throttling).
private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
suspend fun getLyrics(
artist: String,
title: String,
album: String?,
durationSecs: Int?,
): LrcLibResponse? {
val cacheKey = "$artist|$title"
if (cache.containsKey(cacheKey)) return cache[cacheKey]
val mutex = locks.getOrPut(cacheKey) { Mutex() }
return mutex.withLock { getLyricsInner(artist, title, album, durationSecs, cacheKey) }
}
private suspend fun getLyricsInner(
artist: String,
title: String,
album: String?,
durationSecs: Int?,
cacheKey: String,
): LrcLibResponse? {
if (cache.containsKey(cacheKey)) return cache[cacheKey] // populated while we waited on the lock
// 1a. lrclib exact match with duration
val exact = runCatching {
val resp = client.get("https://lrclib.net/api/get") {
parameter("artist_name", artist)
parameter("track_name", title)
if (!album.isNullOrBlank()) parameter("album_name", album)
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
}
if (resp.status == HttpStatusCode.OK) resp.body<LrcLibResponse>() else null
}.getOrNull()
if (exact.hasContent()) return exact.also { cache[cacheKey] = it }
// 1b. lrclib exact match without duration — handles version mismatches (e.g. 5:58 file vs 4:24 in lrclib)
val exactNoDur = if (durationSecs != null && !album.isNullOrBlank()) runCatching {
val resp = client.get("https://lrclib.net/api/get") {
parameter("artist_name", artist)
parameter("track_name", title)
parameter("album_name", album)
}
if (resp.status == HttpStatusCode.OK) resp.body<LrcLibResponse>() else null
}.getOrNull() else null
if (exactNoDur.hasContent()) return exactNoDur.also { cache[cacheKey] = it }
// 2+3. lrclib search — artist+title and title-only in parallel (2 requests, not 3)
val lrclibResult = coroutineScope {
val artistDef = async {
runCatching {
val resp = client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
if (artist.isNotBlank()) parameter("artist_name", artist)
}
if (resp.status == HttpStatusCode.OK) resp.body<List<LrcLibResponse>>() else null
}.getOrNull()
}
val titleDef = async {
runCatching {
val resp = client.get("https://lrclib.net/api/search") {
parameter("track_name", title)
}
if (resp.status == HttpStatusCode.OK) resp.body<List<LrcLibResponse>>() else null
}.getOrNull()
}
bestMatch(artistDef.await(), durationSecs)
?: bestMatch(titleDef.await(), durationSecs)
}
cache[cacheKey] = lrclibResult
return lrclibResult
}
private fun LrcLibResponse?.hasContent() =
this != null && (instrumental || !syncedLyrics.isNullOrBlank() || !plainLyrics.isNullOrBlank())
private fun bestMatch(candidates: List<LrcLibResponse>?, durationSecs: Int?): LrcLibResponse? {
if (candidates.isNullOrEmpty()) return null
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
val pool = synced.ifEmpty { candidates }
return if (durationSecs != null && durationSecs > 0) {
val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
?: return synced.firstOrNull() ?: candidates.firstOrNull()
if (kotlin.math.abs(best.duration - durationSecs) <= 5.0) best
else synced.firstOrNull() ?: candidates.firstOrNull()
} else {
pool.firstOrNull()
}
}
}
@@ -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()
}
}
@@ -0,0 +1,250 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.*
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.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.security.MessageDigest
import kotlin.random.Random
class SubsonicClient(
private val serverUrl: String,
private val username: String,
private val password: String,
private val clientName: String = "NovaSonic",
private val apiVersion: String = "1.16.1",
) {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
coerceInputValues = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 20_000
connectTimeoutMillis = 12_000
socketTimeoutMillis = 20_000
}
install(Logging) {
level = LogLevel.INFO
}
}
// ── Auth ──────────────────────────────────────────────────────────────────
private fun authParams(): Map<String, String> {
val salt = Random.nextInt(100_000, 999_999).toString()
val token = md5(password + salt)
return mapOf("u" to username, "t" to token, "s" to salt,
"v" to apiVersion, "c" to clientName, "f" to "json")
}
private fun md5(input: String): String =
MessageDigest.getInstance("MD5")
.digest(input.toByteArray())
.joinToString("") { "%02x".format(it) }
private fun url(path: String) = "${serverUrl.trimEnd('/')}/rest/$path"
// ── Core request ──────────────────────────────────────────────────────────
private suspend fun get(path: String, params: Map<String, Any?> = emptyMap()): SubsonicResponse {
val combined = authParams() + params.filterValues { it != null }
.mapValues { it.value.toString() }
val resp: SubsonicWrapper = http.get(url(path)) {
combined.forEach { (k, v) -> parameter(k, v) }
}.body()
if (resp.response.status != "ok") {
val err = resp.response.error
throw SubsonicException(err?.code ?: -1, err?.message ?: "Unknown error")
}
return resp.response
}
// ── Cover art / stream URLs (no auth roundtrip needed for URL building) ───
fun coverArtUrl(id: String, size: Int = 300): String {
val salt = "novasonic"
val token = md5(password + salt)
val base = url("getCoverArt.view")
return "$base?id=$id&size=$size&u=$username&t=$token&s=$salt&v=$apiVersion&c=$clientName&f=json"
}
fun streamUrl(id: String, maxBitRate: Int? = null): String {
val salt = "novasonic"
val token = md5(password + salt)
val base = url("stream.view")
val br = if (maxBitRate != null) "&maxBitRate=$maxBitRate" else ""
return "$base?id=$id$br&u=$username&t=$token&s=$salt&v=$apiVersion&c=$clientName&f=json"
}
// ── API methods ───────────────────────────────────────────────────────────
suspend fun ping() = get("ping.view")
suspend fun getAlbumList(
type: String = "newest",
size: Int = 30,
offset: Int = 0,
): List<Album> = get("getAlbumList2.view",
mapOf("type" to type, "size" to size, "offset" to offset))
.albumList2?.album ?: emptyList()
suspend fun getAlbum(id: String): Album =
get("getAlbum.view", mapOf("id" to id)).album
?: throw SubsonicException(-1, "Album not found")
suspend fun getArtists(): List<ArtistIndex> =
get("getArtists.view").artists?.index ?: emptyList()
suspend fun getArtist(id: String): Artist =
get("getArtist.view", mapOf("id" to id)).artist
?: throw SubsonicException(-1, "Artist not found")
suspend fun getSong(id: String): Song =
get("getSong.view", mapOf("id" to id)).song
?: throw SubsonicException(-1, "Song not found")
suspend fun search(query: String, albumCount: Int = 10, artistCount: Int = 5, songCount: Int = 20): SearchResult3 =
get("search3.view", mapOf(
"query" to query,
"albumCount" to albumCount,
"artistCount" to artistCount,
"songCount" to songCount,
)).searchResult3 ?: SearchResult3()
suspend fun getPlaylists(): List<Playlist> =
get("getPlaylists.view").playlists?.playlist ?: emptyList()
suspend fun getPlaylist(id: String): PlaylistWithSongs =
get("getPlaylist.view", mapOf("id" to id)).playlist
?: throw SubsonicException(-1, "Playlist not found")
suspend fun getStarred(): Starred2 =
get("getStarred2.view").starred2 ?: Starred2()
suspend fun getRandomSongs(size: Int = 50): List<Song> =
get("getRandomSongs.view", mapOf("size" to size)).randomSongs?.song ?: emptyList()
suspend fun getSimilarSongs(songId: String, count: Int = 20): List<Song> =
get("getSimilarSongs2.view", mapOf("id" to songId, "count" to count)).similarSongs2?.song ?: emptyList()
suspend fun star(id: String) = get("star.view", mapOf("id" to id))
suspend fun unstar(id: String) = get("unstar.view", mapOf("id" to id))
suspend fun scrobble(id: String, submission: Boolean = true) =
get("scrobble.view", mapOf("id" to id, "submission" to submission))
suspend fun getArtistInfo(id: String, count: Int = 10): com.novasonic.app.api.model.ArtistInfo =
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
?: com.novasonic.app.api.model.ArtistInfo()
/**
* 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. 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> =
get("getGenres.view").genres?.genre ?: emptyList()
suspend fun getSongsByGenre(genre: String, count: Int = 50): List<com.novasonic.app.api.model.Song> =
get("getSongsByGenre.view", mapOf("genre" to genre, "count" to count)).songsByGenre?.song
?: emptyList()
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
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 {
val params = mutableListOf("name" to name)
songIds.forEach { params += "songId" to it }
return getList("createPlaylist.view", params).playlist
?: throw SubsonicException(-1, "Failed to create playlist")
}
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) =
getList("updatePlaylist.view", listOfNotNull(
"playlistId" to playlistId,
"name" to name,
comment?.let { "comment" to it },
))
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) =
getList("updatePlaylist.view",
listOf("playlistId" to playlistId) + indices.map { "songIndexToRemove" to it.toString() })
suspend fun deletePlaylist(playlistId: String) =
getList("deletePlaylist.view", listOf("id" to playlistId))
// ── Navidrome REST API (cover art) ────────────────────────────────────────
@Serializable private data class NdLoginReq(val username: String, val password: String)
@Serializable private data class NdLoginResp(val token: String = "")
private var _ndToken: String? = null
private suspend fun ndToken(): String {
_ndToken?.let { return it }
val resp = http.post("$serverUrl/auth/login") {
contentType(ContentType.Application.Json)
setBody(NdLoginReq(username, password))
}.body<NdLoginResp>()
val token = resp.token.ifBlank { throw Exception("Navidrome login fehlgeschlagen") }
_ndToken = token
return token
}
suspend fun uploadPlaylistCover(playlistId: String, imageBytes: ByteArray, mimeType: String) {
val token = ndToken()
http.post("$serverUrl/api/playlist/$playlistId/image") {
header("X-Nd-Authorization", "Bearer $token")
setBody(MultiPartFormDataContent(formData {
append("image", imageBytes, Headers.build {
append(HttpHeaders.ContentType, mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"cover\"")
})
}))
}
}
suspend fun removePlaylistCover(playlistId: String) {
val token = ndToken()
http.delete("$serverUrl/api/playlist/$playlistId/image") {
header("X-Nd-Authorization", "Bearer $token")
}
}
fun close() = http.close()
private suspend fun getList(path: String, params: List<Pair<String, String>>): SubsonicResponse {
val allParams = authParams().entries.map { (k, v) -> k to v } + params
val resp: SubsonicWrapper = http.get(url(path)) {
allParams.forEach { (k, v) -> parameter(k, v) }
}.body()
if (resp.response.status != "ok") {
val err = resp.response.error
throw SubsonicException(err?.code ?: -1, err?.message ?: "Unknown error")
}
return resp.response
}
}
class SubsonicException(val code: Int, message: String) : Exception(message)
@@ -0,0 +1,17 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
/**
* A manual "pin" mapping a local Navidrome artist → a specific Deezer artist, fixing same-name
* collisions where NovaSonic would otherwise guess the wrong one (fans / top songs / albums / image).
* Stored server-side (shared across users) on the download backend at /api/artist-overrides, keyed
* by the Navidrome artist id. Mirrors the web PWA's override shape exactly.
*/
@Serializable
data class ArtistOverride(
val deezerArtistId: String = "",
val name: String = "",
val image: String = "",
val fanCount: Long? = null,
)
@@ -0,0 +1,131 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
// ─── DEEZER API MODELS ────────────────────────────────────────────────────────
// Apple's iTunes /search returns 403 from server IPs, so the Explore/search source is
// Deezer (free, no key, returns explicit flags + artist images + durations). Deezer JSON is
// mapped to the existing ItunesItem/ItunesRssEntry shapes so the screens + view models are
// unchanged. See ItunesClient for the endpoint calls.
@Serializable
data class DzList<T>(val data: List<T> = emptyList())
@Serializable
data class DzArtist(
val id: Long? = null,
val name: String? = null,
val picture_medium: String? = null,
val picture_big: String? = null,
val picture_xl: String? = null,
val nb_fan: Long? = null, // popularity — disambiguates same-name artists (5× "Joji")
)
@Serializable
data class DzGenre(val name: String? = null)
@Serializable
data class DzGenres(val data: List<DzGenre> = emptyList())
@Serializable
data class DzAlbum(
val id: Long? = null,
val title: String? = null,
val cover_medium: String? = null,
val cover_big: String? = null,
val cover_xl: String? = null,
val release_date: String? = null,
val nb_tracks: Int? = null,
val record_type: String? = null,
val genres: DzGenres? = null,
val artist: DzArtist? = null,
val tracks: DzList<DzTrack>? = null,
val position: Int? = null,
)
@Serializable
data class DzTrack(
val id: Long? = null,
val title: String? = null,
val duration: Long? = null,
val track_position: Int? = null,
val disk_number: Int? = null,
val explicit_lyrics: Boolean = false,
val preview: String? = null,
val artist: DzArtist? = null,
val album: DzAlbum? = null,
)
// ─── MAPPERS to the legacy ItunesItem shape ───────────────────────────────────
private fun DzAlbum.coverXl(): String? = cover_xl ?: cover_big ?: cover_medium
private fun DzArtist.pictureXl(): String? = picture_xl ?: picture_big ?: picture_medium
/** A Deezer search-result track → ItunesItem (album cover for artwork). */
fun DzTrack.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "track",
kind = "song",
trackId = id,
artistId = artist?.id,
collectionId = album?.id,
artistName = artist?.name,
collectionName = album?.title,
trackName = title,
artworkUrl100 = album?.coverXl() ?: artist?.pictureXl(),
previewUrl = preview,
trackTimeMillis = duration?.let { it * 1000 },
trackNumber = track_position,
)
/** A Deezer album → ItunesItem collection. */
fun DzAlbum.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "collection",
kind = "album",
collectionId = id,
artistId = artist?.id,
artistName = artist?.name,
collectionName = title,
artworkUrl100 = coverXl(),
trackCount = nb_tracks,
releaseDate = release_date,
primaryGenreName = genres?.data?.firstOrNull()?.name,
)
/** A Deezer artist → ItunesItem artist (carries a real picture, unlike iTunes). */
fun DzArtist.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "artist",
artistId = id,
artistName = name,
artworkUrl100 = pictureXl(),
fanCount = nb_fan,
)
/**
* A Deezer album track (from /album/{id}) → ItunesItem song. The embedded tracks carry no
* track_position, so the album ordering supplies the number; genre/release come from the album.
*/
fun DzTrack.toAlbumTrackItem(album: DzAlbum, index: Int): ItunesItem = ItunesItem(
wrapperType = "track",
kind = "song",
trackId = id,
artistId = artist?.id ?: album.artist?.id,
collectionId = album.id,
artistName = artist?.name ?: album.artist?.name,
collectionName = album.title,
trackName = title,
artworkUrl100 = album.coverXl(),
previewUrl = preview,
trackTimeMillis = duration?.let { it * 1000 },
trackNumber = track_position ?: (index + 1),
trackCount = album.nb_tracks,
releaseDate = album.release_date,
primaryGenreName = album.genres?.data?.firstOrNull()?.name,
)
/** A Deezer chart album → ItunesRssEntry (keeps the charts UI unchanged). */
fun DzAlbum.toRssEntry(): ItunesRssEntry = ItunesRssEntry(
name = ItunesLabel(title),
artist = ItunesLabel(artist?.name),
image = listOfNotNull(coverXl()?.let { ItunesLabel(it) }),
id = ItunesId(attributes = ItunesIdAttributes(imId = id?.toString())),
)
@@ -0,0 +1,23 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
@Serializable
data class ConcertEvent(
val datetime: String = "",
val venue: ConcertVenue? = null,
val offers: List<ConcertOffer> = emptyList(),
)
@Serializable
data class ConcertVenue(
val name: String? = null,
val city: String? = null,
val country: String? = null,
)
@Serializable
data class ConcertOffer(
val url: String? = null,
val status: String? = null,
)
@@ -0,0 +1,62 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
/** Response from POST {backend}/api/{platform}/resolve */
@Serializable
data class ImportResolveResponse(
val type: String? = null, // "track" | "album" | "playlist"
val name: String? = null,
val artist: String? = null,
val artworkUrl: String? = null,
val tracks: List<ImportTrack> = emptyList(),
val skippedLocal: Int = 0,
val error: String? = null,
)
@Serializable
data class ImportTrack(
val trackName: String? = null,
val artistName: String? = null,
val collectionName: String? = null,
val artworkUrl100: String? = null,
val previewUrl: String? = null,
val durationMs: Long? = null,
val trackNumber: Int? = null,
)
/** Body for POST {backend}/api/download */
@Serializable
data class DownloadRequest(
val jobId: String,
val type: String, // "track" | "album" | "artist"
val title: String,
val artist: String? = null,
val album: String? = null,
val artworkUrl: String? = null,
val itunesId: Long? = null,
val collectionId: Long? = null,
val tracks: List<ImportTrack> = emptyList(),
)
@Serializable
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} */
@Serializable
data class DownloadStatus(
val status: String? = null, // "pending" | "downloading" | "done" | "error"
val progress: Int? = null,
val message: String? = null,
val completed: Int? = null,
val total: Int? = null,
)
@@ -0,0 +1,94 @@
package com.novasonic.app.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ItunesSearchResponse(
val resultCount: Int = 0,
val results: List<ItunesItem> = emptyList(),
)
@Serializable
data class ItunesItem(
val wrapperType: String? = null, // "track" | "collection" | "artist"
val kind: String? = null, // "song" | "album" etc
val artistId: Long? = null,
val collectionId: Long? = null,
val trackId: Long? = null,
val artistName: String? = null,
val collectionName: String? = null,
val trackName: String? = null,
val artworkUrl100: String? = null,
val previewUrl: String? = null,
val trackTimeMillis: Long? = null,
val releaseDate: String? = null,
val primaryGenreName: String? = null,
val trackCount: Int? = null,
val trackNumber: Int? = null,
val collectionPrice: Double? = null,
val fanCount: Long? = null, // Deezer nb_fan — disambiguates same-name artists + shown in UI
)
// German fan-count format: 559542 → "559.542", 1200000 → "1,2 Mio." (matches the web PWA).
fun fmtFanCount(n: Long): String {
if (n >= 1_000_000) {
val s = String.format(java.util.Locale.GERMAN, "%.1f", n / 1_000_000.0).removeSuffix(",0")
return "$s Mio."
}
return String.format(java.util.Locale.GERMAN, "%,d", n)
}
// iTunes RSS feed response
@Serializable
data class ItunesRssResponse(
val feed: ItunesFeed? = null,
)
@Serializable
data class ItunesFeed(
val entry: List<ItunesRssEntry> = emptyList(),
)
@Serializable
data class ItunesRssEntry(
@SerialName("im:name") val name: ItunesLabel? = null,
@SerialName("im:artist") val artist: ItunesLabel? = null,
@SerialName("im:image") val image: List<ItunesLabel> = emptyList(),
@SerialName("im:itemCount") val itemCount: ItunesLabel? = null,
val id: ItunesId? = null,
val category: ItunesCategory? = null,
)
@Serializable
data class ItunesLabel(
val label: String? = null,
)
@Serializable
data class ItunesId(
val label: String? = null,
val attributes: ItunesIdAttributes? = null,
)
@Serializable
data class ItunesIdAttributes(
@SerialName("im:id") val imId: String? = null,
)
@Serializable
data class ItunesCategory(
val attributes: ItunesCategoryAttributes? = null,
)
@Serializable
data class ItunesCategoryAttributes(
val term: String? = null,
)
// Helper to get high-res artwork (replace 100x100 with 600x600)
fun ItunesItem.artworkHd(): String? =
artworkUrl100?.replace("100x100bb", "600x600bb")?.replace("/100x100/", "/600x600/")
fun ItunesRssEntry.artworkHd(): String? =
image.lastOrNull()?.label // last image is always the largest in the RSS
@@ -0,0 +1,185 @@
package com.novasonic.app.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// ── Subsonic JSON envelope ────────────────────────────────────────────────────
@Serializable
data class SubsonicWrapper(
@SerialName("subsonic-response") val response: SubsonicResponse,
)
@Serializable
data class SubsonicResponse(
val status: String,
val version: String = "1.16.1",
val error: SubsonicError? = null,
val musicFolders: MusicFoldersResult? = null,
val albumList2: AlbumListResult? = null,
val album: Album? = null,
val artists: ArtistsResult? = null,
val artist: Artist? = null,
val searchResult3: SearchResult3? = null,
val playlists: PlaylistsResult? = null,
val playlist: PlaylistWithSongs? = null,
val song: Song? = null,
val starred2: Starred2? = null,
val randomSongs: SongListResult? = null,
val similarSongs2: SongListResult? = null,
val scanStatus: ScanStatus? = null,
val artistInfo2: ArtistInfo? = null,
val topSongs: SongListResult? = null,
val genres: GenresResult? = null,
val songsByGenre: SongListResult? = null,
)
@Serializable
data class SubsonicError(val code: Int, val message: String)
// ── Domain models ─────────────────────────────────────────────────────────────
@Serializable
data class SongArtist(
val id: String = "",
val name: String = "",
)
@Serializable
data class Song(
val id: String,
val title: String,
val album: String? = null,
val albumId: String? = null,
val artist: String? = null,
val artistId: String? = null,
val artists: List<SongArtist> = emptyList(),
val track: Int? = null,
val year: Int? = null,
val genre: String? = null,
val coverArt: String? = null,
val size: Long? = null,
val contentType: String? = null,
val suffix: String? = null,
val duration: Int? = null,
val bitRate: Int? = null,
val path: String? = null,
val playCount: Long? = null,
val starred: String? = null, // ISO date string when starred
)
@Serializable
data class Album(
val id: String,
val name: String,
val artist: String? = null,
val artistId: String? = null,
val coverArt: String? = null,
val songCount: Int = 0,
val duration: Int = 0,
val year: Int? = null,
val genre: String? = null,
val starred: String? = null,
val song: List<Song> = emptyList(),
)
@Serializable
data class Artist(
val id: String,
val name: String,
val coverArt: String? = null,
val albumCount: Int = 0,
val starred: String? = null,
val album: List<Album> = emptyList(),
)
@Serializable
data class ArtistIndex(
val name: String,
val artist: List<Artist> = emptyList(),
)
@Serializable
data class Playlist(
val id: String,
val name: String,
val comment: String? = null,
val owner: String? = null,
val songCount: Int = 0,
val duration: Int = 0,
val coverArt: String? = null,
)
@Serializable
data class PlaylistWithSongs(
val id: String,
val name: String,
val comment: String? = null,
val owner: String? = null,
val songCount: Int = 0,
val duration: Int = 0,
val coverArt: String? = null,
val entry: List<Song> = emptyList(),
)
// ── Result wrappers ───────────────────────────────────────────────────────────
@Serializable
data class MusicFoldersResult(val musicFolder: List<MusicFolder> = emptyList())
@Serializable
data class MusicFolder(val id: String, val name: String? = null)
@Serializable
data class AlbumListResult(val album: List<Album> = emptyList())
@Serializable
data class ArtistsResult(val index: List<ArtistIndex> = emptyList())
@Serializable
data class PlaylistsResult(val playlist: List<Playlist> = emptyList())
@Serializable
data class SongListResult(val song: List<Song> = emptyList())
@Serializable
data class Starred2(
val song: List<Song> = emptyList(),
val album: List<Album> = emptyList(),
val artist: List<Artist> = emptyList(),
)
@Serializable
data class SearchResult3(
val song: List<Song> = emptyList(),
val album: List<Album> = emptyList(),
val artist: List<Artist> = emptyList(),
)
@Serializable
data class ScanStatus(val scanning: Boolean, val count: Long? = null)
// ── Artist info (Last.fm bio + similar artists) ───────────────────────────────
@Serializable
data class ArtistInfo(
val biography: String? = null,
val musicBrainzId: String? = null,
val lastFmUrl: String? = null,
val smallImageUrl: String? = null,
val mediumImageUrl: String? = null,
val largeImageUrl: String? = null,
val similarArtist: List<Artist> = emptyList(),
)
// ── Genres ────────────────────────────────────────────────────────────────────
@Serializable
data class Genre(
val value: String, // genre name is the text value
val songCount: Int = 0,
val albumCount: Int = 0,
)
@Serializable
data class GenresResult(val genre: List<Genre> = emptyList())
@@ -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>)
@@ -0,0 +1,146 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
// Mirrors the JSON from the scrobble service GET /scrobble/stats.
@Serializable
data class ScrobbleStats(
val firstPlayedAt: Long? = null,
val totalPlays: Int = 0,
val totalSeconds: Long = 0,
val topArtists: List<StatArtist> = emptyList(),
val topAlbums: List<StatAlbum> = emptyList(),
val topTracks: List<StatTrack> = emptyList(),
val topGenres: List<StatGenre> = emptyList(),
val series: List<StatBucket> = emptyList(),
val byHour: List<StatHour> = emptyList(),
)
@Serializable
data class StatGenre(
val name: String? = null,
val plays: Int = 0,
)
@Serializable
data class StatArtist(
val name: String? = null,
val artistId: String? = null,
val plays: Int = 0,
val seconds: Long = 0,
)
@Serializable
data class StatAlbum(
val name: String? = null,
val albumId: String? = null,
val artist: String? = null,
val coverArt: String? = null,
val plays: Int = 0,
)
@Serializable
data class StatTrack(
val title: String? = null,
val artist: String? = null,
val album: String? = null,
val albumId: String? = null,
val artistId: String? = null,
val coverArt: String? = null,
val songId: String? = null,
val plays: Int = 0,
)
@Serializable
data class StatBucket(
val bucket: String = "",
val plays: Int = 0,
val seconds: Long = 0,
)
@Serializable
data class StatHour(
val hour: Int = 0,
val plays: Int = 0,
)
// ─── Head-to-Head "Musik-Match" ─────────────────────────────────────────────
// GET /scrobble/users — distinct users in the log, for the partner picker.
@Serializable
data class ScrobbleUsersResponse(
val me: String = "",
val users: List<ScrobbleUser> = emptyList(),
)
@Serializable
data class ScrobbleUser(
val user: String = "",
val plays: Int = 0,
val lastPlayedAt: Long? = null,
)
// GET /scrobble/compare?with= — taste comparison of the authed user (A) vs another (B).
@Serializable
data class CompareResult(
val userA: String = "",
val userB: String = "",
val matchScore: Int = 0,
val battle: Battle? = null,
val totalsA: CompareTotals = CompareTotals(),
val totalsB: CompareTotals = CompareTotals(),
val sharedArtists: List<SharedArtist> = emptyList(),
val sharedTracks: List<SharedTrack> = emptyList(),
val onlyA: 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
data class CompareTotals(
val plays: Int = 0,
val seconds: Long = 0,
val artists: Int = 0,
)
@Serializable
data class SharedArtist(
val name: String? = null,
val artistId: String? = null,
val playsA: Int = 0,
val playsB: Int = 0,
)
@Serializable
data class SharedTrack(
val songId: String? = null,
val title: String? = null,
val artist: String? = null,
val artistId: String? = null,
val albumId: String? = null,
val coverArt: String? = null,
val playsA: Int = 0,
val playsB: Int = 0,
)
@Serializable
data class UniqueArtist(
val name: String? = null,
val artistId: String? = null,
val plays: Int = 0,
)
@@ -0,0 +1,96 @@
package com.novasonic.app.data
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.novasonic.app.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* In-app updater for the sideloaded APK. Talks ONLY to the backend the app already knows:
* GET {backend}/api/apk/info → is there a newer versionCode than this build?
* GET {backend}/api/apk → download it (the backend proxies the fixed Gitea release)
* The user still confirms the system install (Android requires that for sideloaded APKs).
*
* No APK hoarding: the download goes to a single file under cacheDir/updates that is wiped before
* each download. An update (same package + signature) is an in-place install, so app data / offline
* downloads survive — provided the signing key stays stable across builds.
*/
object AppUpdater {
@Serializable
data class ApkInfo(
val available: Boolean = false,
val version: String? = null,
val versionCode: Int? = null,
val size: Long? = null,
)
private val json = Json { ignoreUnknownKeys = true }
private val _available = MutableStateFlow<ApkInfo?>(null)
val available: StateFlow<ApkInfo?> = _available
private val _downloading = MutableStateFlow(false)
val downloading: StateFlow<Boolean> = _downloading
// Versions the user waved away this session — don't nag again until app restart.
@Volatile private var dismissedCode: Int = -1
suspend fun check(backendUrl: String) {
if (backendUrl.isBlank()) return
val info = withContext(Dispatchers.IO) {
runCatching {
val c = (URL("${backendUrl.trimEnd('/')}/api/apk/info").openConnection() as HttpURLConnection)
.apply { connectTimeout = 8000; readTimeout = 8000 }
c.inputStream.bufferedReader().use { it.readText() }
.let { json.decodeFromString<ApkInfo>(it) }
}.getOrNull()
} ?: return
val vc = info.versionCode ?: return // no reliable integer → don't prompt
if (info.available && vc > BuildConfig.VERSION_CODE && vc != dismissedCode) {
_available.value = info
}
}
fun dismiss() {
dismissedCode = _available.value?.versionCode ?: dismissedCode
_available.value = null
}
suspend fun downloadAndInstall(context: Context, backendUrl: String, onError: (String) -> Unit) {
if (_downloading.value || backendUrl.isBlank()) return
_downloading.value = true
try {
val apk = withContext(Dispatchers.IO) {
val dir = File(context.cacheDir, "updates").apply { mkdirs(); listFiles()?.forEach { it.delete() } }
val out = File(dir, "NovaSonic-update.apk")
val c = (URL("${backendUrl.trimEnd('/')}/api/apk").openConnection() as HttpURLConnection)
.apply { connectTimeout = 15000; readTimeout = 30000; instanceFollowRedirects = true }
if (c.responseCode !in 200..299) throw RuntimeException("HTTP ${c.responseCode}")
c.inputStream.use { input -> out.outputStream().use { input.copyTo(it) } }
out
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
context.startActivity(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
_available.value = null // installer takes over
} catch (e: Exception) {
onError(e.message ?: "Download fehlgeschlagen")
} finally {
_downloading.value = false
}
}
}
@@ -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
}
@@ -0,0 +1,208 @@
package com.novasonic.app.data
import android.content.Context
import android.net.Uri
import com.novasonic.app.NovaSonicApp
import com.novasonic.app.api.SubsonicClient
import com.novasonic.app.api.model.*
import com.novasonic.app.offline.OfflineRepository
import kotlinx.coroutines.flow.first
import java.io.File
object ClientHolder {
@Volatile private var client: SubsonicClient? = null
@Volatile private var lastCreds: PrefsStore.Credentials? = null
fun get(creds: PrefsStore.Credentials): SubsonicClient {
if (creds != lastCreds) {
client?.close()
client = SubsonicClient(creds.serverUrl, creds.username, creds.password)
lastCreds = creds
}
return client!!
}
fun invalidate() {
client?.close()
client = null
lastCreds = null
}
}
class MusicRepository(private val context: Context) {
private val prefs = PrefsStore(context)
private val playLog = PlayLogClient(context)
private suspend fun api(): SubsonicClient =
ClientHolder.get(prefs.credentials.first())
suspend fun ping() = api().ping()
suspend fun getRecentAlbums(size: Int = 20) = api().getAlbumList("newest", size)
suspend fun getFrequentAlbums(size: Int = 20) = api().getAlbumList("frequent", size)
suspend fun getRecentlyPlayedAlbums(size: Int = 20) = api().getAlbumList("recent", size)
suspend fun getRandomAlbums(size: Int = 20) = api().getAlbumList("random", size)
suspend fun getAlphaAlbums(size: Int = 40, offset: Int = 0) =
api().getAlbumList("alphabeticalByName", size, offset)
suspend fun getAlbum(id: String) = api().getAlbum(id)
suspend fun getArtists() = api().getArtists()
suspend fun getArtist(id: String) = api().getArtist(id)
suspend fun getSong(id: String) = api().getSong(id)
suspend fun search(query: String) = api().search(query)
suspend fun getPlaylists() = api().getPlaylists()
suspend fun getPlaylist(id: String) = api().getPlaylist(id)
suspend fun getStarred() = api().getStarred()
suspend fun getRandomSongs() = api().getRandomSongs()
suspend fun getSimilarSongs(songId: String, count: Int = 20) = api().getSimilarSongs(songId, count)
suspend fun star(id: String) = api().star(id)
suspend fun unstar(id: String) = api().unstar(id)
suspend fun scrobble(id: String) = api().scrobble(id)
// Our own timestamped play-log (stats + Wrapped), in addition to the Navidrome scrobble above.
suspend fun logPlay(song: Song) = playLog.logPlay(song)
suspend fun flushPlayLog() = playLog.flush()
suspend fun fetchScrobbleStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int) =
playLog.fetchStats(from, to, tz, bucket, limit)
suspend fun fetchScrobbleUsers() = playLog.fetchUsers()
suspend fun fetchScrobbleCompare(with: String) = playLog.fetchCompare(with)
// ── Artist pins (shared, server-side same-name disambiguation) ───────────────
@Volatile private var _artistOverrides: Map<String, ArtistOverride>? = null
/** Full pin map, cached after first load. Empty if no backend / unreachable. */
suspend fun artistOverrides(): Map<String, ArtistOverride> {
_artistOverrides?.let { return it }
val url = getBackendUrl()
if (url.isBlank()) return emptyMap<String, ArtistOverride>().also { _artistOverrides = it }
val c = com.novasonic.app.api.ArtistOverrideClient(url)
val m = runCatching { c.loadAll() }.getOrDefault(emptyMap())
c.close()
return m.also { _artistOverrides = it }
}
suspend fun getArtistOverride(ndArtistId: String): ArtistOverride? = artistOverrides()[ndArtistId]
suspend fun setArtistOverride(ndArtistId: String, ov: ArtistOverride): Boolean {
val url = getBackendUrl()
if (url.isBlank()) return false
val c = com.novasonic.app.api.ArtistOverrideClient(url)
val ok = runCatching { c.set(ndArtistId, ov) }.getOrDefault(false)
c.close()
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) + (ndArtistId to ov)
return ok
}
suspend fun removeArtistOverride(ndArtistId: String): Boolean {
val url = getBackendUrl()
if (url.isBlank()) return false
val c = com.novasonic.app.api.ArtistOverrideClient(url)
val ok = runCatching { c.remove(ndArtistId) }.getOrDefault(false)
c.close()
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) - ndArtistId
return ok
}
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
/** 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 getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
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 startScan() = api().startScan()
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 deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId)
suspend fun uploadPlaylistCover(playlistId: String, imageBytes: ByteArray, mimeType: String) = api().uploadPlaylistCover(playlistId, imageBytes, mimeType)
suspend fun removePlaylistCover(playlistId: String) = api().removePlaylistCover(playlistId)
fun coverArtUrl(id: String?, size: Int = 300): String? {
if (id != null) {
offlineRepo()?.localCoverPath(id)?.let { path ->
val f = File(path)
if (f.exists()) return Uri.fromFile(f).toString()
}
}
val creds = lastCredentials ?: return null
// Reuse the cached client — building a fresh SubsonicClient here spins up a whole Ktor
// HttpClient per cover (and never closes it), which is heavy GC/alloc churn while scrolling.
return ClientHolder.get(creds).coverArtUrl(id ?: return null, size)
}
fun streamUrl(id: String): String? {
offlineRepo()?.localAudioPath(id)?.let { path ->
val f = File(path)
if (f.exists()) return Uri.fromFile(f).toString()
}
val creds = lastCredentials ?: return null
return ClientHolder.get(creds).streamUrl(id) // reuse cached client (no per-call HttpClient)
}
private fun offlineRepo(): OfflineRepository? = runCatching {
(context.applicationContext as NovaSonicApp).offlineRepository
}.getOrNull()
var lastCredentials: PrefsStore.Credentials? = null
fun credentialsFlow() = prefs.credentials
fun appSettingsFlow() = prefs.appSettings
suspend fun getBackendUrl(): String = prefs.appSettings.first().backendUrl
/** Finds Navidrome albums for a given artist name (for iTunes cross-reference). */
suspend fun getNdAlbumsForArtist(artistName: String): List<com.novasonic.app.api.model.Album> {
// Try exact artist match first, then fall back to album search
val artistResult = api().search(artistName, albumCount = 0, artistCount = 10, songCount = 0)
val ndArtist = artistResult.artist.find { a ->
a.name.equals(artistName, ignoreCase = true) ||
a.name.contains(artistName, ignoreCase = true) ||
artistName.contains(a.name, ignoreCase = true)
}
return if (ndArtist != null) {
runCatching { api().getArtist(ndArtist.id).album }.getOrDefault(emptyList())
} else {
api().search(artistName, albumCount = 50, artistCount = 0, songCount = 0).album
.filter { it.artist?.contains(artistName, ignoreCase = true) == true }
}
}
suspend fun saveCredentials(serverUrl: String, username: String, password: String) {
ClientHolder.invalidate()
prefs.save(serverUrl, username, password)
}
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) =
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
fun lastPlayedSongFlow() = prefs.lastPlayedSong
suspend fun absClient(): com.novasonic.app.api.AbsClient? {
val s = prefs.appSettings.first()
if (s.absUrl.isBlank() || s.absApiKey.isBlank()) return null
return com.novasonic.app.api.AbsClient(s.absUrl.trimEnd('/'), s.absApiKey, s.backendUrl.trimEnd('/'))
}
suspend fun saveAppSettings(settings: PrefsStore.AppSettings) {
val urlChanged = settings.serverUrl != lastCredentials?.serverUrl ||
settings.username != lastCredentials?.username ||
settings.password != lastCredentials?.password
if (urlChanged) ClientHolder.invalidate()
prefs.saveAll(settings)
}
suspend fun getScrobbleDelayMs(durationMs: Long): Long {
val mode = prefs.appSettings.first().scrobbleMode
return when (mode) {
"15s" -> 15_000L
"30s" -> 30_000L
"third" -> (durationMs / 3).coerceAtLeast(15_000L)
"two_thirds" -> (durationMs * 2 / 3).coerceAtLeast(15_000L)
"end" -> (durationMs - 5_000L).coerceAtLeast(15_000L)
else -> 30_000L
}
}
}
@@ -0,0 +1,94 @@
package com.novasonic.app.data
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.model.DownloadStatus
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class AppNotification(
val id: String,
val title: String,
val artist: String = "",
val status: String, // "done" | "error" | "pending"
val message: String = "",
val timestamp: Long = System.currentTimeMillis(),
val read: Boolean = false,
// Navigation — mirrors web notify.js fields
val navType: String = "", // "track" | "album" | "playlist"
val navAlbum: String = "",
val navArtist: String = "",
val navId: String = "", // playlist ID if known
)
object NotificationStore {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _items = MutableStateFlow<List<AppNotification>>(emptyList())
val items: StateFlow<List<AppNotification>> = _items.asStateFlow()
fun add(n: AppNotification) = _items.update { listOf(n) + it }
fun markAllRead() = _items.update { list -> list.map { it.copy(read = true) } }
fun remove(id: String) = _items.update { it.filter { n -> n.id != id } }
fun clear() { _items.value = emptyList() }
/**
* Submit a download job to the backend and poll until done/error,
* then add a notification. Safe to call from any ViewModel — uses
* an app-lifetime coroutine scope so the ViewModel can be cleared
* while polling continues.
*/
fun startPoll(
backendUrl: String,
jobId: String,
title: String,
artist: String,
navType: String = "",
navAlbum: String = "",
navArtist: String = "",
navId: String = "",
) {
add(AppNotification(id = jobId, title = title, artist = artist, status = "pending"))
scope.launch {
val client = ImporterClient(backendUrl)
try {
repeat(400) { // up to ~10 minutes at 1.5 s interval
delay(1500)
val st: DownloadStatus = runCatching { client.status(jobId) }.getOrNull()
?: return@repeat
when (st.status) {
"done" -> {
replace(AppNotification(
id = jobId, title = title, artist = artist,
status = "done", message = st.message ?: "Fertig!",
navType = navType, navAlbum = navAlbum,
navArtist = navArtist, navId = navId,
))
return@launch
}
"error" -> {
replace(AppNotification(
id = jobId, title = title, artist = artist,
status = "error", message = st.message ?: "Fehler",
navType = navType, navAlbum = navAlbum,
navArtist = navArtist, navId = navId,
))
return@launch
}
}
}
} finally {
client.close()
}
}
}
private fun replace(n: AppNotification) =
_items.update { list -> listOf(n) + list.filter { it.id != n.id } }
}
@@ -0,0 +1,186 @@
package com.novasonic.app.data
import android.content.Context
import com.novasonic.app.api.model.ScrobbleStats
import com.novasonic.app.api.model.Song
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.encodeURLParameter
import io.ktor.http.isSuccess
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.security.MessageDigest
import java.util.UUID
import kotlin.random.Random
/**
* Records timestamped plays into NovaSonic's own scrobble service (separate origin,
* settings.scrobbleUrl) — the basis for day/month/year stats + Wrapped.
*
* Runs IN ADDITION to the existing Navidrome scrobble (playCount + Last.fm); never
* replaces it. Called at the same "counted play" moment as the Navidrome scrobble
* (see PlayerController.scheduleScrobble).
*
* Offline-safe: entries persist to a JSON file and flush as an idempotent batch
* (the service dedups by uuid), so a play made offline is delivered later exactly once.
*
* One instance lives on MusicRepository → a single reused HttpClient (never per-call).
*/
class PlayLogClient(context: Context) {
private val prefs = PrefsStore(context)
private val queueFile = File(context.filesDir, "play-queue.json")
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val http = HttpClient(Android) {
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
}
@Serializable
data class PlayEntry(
val uuid: String,
val songId: String,
val title: String? = null,
val artist: String? = null,
val artistId: String? = null,
val album: String? = null,
val albumId: String? = null,
val coverArt: String? = null,
val genre: String? = null,
val duration: Int? = null,
val playedAt: Long,
val source: String = "android",
)
@Serializable
private data class Batch(val plays: List<PlayEntry>)
/** Queue one counted play and try to deliver it. */
suspend fun logPlay(song: Song) {
val s = prefs.appSettings.first()
if (s.scrobbleUrl.isBlank()) return
val entry = PlayEntry(
uuid = UUID.randomUUID().toString(),
songId = song.id,
title = song.title,
artist = song.artist,
artistId = song.artistId,
album = song.album,
albumId = song.albumId,
coverArt = song.coverArt,
genre = song.genre,
duration = song.duration,
playedAt = System.currentTimeMillis() / 1000,
)
mutex.withLock {
val q = readQueue().toMutableList()
q.add(entry)
writeQueue(q.takeLast(MAX_QUEUE))
}
flush(s)
}
/** Deliver everything queued as one idempotent batch. Safe to call repeatedly. */
suspend fun flush(settings: PrefsStore.AppSettings? = null) {
val s = settings ?: prefs.appSettings.first()
val base = s.scrobbleUrl.trimEnd('/')
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return
val batch = mutex.withLock { readQueue() }
if (batch.isEmpty()) return
val auth = authParams(s.username, s.password)
val ok = runCatching {
val resp: HttpResponse = http.post("$base/scrobble/plays?$auth") {
contentType(ContentType.Application.Json)
setBody(json.encodeToString(Batch(batch)))
}
resp.status.isSuccess()
}.getOrDefault(false)
if (ok) {
// Remove exactly what we sent (accepted + duplicates are durable), preserving
// any plays queued while the request was in flight.
val sent = batch.map { it.uuid }.toHashSet()
mutex.withLock { writeQueue(readQueue().filterNot { it.uuid in sent }) }
}
}
/** Read aggregated stats for the current user. Returns null on any failure (unconfigured,
* unauthorized, network). Decodes with the tolerant json instance (ignoreUnknownKeys). */
suspend fun fetchStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int): ScrobbleStats? {
val s = prefs.appSettings.first()
val base = s.scrobbleUrl.trimEnd('/')
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
val auth = authParams(s.username, s.password)
val url = "$base/scrobble/stats?$auth&from=$from&to=$to&tz=$tz&bucket=$bucket&limit=$limit"
return runCatching {
val resp = http.get(url)
if (!resp.status.isSuccess()) return null
json.decodeFromString<ScrobbleStats>(resp.bodyAsText())
}.getOrNull()
}
/** Head-to-Head: list the users in the log (for the partner picker). Null on failure. */
suspend fun fetchUsers(): com.novasonic.app.api.model.ScrobbleUsersResponse? {
val s = prefs.appSettings.first()
val base = s.scrobbleUrl.trimEnd('/')
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
val auth = authParams(s.username, s.password)
return runCatching {
val resp = http.get("$base/scrobble/users?$auth")
if (!resp.status.isSuccess()) return null
json.decodeFromString<com.novasonic.app.api.model.ScrobbleUsersResponse>(resp.bodyAsText())
}.getOrNull()
}
/** Head-to-Head: compare the authed user against [with]. Null on failure. */
suspend fun fetchCompare(with: String): com.novasonic.app.api.model.CompareResult? {
val s = prefs.appSettings.first()
val base = s.scrobbleUrl.trimEnd('/')
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
val auth = authParams(s.username, s.password)
val url = "$base/scrobble/compare?$auth&with=${with.encodeURLParameter()}"
return runCatching {
val resp = http.get(url)
if (!resp.status.isSuccess()) return null
json.decodeFromString<com.novasonic.app.api.model.CompareResult>(resp.bodyAsText())
}.getOrNull()
}
private fun readQueue(): List<PlayEntry> =
runCatching {
if (!queueFile.exists()) emptyList()
else json.decodeFromString<List<PlayEntry>>(queueFile.readText())
}.getOrDefault(emptyList())
private fun writeQueue(q: List<PlayEntry>) {
runCatching { queueFile.writeText(json.encodeToString(q)) }
}
private fun authParams(user: String, pass: String): String {
val salt = Random.nextInt(100_000, 999_999).toString()
val token = md5(pass + salt)
return "u=${user.encodeURLParameter()}&t=$token&s=$salt"
}
private fun md5(input: String): String =
MessageDigest.getInstance("MD5").digest(input.toByteArray())
.joinToString("") { "%02x".format(it) }
companion object { private const val MAX_QUEUE = 500 }
}
@@ -0,0 +1,212 @@
package com.novasonic.app.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore("novasonic_prefs")
class PrefsStore(private val context: Context) {
companion object {
val KEY_SERVER = stringPreferencesKey("server_url")
val KEY_USERNAME = stringPreferencesKey("username")
val KEY_PASSWORD = stringPreferencesKey("password")
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
val KEY_SCROBBLE_URL = stringPreferencesKey("scrobble_url")
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
val KEY_THEME = stringPreferencesKey("theme")
val KEY_ABS_URL = stringPreferencesKey("abs_url")
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
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_TITLE = stringPreferencesKey("last_song_title")
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
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) {
val isValid get() = serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
}
data class AppSettings(
val serverUrl: String = "",
val username: String = "",
val password: String = "",
val lastFmKey: String = "",
val backendUrl: String = "",
val scrobbleUrl: String = "",
val scrobbleMode: String = "30s",
val showPlayCount: Boolean = false,
val theme: String = "dark-ocean",
val absUrl: String = "",
val absApiKey: String = "",
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(
val id: String,
val title: String,
val artist: String,
val coverArtId: String?,
)
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
Credentials(
// 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] ?: "",
password = prefs[KEY_PASSWORD] ?: "",
)
}
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
AppSettings(
serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
username = prefs[KEY_USERNAME] ?: "",
password = prefs[KEY_PASSWORD] ?: "",
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
backendUrl = prefs[KEY_BACKEND_URL]?.ifBlank { null }
?: 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",
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
theme = prefs[KEY_THEME] ?: "dark-ocean",
absUrl = prefs[KEY_ABS_URL] ?: "",
absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
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,
)
}
val lastPlayedSong: Flow<LastPlayedSong?> = context.dataStore.data.map { prefs ->
val id = prefs[KEY_LAST_SONG_ID]?.takeIf { it.isNotBlank() } ?: return@map null
LastPlayedSong(
id = id,
title = prefs[KEY_LAST_SONG_TITLE] ?: "",
artist = prefs[KEY_LAST_SONG_ARTIST] ?: "",
coverArtId = prefs[KEY_LAST_SONG_COVER]?.takeIf { it.isNotBlank() },
)
}
suspend fun save(serverUrl: String, username: String, password: String) {
context.dataStore.edit { prefs ->
prefs[KEY_SERVER] = serverUrl.trimEnd('/')
prefs[KEY_USERNAME] = username
prefs[KEY_PASSWORD] = password
}
}
suspend fun saveAll(settings: AppSettings) {
context.dataStore.edit { prefs ->
prefs[KEY_SERVER] = settings.serverUrl.trimEnd('/')
prefs[KEY_USERNAME] = settings.username
prefs[KEY_PASSWORD] = settings.password
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
prefs[KEY_BACKEND_URL] = settings.backendUrl
prefs[KEY_SCROBBLE_URL] = settings.scrobbleUrl
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
prefs[KEY_THEME] = settings.theme
prefs[KEY_ABS_URL] = settings.absUrl
prefs[KEY_ABS_API_KEY] = settings.absApiKey
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) {
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
}
suspend fun saveLastSong(id: String, title: String, artist: String, coverArt: String?) {
context.dataStore.edit { prefs ->
prefs[KEY_LAST_SONG_ID] = id
prefs[KEY_LAST_SONG_TITLE] = title
prefs[KEY_LAST_SONG_ARTIST] = artist
prefs[KEY_LAST_SONG_COVER] = coverArt ?: ""
}
}
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() {
context.dataStore.edit { it.clear() }
}
}
@@ -0,0 +1,33 @@
package com.novasonic.app.data
import com.novasonic.app.api.model.Song
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Global, reactive star/favorite overrides. Tapping a star flips the state here *immediately*
* (optimistic), so every screen showing that song updates at once — regardless of when each
* screen last fetched `song.starred`. The API call runs in the background and reverts on failure.
*/
object StarState {
private val _overrides = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val overrides: StateFlow<Map<String, Boolean>> = _overrides.asStateFlow()
/** Current starred state for a song: the live override if present, else the song's own flag. */
fun isStarred(song: Song): Boolean = _overrides.value[song.id] ?: (song.starred != null)
fun isStarred(songId: String, fallback: Boolean): Boolean = _overrides.value[songId] ?: fallback
/** Optimistically toggle, then hit the API; revert the override if the call fails. */
fun toggle(repo: MusicRepository, scope: CoroutineScope, songId: String, fallbackStarred: Boolean) {
val current = _overrides.value[songId] ?: fallbackStarred
val next = !current
_overrides.value = _overrides.value + (songId to next)
scope.launch {
val ok = runCatching { if (next) repo.star(songId) else repo.unstar(songId) }.isSuccess
if (!ok) _overrides.value = _overrides.value + (songId to current) // revert on failure
}
}
}
@@ -0,0 +1,155 @@
package com.novasonic.app.offline
import android.content.Context
import android.util.Log
import androidx.work.*
import com.novasonic.app.app
import com.novasonic.app.data.PrefsStore
import com.novasonic.app.api.SubsonicClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
class DownloadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
companion object {
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
override suspend fun doWork(): Result {
val groupId = inputData.getString(EXTRA_GROUP_ID) ?: 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)
if (songIds.isEmpty()) return Result.success()
val creds = PrefsStore(applicationContext).credentials.first()
if (!creds.isValid) return Result.failure()
val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password)
// 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()
return try {
var gestoppt = false
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) {
// Kein Schreibvorgang beim Ueberspringen: der Fortschritt aendert sich dabei
// nicht, und bei 1900 Titeln waeren das 1900 unnoetige Schreibzugriffe je Lauf.
done++
continue
}
val track = offlineRepo.getTrack(songId) ?: continue
dao.updateState(songId, DownloadState.DOWNLOADING, 0f)
val suffix = track.suffix?.takeIf { it.isNotBlank() } ?: "mp3"
val audioFile = offlineRepo.audioFile(songId, suffix)
try {
streamToFile(subsonicClient.streamUrl(songId), audioFile)
val coverPath = track.coverArt?.let { coverArtId ->
val existing = offlineRepo.localCoverPath(coverArtId)
if (existing != null && File(existing).exists()) {
existing
} else {
val coverFile = offlineRepo.coverFile(coverArtId)
runCatching { streamToFile(subsonicClient.coverArtUrl(coverArtId, 512), coverFile) }
if (coverFile.exists()) coverFile.absolutePath else null
}
}
offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
done++
} catch (e: Exception) {
Log.e(TAG, "Titel $songId fehlgeschlagen", e)
audioFile.delete()
offlineRepo.markFailed(songId)
}
dao.updateGroupProgress(groupId, done)
}
// 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) {
Log.e(TAG, "Lauf fuer Gruppe $groupId abgebrochen", e)
// Frueher Result.failure(): ein einzelner Netzfehler beendete die ganze Kette endgueltig.
if (runAttemptCount < MAX_VERSUCHE) Result.retry() else Result.failure()
}
}
private suspend fun streamToFile(url: String, dest: File) = withContext(Dispatchers.IO) {
val tmp = File("${dest.absolutePath}.tmp")
try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 30_000
conn.readTimeout = 120_000
conn.connect()
if (conn.responseCode !in 200..299) {
throw Exception("HTTP ${conn.responseCode} for ${dest.name}")
}
conn.inputStream.use { input ->
FileOutputStream(tmp).use { out ->
val buf = ByteArray(32_768)
var n: Int
while (input.read(buf).also { n = it } >= 0) {
out.write(buf, 0, n)
}
}
}
if (!tmp.renameTo(dest)) {
tmp.copyTo(dest, overwrite = true)
tmp.delete()
}
} catch (e: Exception) {
tmp.delete()
throw e
}
}
}
@@ -0,0 +1,125 @@
package com.novasonic.app.offline
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface OfflineDao {
// ── Tracks ────────────────────────────────────────────────────────────────
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTrack(track: DownloadedTrackEntity)
@Query("UPDATE downloaded_tracks SET state = :state, progress = :progress WHERE songId = :songId")
suspend fun updateState(songId: String, state: DownloadState, progress: Float)
@Query("UPDATE downloaded_tracks SET state = 'DONE', progress = 1, localPath = :localPath, coverPath = :coverPath, downloadedAt = :at WHERE songId = :songId")
suspend fun markDone(songId: String, localPath: String, coverPath: String?, at: Long)
/** Emits whenever any downloaded track changes — drives the hot in-memory maps. */
@Query("SELECT songId, localPath, coverArt, coverPath FROM downloaded_tracks WHERE state = 'DONE'")
fun doneTracksFlow(): Flow<List<DoneTrackInfo>>
@Query("SELECT * FROM downloaded_tracks WHERE songId = :songId")
fun trackFlow(songId: String): Flow<DownloadedTrackEntity?>
@Query("SELECT * FROM downloaded_tracks WHERE songId = :songId")
suspend fun getTrackById(songId: String): DownloadedTrackEntity?
@Query("SELECT state FROM downloaded_tracks WHERE songId = :songId")
suspend fun getTrackStateRaw(songId: String): String?
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertTrackIfAbsent(track: DownloadedTrackEntity)
@Query("SELECT * FROM downloaded_tracks ORDER BY downloadedAt DESC")
fun allTracksFlow(): Flow<List<DownloadedTrackEntity>>
@Query("SELECT COUNT(*) FROM group_track_cross_refs WHERE songId = :songId")
suspend fun groupCountForSong(songId: String): Int
@Query("DELETE FROM downloaded_tracks WHERE songId = :songId")
suspend fun deleteTrackById(songId: String)
// ── Groups ────────────────────────────────────────────────────────────────
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGroup(group: DownloadGroupEntity)
@Query("SELECT * FROM download_groups ORDER BY createdAt DESC")
fun allGroupsFlow(): Flow<List<DownloadGroupEntity>>
@Query("SELECT * FROM download_groups WHERE groupId = :groupId")
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")
suspend fun deleteGroup(groupId: String)
@Query("UPDATE download_groups SET downloadedTracks = :count WHERE groupId = :groupId")
suspend fun updateGroupProgress(groupId: String, count: Int)
@Query("UPDATE download_groups SET totalTracks = :total WHERE groupId = :groupId")
suspend fun updateGroupTotal(groupId: String, total: Int)
// ── Cross-refs ────────────────────────────────────────────────────────────
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertCrossRef(ref: GroupTrackCrossRef)
@Query("SELECT songId FROM group_track_cross_refs WHERE groupId = :groupId")
suspend fun songIdsForGroup(groupId: String): List<String>
@Query("""
SELECT t.* FROM downloaded_tracks t
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
WHERE r.groupId = :groupId
""")
suspend fun tracksForGroup(groupId: String): List<DownloadedTrackEntity>
@Query("""
SELECT t.* FROM downloaded_tracks t
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
WHERE r.groupId = :groupId
""")
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")
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). */
@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?
}
@@ -0,0 +1,38 @@
package com.novasonic.app.offline
import android.content.Context
import androidx.room.*
@TypeConverters(OfflineConverters::class)
@Database(
entities = [
DownloadedTrackEntity::class,
DownloadGroupEntity::class,
GroupTrackCrossRef::class,
],
version = 1,
exportSchema = false,
)
abstract class OfflineDatabase : RoomDatabase() {
abstract fun offlineDao(): OfflineDao
companion object {
@Volatile private var INSTANCE: OfflineDatabase? = null
fun get(context: Context): OfflineDatabase = INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
OfflineDatabase::class.java,
"novasonic_offline.db",
).build().also { INSTANCE = it }
}
}
}
class OfflineConverters {
@TypeConverter fun fromState(v: DownloadState): String = v.name
@TypeConverter fun toState(v: String): DownloadState = DownloadState.valueOf(v)
@TypeConverter fun fromType(v: GroupType): String = v.name
@TypeConverter fun toType(v: String): GroupType = GroupType.valueOf(v)
}
@@ -0,0 +1,91 @@
package com.novasonic.app.offline
import androidx.room.*
import com.novasonic.app.api.model.Song
enum class DownloadState { QUEUED, DOWNLOADING, DONE, FAILED }
enum class GroupType { SONG, ALBUM, PLAYLIST, FAVORITES }
/** Maps a stored offline track back to a domain [Song] for playback / list display. */
fun DownloadedTrackEntity.toSong() = Song(
id = songId,
title = title,
album = album,
albumId = albumId,
artist = artist,
artistId = artistId,
track = track,
year = year,
genre = genre,
coverArt = coverArt,
duration = duration,
suffix = suffix,
contentType = contentType,
bitRate = bitRate,
starred = starred,
)
/** Lightweight projection used to keep the hot in-memory maps up to date. */
data class DoneTrackInfo(
val songId: String,
val localPath: String,
val coverArt: String?,
val coverPath: String?,
)
@Entity(tableName = "downloaded_tracks")
data class DownloadedTrackEntity(
@PrimaryKey val songId: String,
val title: String,
val artist: String?,
val artistId: String?,
val album: String?,
val albumId: String?,
val coverArt: String?,
val track: Int?,
val year: Int?,
val genre: String?,
val duration: Int?,
val suffix: String?,
val contentType: String?,
val bitRate: Int?,
val starred: String?,
val localPath: String,
val coverPath: String?,
val state: DownloadState,
val progress: Float = 0f,
val downloadedAt: Long = 0L,
)
@Entity(tableName = "download_groups")
data class DownloadGroupEntity(
@PrimaryKey val groupId: String, // e.g. "album:abc123", "playlist:xyz", "favorites"
val type: GroupType,
val sourceId: String,
val name: String,
val coverArt: String?,
val totalTracks: Int = 0,
val downloadedTracks: Int = 0,
val createdAt: Long = System.currentTimeMillis(),
)
@Entity(
tableName = "group_track_cross_refs",
primaryKeys = ["groupId", "songId"],
foreignKeys = [
ForeignKey(
entity = DownloadGroupEntity::class,
parentColumns = ["groupId"],
childColumns = ["groupId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = DownloadedTrackEntity::class,
parentColumns = ["songId"],
childColumns = ["songId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("songId")],
)
data class GroupTrackCrossRef(val groupId: String, val songId: String)
@@ -0,0 +1,287 @@
package com.novasonic.app.offline
import android.content.Context
import android.util.Log
import androidx.work.*
import com.novasonic.app.api.model.Song
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.io.File
class OfflineRepository(context: Context) {
private val appContext = context.applicationContext
private val dao = OfflineDatabase.get(appContext).offlineDao()
private val scope = CoroutineScope(
SupervisorJob() + Dispatchers.IO +
CoroutineExceptionHandler { _, t -> Log.e("OfflineRepository", "Unhandled exception", t) }
)
// ── Hot in-memory maps (no suspend needed for lookups) ────────────────────
// songId → absolute local audio path
private val _audioMap = MutableStateFlow<Map<String, String>>(emptyMap())
// coverArtId (Navidrome) → absolute local cover path
private val _coverMap = MutableStateFlow<Map<String, String>>(emptyMap())
/** Reactive set of all downloaded song IDs — collect in UI for per-row indicators. */
val downloadedIds: StateFlow<Set<String>> = _audioMap
.map { it.keys.toSet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
init {
scope.launch {
dao.doneTracksFlow().collect { tracks ->
_audioMap.value = tracks.associate { it.songId to it.localPath }
_coverMap.value = tracks
.filter { it.coverArt != null && it.coverPath != null }
.associate { it.coverArt!! to it.coverPath!! }
}
}
}
// ── Synchronous lookups (called from MusicRepository — no coroutine) ──────
fun isDownloaded(songId: String): Boolean = songId in _audioMap.value
fun localAudioPath(songId: String): String? = _audioMap.value[songId]
fun localCoverPath(coverArtId: String): String? = _coverMap.value[coverArtId]
// ── Storage layout ────────────────────────────────────────────────────────
fun audioDir(): File = File(appContext.filesDir, "offline/audio").also { it.mkdirs() }
fun coversDir(): File = File(appContext.filesDir, "offline/covers").also { it.mkdirs() }
fun audioFile(songId: String, suffix: String): File = File(audioDir(), "$songId.$suffix")
fun coverFile(coverArtId: String): File = File(coversDir(), "$coverArtId.jpg")
// ── Reactive flows for UI ─────────────────────────────────────────────────
val allGroupsFlow: Flow<List<DownloadGroupEntity>> = dao.allGroupsFlow()
val allTracksFlow: Flow<List<DownloadedTrackEntity>> = dao.allTracksFlow()
fun trackFlow(songId: String): Flow<DownloadedTrackEntity?> = dao.trackFlow(songId)
// ── Write operations (used by DownloadWorker — Phase 2) ──────────────────
suspend fun insertTrack(entity: DownloadedTrackEntity) = dao.insertTrack(entity)
suspend fun updateState(songId: String, state: DownloadState, progress: Float) =
dao.updateState(songId, state, progress)
suspend fun markDone(songId: String, localPath: String, coverPath: String?) =
dao.markDone(songId, localPath, coverPath, System.currentTimeMillis())
suspend fun markFailed(songId: String) =
dao.updateState(songId, DownloadState.FAILED, 0f)
suspend fun insertGroup(group: DownloadGroupEntity) = dao.insertGroup(group)
suspend fun getGroup(groupId: String) = dao.getGroup(groupId)
suspend fun updateGroupProgress(groupId: String, count: Int) =
dao.updateGroupProgress(groupId, count)
suspend fun updateGroupTotal(groupId: String, total: Int) =
dao.updateGroupTotal(groupId, total)
suspend fun insertCrossRef(groupId: String, songId: String) =
dao.insertCrossRef(GroupTrackCrossRef(groupId, songId))
suspend fun songIdsForGroup(groupId: String): List<String> = dao.songIdsForGroup(groupId)
suspend fun getTrack(songId: String): DownloadedTrackEntity? = dao.getTrackById(songId)
suspend fun tracksForGroup(groupId: String): List<DownloadedTrackEntity> = dao.tracksForGroup(groupId)
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>> = dao.tracksForGroupFlow(groupId)
suspend fun getTrackState(songId: String): DownloadState? =
dao.getTrackStateRaw(songId)?.let { DownloadState.valueOf(it) }
/**
* Inserts all songs as QUEUED into Room, creates a download group, and enqueues a WorkManager
* job that downloads the files in the background. Already-DONE tracks are counted but not
* re-downloaded. Safe to call for an existing groupId — WorkManager KEEP policy is used.
*/
suspend fun enqueueGroup(
type: GroupType,
sourceId: String,
name: String,
coverArt: String?,
songs: List<Song>,
) {
val groupId = "${type.name.lowercase()}:$sourceId"
// Preserve creation time + progress when re-enqueuing (resume / re-download missing songs).
val existing = dao.getGroup(groupId)
dao.insertGroup(
DownloadGroupEntity(
groupId = groupId,
type = type,
sourceId = sourceId,
name = name,
coverArt = coverArt,
totalTracks = songs.size,
downloadedTracks = existing?.downloadedTracks ?: 0,
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
)
)
var alreadyDone = 0
for (song in songs) {
if (isDownloaded(song.id)) {
dao.insertCrossRef(GroupTrackCrossRef(groupId, song.id))
alreadyDone++
} else {
dao.insertTrackIfAbsent(
DownloadedTrackEntity(
songId = song.id,
title = song.title,
artist = song.artist,
artistId = song.artistId,
album = song.album,
albumId = song.albumId,
coverArt = song.coverArt,
track = song.track,
year = song.year,
genre = song.genre,
duration = song.duration,
suffix = song.suffix,
contentType = song.contentType,
bitRate = song.bitRate,
starred = song.starred,
localPath = "",
coverPath = null,
state = DownloadState.QUEUED,
)
)
dao.insertCrossRef(GroupTrackCrossRef(groupId, song.id))
}
}
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>()
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(if (nurWlan) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresStorageNotLow(true)
.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()
WorkManager.getInstance(appContext).enqueueUniqueWork(
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 ──────────────────────────────
/** Removes a group and any tracks that are no longer referenced by another group. */
suspend fun deleteGroup(groupId: String) {
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
for (songId in songIds) {
if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
}
}
/** Removes a single track from a group, deleting the file if no other group still needs it. */
suspend fun removeSongFromGroup(groupId: String, songId: String) {
dao.removeCrossRef(groupId, songId)
if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
recountGroup(groupId)
}
/** Recomputes a group's total + downloaded counts from its cross-refs; deletes it if empty. */
private suspend fun recountGroup(groupId: String) {
val tracks = dao.tracksForGroup(groupId)
if (tracks.isEmpty()) {
dao.deleteGroup(groupId)
return
}
dao.updateGroupTotal(groupId, tracks.size)
dao.updateGroupProgress(groupId, tracks.count { it.state == DownloadState.DONE })
}
// ── Storage accounting ────────────────────────────────────────────────────
fun storageUsedBytes(): Long =
audioDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } +
coversDir().walkTopDown().filter { it.isFile }.sumOf { it.length() }
}
@@ -0,0 +1,314 @@
package com.novasonic.app.player
import android.content.Context
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import com.novasonic.app.app
import com.novasonic.app.api.AbsChapter
import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibraryItem
import com.novasonic.app.api.AbsAudioTrack
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class AbsPlayState(
val isLoading: Boolean = false,
val isPlaying: Boolean = false,
val item: AbsLibraryItem? = null,
val chapters: List<AbsChapter> = emptyList(),
val duration: Double = 0.0,
val currentTime: Double = 0.0,
val chapterIdx: Int = 0,
val speed: Float = 1f,
val sleepRemainingSecs: Int? = null,
val chapterTrackMode: Boolean = false,
val sessionId: String? = null,
val error: String? = null,
)
class AbsPlayerController(context: Context) {
private val appContext = context.applicationContext
var scope = CoroutineScope(
Dispatchers.Main + SupervisorJob() +
CoroutineExceptionHandler { _, t -> android.util.Log.e("AbsPlayerController", "Uncaught coroutine exception", t) }
)
private set
private val _state = MutableStateFlow(AbsPlayState())
val state: StateFlow<AbsPlayState> = _state.asStateFlow()
val isActive: Boolean get() = _state.value.item != null
private var absClient: AbsClient? = null
private var audioTracks: List<AbsAudioTrack> = emptyList()
private var currentItemId: String? = null
private var isClosed = false
private var positionObserverJob: Job? = null
private var syncJob: Job? = null
private var sleepJob: Job? = null
private var sleepTickJob: Job? = null
fun currentCoverUrl(): String? {
val item = _state.value.item ?: return null
val base = absClient?.coverUrl(item.id) ?: return null
return "$base&width=512"
}
fun setChapterTrackMode(enabled: Boolean) {
_state.value = _state.value.copy(chapterTrackMode = enabled)
}
suspend fun playItem(item: AbsLibraryItem, client: AbsClient) {
if (isClosed) {
isClosed = false
scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
}
absClient = client
currentItemId = item.id
val prevSpeed = _state.value.speed
val chTrackMode = _state.value.chapterTrackMode
_state.value = AbsPlayState(isLoading = true, item = item, speed = prevSpeed, chapterTrackMode = chTrackMode)
// Cancel in-progress jobs from a previous session
positionObserverJob?.cancel()
stopSync()
cancelSleepInternal()
try {
val session = client.startSession(item.id)
val tracks = session.audioTracks
val chapters = session.chapters.ifEmpty { client.getItemChapters(item.id) }
val duration = tracks.sumOf { it.duration }.let { if (it > 0.0) it else item.media.duration }
val saved = client.getProgress(item.id)
val startTime = when {
(saved?.currentTime ?: 0.0) > 10.0 -> saved!!.currentTime
session.currentTime > 10.0 -> session.currentTime
else -> 0.0
}
audioTracks = tracks
currentItemId = item.id
_state.value = _state.value.copy(
isLoading = false,
sessionId = session.id,
chapters = chapters,
duration = duration,
currentTime = startTime,
chapterIdx = findChapterIdx(chapters, startTime),
)
if (tracks.isEmpty()) {
_state.value = _state.value.copy(error = "Keine Audiotracks gefunden")
return
}
val meta = item.media.metadata
val artworkUri = client.coverUrl(item.id)?.let { Uri.parse(it) }
// Each ABS track gets mediaId "abs:<contentUrl>" so PlayerController can detect
// ABS items and skip Navidrome scrobbling / last-song tracking.
val mediaItems = tracks.map { t ->
MediaItem.Builder()
.setMediaId("abs:${t.contentUrl}")
.setUri(client.audioTrackUrl(t.contentUrl))
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(meta?.title ?: "Hörbuch")
.setArtist(meta?.authorName ?: "")
.setArtworkUri(artworkUri)
.build()
)
.build()
}
val (trackIdx, localSecs) = findTrack(tracks, startTime)
// Route audio through MusicService — same notification, same mini player
appContext.app.playerController.playAbsItems(
items = mediaItems,
startIndex = trackIdx,
startPositionMs = (localSecs * 1000).toLong(),
speed = prevSpeed,
)
startPositionObserver()
startSync()
} catch (e: Exception) {
_state.value = _state.value.copy(isLoading = false, error = e.message ?: "Fehler beim Laden")
}
}
// ── Position observer — keeps AbsPlayState in sync with MusicService player ──
private fun startPositionObserver() {
positionObserverJob?.cancel()
positionObserverJob = scope.launch {
appContext.app.playerController.state.collect { playerState ->
val s = _state.value
if (s.item == null || audioTracks.isEmpty()) return@collect
val trackIdx = playerState.currentIndex.coerceAtLeast(0)
val track = audioTracks.getOrNull(trackIdx) ?: return@collect
val localSecs = playerState.positionMs / 1000.0
val globalTime = track.startOffset + localSecs
val chapterIdx = findChapterIdx(s.chapters, globalTime)
_state.value = s.copy(
isPlaying = playerState.isPlaying,
currentTime = globalTime,
chapterIdx = chapterIdx,
)
if (playerState.isPlaying) startSync() else { stopSync(); doSync() }
}
}
}
// ── Playback controls — delegate to PlayerController ─────────────────────
fun togglePlay() = appContext.app.playerController.togglePlay()
fun pause() = appContext.app.playerController.pause()
fun resume() = appContext.app.playerController.play()
fun seekToGlobal(globalSecs: Double) {
val clamped = globalSecs.coerceIn(0.0, _state.value.duration)
val (trackIdx, localSecs) = findTrack(audioTracks, clamped)
appContext.app.playerController.seekToAbs(trackIdx, (localSecs * 1000).toLong())
_state.value = _state.value.copy(
currentTime = clamped,
chapterIdx = findChapterIdx(_state.value.chapters, clamped),
)
}
fun nextChapter() {
val s = _state.value
val next = s.chapters.getOrNull(s.chapterIdx + 1) ?: return
seekToGlobal(next.start)
}
fun prevChapter() {
val s = _state.value
val cur = s.chapters.getOrNull(s.chapterIdx)
if (cur != null && s.currentTime - cur.start > 5) { seekToGlobal(cur.start); return }
val prev = s.chapters.getOrNull(s.chapterIdx - 1) ?: return
seekToGlobal(prev.start)
}
fun seekToChapter(idx: Int) {
val ch = _state.value.chapters.getOrNull(idx) ?: return
seekToGlobal(ch.start)
}
fun skipSecs(secs: Double) = seekToGlobal(_state.value.currentTime + secs)
fun setSpeed(speed: Float) {
_state.value = _state.value.copy(speed = speed)
appContext.app.playerController.setSpeed(speed)
}
// ── Sleep timer ───────────────────────────────────────────────────────────
fun setSleepTimer(minutes: Int) {
cancelSleepInternal()
if (minutes <= 0) { _state.value = _state.value.copy(sleepRemainingSecs = null); return }
val totalSecs = minutes * 60
_state.value = _state.value.copy(sleepRemainingSecs = totalSecs)
sleepJob = scope.launch {
delay(totalSecs * 1000L)
appContext.app.playerController.pause()
_state.value = _state.value.copy(sleepRemainingSecs = null)
}
sleepTickJob = scope.launch {
var rem = totalSecs
while (rem > 0 && isActive) {
delay(1000)
rem--
_state.value = _state.value.copy(sleepRemainingSecs = rem)
}
}
}
fun cancelSleep() {
cancelSleepInternal()
_state.value = _state.value.copy(sleepRemainingSecs = null)
}
private fun cancelSleepInternal() {
sleepJob?.cancel(); sleepJob = null
sleepTickJob?.cancel(); sleepTickJob = null
}
// ── Progress sync to ABS server ───────────────────────────────────────────
private fun startSync() {
if (syncJob?.isActive == true) return
syncJob = scope.launch {
while (isActive) {
delay(30_000)
doSync()
}
}
}
private fun stopSync() { syncJob?.cancel(); syncJob = null }
private fun doSync() {
val itemId = currentItemId ?: return
val client = absClient ?: return
val s = _state.value
scope.launch { runCatching { client.saveProgress(itemId, s.currentTime, s.duration) } }
}
// ── Close ─────────────────────────────────────────────────────────────────
fun close() {
if (isClosed) return
isClosed = true
// Save progress and close ABS session on a detached scope so it survives
// cancellation of the main scope below.
val sessionId = _state.value.sessionId
val client = absClient
val itemId = currentItemId
val ct = _state.value.currentTime
val dur = _state.value.duration
if (sessionId != null && client != null && itemId != null) {
CoroutineScope(Dispatchers.IO).launch {
runCatching { client.saveProgress(itemId, ct, dur) }
runCatching { client.closeSession(sessionId) }
}
}
positionObserverJob?.cancel()
stopSync()
cancelSleepInternal()
appContext.app.playerController.stop()
_state.value = AbsPlayState()
scope.cancel()
}
// ── Helpers ───────────────────────────────────────────────────────────────
private fun findChapterIdx(chapters: List<AbsChapter>, time: Double): Int {
if (chapters.isEmpty()) return 0
var idx = 0
for (i in chapters.indices) { if (chapters[i].start <= time) idx = i }
return idx
}
private fun findTrack(tracks: List<AbsAudioTrack>, globalTime: Double): Pair<Int, Double> {
if (tracks.isEmpty()) return Pair(0, globalTime)
var idx = 0
for (i in tracks.indices) { if (tracks[i].startOffset <= globalTime) idx = i }
val local = (globalTime - tracks[idx].startOffset).coerceAtLeast(0.0)
return Pair(idx, local)
}
}
@@ -0,0 +1,602 @@
package com.novasonic.app.player
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import androidx.media3.common.*
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.*
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.google.common.util.concurrent.SettableFuture
import com.novasonic.app.NovaSonicApp
import com.novasonic.app.R
import com.novasonic.app.data.MusicRepository
import com.novasonic.app.ui.MainActivity
import android.util.Log
import kotlinx.coroutines.*
import java.util.concurrent.Executors
class MusicService : MediaLibraryService() {
companion object {
// Android Auto root IDs
const val ROOT_ID = "ROOT"
const val RECENT_ID = "RECENT"
const val PLAYLISTS_ID = "PLAYLISTS"
const val FAVORITES_ID = "FAVORITES"
const val ITEM_PREFIX_ALBUM = "album:"
const val ITEM_PREFIX_PL = "playlist:"
// Pseudo-items for play-all / shuffle at top of each list
const val PLAY_ALL_PREFIX = "playall:"
const val SHUFFLE_PREFIX = "shuffle:"
// Encodes a song's browse context so a tap plays the whole list from that song:
// "songctx:<contextId>|<songId>" (contextId may itself be "album:…"/"playlist:…").
const val SONG_CTX_PREFIX = "songctx:"
// ABS session commands (handled via onCustomCommand)
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
const val ACTION_ABS_SKIP_FWD = "novasonic.abs.SKIP_FORWARD"
const val ACTION_ABS_NEXT_CH = "novasonic.abs.NEXT_CHAPTER"
const val ACTION_ABS_TOGGLE = "novasonic.abs.TOGGLE_PLAY"
}
private lateinit var player: ExoPlayer
private lateinit var session: MediaLibrarySession
private lateinit var repo: MusicRepository
private val scope = CoroutineScope(
Dispatchers.Main + SupervisorJob() +
CoroutineExceptionHandler { _, t -> Log.e("MusicService", "Uncaught coroutine exception", t) }
)
private var lastAbsChapterIdx = -1
override fun onCreate() {
super.onCreate()
repo = MusicRepository(applicationContext)
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent("NovaSonic-Android/1.0")
.setConnectTimeoutMs(15_000)
.setReadTimeoutMs(15_000)
.setAllowCrossProtocolRedirects(true)
val dataFactory = DefaultDataSource.Factory(this, httpFactory)
val executor = Executors.newSingleThreadExecutor()
val bitmapLoader = CacheBitmapLoader(
DataSourceBitmapLoader(MoreExecutors.listeningDecorator(executor), dataFactory)
)
val audioAttr = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataFactory))
.setAudioAttributes(audioAttr, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.build()
val wrappedPlayer = AbsAwareForwardingPlayer(player)
val activityIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
session = MediaLibrarySession.Builder(this, wrappedPlayer, LibraryCallback())
.setBitmapLoader(bitmapLoader)
.setSessionActivity(activityIntent)
.build()
scope.launch {
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
}
scope.launch {
(applicationContext as NovaSonicApp).absPlayerController.state.collect { s ->
session.setCustomLayout(if (s.item != null) buildAbsCustomLayout() else ImmutableList.of())
val newIdx = if (s.item != null) s.chapterIdx else -1
if (newIdx != lastAbsChapterIdx) {
lastAbsChapterIdx = newIdx
if (s.item != null) {
val chapter = s.chapters.getOrNull(s.chapterIdx)
val bookTitle = s.item.media.metadata?.title ?: "Hörbuch"
val idx = player.currentMediaItemIndex.takeIf { it >= 0 } ?: return@collect
val cur = player.getMediaItemAt(idx)
player.replaceMediaItem(idx, cur.buildUpon()
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(chapter?.title?.takeIf { it.isNotBlank() } ?: bookTitle)
.setArtist(if (!chapter?.title.isNullOrBlank()) bookTitle else cur.mediaMetadata.artist)
.setArtworkUri(cur.mediaMetadata.artworkUri)
.build()
).build()
)
}
}
}
}
setMediaNotificationProvider(AbsAwareDnmp())
}
private fun buildAbsButtons(isPlaying: Boolean): ImmutableList<CommandButton> {
val ppIcon = if (isPlaying) R.drawable.ic_abs_pause else R.drawable.ic_abs_play
val ppLabel = if (isPlaying) "Pause" else "Abspielen"
return ImmutableList.of(
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_prev)
.setDisplayName("Kapitel zurück")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_rewind)
.setDisplayName("30s zurück")
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 0) })
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_TOGGLE, Bundle.EMPTY))
.setIconResId(ppIcon)
.setDisplayName(ppLabel)
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 1) })
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_forward)
.setDisplayName("30s vor")
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 2) })
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_next)
.setDisplayName("Kapitel vor")
.build(),
)
}
// 4-button layout for the Samsung media widget — play/pause is the standard player control.
// prevOrCustom → Kapitel zurück, custom0 → 30s zurück, custom1 → 30s vor, nextOrCustom → Kapitel vor.
// Samsung One UI slot mapping (empirically determined):
// prevOrCustom → custom[2], custom0 → custom[0], custom1 → custom[1], nextOrCustom → custom[3]
// Desired display: Kapitel zurück | 30s zurück | play | 30s vor | Kapitel vor
// Required layout order: [30s zurück, 30s vor, Kapitel zurück, Kapitel vor]
private fun buildAbsCustomLayout(): ImmutableList<CommandButton> = ImmutableList.of(
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_rewind)
.setDisplayName("30s zurück")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_forward)
.setDisplayName("30s vor")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_prev)
.setDisplayName("Kapitel zurück")
.build(),
CommandButton.Builder()
.setSessionCommand(SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY))
.setIconResId(R.drawable.ic_abs_skip_next)
.setDisplayName("Kapitel vor")
.build(),
)
// Removes SEEK_TO_PREV/NEXT from available commands when ABS is active so Samsung One UI
// replaces its standard prev/next slots with our first/last custom layout buttons.
private inner class AbsAwareForwardingPlayer(player: ExoPlayer) : ForwardingPlayer(player) {
override fun getAvailableCommands(): Player.Commands {
val base = super.getAvailableCommands()
val isAbs = currentMediaItem?.mediaId?.startsWith("abs:") == true ||
(applicationContext as NovaSonicApp).absPlayerController.state.value.item != null
return if (isAbs) {
base.buildUpon()
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.remove(Player.COMMAND_SEEK_TO_NEXT)
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.build()
} else base
}
}
// Subclasses DNMP so we stay inside the class hierarchy Media3 expects.
// createNotification() stores the session and always passes ImmutableList.of() to super
// so DNMP never appends setCustomLayout() buttons after getMediaButtons().
// getMediaButtons() detects ABS via currentMediaItem.mediaId — reliable, no state race.
// getMediaButtons() receives the full MediaSession — detect ABS via currentMediaItem.mediaId.
// No createNotification() override needed. setCustomLayout() is always called with empty list
// (in the onCreate observer) so DNMP appends zero extra buttons after getMediaButtons().
private inner class AbsAwareDnmp : DefaultMediaNotificationProvider(this@MusicService) {
override fun getMediaButtons(
mediaSession: MediaSession,
playerCommands: Player.Commands,
customLayout: ImmutableList<CommandButton>,
showPauseButton: Boolean,
): ImmutableList<CommandButton> {
val mediaId = mediaSession.player.currentMediaItem?.mediaId
val absItem = (applicationContext as NovaSonicApp).absPlayerController.state.value.item
android.util.Log.e("ABS_NOTIF", "getMediaButtons: mediaId=$mediaId absItem=${absItem?.id} customLayout.size=${customLayout.size}")
val isAbs = mediaId?.startsWith("abs:") == true || absItem != null
if (!isAbs) return super.getMediaButtons(mediaSession, playerCommands, ImmutableList.of(), showPauseButton)
return buildAbsButtons(mediaSession.player.isPlaying)
}
}
override fun onGetSession(info: MediaSession.ControllerInfo) = session
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() {
scope.cancel()
session.release()
player.release()
super.onDestroy()
}
// ── Android Auto / MediaBrowser library callbacks ─────────────────────────
private inner class LibraryCallback : MediaLibrarySession.Callback {
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo,
): MediaSession.ConnectionResult {
val absCommands = listOf(
SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY),
SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY),
SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY),
SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY),
SessionCommand(ACTION_ABS_TOGGLE, Bundle.EMPTY),
)
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
.addSessionCommands(absCommands).build()
).build()
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle,
): ListenableFuture<SessionResult> {
val abs = (applicationContext as NovaSonicApp).absPlayerController
when (customCommand.customAction) {
ACTION_ABS_PREV_CH -> abs.prevChapter()
ACTION_ABS_SKIP_BACK -> abs.skipSecs(-30.0)
ACTION_ABS_SKIP_FWD -> abs.skipSecs(30.0)
ACTION_ABS_NEXT_CH -> abs.nextChapter()
ACTION_ABS_TOGGLE -> abs.togglePlay()
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: LibraryParams?,
): ListenableFuture<LibraryResult<MediaItem>> {
val root = MediaItem.Builder()
.setMediaId(ROOT_ID)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("NovaSonic")
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build()
).build()
return Futures.immediateFuture(LibraryResult.ofItem(root, null))
}
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val future = scope.async { buildChildren(parentId) }
val settable = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
scope.launch {
try {
settable.set(LibraryResult.ofItemList(future.await(), null))
} catch (e: Exception) {
settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
}
}
return settable
}
override fun onGetItem(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
mediaId: String,
): ListenableFuture<LibraryResult<MediaItem>> {
val settable = SettableFuture.create<LibraryResult<MediaItem>>()
scope.launch {
try {
val item = when {
mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(mediaId.removePrefix(ITEM_PREFIX_ALBUM))
album.toMediaItem(repo)
}
mediaId.startsWith(ITEM_PREFIX_PL) -> {
val pl = repo.getPlaylist(mediaId.removePrefix(ITEM_PREFIX_PL))
val artUri = pl.coverArt?.let { repo.coverArtUrl(it, 300)?.let { url -> android.net.Uri.parse(url) } }
MediaItem.Builder()
.setMediaId(mediaId)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(pl.name)
.setArtworkUri(artUri)
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.build()
).build()
}
mediaId.startsWith(SONG_CTX_PREFIX) -> {
val songId = mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')
repo.getSong(songId).toMediaItem(repo).buildUpon().setMediaId(mediaId).build()
}
else -> null
}
if (item != null) settable.set(LibraryResult.ofItem(item, null))
else settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
} catch (e: Exception) {
settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
}
}
return settable
}
// Android Auto "add to queue" path — resolve browsable items to their songs.
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
): ListenableFuture<MutableList<MediaItem>> {
val settable = SettableFuture.create<MutableList<MediaItem>>()
scope.launch {
try { settable.set(expandForPlayback(mediaItems).first.toMutableList()) }
catch (e: Exception) { settable.set(mediaItems) }
}
return settable
}
// Android Auto "tap to play" path — return the resolved list AND the index to start at, so
// tapping a song plays its whole list from that song (not just the one song).
override fun onSetMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
startIndex: Int,
startPositionMs: Long,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
val settable = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
scope.launch {
try {
val (list, idx) = expandForPlayback(mediaItems)
// A single browse tap expands into a full list → start at the tapped index.
// Otherwise honour the caller's startIndex.
val expanded = list.size != mediaItems.size
val start = if (expanded) idx else startIndex.coerceIn(0, (list.size - 1).coerceAtLeast(0))
settable.set(MediaSession.MediaItemsWithStartPosition(list, start, startPositionMs))
} catch (e: Exception) {
settable.set(MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
}
}
return settable
}
// Resolve browse items (play-all / shuffle / album / playlist / context-song / plain song)
// into a flat, playable song list plus the index to start at (the tapped song, else 0).
private suspend fun expandForPlayback(items: List<MediaItem>): Pair<List<MediaItem>, Int> {
if (items.size == 1 && items[0].mediaId.startsWith(SONG_CTX_PREFIX)) {
val rest = items[0].mediaId.removePrefix(SONG_CTX_PREFIX)
val ctx = rest.substringBefore('|')
val songId = rest.substringAfter('|')
val full = resolveSongs(ctx, shuffle = false)
if (full.isNotEmpty()) {
val idx = full.indexOfFirst { it.mediaId == songId }.coerceAtLeast(0)
return full to idx
}
return listOf(reattachUri(items[0], songId)) to 0
}
val resolved = items.flatMap { item ->
when {
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), false)
item.mediaId.startsWith(SHUFFLE_PREFIX) -> resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), true)
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM)).song.map { it.toMediaItem(repo) }
item.mediaId.startsWith(ITEM_PREFIX_PL) -> repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL)).entry.map { it.toMediaItem(repo) }
item.mediaId.startsWith(SONG_CTX_PREFIX) -> listOf(reattachUri(item, item.mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')))
else -> listOf(reattachUri(item))
}
}
return resolved to 0
}
// Android Auto strips the URI when a browse item crosses the process boundary — re-resolve it.
private fun reattachUri(item: MediaItem, songId: String = item.mediaId): MediaItem {
val uri = repo.streamUrl(songId)?.let { android.net.Uri.parse(it) }
return item.buildUpon().setMediaId(songId).setUri(uri).build()
}
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
val songs: List<com.novasonic.app.api.model.Song> = when {
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
repo.getAlbum(contextId.removePrefix(ITEM_PREFIX_ALBUM)).song
contextId.startsWith(ITEM_PREFIX_PL) ->
repo.getPlaylist(contextId.removePrefix(ITEM_PREFIX_PL)).entry
contextId == FAVORITES_ID ->
repo.getStarred().song
contextId == RECENT_ID -> {
repo.getRecentlyPlayedAlbums(20).flatMap { album ->
runCatching { repo.getAlbum(album.id).song }.getOrDefault(emptyList())
}
}
else -> emptyList()
}
val ordered = if (shuffle) songs.shuffled() else songs
return ordered.map { it.toMediaItem(repo) }
}
}
// ── Build Android Auto browsable tree ─────────────────────────────────────
private suspend fun buildChildren(parentId: String): ImmutableList<MediaItem> {
val items = when (parentId) {
ROOT_ID -> listOf(
browseFolder(RECENT_ID, "Zuletzt gespielt", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
browseFolder(FAVORITES_ID, "Favoriten", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED),
browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
)
RECENT_ID -> {
val albums = repo.getRecentlyPlayedAlbums(20)
listOf(playAllItem(RECENT_ID), shuffleItem(RECENT_ID)) + albums.map { it.toMediaItem(repo) }
}
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
FAVORITES_ID -> {
val songs = repo.getStarred().song
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { browseSong(it, FAVORITES_ID) }
}
else -> when {
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { browseSong(it, parentId) }
}
parentId.startsWith(ITEM_PREFIX_PL) -> {
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { browseSong(it, parentId) }
}
else -> emptyList()
}
}
return ImmutableList.copyOf(items)
}
// A song shown in a browse list, with its context encoded in the mediaId so a tap can play the
// whole list from that song (Android Auto strips the URI, so it's re-resolved on playback).
private fun browseSong(song: com.novasonic.app.api.model.Song, contextId: String): MediaItem =
song.toMediaItem(repo).buildUpon()
.setMediaId("$SONG_CTX_PREFIX$contextId|${song.id}")
.build()
private fun browseFolder(id: String, title: String, mediaType: Int) =
MediaItem.Builder()
.setMediaId(id)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(title)
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(mediaType)
.build()
).build()
private fun playAllItem(contextId: String) = MediaItem.Builder()
.setMediaId("$PLAY_ALL_PREFIX$contextId")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("▶ Alle abspielen")
.setIsPlayable(true)
.setIsBrowsable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.build()
).build()
private fun shuffleItem(contextId: String) = MediaItem.Builder()
.setMediaId("$SHUFFLE_PREFIX$contextId")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("⇀ Zufällig abspielen")
.setIsPlayable(true)
.setIsBrowsable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.build()
).build()
private fun isAndroidAuto(info: MediaSession.ControllerInfo): Boolean =
info.packageName.contains("android.auto", ignoreCase = true) ||
info.packageName.contains("goog.android.projected", ignoreCase = true)
}
// ── Extension: model → MediaItem ─────────────────────────────────────────────
fun com.novasonic.app.api.model.Song.toMediaItem(repo: MusicRepository): MediaItem {
val artUri = coverArt?.let { repo.coverArtUrl(it, 512)?.let { url ->
android.net.Uri.parse(url) } }
val streamUri = repo.streamUrl(id)?.let { android.net.Uri.parse(it) }
return MediaItem.Builder()
.setMediaId(id)
.setUri(streamUri)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setAlbumTitle(album)
.setArtworkUri(artUri)
.setTrackNumber(track)
.setIsPlayable(true)
.setIsBrowsable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.build()
).build()
}
fun com.novasonic.app.api.model.Album.toMediaItem(repo: MusicRepository): MediaItem {
val artUri = coverArt?.let { repo.coverArtUrl(it, 300)?.let { url ->
android.net.Uri.parse(url) } }
return MediaItem.Builder()
.setMediaId("${MusicService.ITEM_PREFIX_ALBUM}$id")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(name)
.setArtist(artist)
.setArtworkUri(artUri)
.setIsPlayable(true)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.build()
).build()
}
fun com.novasonic.app.api.model.Playlist.toMediaItem(repo: MusicRepository): MediaItem {
val artUri = coverArt?.let { repo.coverArtUrl(it, 300)?.let { url -> android.net.Uri.parse(url) } }
return MediaItem.Builder()
.setMediaId("${MusicService.ITEM_PREFIX_PL}$id")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(name)
.setArtworkUri(artUri)
.setIsPlayable(false) // browse-only: Auto opens the song list instead of playing immediately
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.build()
).build()
}
@@ -0,0 +1,470 @@
package com.novasonic.app.player
import android.content.ComponentName
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.novasonic.app.NovaSonicApp
import com.novasonic.app.api.LrcLibClient
import com.novasonic.app.api.model.Song
import com.novasonic.app.data.MusicRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class PlayerState(
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val currentSong: Song? = null,
val currentIndex: Int = -1,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val queue: List<Song> = emptyList(),
val repeatMode: Int = Player.REPEAT_MODE_OFF,
val shuffled: Boolean = false,
val volume: Float = 1f,
val artworkUri: String? = null,
val radioMode: Boolean = false,
)
class PlayerController(private val context: Context, private val repo: MusicRepository) {
private val _state = MutableStateFlow(PlayerState())
val state: StateFlow<PlayerState> = _state.asStateFlow()
private val TAG_QUEUE = "NsQueue"
private val scope = CoroutineScope(
Dispatchers.Main + SupervisorJob() +
CoroutineExceptionHandler { _, t -> android.util.Log.e("PlayerController", "Uncaught coroutine exception", t) }
)
private var controller: MediaController? = null
private var queue: List<Song> = emptyList()
private var originalQueue: List<Song> = emptyList()
private var isManuallyShuffled = false
private var tickerJob: Job? = null
private var scrobbleJob: Job? = null
fun connect() {
if (controller != null) return
val token = SessionToken(context, ComponentName(context, MusicService::class.java))
MediaController.Builder(context, token).buildAsync().also { future ->
future.addListener({
controller = runCatching { future.get() }.getOrNull()
controller?.addListener(PlayerListener())
syncState()
restoreQueue()
}, 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 ─────────────────────────────────────────────────────
/**
* 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) {
// If ABS is active, close it — user explicitly switched to music
(context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs ->
if (abs.isActive) abs.close()
}
val ctrl = controller ?: return
val gemischt = startIndex < 0
val reihenfolge = if (gemischt) songs.shuffled() else songs
queue = reihenfolge
originalQueue = songs // immer die UNGEMISCHTE Liste — Rueckweg fuer "Shuffle aus"
isManuallyShuffled = gemischt
ctrl.shuffleModeEnabled = false
val items = reihenfolge.map { it.toMediaItem(repo) }
ctrl.setMediaItems(items, if (gemischt) 0 else startIndex.coerceIn(0, maxOf(0, songs.lastIndex)), 0L)
ctrl.prepare()
ctrl.play()
syncState()
}
/** Called by AbsPlayerController to route ABS audio through this service. */
fun playAbsItems(
items: List<MediaItem>,
startIndex: Int,
startPositionMs: Long,
speed: Float = 1f,
) {
val ctrl = controller ?: return
queue = emptyList()
originalQueue = emptyList()
isManuallyShuffled = false
ctrl.setMediaItems(items, startIndex, startPositionMs)
ctrl.prepare()
ctrl.setPlaybackSpeed(speed)
ctrl.play()
syncState()
}
/** Stop playback and clear the queue (used when ABS session ends). */
fun stop() {
val ctrl = controller ?: return
ctrl.setPlaybackSpeed(1f)
ctrl.pause()
ctrl.clearMediaItems()
queue = emptyList()
originalQueue = emptyList()
syncState()
}
fun play() = controller?.play()
fun pause() = controller?.pause()
fun togglePlay() = controller?.let { if (it.isPlaying) it.pause() else it.play() }
fun seekTo(ms: Long) = controller?.seekTo(ms)
fun skipNext() = controller?.seekToNextMediaItem()
fun skipPrev() = controller?.seekToPreviousMediaItem()
fun seekToItem(index: Int) = controller?.seekToDefaultPosition(index)
/** Seek to a specific track index and position within that track (for ABS chapter navigation). */
fun seekToAbs(windowIndex: Int, positionMs: Long) = controller?.seekTo(windowIndex, positionMs)
fun setSpeed(speed: Float) = controller?.setPlaybackSpeed(speed)
fun setVolume(v: Float) {
val clamped = v.coerceIn(0f, 1f)
controller?.volume = clamped
_state.update { it.copy(volume = clamped) }
}
fun insertNext(song: Song) {
val ctrl = controller ?: return
val idx = (ctrl.currentMediaItemIndex + 1).coerceAtMost(queue.size)
queue = queue.toMutableList().apply { add(idx, song) }
originalQueue = originalQueue + song
ctrl.addMediaItem(idx, song.toMediaItem(repo))
}
fun addToQueueEnd(song: Song) {
val ctrl = controller ?: return
queue = queue + song
originalQueue = originalQueue + song
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() {
val ctrl = controller ?: return
ctrl.repeatMode = when (ctrl.repeatMode) {
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
else -> Player.REPEAT_MODE_OFF
}
}
fun toggleShuffle() {
val ctrl = controller ?: return
val currentIdx = ctrl.currentMediaItemIndex
// 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
queue.drop(currentIdx + 1).shuffled()
} else {
isManuallyShuffled = false
val currentSong = queue.getOrNull(currentIdx)
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
originalQueue.drop(origIdx + 1)
}
queue = queue.take(currentIdx + 1) + neuerRest
ctrl.replaceMediaItems(currentIdx + 1, ctrl.mediaItemCount, neuerRest.map { it.toMediaItem(repo) })
syncState()
}
fun addToQueue(song: Song) = addToQueueEnd(song)
// ── Radio mode ────────────────────────────────────────────────────────────
private var _radioMode = false
fun toggleRadio() {
_radioMode = !_radioMode
_state.update { it.copy(radioMode = _radioMode) }
if (_radioMode) scope.launch { fillRadioQueue() }
}
private fun checkRadioRefill() {
if (!_radioMode) return
val remaining = queue.size - (_state.value.currentIndex + 1)
if (remaining < 5) scope.launch { fillRadioQueue() }
}
private suspend fun fillRadioQueue() {
val currentSong = _state.value.currentSong ?: return
val recentIds = queue.takeLast(50).map { it.id }.toHashSet()
var candidates = runCatching {
repo.getSimilarSongs(currentSong.id, 20)
}.getOrDefault(emptyList()).filter { it.id !in recentIds }
if (candidates.isEmpty()) {
candidates = runCatching {
repo.getRandomSongs()
}.getOrDefault(emptyList()).filter { it.id !in recentIds }
}
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())
.filter { it.id != currentSong.id }
}
val ctrl = controller ?: return
candidates.forEach { song ->
queue = queue + song
originalQueue = originalQueue + song
ctrl.addMediaItem(song.toMediaItem(repo))
}
syncState()
}
// ── Ticker ────────────────────────────────────────────────────────────────
private fun startTicker() {
tickerJob?.cancel()
tickerJob = scope.launch {
while (isActive) {
delay(500)
val ctrl = controller ?: break
// Refresh durationMs too — after a SEAMLESS track transition (skipping through
// already-buffered tracks) the player stays READY, so onPlaybackStateChanged never
// fires and syncState() isn't re-called; ctrl.duration was TIME_UNSET(→0) at the
// transition, leaving durationMs stuck at 0. Seek then computes fraction*0 = 0 and
// snaps to the start every time. Re-reading here lets it self-heal within 500ms.
_state.update { it.copy(positionMs = ctrl.currentPosition, durationMs = resolveDurationMs(ctrl)) }
}
}
}
/** Player duration, falling back to the current song's known duration while ctrl.duration is
* still TIME_UNSET (right after a media transition). Prevents a 0-duration seek → snap-to-0. */
private fun resolveDurationMs(ctrl: MediaController): Long {
val d = ctrl.duration
if (d > 0) return d
val songDurSecs = queue.getOrNull(ctrl.currentMediaItemIndex)?.duration?.toLong() ?: 0L
return songDurSecs * 1000L
}
private fun stopTicker() {
tickerJob?.cancel()
tickerJob = null
}
// ── Scrobbling ────────────────────────────────────────────────────────────
private fun scheduleScrobble(song: Song, durationMs: Long) {
scrobbleJob?.cancel()
scrobbleJob = scope.launch {
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
delay(delayMs)
runCatching { repo.scrobble(song.id) }
runCatching { repo.logPlay(song) } // our own play-log, same counted moment
}
}
// ── Internal ──────────────────────────────────────────────────────────────
private fun songFromMediaItem(item: MediaItem): Song {
val meta = item.mediaMetadata
return Song(
id = item.mediaId,
title = meta.title?.toString() ?: "",
artist = meta.artist?.toString(),
album = meta.albumTitle?.toString(),
track = meta.trackNumber,
)
}
private fun syncState() {
val ctrl = controller ?: return
val idx = ctrl.currentMediaItemIndex
_state.value = PlayerState(
isPlaying = ctrl.isPlaying,
isBuffering = ctrl.playbackState == Player.STATE_BUFFERING,
currentSong = queue.getOrNull(idx),
currentIndex = idx,
positionMs = ctrl.currentPosition,
durationMs = resolveDurationMs(ctrl),
queue = queue,
repeatMode = ctrl.repeatMode,
shuffled = isManuallyShuffled,
volume = ctrl.volume,
artworkUri = ctrl.currentMediaItem?.mediaMetadata?.artworkUri?.toString(),
radioMode = _radioMode,
)
}
private var _lyricsPrefetchJob: Job? = null
/** Warm the LrcLib cache shortly after a track settles. Debounced so rapid skips don't
* hammer lrclib (which throttles the IP and then no lyrics load at all). */
private fun prefetchLyrics(song: Song) {
_lyricsPrefetchJob?.cancel()
_lyricsPrefetchJob = scope.launch {
delay(1500) // skip quickly without firing a request per track
runCatching {
LrcLibClient.getLyrics(
artist = song.artist ?: "",
title = song.title,
album = song.album,
durationSecs = song.duration,
)
}
}
}
private inner class PlayerListener : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) startTicker() else stopTicker()
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 onMediaItemTransition(item: MediaItem?, reason: Int) {
syncState()
checkRadioRefill()
val ctrl = controller ?: return
// Skip scrobble and last-song tracking for ABS tracks (mediaId starts with "abs:")
val mediaId = ctrl.currentMediaItem?.mediaId ?: return
if (mediaId.startsWith("abs:")) return
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
// Use the song's KNOWN duration — at a media transition ctrl.duration is still
// TIME_UNSET (→0), which collapsed the 1/3 / 2/3 modes to the 15s floor and scrobbled
// far too early. Fall back to the player only if the song has no duration.
val songDurMs = (song.duration?.toLong() ?: 0L) * 1000L
val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0)
scheduleScrobble(song, dur)
scope.launch { runCatching { repo.saveLastSong(song) } }
persistQueueState()
prefetchLyrics(song)
}
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
override fun onRepeatModeChanged(mode: Int) = syncState()
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
val ctrl = controller ?: return
val ctrlCount = ctrl.mediaItemCount
// Rebuild queue from controller when items were set externally (e.g. Android Auto or ABS)
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED && ctrlCount != queue.size) {
queue = (0 until ctrlCount).map { i -> songFromMediaItem(ctrl.getMediaItemAt(i)) }
originalQueue = queue
isManuallyShuffled = false
}
syncState()
}
}
}
@@ -0,0 +1,79 @@
package com.novasonic.app.player
import android.media.MediaPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* App-level 30s preview player (iTunes/Deezer preview clips). Kept on the Application — NOT in a
* screen ViewModel — so a preview keeps a **global control bar** (see AppNavigation) even after the
* user leaves the album/artist/Explore screen it started on. Previously the preview lived in
* ExploreViewModel and became uncontrollable once you navigated away.
*/
data class PreviewState(
val trackId: Long? = null,
val title: String = "",
val artist: String = "",
val artworkUrl: String? = null,
val isPlaying: Boolean = false, // true = actively playing, false = paused
)
class PreviewController {
private val _state = MutableStateFlow(PreviewState())
val state: StateFlow<PreviewState> = _state
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var mp: MediaPlayer? = null
/** Tap on a track: same track → toggle play/pause; different track → start it. */
fun toggle(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) {
val cur = _state.value
when {
cur.trackId == trackId && cur.isPlaying -> pause()
cur.trackId == trackId && mp != null -> resume()
else -> start(trackId, url, title, artist, artworkUrl)
}
}
private fun start(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) {
stop()
_state.value = PreviewState(trackId, title, artist, artworkUrl, isPlaying = true)
scope.launch(Dispatchers.IO) {
try {
val player = MediaPlayer().apply {
setDataSource(url)
prepare()
start()
setOnCompletionListener { stop() }
}
mp = player
} catch (e: Exception) {
stop()
}
}
}
fun pause() {
mp?.runCatching { pause() }
_state.update { it.copy(isPlaying = false) }
}
fun resume() {
val player = mp
if (player == null) return
player.runCatching { start() }
_state.update { it.copy(isPlaying = true) }
}
fun stop() {
mp?.runCatching { stop(); release() }
mp = null
_state.value = PreviewState()
}
}
@@ -0,0 +1,748 @@
package com.novasonic.app.ui
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.*
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import android.content.Context
import android.widget.Toast
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.*
import androidx.navigation.compose.*
import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibraryItem
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.app
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.PrefsStore
import com.novasonic.app.ui.component.MiniPlayer
import com.novasonic.app.ui.component.PreviewBar
import com.novasonic.app.ui.component.NotificationSheet
import com.novasonic.app.ui.component.PlaylistPickerSheet
import com.novasonic.app.ui.component.SongContextSheet
import com.novasonic.app.ui.component.SongInfoSheet
import com.novasonic.app.ui.screen.*
import com.novasonic.app.ui.theme.*
import kotlinx.coroutines.launch
private enum class TopLevel(val route: String, val icon: ImageVector, val label: String) {
HOME("home", Icons.Default.Home, "Home"),
LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
STARRED("starred", Icons.Default.Favorite, "Favoriten"),
ABS("abs", Icons.Default.Book, "Bücher"),
SETTINGS("settings", Icons.Default.Settings, "Einst."),
}
private val NavBg = Color(0xF7080814)
// Position of a route among the top-level tabs (in bottom-bar order), or -1 if it isn't one.
// Used to slide tab switches toward the side the target tab sits on, instead of a push/pop feel.
private fun topLevelIndex(route: String?): Int = TopLevel.entries.indexOfFirst { it.route == route }
@Composable
fun AppNavigation() {
val context = LocalContext.current
val app = context.app
val repo = app.repository
val player = app.playerController
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
}
LaunchedEffect(Unit) {
repo.appSettingsFlow().collect { settings -> applyNsTheme(settings.theme) }
}
val playerState by player.state.collectAsStateWithLifecycle()
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null)
// ── In-app updater — offer the newer APK the backend advertises ─────────────
val updateInfo by com.novasonic.app.data.AppUpdater.available.collectAsStateWithLifecycle()
val updating by com.novasonic.app.data.AppUpdater.downloading.collectAsStateWithLifecycle()
val updateScope = rememberCoroutineScope()
LaunchedEffect(appSettings.backendUrl) { com.novasonic.app.data.AppUpdater.check(appSettings.backendUrl) }
updateInfo?.let { info ->
AlertDialog(
onDismissRequest = { com.novasonic.app.data.AppUpdater.dismiss() },
title = { Text("Update verfügbar") },
text = { Text("NovaSonic ${info.version ?: ""} ist verfügbar. Jetzt laden und installieren? Deine Downloads und Einstellungen bleiben erhalten.") },
confirmButton = {
TextButton(enabled = !updating, onClick = {
updateScope.launch {
com.novasonic.app.data.AppUpdater.downloadAndInstall(context, appSettings.backendUrl) { msg ->
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_LONG).show()
}
}
}) { Text(if (updating) "Lädt…" else "Laden & installieren") }
},
dismissButton = { TextButton(onClick = { com.novasonic.app.data.AppUpdater.dismiss() }) { Text("Später") } },
)
}
// 30s preview (app-level, so its control bar survives navigation). Starting real playback
// cancels any active preview so the two don't overlap.
val previewState by app.previewController.state.collectAsStateWithLifecycle()
LaunchedEffect(playerState.isPlaying) { if (playerState.isPlaying) app.previewController.stop() }
val absConfigured = appSettings.absUrl.isNotBlank() && appSettings.absApiKey.isNotBlank()
val absClient: AbsClient? = remember(appSettings.absUrl, appSettings.absApiKey, appSettings.backendUrl) {
if (absConfigured) AbsClient(appSettings.absUrl.trimEnd('/'), appSettings.absApiKey, appSettings.backendUrl.trimEnd('/'))
else null
}
val absPlayerController = context.app.absPlayerController
val absState by absPlayerController.state.collectAsStateWithLifecycle()
DisposableEffect(Unit) { onDispose { absPlayerController.close() } }
LaunchedEffect(appSettings.absChapterTrackMode) {
absPlayerController.setChapterTrackMode(appSettings.absChapterTrackMode)
}
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 contextMenuSong by remember { mutableStateOf<Song?>(null) }
var playlistPickerSong 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 selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
var showAbsPlayer by remember { mutableStateOf(false) }
// Close action for the Erkunden (Apple) in-screen overlay (iTunes artist / album). Handled at the
// app root because a BackHandler inside the NavHost "erkunden" destination loses back priority to
// the NavHost, which pops Erkunden → Home instead of dismissing the overlay.
var exploreOverlayBack by remember { mutableStateOf<(() -> Unit)?>(null) }
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val currentBackEntry by navController.currentBackStackEntryAsState()
// Returning from the background re-registers the NavController's OWN back callback on top of the
// dispatcher, which stole back-priority from the NowPlaying/ABS overlays: pressing back then popped
// the NavHost (invisibly, all the way to Home) before dismissing the overlay. Bump this tick on
// ON_RESUME (and re-key on navigation) so the overlay callbacks below re-add above it again.
var resumeTick by remember { mutableIntStateOf(0) }
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { resumeTick++ }
DisposableEffect(showNowPlaying, backDispatcher, currentBackEntry, resumeTick) {
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { showNowPlaying = false }
}
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
DisposableEffect(showAbsPlayer, backDispatcher, currentBackEntry, resumeTick) {
if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { showAbsPlayer = false }
}
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
DisposableEffect(selectedAbsItem, backDispatcher, currentBackEntry, resumeTick) {
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { selectedAbsItem = null }
}
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
fun starToggle(songId: String, currentlyStarred: Boolean) {
// Optimistic + reactive: flips StarState instantly so every star icon updates, then hits the API.
com.novasonic.app.data.StarState.toggle(repo, scope, songId, currentlyStarred)
}
fun showContextMenu(song: Song) { contextMenuSong = song }
var homeRefreshKey by remember { mutableIntStateOf(0) }
val currentRoute = currentBackEntry?.destination?.route
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
// Root-level back for the Erkunden (Apple) overlay. Keyed on currentBackEntry as well, so the
// callback is re-added (bumped to the top of the OnBackPressedDispatcher) after every navigation
// — otherwise, once the NavHost re-registers its own callback during an album drill-down or tab
// switch, it could regain priority and pop Erkunden → Home on the next back ("sometimes" bug).
DisposableEffect(exploreOverlayBack, backDispatcher, currentBackEntry, resumeTick) {
val action = exploreOverlayBack
if (action == null || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { action() }
}
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
val bgGlow = Brush.radialGradient(
colors = listOf(Color(0x0AE8A020), Color(0x00000000)),
center = Offset.Unspecified, radius = 900f,
)
// Nav items — ABS tab only shown when configured; Settings moved to HomeScreen header
val navItems = remember(absConfigured) {
TopLevel.entries.filter { it != TopLevel.ABS || absConfigured }
.filter { it != TopLevel.SETTINGS }
}
Box(Modifier.fillMaxSize()) {
Scaffold(
containerColor = NsBackground,
modifier = Modifier.background(NsBackground).background(bgGlow),
bottomBar = {
// MiniPlayer shows on EVERY screen (incl. album/artist/playlist detail) so playback
// is always reachable; the tab bar stays top-level-only. On detail screens there is no
// NavigationBar to absorb the system-navigation inset, so pad the bar itself — otherwise
// the MiniPlayer sits behind the phone's gesture/nav bar.
Column(modifier = if (isTopLevel) Modifier else Modifier.navigationBarsPadding()) {
// 30s preview control bar — above the MiniPlayer, on every screen, so a preview
// started on an album/artist/Explore screen can be paused/stopped from anywhere.
AnimatedVisibility(
visible = previewState.trackId != null,
enter = slideInVertically { it } + fadeIn(tween(220)),
exit = slideOutVertically { it } + fadeOut(tween(180)),
) {
PreviewBar(
state = previewState,
onToggle = { if (previewState.isPlaying) app.previewController.pause() else app.previewController.resume() },
onClose = { app.previewController.stop() },
)
}
// ABS audio routes through MusicService — one notification, one mini player.
// When an ABS book is loaded, tapping the mini player opens the ABS overlay
// instead of the music NowPlaying screen.
val hasAbsItem = absState.item != null
val showMiniPlayer = playerState.isPlaying ||
playerState.currentSong != null ||
(lastPlayedSong != null && !showNowPlaying)
AnimatedVisibility(
visible = showMiniPlayer,
enter = slideInVertically { it } + fadeIn(tween(220)),
exit = slideOutVertically { it } + fadeOut(tween(180)),
) {
MiniPlayer(
state = playerState,
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
onTap = { if (hasAbsItem) showAbsPlayer = true else showNowPlaying = true },
onTogglePlay = { if (hasAbsItem) absPlayerController.togglePlay() else player.togglePlay() },
onSkipPrev = { if (hasAbsItem) absPlayerController.prevChapter() else player.skipPrev() },
onSkipNext = { if (hasAbsItem) absPlayerController.nextChapter() else player.skipNext() },
lastSong = if (playerState.currentSong == null) lastPlayedSong else null,
lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) },
onPlayLast = {
lastPlayedSong?.let { ls ->
scope.launch {
runCatching {
val song = repo.getSong(ls.id)
player.playSongs(listOf(song), 0)
}
}
}
},
)
}
if (isTopLevel) {
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
navItems.forEach { dest ->
NavigationBarItem(
selected = currentRoute == dest.route,
onClick = {
if (dest == TopLevel.HOME && currentRoute == TopLevel.HOME.route) {
homeRefreshKey++
} else {
navController.navigate(dest.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true; restoreState = true
}
}
},
icon = { Icon(dest.icon, contentDescription = dest.label) },
label = { Text(dest.label, style = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 9.sp), maxLines = 1) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = NsAccent,
selectedTextColor = NsAccent,
indicatorColor = NsAccent.copy(alpha = 0.15f),
unselectedIconColor = Color(0xFF8888AA),
unselectedTextColor = Color(0xFF8888AA),
),
)
}
}
}
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = TopLevel.HOME.route,
modifier = Modifier.padding(innerPadding),
// Direction of a tab switch: +1 if the target tab sits to the right of the current
// one, -1 if to the left, 0 if this isn't a tab-to-tab move (detail push/pop).
enterTransition = {
val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeIn(tween(200))
},
exitTransition = {
val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeOut(tween(180))
},
popEnterTransition = {
val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeIn(tween(200))
},
popExitTransition = {
val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeOut(tween(180))
},
) {
composable(TopLevel.HOME.route) {
HomeScreen(
coverUrl = { album -> coverUrl(album.coverArt) },
onBellClick = { showNotifications = true },
onSettingsClick = { navController.navigate(TopLevel.SETTINGS.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true; restoreState = true
} },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onAlbumPlay = { album ->
scope.launch { runCatching {
val full = repo.getAlbum(album.id)
if (full.song.isNotEmpty()) player.playSongs(full.song, 0)
}}
},
onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") },
onGenrePlay = { genre ->
scope.launch { runCatching {
val songs = repo.getSongsByGenre(genre.value)
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
}}
},
onShowAllGenres = { navController.navigate("genres") },
refreshKey = homeRefreshKey,
)
}
composable(TopLevel.LIBRARY.route) {
LibraryScreen(
coverUrl = { id -> coverUrl(id) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
onPlaylistClick = { pl -> navController.navigate("playlist/${pl.id}") },
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onOpenOffline = { group -> navController.navigate("offline/${encodeRoute(group.groupId)}") },
)
}
composable(TopLevel.ERKUNDEN.route) {
ExploreScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onSongPlay = { songs, idx -> player.playSongs(songs, idx) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
onShowContextMenu = ::showContextMenu,
onOverlayBackChange = { exploreOverlayBack = it },
onOpenImporter = { navController.navigate("importer") },
)
}
composable("importer") {
ImporterScreen(onBack = { navController.popBackStack() })
}
composable(TopLevel.STARRED.route) {
StarredScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
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) {
val client = absClient
if (client != null) {
if (selectedAbsItem != null) {
AbsBookDetailScreen(
absClient = client,
libraryItem = selectedAbsItem!!,
absPlayerController = absPlayerController,
onPlay = { showAbsPlayer = true },
onBack = { selectedAbsItem = null },
)
} else {
AbsBooksScreen(
absClient = client,
onItemClick = { selectedAbsItem = it },
)
}
}
}
composable(TopLevel.SETTINGS.route) {
SettingsScreen(
onOpenStats = { navController.navigate("stats") },
onOpenCompare = { navController.navigate("compare") },
)
}
composable("stats") {
StatsScreen(
coverUrl = { id -> coverUrl(id) },
onBack = { navController.popBackStack() },
onArtistClick = { id -> navController.navigate("artist/$id") },
onAlbumClick = { id -> navController.navigate("album/$id") },
)
}
composable("compare") {
CompareScreen(
coverUrl = { id -> coverUrl(id) },
onBack = { navController.popBackStack() },
onArtistClick = { id -> navController.navigate("artist/$id") },
onAlbumClick = { id -> navController.navigate("album/$id") },
)
}
composable(
"album/{albumId}",
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
) {
AlbumScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
onBack = { navController.popBackStack() },
)
}
composable(
"artist/{artistId}",
arguments = listOf(navArgument("artistId") { type = NavType.StringType }),
) {
ArtistDetailScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
onBack = { navController.popBackStack() },
)
}
composable(
"playlist/{playlistId}",
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
) {
PlaylistDetailScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onDeleted = { navController.popBackStack() },
onBack = { navController.popBackStack() },
)
}
composable(
"offline/{groupId}",
arguments = listOf(navArgument("groupId") { type = NavType.StringType }),
) {
OfflineDetailScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onBack = { navController.popBackStack() },
)
}
composable(
"genre/{genreName}",
arguments = listOf(navArgument("genreName") { type = NavType.StringType }),
) { backEntry ->
val genreName = backEntry.arguments?.getString("genreName") ?: ""
GenreSongsScreen(
genreName = genreName,
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onBack = { navController.popBackStack() },
)
}
composable("genres") {
GenreListScreen(
onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") },
onGenrePlay = { genre ->
scope.launch { runCatching {
val songs = repo.getSongsByGenre(genre.value)
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
}}
},
onBack = { navController.popBackStack() },
)
}
}
}
// NowPlaying overlay
AnimatedVisibility(
visible = showNowPlaying,
enter = slideInVertically { it } + fadeIn(tween(220)),
exit = slideOutVertically { it } + fadeOut(tween(180)),
) {
NowPlayingScreen(
state = playerState,
coverUrl = heroCoverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
queueCoverUrl = { id -> coverUrl(id) },
controller = player,
onDismiss = { showNowPlaying = false },
onStarToggle = { playerState.currentSong?.let { s -> starToggle(s.id, s.starred != null) } },
onShowOptions = { playerState.currentSong?.let { s -> contextMenuSong = s } },
onArtistClick = { artistId ->
showNowPlaying = false
if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
},
onToggleRadio = { player.toggleRadio() },
)
}
// ABS player overlay
AnimatedVisibility(
visible = showAbsPlayer,
enter = slideInVertically { it } + fadeIn(tween(220)),
exit = slideOutVertically { it } + fadeOut(tween(180)),
) {
val client = absClient
if (client != null) {
AbsNowPlayingScreen(
absPlayerController = absPlayerController,
absClient = client,
onDismiss = { showAbsPlayer = false },
)
}
}
contextMenuSong?.let { song ->
SongContextSheet(
song = song,
isStarred = com.novasonic.app.data.StarState.isStarred(song),
onDismiss = { contextMenuSong = null },
onPlay = { player.playSongs(listOf(song), 0) },
onInsertNext = { player.insertNext(song) },
onAddToQueue = { player.addToQueueEnd(song) },
onStarToggle = { starToggle(song.id, com.novasonic.app.data.StarState.isStarred(song)) },
onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
onShowInfo = { infoSheetSong = song; contextMenuSong = null },
onDownload = {
scope.launch {
runCatching {
app.offlineRepository.enqueueGroup(
type = com.novasonic.app.offline.GroupType.SONG,
sourceId = song.id,
name = song.title,
coverArt = song.coverArt,
songs = listOf(song),
)
}.onFailure { android.util.Log.e("AppNavigation", "enqueueGroup failed", it) }
}
Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show()
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") } },
)
}
playlistPickerSong?.let { song ->
PlaylistPickerSheet(
song = song,
loadPlaylists = { repo.getPlaylists() },
coverUrl = { id -> coverUrl(id) },
onAdd = { pl ->
playlistPickerSong = null
scope.launch {
runCatching { repo.addSongToPlaylist(pl.id, song.id) }
.onSuccess { Toast.makeText(context, "✓ ${song.title} → ${pl.name}", Toast.LENGTH_SHORT).show() }
.onFailure { Toast.makeText(context, "Fehler: ${it.message}", Toast.LENGTH_SHORT).show() }
}
},
onCreate = { name ->
playlistPickerSong = null
scope.launch {
runCatching { repo.createPlaylist(name, listOf(song.id)) }
.onSuccess { Toast.makeText(context, "✓ Playlist \"$name\" erstellt", Toast.LENGTH_SHORT).show() }
.onFailure { Toast.makeText(context, "Fehler: ${it.message}", Toast.LENGTH_SHORT).show() }
}
},
onDismiss = { playlistPickerSong = null },
)
}
infoSheetSong?.let { song ->
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null })
}
if (showNotifications) {
NotificationSheet(
onDismiss = { showNotifications = false },
onNavigate = { n ->
showNotifications = false
scope.launch {
runCatching {
when {
n.navType == "playlist" && n.navId.isNotBlank() -> {
navController.navigate("playlist/${n.navId}")
}
n.navAlbum.isNotBlank() -> {
val res = repo.search(n.navAlbum)
val albums = res.album
val norm = { s: String -> s.lowercase().trim() }
val na = norm(n.navArtist)
// REQUIRE the artist match when we know it — several artists share an
// album title ("I LIKE IT!"); the old name-only / firstOrNull fallback
// opened a completely different artist's album (wrong song).
val match = albums.firstOrNull {
val aa = norm(it.artist ?: "")
norm(it.name) == norm(n.navAlbum) && na.isNotBlank() &&
(aa == na || aa.contains(na) || na.contains(aa))
} ?: if (na.isBlank()) albums.firstOrNull { norm(it.name) == norm(n.navAlbum) } else null
if (match != null) {
navController.navigate("album/${match.id}")
} else {
Toast.makeText(context, "Album noch nicht gefunden — evtl. wird die Library gerade gescannt", Toast.LENGTH_SHORT).show()
}
}
}
}
}
},
)
}
}
}
private fun encodeRoute(s: String) = java.net.URLEncoder.encode(s, "UTF-8")
@@ -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."
}
@@ -0,0 +1,61 @@
package com.novasonic.app.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.novasonic.app.app
import com.novasonic.app.data.PrefsStore
import com.novasonic.app.ui.screen.LoginScreen
import com.novasonic.app.ui.theme.NovaSonicTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Geteilter Link, mit dem die App gestartet wurde.
com.novasonic.app.data.DeepLinkState.offer(intent?.data)
enableEdgeToEdge()
setContent {
NovaSonicTheme {
Surface(modifier = Modifier.fillMaxSize()) {
NovaSonicRoot()
}
}
}
}
// 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
private fun NovaSonicRoot() {
val context = LocalContext.current
val prefs = remember { PrefsStore(context) }
val creds by prefs.credentials.collectAsStateWithLifecycle(
initialValue = null,
)
var forceLogin by remember { mutableStateOf(false) }
// null = still loading from DataStore; don't show anything yet to avoid login screen flash
if (creds == null) return
if (!creds!!.isValid || forceLogin) {
LoginScreen(onLoggedIn = { forceLogin = false })
} else {
AppNavigation()
}
}
@@ -0,0 +1,107 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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 coil.compose.AsyncImage
import com.novasonic.app.api.AbsClient
import com.novasonic.app.player.AbsPlayState
import com.novasonic.app.ui.theme.*
@Composable
fun AbsMiniPlayer(
state: AbsPlayState,
absClient: AbsClient,
onTap: () -> Unit,
onTogglePlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val item = state.item ?: return
val meta = item.media.metadata
val chapterName = state.chapters.getOrNull(state.chapterIdx)?.title
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(top = 6.dp, bottom = 4.dp)
.shadow(12.dp, RoundedCornerShape(18.dp))
.clip(RoundedCornerShape(18.dp))
.background(NsSurface),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onTap)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AsyncImage(
model = absClient.coverUrl(item.id),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(8.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = meta?.title ?: "Hörbuch",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
text = chapterName ?: meta?.authorName ?: "",
fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(NsAccent)
.clickable(onClick = onTogglePlay),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp),
)
}
}
if (state.duration > 0) {
LinearProgressIndicator(
progress = { (state.currentTime / state.duration).toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().height(3.dp),
color = NsAccent,
trackColor = NsAccent.copy(alpha = 0.15f),
)
}
}
}
@@ -0,0 +1,101 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DownloadForOffline
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Album
import com.novasonic.app.ui.theme.*
private val cardBorder = Color(0x12FFFFFF) // rgba(255,255,255,0.07)
@Composable
fun AlbumCard(
album: Album,
coverUrl: String?,
width: Dp = 145.dp,
downloaded: Boolean = false,
onClick: () -> Unit,
) {
val shape = RoundedCornerShape(12.dp)
Column(
modifier = Modifier
.width(width)
.clip(shape)
.background(NsSurface)
.border(1.dp, cardBorder, shape)
.clickable(onClick = onClick),
) {
Box {
AsyncImage(
model = coverUrl,
contentDescription = album.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
if (downloaded) {
Box(
Modifier
.align(Alignment.BottomEnd)
.padding(6.dp)
.size(22.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.DownloadForOffline,
contentDescription = "Heruntergeladen",
tint = NsAccent,
modifier = Modifier.size(16.dp),
)
}
}
}
Column(Modifier.padding(10.dp)) {
Text(
text = album.name,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
color = NsOnSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Spacer(Modifier.height(2.dp))
Text(
text = album.artist,
fontFamily = Outfit,
fontSize = 11.sp,
color = NsOnSurface2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -0,0 +1,193 @@
package com.novasonic.app.ui.component
import android.content.Intent
import android.net.Uri
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.material3.*
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.novasonic.app.api.model.ConcertEvent
import com.novasonic.app.ui.theme.*
import java.time.LocalDateTime
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcertsSheet(
artistName: String,
events: List<ConcertEvent>,
onDismiss: () -> Unit,
eventsError: String? = null,
) {
val context = LocalContext.current
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
) {
Text(
"Konzerte · $artistName",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 16.sp,
color = NsOnSurface,
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
)
if (events.isEmpty()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 40.dp),
) {
Text(
if (eventsError != null) "Konzerte konnten nicht geladen werden" else "Keine Konzerte angekündigt",
fontFamily = Outfit, fontSize = 14.sp,
color = NsOnSurface2,
)
if (eventsError != null) {
Spacer(Modifier.height(6.dp))
Text(
eventsError,
fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2.copy(alpha = 0.6f),
)
}
}
} else {
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(events) { event ->
EventRow(event = event, onTicketClick = { url ->
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
})
}
}
}
}
}
@Composable
private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
val w = formatEventWhen(event.datetime)
val venue = event.venue?.name.orEmpty()
val city = listOfNotNull(event.venue?.city, event.venue?.country).joinToString(", ")
val offer = event.offers.firstOrNull { !it.url.isNullOrBlank() }
val soldOut = offer?.status == "unavailable"
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(NsSurface2)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
// Date block — fixed width, calendar style (day big, month/year + time small) so every
// row is the same height and the divider/venue line up.
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(56.dp),
) {
Text(
w.day,
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 18.sp, color = NsAccent, maxLines = 1,
)
if (w.monthYear.isNotBlank()) {
Text(
w.monthYear,
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
)
}
if (w.time.isNotBlank()) {
Text(
w.time,
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
)
}
}
Spacer(Modifier.width(14.dp))
Box(Modifier.width(1.dp).height(40.dp).background(NsBorder))
Spacer(Modifier.width(14.dp))
// Venue
Column(Modifier.weight(1f)) {
if (venue.isNotBlank()) {
Text(
venue, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface,
maxLines = 1,
)
}
if (city.isNotBlank()) {
Text(
city, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1,
)
}
}
// Ticket button
if (offer?.url != null) {
Spacer(Modifier.width(10.dp))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.then(
if (soldOut) Modifier.border(1.dp, NsOnSurface2.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
else Modifier.background(NsAccent)
)
.clickable(enabled = !soldOut) { onTicketClick(offer.url) }
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
if (soldOut) "Ausverkauft" else "Tickets →",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
color = if (soldOut) NsOnSurface2 else Color.White,
)
}
}
}
}
private data class EventWhen(val day: String, val monthYear: String, val time: String)
private val DAY_FMT = DateTimeFormatter.ofPattern("dd", Locale.GERMAN)
private val MONTH_FMT = DateTimeFormatter.ofPattern("MMM yyyy", Locale.GERMAN)
private val TIME_FMT = DateTimeFormatter.ofPattern("HH:mm")
private fun formatEventWhen(datetime: String): EventWhen {
return try {
// Backend now sends a naive venue-local datetime ("2026-09-16T20:00:00"); older/zoned
// values ("…Z") still parse via the ZonedDateTime fallback.
val ldt = runCatching { LocalDateTime.parse(datetime) }
.getOrElse { ZonedDateTime.parse(datetime).toLocalDateTime() }
val time = if (ldt.hour == 0 && ldt.minute == 0) "" else ldt.format(TIME_FMT)
EventWhen(ldt.format(DAY_FMT), ldt.format(MONTH_FMT), time)
} catch (_: Exception) {
EventWhen(datetime.take(10), "", "")
}
}
@@ -0,0 +1,191 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
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.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
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 coil.compose.AsyncImage
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ItunesDownloadSheet(
item: ItunesItem,
onDownload: (type: String) -> Unit,
onDismiss: () -> Unit,
) {
val hasAlbum = !item.collectionName.isNullOrBlank()
val hasArtist = !item.artistName.isNullOrBlank()
var selected by remember {
mutableStateOf(if (item.trackName != null) "track" else if (hasAlbum) "album" else "artist")
}
ModalBottomSheet(
onDismissRequest = onDismiss,
// Open fully so the "Herunterladen" button is visible right away (it sits above the system
// nav bar via navigationBarsPadding below) — no drag-up needed.
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = NsSurface,
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2.copy(0.3f)) },
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.navigationBarsPadding()
.padding(bottom = 24.dp),
) {
// ── Header ────────────────────────────────────────────────────────
Row(verticalAlignment = Alignment.CenterVertically) {
val art = item.artworkUrl100?.replace("100x100bb", "80x80bb")
AsyncImage(
model = art, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
item.trackName ?: item.collectionName ?: item.artistName ?: "—",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 15.sp, color = NsOnSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (hasArtist) {
Text(
item.artistName!!,
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
if (hasAlbum && item.trackName != null) {
Text(
item.collectionName!!,
fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2.copy(0.6f),
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
Spacer(Modifier.height(20.dp))
Text(
"Was herunterladen?",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp, color = NsOnSurface2,
)
Spacer(Modifier.height(10.dp))
// ── Options ───────────────────────────────────────────────────────
if (item.trackName != null) {
DownloadTypeRow(
icon = Icons.Default.MusicNote,
title = "Nur dieser Titel",
subtitle = item.trackName!!,
selected = selected == "track",
onClick = { selected = "track" },
)
Spacer(Modifier.height(8.dp))
}
if (hasAlbum) {
DownloadTypeRow(
icon = Icons.Default.Album,
title = "Ganzes Album",
subtitle = "\"${item.collectionName}\" herunterladen",
selected = selected == "album",
onClick = { selected = "album" },
)
Spacer(Modifier.height(8.dp))
}
if (hasArtist) {
DownloadTypeRow(
icon = Icons.Default.Person,
title = "Ganzer Künstler",
subtitle = "Alle Songs von ${item.artistName}",
selected = selected == "artist",
onClick = { selected = "artist" },
)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { onDownload(selected) },
modifier = Modifier.fillMaxWidth().height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
) {
Icon(Icons.Default.Download, null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text("Herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
}
}
}
@Composable
private fun DownloadTypeRow(
icon: ImageVector,
title: String,
subtitle: String,
selected: Boolean,
onClick: () -> Unit,
) {
val borderColor = if (selected) NsAccent else NsBorder
val bgColor = if (selected) NsAccent.copy(alpha = 0.08f) else NsSurface2
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(bgColor)
.border(1.dp, borderColor, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(if (selected) NsAccent else NsOnSurface2.copy(0.12f)),
) {
Icon(icon, null, modifier = Modifier.size(18.dp),
tint = if (selected) Color.White else NsOnSurface2)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = if (selected) NsAccent else NsOnSurface)
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (selected) {
Icon(Icons.Default.CheckCircle, null,
tint = NsAccent, modifier = Modifier.size(20.dp))
}
}
}
@@ -0,0 +1,164 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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 coil.compose.AsyncImage
import com.novasonic.app.data.PrefsStore
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.theme.*
@Composable
fun MiniPlayer(
state: PlayerState,
coverUrl: String?,
onTap: () -> Unit,
onTogglePlay: () -> Unit,
onSkipPrev: () -> Unit,
onSkipNext: () -> Unit,
lastSong: PrefsStore.LastPlayedSong? = null,
lastSongCoverUrl: String? = null,
onPlayLast: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val song = state.currentSong
// If no active song but last song exists, show it in "resume" mode
if (song == null) {
val ls = lastSong ?: return
LastSongMiniPlayer(
lastSong = ls,
coverUrl = lastSongCoverUrl,
onTap = onTap,
onPlay = onPlayLast ?: {},
modifier = modifier,
)
return
}
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(top = 6.dp, bottom = 4.dp)
.shadow(12.dp, RoundedCornerShape(18.dp))
.clip(RoundedCornerShape(18.dp))
.background(NsSurface),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onTap)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AsyncImage(
model = coverUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = song.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (!song.artist.isNullOrBlank()) {
Text(
text = song.artist!!, fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(onClick = onSkipPrev, modifier = Modifier.size(40.dp)) {
Icon(Icons.Default.SkipPrevious, contentDescription = null, tint = NsOnSurface2, modifier = Modifier.size(24.dp))
}
Box(
Modifier.size(40.dp).clip(CircleShape).background(NsAccent).clickable(onClick = onTogglePlay),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(4.dp))
IconButton(onClick = onSkipNext, modifier = Modifier.size(40.dp)) {
Icon(Icons.Default.SkipNext, contentDescription = null, tint = NsOnSurface2, modifier = Modifier.size(24.dp))
}
}
if (state.durationMs > 0) {
LinearProgressIndicator(
progress = { (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().height(3.dp),
color = NsAccent, trackColor = NsAccent.copy(alpha = 0.15f),
)
}
}
}
@Composable
private fun LastSongMiniPlayer(
lastSong: PrefsStore.LastPlayedSong,
coverUrl: String?,
onTap: () -> Unit,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(top = 6.dp, bottom = 4.dp)
.shadow(12.dp, RoundedCornerShape(18.dp))
.clip(RoundedCornerShape(18.dp))
.background(NsSurface)
.clickable(onClick = onTap)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AsyncImage(
model = coverUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = lastSong.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (lastSong.artist.isNotBlank()) {
Text(
text = lastSong.artist, fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
Box(
Modifier.size(40.dp).clip(CircleShape).background(NsAccent.copy(alpha = 0.6f)).clickable(onClick = onPlay),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp))
}
}
}
@@ -0,0 +1,200 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
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.ArrowForward
import androidx.compose.material.icons.filled.Close
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.NotificationStore
import com.novasonic.app.ui.theme.*
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import androidx.compose.ui.res.painterResource
import com.novasonic.app.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationSheet(
onDismiss: () -> Unit,
onNavigate: (AppNotification) -> Unit = {},
) {
val items by NotificationStore.items.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { NotificationStore.markAllRead() }
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
dragHandle = null,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 8.dp, top = 18.dp, bottom = 12.dp),
) {
Icon(painterResource(R.drawable.ic_honigtopf_brief), null,
tint = NsAccent, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(
"Benachrichtigungen",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, color = NsOnSurface,
modifier = Modifier.weight(1f),
)
if (items.isNotEmpty()) {
TextButton(onClick = { NotificationStore.clear() }) {
Text("Alle löschen", color = NsOnSurface2, fontFamily = Outfit, fontSize = 12.sp)
}
}
}
HorizontalDivider(color = NsBorder)
if (items.isEmpty()) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().height(140.dp),
) {
Text("Keine Benachrichtigungen", color = NsOnSurface2, fontFamily = Outfit)
}
} else {
LazyColumn(
contentPadding = PaddingValues(top = 4.dp, bottom = 24.dp),
modifier = Modifier.navigationBarsPadding(),
) {
items(items, key = { it.id }) { n ->
NotifCard(
n = n,
onDelete = { NotificationStore.remove(n.id) },
onNavigate = { onNavigate(n); onDismiss() },
)
}
}
}
}
}
@Composable
private fun NotifCard(n: AppNotification, onDelete: () -> Unit, onNavigate: () -> Unit) {
val hasNav = n.status == "done" && n.navType.isNotBlank()
val navLabel = when (n.navType) {
"playlist" -> "Öffnen →"
else -> "Zum Album →"
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(if (!n.read) NsAccent.copy(alpha = 0.05f) else Color.Transparent),
) {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = if (hasNav) 6.dp else 12.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.background(
color = if (n.status == "done") Color(0xFF22C55E).copy(0.15f)
else if (n.status == "error") Color(0xFFEF4444).copy(0.15f)
else NsAccent.copy(0.15f),
shape = RoundedCornerShape(8.dp),
),
) {
Text(
text = when (n.status) {
"done" -> "✓"
"error" -> "✕"
else -> "…"
},
fontSize = 14.sp,
color = when (n.status) {
"done" -> Color(0xFF22C55E)
"error" -> Color(0xFFEF4444)
else -> NsAccent
},
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
n.title,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface,
)
if (n.artist.isNotBlank()) {
Text(n.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
if (n.message.isNotBlank() && n.status != "pending") {
Text(
n.message,
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 2.dp),
)
}
Text(
formatTs(n.timestamp),
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2.copy(alpha = 0.55f),
modifier = Modifier.padding(top = 4.dp),
)
}
if (n.status != "pending") {
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, null, tint = NsOnSurface2, modifier = Modifier.size(16.dp))
}
}
}
if (hasNav) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(start = 64.dp, end = 16.dp, bottom = 12.dp)
.clip(RoundedCornerShape(8.dp))
.background(NsAccent.copy(alpha = 0.12f))
.clickable(onClick = onNavigate)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
navLabel,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp, color = NsAccent,
)
Spacer(Modifier.width(4.dp))
Icon(
Icons.AutoMirrored.Filled.ArrowForward, null,
tint = NsAccent, modifier = Modifier.size(13.dp),
)
}
}
HorizontalDivider(
color = NsBorder.copy(alpha = 0.4f),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
private val _fmt = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN)
private fun formatTs(ts: Long): String = _fmt.format(Date(ts))
@@ -0,0 +1,165 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
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.PlaylistAdd
import androidx.compose.material.icons.automirrored.filled.QueueMusic
import androidx.compose.material.icons.filled.Add
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Playlist
import com.novasonic.app.api.model.Song
import com.novasonic.app.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaylistPickerSheet(
song: Song,
loadPlaylists: suspend () -> List<Playlist>,
coverUrl: (String?) -> String?,
onAdd: (Playlist) -> Unit,
onCreate: (String) -> Unit,
onDismiss: () -> Unit,
) {
var playlists by remember { mutableStateOf<List<Playlist>?>(null) }
var isLoading by remember { mutableStateOf(true) }
var loadError by remember { mutableStateOf<String?>(null) }
var newName by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
isLoading = true
try { playlists = loadPlaylists() }
catch (e: Exception) { loadError = e.message ?: e.toString() }
finally { isLoading = false }
}
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
) {
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp)) {
Text(
"\"${song.title}\" zu Playlist",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, color = NsOnSurface, maxLines = 1,
)
if (!song.artist.isNullOrBlank()) {
Text(song.artist!!, fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
}
}
HorizontalDivider(color = NsBorder)
// Create new playlist row
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
placeholder = { Text("Neue Playlist…", color = NsOnSurface2, fontSize = 14.sp) },
leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, null, tint = NsOnSurface2) },
singleLine = true,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.weight(1f),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
focusedTextColor = NsOnSurface,
unfocusedTextColor = NsOnSurface,
focusedContainerColor = NsSurface2,
unfocusedContainerColor = NsSurface2,
),
textStyle = androidx.compose.ui.text.TextStyle(
fontFamily = Outfit, fontSize = 14.sp,
),
)
IconButton(
onClick = {
val name = newName.trim()
if (name.isNotBlank()) { onCreate(name) }
},
enabled = newName.isNotBlank(),
) {
Icon(Icons.Default.Add, contentDescription = "Erstellen", tint = NsAccent)
}
}
HorizontalDivider(color = NsBorder)
when {
isLoading -> Box(
Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(24.dp)) }
loadError != null -> Box(
Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) { Text(loadError!!, color = MaterialTheme.colorScheme.error, fontSize = 13.sp) }
playlists.isNullOrEmpty() -> Box(
Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) { Text("Noch keine Playlists vorhanden", color = NsOnSurface2, fontSize = 13.sp) }
else -> LazyColumn(Modifier.fillMaxWidth()) {
items(playlists!!, key = { it.id }) { pl ->
Row(
Modifier
.fillMaxWidth()
.clickable { onAdd(pl) }
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val cv = coverUrl(pl.coverArt)
Box(
Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
contentAlignment = Alignment.Center,
) {
if (cv != null) {
AsyncImage(
model = cv, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(Icons.AutoMirrored.Filled.QueueMusic, null,
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
}
}
Spacer(Modifier.width(14.dp))
Text(
pl.name,
fontFamily = Outfit, fontWeight = FontWeight.Medium,
fontSize = 15.sp, color = NsOnSurface,
modifier = Modifier.weight(1f),
)
Text(
"${pl.songCount} Titel",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
}
}
}
Spacer(Modifier.height(24.dp))
}
}
@@ -0,0 +1,92 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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 coil.compose.AsyncImage
import com.novasonic.app.player.PreviewState
import com.novasonic.app.ui.theme.*
/**
* Global 30s-preview control bar. Rendered above the MiniPlayer on every screen while a preview
* clip is active, so the user can pause/stop it after navigating away from the album/artist/Explore
* screen it was started on.
*/
@Composable
fun PreviewBar(
state: PreviewState,
onToggle: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(top = 6.dp, bottom = 2.dp)
.shadow(10.dp, RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(NsSurface2)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
AsyncImage(
model = state.artworkUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(38.dp).clip(RoundedCornerShape(7.dp)).background(NsSurface),
)
Spacer(Modifier.width(11.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "30s Preview", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 9.sp, color = NsAccent, letterSpacing = 0.5.sp,
)
}
Text(
text = state.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (state.artist.isNotBlank()) {
Text(
text = state.artist, fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
Box(
Modifier.size(36.dp).clip(CircleShape).background(NsAccent).clickable(onClick = onToggle),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (state.isPlaying) "Pause" else "Abspielen",
tint = Color.White, modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(2.dp))
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Close, contentDescription = "Preview beenden", tint = NsOnSurface2, modifier = Modifier.size(20.dp))
}
}
}
@@ -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,
)
@@ -0,0 +1,117 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.automirrored.filled.QueueMusic
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.novasonic.app.api.model.Song
import com.novasonic.app.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SongContextSheet(
song: Song,
isStarred: Boolean,
onDismiss: () -> Unit,
onPlay: () -> Unit,
onInsertNext: () -> Unit,
onAddToQueue: () -> Unit,
onStarToggle: () -> Unit,
onAddToPlaylist: (() -> Unit)? = null,
onGoToAlbum: (() -> Unit)? = null,
onShowInfo: (() -> Unit)? = null,
onDownload: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
onShare: (() -> Unit)? = null,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
// Open fully expanded so all options (incl. "Herunterladen" at the bottom) are reachable
// right away — no extra drag-up needed.
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = NsSurface,
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
) {
// Header: song title + artist
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp)) {
Text(
song.title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, color = NsOnSurface, maxLines = 1,
)
if (!song.artist.isNullOrBlank()) {
Text(song.artist!!, fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
}
}
HorizontalDivider(color = NsBorder)
CtxItem(Icons.Default.PlayArrow, "Abspielen", onClick = { onDismiss(); onPlay() })
CtxItem(Icons.AutoMirrored.Filled.QueueMusic, "Als Nächstes einfügen", onClick = { onDismiss(); onInsertNext() })
CtxItem(Icons.Default.AddToQueue, "An Queue anfügen", onClick = { onDismiss(); onAddToQueue() })
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
if (onAddToPlaylist != null) {
CtxItem(Icons.AutoMirrored.Filled.PlaylistAdd, "Zu Playlist hinzufügen",
onClick = { onDismiss(); onAddToPlaylist() })
}
CtxItem(
icon = if (isStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
label = if (isStarred) "Aus Favoriten entfernen" else "Zu Favoriten",
tint = if (isStarred) NsAccent else NsOnSurface,
onClick = { onDismiss(); onStarToggle() },
)
if (onGoToAlbum != null || onShowInfo != null || onDownload != null) {
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) }
onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", 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))
}
}
@Composable
private fun CtxItem(
icon: ImageVector,
label: String,
onClick: () -> Unit,
tint: androidx.compose.ui.graphics.Color = NsOnSurface,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 14.dp),
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(16.dp))
Text(label, fontFamily = Outfit, fontWeight = FontWeight.Medium,
fontSize = 15.sp, color = tint)
}
}
@@ -0,0 +1,72 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.novasonic.app.api.model.Song
import com.novasonic.app.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SongInfoSheet(song: Song, onDismiss: () -> Unit) {
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
) {
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) {
Text(
"Song-Info",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, color = NsOnSurface,
)
Spacer(Modifier.height(16.dp))
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(bottom = 12.dp))
InfoRow("Titel", song.title)
song.artist?.takeIf { it.isNotBlank() }?.let { InfoRow("Künstler", it) }
song.album?.takeIf { it.isNotBlank() }?.let { InfoRow("Album", it) }
song.year?.let { InfoRow("Jahr", it.toString()) }
song.genre?.takeIf { it.isNotBlank() }?.let { InfoRow("Genre", it) }
song.track?.let { InfoRow("Track", it.toString()) }
song.duration?.let { InfoRow("Dauer", formatDur(it)) }
song.bitRate?.let { InfoRow("Bitrate", "${it} kbps") }
song.suffix?.let { InfoRow("Format", it.uppercase()) }
song.size?.let { InfoRow("Größe", formatSize(it)) }
}
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
Modifier.fillMaxWidth().padding(vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
label,
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.width(70.dp),
)
Text(
value,
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
modifier = Modifier.weight(1f),
)
}
}
private fun formatDur(s: Int): String {
val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%d:%02d".format(m, sec)
}
private fun formatSize(bytes: Long): String = when {
bytes < 1024L -> "$bytes B"
bytes < 1024L * 1024 -> "${bytes / 1024} KB"
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
}
@@ -0,0 +1,141 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Song
import com.novasonic.app.ui.theme.*
@Composable
fun SongRow(
song: Song,
coverUrl: String?,
isPlaying: Boolean = false,
trackNumber: Int? = null,
starred: Boolean = false,
onPlay: () -> Unit,
onStarToggle: (() -> Unit)? = null,
onMore: (() -> Unit)? = null,
) {
// Reactive star: reflects an optimistic StarState override the instant it's toggled anywhere.
val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState()
val effectiveStarred = starOverrides[song.id] ?: starred
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onPlay)
.background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent)
// 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
if (isPlaying) {
Box(
Modifier
.width(3.dp)
.height(52.dp)
.background(NsAccent, RoundedCornerShape(topEnd = 3.dp, bottomEnd = 3.dp))
)
Spacer(Modifier.width(9.dp))
}
// Track number OR album art
if (trackNumber != null) {
Text(
text = trackNumber.toString(),
fontFamily = JetBrainsMono,
fontSize = 11.sp,
color = if (isPlaying) NsAccent else NsOnSurface2,
modifier = Modifier.width(34.dp).padding(start = if (isPlaying) 0.dp else 12.dp),
)
} else {
Spacer(Modifier.width(if (isPlaying) 0.dp else 12.dp))
AsyncImage(
model = coverUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(46.dp)
.clip(RoundedCornerShape(6.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
}
Column(
Modifier
.weight(1f)
.padding(vertical = 10.dp)
.padding(end = 4.dp),
) {
Text(
text = song.title,
fontFamily = Outfit,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = if (isPlaying) NsAccent else NsOnSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val sub = if (trackNumber != null) {
// in album context: show artist only (album name redundant)
song.artist?.takeIf { it.isNotBlank() } ?: ""
} else {
listOfNotNull(song.artist, song.album).joinToString(" · ")
}
if (sub.isNotBlank()) {
Spacer(Modifier.height(2.dp))
Text(
text = sub,
fontFamily = Outfit,
fontSize = 12.sp,
color = NsOnSurface2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (onStarToggle != null) {
IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) {
Icon(
if (effectiveStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null,
tint = if (effectiveStarred) NsAccent else NsOnSurface2.copy(alpha = 0.6f),
modifier = Modifier.size(17.dp),
)
}
}
if (onMore != null) {
IconButton(onClick = onMore, modifier = Modifier.size(38.dp)) {
Icon(
Icons.Default.MoreVert, contentDescription = null,
tint = NsOnSurface2.copy(alpha = 0.6f), modifier = Modifier.size(18.dp),
)
}
}
}
}
@@ -0,0 +1,951 @@
package com.novasonic.app.ui.screen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
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.*
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.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
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 coil.compose.AsyncImage
import com.novasonic.app.api.AbsChapter
import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibrary
import com.novasonic.app.api.AbsLibraryItem
import com.novasonic.app.player.AbsPlayState
import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.ui.theme.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private fun String.stripHtml(): String =
replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim()
// ── Library list ─────────────────────────────────────────────────────────────
@Composable
fun AbsScreen(
absClient: AbsClient,
onLibraryClick: (AbsLibrary) -> Unit,
) {
val scope = rememberCoroutineScope()
var libraries by remember { mutableStateOf<List<AbsLibrary>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
scope.launch {
isLoading = true
val result = runCatching { absClient.getLibraries() }
if (result.isSuccess) {
libraries = result.getOrDefault(emptyList())
} else {
error = result.exceptionOrNull()?.message ?: "Fehler"
}
isLoading = false
}
}
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp)) {
Text(
"Audiobookshelf",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface,
modifier = Modifier.padding(bottom = 16.dp),
)
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
}
libraries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Bibliotheken gefunden", fontFamily = Outfit, color = NsOnSurface2)
}
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(libraries) { lib ->
LibraryRow(library = lib, onClick = { onLibraryClick(lib) })
}
}
}
}
}
// ── Combined books screen (skips library list, auto-loads first library) ─────
@Composable
fun AbsBooksScreen(
absClient: AbsClient,
onItemClick: (AbsLibraryItem) -> Unit,
) {
var libraries by remember { mutableStateOf<List<AbsLibrary>>(emptyList()) }
var selectedLibIdx by remember { mutableIntStateOf(0) }
var items by remember { mutableStateOf<List<AbsLibraryItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var query by remember { mutableStateOf("") }
// Initial load: fetch libraries + first library's items
LaunchedEffect(Unit) {
isLoading = true
val libs = runCatching { absClient.getLibraries() }.getOrDefault(emptyList())
libraries = libs
val first = libs.firstOrNull() ?: run { isLoading = false; return@LaunchedEffect }
val r = runCatching { absClient.getLibraryItems(first.id).results }
items = r.getOrDefault(emptyList())
error = r.exceptionOrNull()?.message
isLoading = false
}
// Reload when user switches library tab
LaunchedEffect(selectedLibIdx) {
val lib = libraries.getOrNull(selectedLibIdx) ?: return@LaunchedEffect
query = ""
isLoading = true; error = null
items = runCatching { absClient.getLibraryItems(lib.id).results }.getOrDefault(emptyList())
isLoading = false
}
// Debounced search — 400ms after last keystroke
LaunchedEffect(query) {
val lib = libraries.getOrNull(selectedLibIdx) ?: return@LaunchedEffect
if (query.isBlank()) {
isLoading = true; error = null
items = runCatching { absClient.getLibraryItems(lib.id).results }.getOrDefault(emptyList())
isLoading = false
return@LaunchedEffect
}
delay(400)
isLoading = true; error = null
items = runCatching { absClient.searchLibraryItems(lib.id, query) }.getOrDefault(emptyList())
isLoading = false
}
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp)) {
Text(
"Hörbücher",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface,
)
Spacer(Modifier.height(10.dp))
// Search bar
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Suchen…", fontFamily = Outfit, color = NsOnSurface3) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = NsOnSurface2) },
trailingIcon = if (query.isNotEmpty()) {{
IconButton(onClick = { query = "" }) {
Icon(Icons.Default.Clear, contentDescription = "Löschen", tint = NsOnSurface2)
}
}} else null,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsBorder,
focusedTextColor = NsOnSurface,
unfocusedTextColor = NsOnSurface,
cursorColor = NsAccent,
focusedContainerColor = NsSurface,
unfocusedContainerColor = NsSurface,
),
)
// Library switcher — only shown when more than one library exists
if (libraries.size > 1) {
Spacer(Modifier.height(10.dp))
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
libraries.forEachIndexed { idx, lib ->
val sel = idx == selectedLibIdx
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(if (sel) NsAccent else NsSurface)
.clickable { selectedLibIdx = idx }
.padding(horizontal = 14.dp, vertical = 6.dp),
) {
Text(
lib.name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = if (sel) NsBackground else NsOnSurface2,
)
}
}
}
}
Spacer(Modifier.height(12.dp))
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
}
items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
if (query.isNotBlank()) "Keine Ergebnisse für \"$query\"" else "Keine Bücher gefunden",
fontFamily = Outfit, color = NsOnSurface2,
)
}
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(items) { item ->
AbsItemRow(item = item, absClient = absClient, onClick = { onItemClick(item) })
}
}
}
}
}
@Composable
private fun LibraryRow(library: AbsLibrary, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(NsSurface)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 14.dp),
) {
Box(
Modifier.size(44.dp).clip(RoundedCornerShape(10.dp)).background(NsAccent.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Book, contentDescription = null, tint = NsAccent, modifier = Modifier.size(24.dp))
}
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
text = library.name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 15.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
text = library.mediaType.replaceFirstChar { it.uppercase() },
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
}
}
// ── Library items list ────────────────────────────────────────────────────────
@Composable
fun AbsLibraryScreen(
absClient: AbsClient,
library: AbsLibrary,
onItemClick: (AbsLibraryItem) -> Unit,
onBack: () -> Unit,
) {
var items by remember { mutableStateOf<List<AbsLibraryItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(library.id) {
isLoading = true
val result = runCatching { absClient.getLibraryItems(library.id).results }
if (result.isSuccess) {
items = result.getOrDefault(emptyList())
} else {
error = result.exceptionOrNull()?.message ?: "Fehler"
}
isLoading = false
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
}
Text(
library.name, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 20.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
}
items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Bücher gefunden", fontFamily = Outfit, color = NsOnSurface2)
}
else -> LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items) { item ->
AbsItemRow(item = item, absClient = absClient, onClick = { onItemClick(item) })
}
}
}
}
}
@Composable
private fun AbsItemRow(item: AbsLibraryItem, absClient: AbsClient, onClick: () -> Unit) {
val meta = item.media.metadata
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(NsSurface)
.clickable(onClick = onClick)
.padding(10.dp),
) {
AsyncImage(
model = absClient.coverUrl(item.id),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = meta.title ?: item.id,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
)
if (!meta.authorName.isNullOrBlank()) {
Text(
text = meta.authorName, fontFamily = Outfit,
fontSize = 12.sp, color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
val durationMin = (item.media.duration / 60).toInt()
if (durationMin > 0) {
Text(
text = "${durationMin / 60}h ${durationMin % 60}m",
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2.copy(alpha = 0.7f),
)
}
}
Icon(
Icons.Default.ChevronRight, contentDescription = null,
tint = NsOnSurface2, modifier = Modifier.size(20.dp),
)
}
}
// ── Book detail ───────────────────────────────────────────────────────────────
@Composable
fun AbsBookDetailScreen(
absClient: AbsClient,
libraryItem: AbsLibraryItem,
absPlayerController: AbsPlayerController,
onPlay: () -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var chapters by remember { mutableStateOf(libraryItem.media.chapters) }
var progress by remember { mutableStateOf<Double?>(null) }
var isLoadingDetail by remember { mutableStateOf(libraryItem.media.chapters.isEmpty()) }
var isStartingPlay by remember { mutableStateOf(false) }
LaunchedEffect(libraryItem.id) {
if (chapters.isEmpty()) {
isLoadingDetail = true
chapters = runCatching { absClient.getItemChapters(libraryItem.id) }.getOrDefault(emptyList())
isLoadingDetail = false
}
progress = runCatching { absClient.getProgress(libraryItem.id)?.currentTime }.getOrNull()
}
val meta = libraryItem.media.metadata
val duration = libraryItem.media.duration
val progressPct = if (duration > 0 && (progress ?: 0.0) > 0) ((progress!! / duration) * 100).toInt() else 0
Column(modifier = Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
}
Text(
meta.title ?: "Hörbuch", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 18.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Header
item(key = "header") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
AsyncImage(
model = absClient.coverUrl(libraryItem.id),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)).background(NsSurface2),
)
Column(Modifier.weight(1f)) {
Text(
text = meta.title ?: "Unbekannt",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, color = NsOnSurface,
)
if (!meta.authorName.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = meta.authorName, fontFamily = Outfit,
fontSize = 13.sp, color = NsOnSurface2,
)
}
if (duration > 0) {
Spacer(Modifier.height(4.dp))
val h = (duration / 3600).toInt()
val m = ((duration % 3600) / 60).toInt()
Text(
text = if (h > 0) "${h}h ${m}m" else "${m}m",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
if (progressPct in 1..98) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { progressPct / 100f },
modifier = Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)),
color = NsAccent, trackColor = NsAccent.copy(alpha = 0.2f),
)
Spacer(Modifier.height(2.dp))
Text(
text = "$progressPct% gehört",
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
)
}
}
}
}
// Play button
item(key = "play") {
Button(
onClick = {
isStartingPlay = true
scope.launch {
runCatching { absPlayerController.playItem(libraryItem, absClient) }
isStartingPlay = false
onPlay()
}
},
enabled = !isStartingPlay,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
) {
if (isStartingPlay) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
} else {
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
if (progressPct > 5) "Weiterhören" else "Abspielen",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
)
}
}
}
// Description (strip HTML tags from ABS markdown/HTML content)
if (!meta.description.isNullOrBlank()) {
item(key = "desc") {
Text(
text = meta.description.stripHtml(),
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
lineHeight = 20.sp,
)
}
}
// Chapters
if (chapters.isNotEmpty()) {
item(key = "chHeader") {
Text(
"Kapitel (${chapters.size})",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 14.sp, color = NsOnSurface,
)
}
itemsIndexed(chapters, key = { idx, _ -> "ch_$idx" }) { idx, ch ->
ChapterDetailRow(
chapter = ch,
index = idx + 1,
onTap = {
scope.launch {
isStartingPlay = true
runCatching {
absPlayerController.playItem(libraryItem, absClient)
absPlayerController.seekToGlobal(ch.start)
}
isStartingPlay = false
onPlay()
}
},
)
}
} else if (isLoadingDetail) {
item(key = "chLoading") {
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = NsAccent)
}
}
}
}
}
}
@Composable
private fun ChapterDetailRow(chapter: AbsChapter, index: Int, onTap: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(NsSurface)
.clickable(onClick = onTap)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Text(
text = "$index",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 12.sp, color = NsAccent,
modifier = Modifier.width(28.dp),
)
Text(
text = chapter.title.ifBlank { "Kapitel $index" },
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
modifier = Modifier.weight(1f),
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
text = fmtSecs(chapter.start),
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
}
// ── ABS Now Playing overlay ───────────────────────────────────────────────────
@Composable
fun AbsNowPlayingScreen(
absPlayerController: AbsPlayerController,
absClient: AbsClient,
onDismiss: () -> Unit,
) {
val state by absPlayerController.state.collectAsStateWithLifecycle()
Box(
modifier = Modifier
.fillMaxSize()
.background(NsBackground)
.pointerInput(Unit) {
detectVerticalDragGestures { _, delta -> if (delta > 80f) onDismiss() }
},
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Top bar
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) {
IconButton(onClick = onDismiss) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Schließen", tint = NsOnSurface)
}
Text(
"Wird abgespielt", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface2,
modifier = Modifier.weight(1f), textAlign = TextAlign.Center,
)
Spacer(Modifier.width(48.dp))
}
Spacer(Modifier.height(20.dp))
// Cover
AsyncImage(
model = state.item?.let { absClient.coverUrl(it.id) },
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(220.dp)
.clip(RoundedCornerShape(16.dp))
.background(NsSurface2),
)
Spacer(Modifier.height(20.dp))
// Title / Author / Chapter
val meta = state.item?.media?.metadata
Text(
text = meta?.title ?: "Unbekannt",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = NsOnSurface,
maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
)
if (!meta?.authorName.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = meta?.authorName ?: "",
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, textAlign = TextAlign.Center,
)
}
val currentChapter = state.chapters.getOrNull(state.chapterIdx)
if (currentChapter != null && currentChapter.title.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = currentChapter.title,
fontFamily = Outfit, fontSize = 12.sp, color = NsAccent, textAlign = TextAlign.Center,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(20.dp))
// Progress bar
val chWin = chapterWindow(state)
val dispCur = if (chWin != null) (state.currentTime - chWin.first).coerceAtLeast(0.0) else state.currentTime
val dispDur = if (chWin != null) chWin.second - chWin.first else state.duration
val progress = if (dispDur > 0) (dispCur / dispDur).coerceIn(0.0, 1.0).toFloat() else 0f
Slider(
value = progress,
onValueChange = { v ->
val globalTarget = if (chWin != null) chWin.first + v * (chWin.second - chWin.first)
else v * state.duration
absPlayerController.seekToGlobal(globalTarget)
},
modifier = Modifier.fillMaxWidth(),
colors = SliderDefaults.colors(
thumbColor = NsAccent, activeTrackColor = NsAccent,
inactiveTrackColor = NsAccent.copy(alpha = 0.2f),
),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(fmtSecs(dispCur), fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
Text("-${fmtSecs(dispDur - dispCur)}", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
}
Spacer(Modifier.height(12.dp))
// Controls
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth(),
) {
IconButton(onClick = { absPlayerController.prevChapter() }) {
Icon(Icons.Default.SkipPrevious, contentDescription = "Voriges Kapitel", tint = NsOnSurface, modifier = Modifier.size(30.dp))
}
IconButton(onClick = { absPlayerController.skipSecs(-30.0) }) {
Icon(Icons.Default.Replay30, contentDescription = "-30s", tint = NsOnSurface, modifier = Modifier.size(30.dp))
}
Box(
Modifier.size(64.dp).clip(CircleShape).background(NsAccent)
.clickable { absPlayerController.togglePlay() },
contentAlignment = Alignment.Center,
) {
Icon(
if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null, tint = NsBackground, modifier = Modifier.size(34.dp),
)
}
IconButton(onClick = { absPlayerController.skipSecs(30.0) }) {
Icon(Icons.Default.Forward30, contentDescription = "+30s", tint = NsOnSurface, modifier = Modifier.size(30.dp))
}
IconButton(onClick = { absPlayerController.nextChapter() }) {
Icon(Icons.Default.SkipNext, contentDescription = "Nächstes Kapitel", tint = NsOnSurface, modifier = Modifier.size(30.dp))
}
}
Spacer(Modifier.height(12.dp))
// Speed + Sleep + Chapter track row
AbsBottomControls(state = state, absPlayerController = absPlayerController)
Spacer(Modifier.height(16.dp))
// Chapter list
if (state.chapters.isNotEmpty()) {
AbsChapterList(
state = state,
absPlayerController = absPlayerController,
modifier = Modifier.weight(1f),
)
}
}
// Loading overlay
if (state.isLoading) {
Box(Modifier.fillMaxSize().background(NsBackground.copy(alpha = 0.7f)), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
}
}
}
@Composable
private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlayerController) {
var showSpeedPicker by remember { mutableStateOf(false) }
var showSleepPicker by remember { mutableStateOf(false) }
var showCustomSleepDialog by remember { mutableStateOf(false) }
var customMinutesText by remember { mutableStateOf("") }
val speedOptions = listOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f)
val sleepOptions = listOf(5 to "5 min", 10 to "10 min", 15 to "15 min", 30 to "30 min", 60 to "1 Stunde")
if (showCustomSleepDialog) {
AlertDialog(
onDismissRequest = { showCustomSleepDialog = false },
title = { Text("Sleep Timer", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
Column {
Text("Minuten eingeben:", fontFamily = Outfit, fontSize = 14.sp, color = NsOnSurface2)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = customMinutesText,
onValueChange = { v -> if (v.all { it.isDigit() } && v.length <= 4) customMinutesText = v },
label = { Text("Minuten", fontFamily = Outfit) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent, focusedLabelColor = NsAccent, cursorColor = NsAccent,
unfocusedBorderColor = NsOnSurface3, focusedTextColor = NsOnSurface, unfocusedTextColor = NsOnSurface,
),
)
}
},
confirmButton = {
TextButton(onClick = {
val mins = customMinutesText.toIntOrNull()
if (mins != null && mins > 0) absPlayerController.setSleepTimer(mins)
showCustomSleepDialog = false
}) { Text("Starten", color = NsAccent, fontFamily = Outfit) }
},
dismissButton = {
TextButton(onClick = { showCustomSleepDialog = false }) {
Text("Abbrechen", fontFamily = Outfit, color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// Speed
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
TextButton(onClick = { showSpeedPicker = !showSpeedPicker; showSleepPicker = false }) {
Text(
"${state.speed}×",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = if (state.speed != 1f) NsAccent else NsOnSurface2,
)
}
DropdownMenu(
expanded = showSpeedPicker,
onDismissRequest = { showSpeedPicker = false },
containerColor = NsSurface2,
) {
speedOptions.forEach { s ->
DropdownMenuItem(
text = { Text("${s}×", fontFamily = Outfit, color = if (s == state.speed) NsAccent else NsOnSurface) },
onClick = { absPlayerController.setSpeed(s); showSpeedPicker = false },
)
}
}
}
// Sleep timer
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
val sleepRem = state.sleepRemainingSecs
TextButton(onClick = { showSleepPicker = !showSleepPicker; showSpeedPicker = false }) {
Icon(
Icons.Default.Bedtime, contentDescription = "Sleep Timer",
tint = if (sleepRem != null) NsAccent else NsOnSurface2,
modifier = Modifier.size(18.dp),
)
if (sleepRem != null) {
Spacer(Modifier.width(4.dp))
val m = sleepRem / 60
val s = (sleepRem % 60).toString().padStart(2, '0')
Text("$m:$s", fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
}
}
DropdownMenu(
expanded = showSleepPicker,
onDismissRequest = { showSleepPicker = false },
containerColor = NsSurface2,
) {
if (state.sleepRemainingSecs != null) {
DropdownMenuItem(
text = { Text("Abbrechen", fontFamily = Outfit, color = MaterialTheme.colorScheme.error) },
onClick = { absPlayerController.cancelSleep(); showSleepPicker = false },
)
}
sleepOptions.forEach { (mins, label) ->
DropdownMenuItem(
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
onClick = { absPlayerController.setSleepTimer(mins); showSleepPicker = false },
)
}
DropdownMenuItem(
text = { Text("Benutzerdefiniert…", fontFamily = Outfit, color = NsOnSurface) },
onClick = { showSleepPicker = false; customMinutesText = ""; showCustomSleepDialog = true },
)
}
}
// Chapter track mode toggle
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
TextButton(onClick = { absPlayerController.setChapterTrackMode(!state.chapterTrackMode) }) {
Icon(
Icons.Default.Bookmark, contentDescription = "Kapitel-Modus",
tint = if (state.chapterTrackMode) NsAccent else NsOnSurface2,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(4.dp))
Text(
"Kapitel", fontFamily = Outfit, fontSize = 12.sp,
color = if (state.chapterTrackMode) NsAccent else NsOnSurface2,
)
}
}
}
}
@Composable
private fun AbsChapterList(
state: AbsPlayState,
absPlayerController: AbsPlayerController,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
LaunchedEffect(state.chapterIdx) {
if (state.chapters.isNotEmpty()) {
listState.animateScrollToItem(state.chapterIdx.coerceAtLeast(0))
}
}
Column(modifier = modifier) {
Text(
"Kapitel",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(bottom = 6.dp),
)
HorizontalDivider(color = NsBorder)
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
itemsIndexed(state.chapters) { idx, ch ->
val isActive = idx == state.chapterIdx
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(if (isActive) NsAccent.copy(alpha = 0.08f) else androidx.compose.ui.graphics.Color.Transparent)
.clickable { absPlayerController.seekToChapter(idx) }
.padding(horizontal = 4.dp, vertical = 10.dp),
) {
Text(
"${idx + 1}",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
color = if (isActive) NsAccent else NsOnSurface2,
modifier = Modifier.width(28.dp),
)
Text(
ch.title.ifBlank { "Kapitel ${idx + 1}" },
fontFamily = Outfit, fontSize = 13.sp,
color = if (isActive) NsAccent else NsOnSurface,
modifier = Modifier.weight(1f),
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
fmtSecs(ch.start),
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
)
}
HorizontalDivider(color = NsBorder.copy(alpha = 0.4f))
}
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private fun chapterWindow(state: AbsPlayState): Pair<Double, Double>? {
if (!state.chapterTrackMode || state.chapters.isEmpty()) return null
val ch = state.chapters.getOrNull(state.chapterIdx) ?: return null
val next = state.chapters.getOrNull(state.chapterIdx + 1)
val end = next?.start ?: ch.end.takeIf { it > ch.start } ?: state.duration
if (end <= ch.start) return null
return Pair(ch.start, end)
}
internal fun fmtSecs(secs: Double): String {
val s = secs.toLong().coerceAtLeast(0)
val h = s / 3600
val m = (s % 3600) / 60
val sec = s % 60
return if (h > 0) "$h:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}"
else "$m:${sec.toString().padStart(2, '0')}"
}
@@ -0,0 +1,255 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
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 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.Shuffle
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
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.Song
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumViewModel
import com.novasonic.app.ui.component.Rollbalken
import com.novasonic.app.ui.formatGesamtdauer
@Composable
fun AlbumScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: ((Song) -> Unit)? = null,
onShowContextMenu: ((Song) -> Unit)? = null,
onArtistClick: ((String) -> Unit)? = null,
onBack: () -> Unit,
) {
val vm: AlbumViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val downloadGroup by vm.downloadGroup.collectAsStateWithLifecycle()
val isDownloading by vm.isDownloading.collectAsStateWithLifecycle()
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
val album = state.album ?: run {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
state.error ?: "Album nicht gefunden",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onBack) { Text("Zurück") }
}
}
return
}
val listState = rememberLazyListState()
Box(Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), state = listState) {
item {
// ── Hero ─────────────────────────────────────────────────────────
Box(Modifier.fillMaxWidth()) {
AsyncImage(
model = coverUrl(album.coverArt),
contentDescription = album.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
// gradient overlay
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(
Brush.verticalGradient(
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) } },
)
}
item { Spacer(Modifier.height(16.dp)) }
}
Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
)
}
}
@@ -0,0 +1,826 @@
package com.novasonic.app.ui.screen
import android.widget.Toast
import androidx.activity.compose.BackHandler
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.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
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.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
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.Song
import com.novasonic.app.api.model.fmtFanCount
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.component.ConcertsSheet
import com.novasonic.app.ui.component.ItunesDownloadSheet
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ArtistViewModel
import kotlinx.coroutines.launch
@Composable
fun ArtistDetailScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onPlay: (List<Song>, Int) -> Unit,
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onArtistClick: ((String) -> Unit)? = null,
onBack: () -> Unit,
) {
val vm: ArtistViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
// Which "all albums" section is expanded (by title), or null. rememberSaveable so it survives
// navigating into an album and back — otherwise back would land on the collapsed artist page.
var openAllAlbums by rememberSaveable { mutableStateOf<String?>(null) }
val showAllAlbums = openAllAlbums != null
BackHandler(enabled = showAllAlbums && state.partialAlbum == null && state.subAlbumItem == null) {
openAllAlbums = null
}
LaunchedEffect(state.pendingShufflePlay) {
state.pendingShufflePlay?.let { songs ->
onPlay(songs, -1) // -1 = gemischt starten, der Player mischt
vm.consumeShufflePlay()
}
}
// Concerts sheet
if (state.showEventsSheet) {
val artist = state.artist
if (artist != null) {
ConcertsSheet(
artistName = artist.name,
events = state.events,
onDismiss = vm::dismissEventsSheet,
eventsError = state.eventsError,
)
}
}
// Download sheet
state.downloadSheetItem?.let { item ->
ItunesDownloadSheet(
item = item,
onDownload = { type ->
vm.dismissDownloadSheet()
vm.downloadItem(item, type) { _, msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
},
onDismiss = vm::dismissDownloadSheet,
)
}
// Artist-pin picker
if (state.showPinPicker) {
ArtistPinSheet(
artistName = state.artist?.name ?: "",
pinned = state.pin,
searching = state.pinSearching,
candidates = state.pinCandidates,
onPick = vm::pinCandidate,
onPinLink = { link -> vm.pinByLink(link) { ok ->
if (!ok) Toast.makeText(context, "Deezer-Künstler nicht gefunden", Toast.LENGTH_SHORT).show()
} },
onClear = vm::clearPin,
onDismiss = vm::dismissPinPicker,
)
}
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
val artist = state.artist ?: run {
Box(Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
Text(state.error ?: "Artist not found", color = MaterialTheme.colorScheme.error)
}
return
}
// Partial album detail (incomplete album — downloaded + missing tracks split view)
val partialAlbum = state.partialAlbum
if (partialAlbum != null) {
PartialAlbumScreen(
partialAlbum = partialAlbum,
playerState = playerState,
coverUrl = coverUrl,
previewingId = state.previewingId,
onPlay = onPlay,
onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet,
onBack = vm::dismissPartialAlbum,
onShowContextMenu = onShowContextMenu,
)
return
}
// Sub-album detail (not-downloaded iTunes album)
val subAlbumItem = state.subAlbumItem
val subAlbumTracks = state.subAlbumTracks
if (subAlbumItem != null) {
if (subAlbumTracks == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
} else {
ItunesAlbumDetail(
title = state.subAlbumTitle,
albumItem = subAlbumItem,
tracks = subAlbumTracks,
previewingId = state.previewingId,
onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet,
onBack = vm::dismissSubAlbum,
)
}
return
}
// Album categories (computed once iTunes data arrives)
val cats = if (state.iAlbums.isNotEmpty()) categorizeAlbums(artist.album, state.iAlbums) else null
// Rebuild the overlay entries from the (surviving) categorized albums for whichever section is
// open. Derived — not stored — so it's correct after returning from an album.
val allAlbumEntries: List<AllAlbumEntry> = when (openAllAlbums) {
"In Library" -> cats?.downloaded?.map { (nd, _) ->
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
}.orEmpty()
"Unvollständig" -> cats?.partial?.map { (nd, ia, counts) ->
AllAlbumEntry(
name = nd.name, year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { vm.loadPartialAlbum(nd, ia) }, onDownload = { vm.showDownloadSheet(ia) },
)
}.orEmpty()
"Nicht heruntergeladen" -> cats?.notDownloaded?.map { ia ->
AllAlbumEntry(
name = ia.collectionName ?: "—", year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"), dimmed = true,
onClick = { vm.loadSubAlbum(ia) }, onDownload = { vm.showDownloadSheet(ia) },
)
}.orEmpty()
else -> emptyList()
}
if (showAllAlbums) {
AllAlbumsOverlay(
title = openAllAlbums ?: "",
entries = allAlbumEntries,
onBack = { openAllAlbums = null },
gridState = vm.allAlbumsGrid,
query = vm.allAlbumsQuery.value,
onQueryChange = { vm.allAlbumsQuery.value = it },
)
return
}
LazyColumn(state = vm.pageListState, modifier = Modifier.fillMaxSize()) {
// Back link
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(onClick = onBack)
.padding(start = 12.dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = NsOnSurface2, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Alle Künstler", fontFamily = Outfit, fontSize = 14.sp,
fontWeight = FontWeight.Medium, color = NsOnSurface2)
}
}
// Hero card
item {
// A pin's Deezer picture wins over the Last.fm image (fixes same-name artists whose
// Navidrome/Last.fm image is the wrong person).
val imgUrl = state.pinnedImage?.takeIf { it.isNotBlank() }
?: state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
?: coverUrl(artist.coverArt)
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(320.dp)
.clip(RoundedCornerShape(14.dp))
.background(NsSurface2),
) {
AsyncImage(
model = imgUrl,
contentDescription = artist.name,
contentScale = ContentScale.Crop,
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.
Surface(
onClick = { vm.openPinPicker() },
shape = RoundedCornerShape(999.dp),
color = Color(0x59000000),
modifier = Modifier.align(Alignment.TopEnd).padding(10.dp),
) {
Text(
"⚙",
fontSize = 14.sp,
color = if (state.pin != null) Color(0xFF7EB8FF) else Color.White.copy(0.72f),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
)
}
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.35f to Color(0x00000000),
1f to Color(0xD107070F),
)
)
)
Column(modifier = Modifier.align(Alignment.BottomStart).padding(20.dp)) {
Text(
artist.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp,
letterSpacing = (-0.5).sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
state.fanCount?.takeIf { it > 0 }?.let {
Text(
"${fmtFanCount(it)} Fans",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
color = Color.White.copy(0.58f), modifier = Modifier.padding(top = 4.dp),
)
}
}
}
Spacer(Modifier.height(16.dp))
}
// Action bar
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 8.dp),
) {
item {
ArtistActionPill(Icons.Default.PlayArrow, "Top Songs") {
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs, 0)
}
}
item {
ArtistActionPill(
icon = Icons.Default.Shuffle,
label = if (state.isShuffling) "Laden…" else "Shuffle",
onClick = { vm.shuffleAll() },
)
}
item {
val eventCount = state.events.size
val konzertLabel = if (eventCount > 0) "Konzerte ($eventCount)" else "Konzerte"
ArtistActionPill(Icons.Default.Event, konzertLabel) {
vm.openEventsSheet()
}
}
}
}
// Similar artists. When pinned, use Deezer "related" for the pinned artist — Last.fm's list
// is keyed to the (possibly merged/wrong) Navidrome artist, so it goes "weird".
val usePinnedRelated = state.pin != null && state.pinnedRelated.isNotEmpty()
val similarArtists = state.info?.similarArtist
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
?: emptyList()
if (usePinnedRelated) {
item {
SectionLabel("Similar Artists")
LazyRow(
contentPadding = PaddingValues(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(bottom = 20.dp),
) {
items(state.pinnedRelated, key = { it.artistId ?: 0L }) { rel ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(70.dp)
// Deezer-related has no Navidrome id → resolve by name on tap; navigate
// if the artist is in the library, else say so.
.clickable {
scope.launch {
val id = vm.libraryArtistId(rel.artistName)
if (id != null && onArtistClick != null) onArtistClick(id)
else Toast.makeText(context, "Nicht in deiner Bibliothek", Toast.LENGTH_SHORT).show()
}
},
) {
AsyncImage(
model = rel.artworkUrl100,
contentDescription = rel.artistName,
contentScale = ContentScale.Crop,
modifier = Modifier.size(60.dp).clip(CircleShape).background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(
rel.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
}
} else if (similarArtists.isNotEmpty()) {
item {
SectionLabel("Similar Artists")
LazyRow(
contentPadding = PaddingValues(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(bottom = 20.dp),
) {
items(similarArtists) { sim ->
val navigable = onArtistClick != null && sim.id.isNotBlank()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(70.dp)
.then(
if (navigable) Modifier.clickable { onArtistClick!!(sim.id) }
else Modifier
),
) {
AsyncImage(
model = coverUrl(sim.coverArt),
contentDescription = sim.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(
sim.name, fontFamily = Outfit, fontSize = 11.sp,
color = if (navigable) NsOnSurface else NsOnSurface2,
maxLines = 2, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
}
}
// Navidrome top songs
if (state.topSongs.isNotEmpty()) {
item { SectionLabel("Top Songs") }
itemsIndexed(state.topSongs, key = { idx, s -> "top_${idx}_${s.id}" }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(state.topSongs, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(8.dp)) }
}
// ── iTunes: Weltweit populär ──────────────────────────────────────────
if (state.iTracks.isNotEmpty()) {
item { ArtistSectionHeader("Weltweit populär") }
itemsIndexed(state.iTracks, key = { idx, t -> "itrack_${idx}_${t.trackId ?: 0L}" }) { _, track ->
ItunesArtistTrackRow(
track = track,
isPreviewing = state.previewingId == track.trackId,
onPreview = { vm.togglePreview(track) },
onDownload = { vm.showDownloadSheet(track) },
)
}
} else if (state.itunesLoading) {
item {
Row(
Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
)
Spacer(Modifier.width(10.dp))
Text("Deezer Daten werden geladen…", fontFamily = Outfit,
fontSize = 12.sp, color = NsOnSurface2)
}
}
}
// ── Album sections (categorized once iTunes data is ready) ────────────
if (cats != null) {
if (cats.downloaded.isNotEmpty()) {
item {
ArtistSectionHeader("In Library", cats.downloaded.size,
onShowAll = if (cats.downloaded.size > 4) ({ openAllAlbums = "In Library" }) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(cats.downloaded) { (nd, _) ->
AlbumThumb(
name = nd.name,
year = nd.year?.toString(),
artUrl = coverUrl(nd.coverArt),
onClick = { onAlbumClick(nd) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
if (cats.partial.isNotEmpty()) {
item {
ArtistSectionHeader("Unvollständig", cats.partial.size,
onShowAll = if (cats.partial.size > 4) ({ openAllAlbums = "Unvollständig" }) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(cats.partial) { (nd, ia, counts) ->
AlbumThumb(
name = nd.name,
year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb")
?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { vm.loadPartialAlbum(nd, ia) },
onDownload = { vm.showDownloadSheet(ia) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
if (cats.notDownloaded.isNotEmpty()) {
item {
ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size,
onShowAll = if (cats.notDownloaded.size > 4) ({ openAllAlbums = "Nicht heruntergeladen" }) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(cats.notDownloaded, key = { it.collectionId ?: 0L }) { ia ->
AlbumThumb(
name = ia.collectionName ?: "—",
year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
dimmed = true,
onClick = { vm.loadSubAlbum(ia) },
onDownload = { vm.showDownloadSheet(ia) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
} else if (!state.itunesLoading && artist.album.isNotEmpty()) {
// Fallback when no iTunes match found
item { SectionLabel("Alben") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(bottom = 20.dp),
) {
items(artist.album, key = { it.id }) { album ->
AlbumCard(
album = album,
coverUrl = coverUrl(album.coverArt),
onClick = { onAlbumClick(album) },
)
}
}
}
}
// Bio
state.info?.biography?.takeIf { it.isNotBlank() }?.let { bio ->
item {
SectionLabel("Über")
Box(
Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp)
.clip(RoundedCornerShape(10.dp))
.background(NsSurface)
.border(1.dp, NsBorder, RoundedCornerShape(10.dp))
.padding(16.dp),
) {
Text(
text = bio.replace(Regex("<[^>]+>"), "").trim(),
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
lineHeight = 21.sp,
)
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
}
@Composable
private fun ArtistActionPill(icon: ImageVector, label: String, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(NsSurface)
.border(1.dp, NsBorder, RoundedCornerShape(20.dp))
.clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 9.dp),
) {
Icon(icon, null, tint = NsOnSurface, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(label, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface)
}
}
@Composable
private fun SectionLabel(title: String) {
Text(
text = title,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 12.dp),
)
}
internal data class AllAlbumEntry(
val name: String,
val year: String?,
val artUrl: String?,
val badge: String? = null,
val dimmed: Boolean = false,
val onClick: () -> Unit,
val onDownload: (() -> Unit)? = null,
)
@Composable
internal fun AllAlbumsOverlay(
title: String,
entries: List<AllAlbumEntry>,
onBack: () -> Unit,
gridState: androidx.compose.foundation.lazy.grid.LazyGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState(),
query: String = "",
onQueryChange: (String) -> Unit = {},
) {
val filtered = remember(query, entries) {
if (query.isBlank()) entries
else entries.filter { it.name.contains(query, ignoreCase = true) }
}
Column(
Modifier
.fillMaxSize()
.background(NsBackground),
) {
// Top bar
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 12.dp, top = 12.dp, bottom = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface2)
}
Text(
title,
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 18.sp, color = NsOnSurface,
modifier = Modifier.weight(1f),
)
Text(
"${filtered.size}",
fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2.copy(0.6f),
)
}
// Search bar
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Suchen…", color = NsOnSurface2) },
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
trailingIcon = if (query.isNotEmpty()) { {
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Default.Clear, null, tint = NsOnSurface2)
}
} } else null,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
)
LazyVerticalGrid(
state = gridState,
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
items(filtered.size, key = { idx -> "${idx}_${filtered[idx].name}${filtered[idx].year ?: ""}" }) { idx ->
val entry = filtered[idx]
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
AlbumThumb(
name = entry.name,
year = entry.year,
artUrl = entry.artUrl,
badge = entry.badge,
dimmed = entry.dimmed,
onClick = entry.onClick,
onDownload = entry.onDownload,
)
}
}
}
}
}
// ─── Artist-pin picker ────────────────────────────────────────────────────────
// Pin a local (Navidrome) artist to a specific Deezer artist, fixing same-name collisions.
// A pasted Deezer link/id is the reliable route when /search/artist can't surface the artist.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ArtistPinSheet(
artistName: String,
pinned: com.novasonic.app.api.model.ArtistOverride?,
searching: Boolean,
candidates: List<com.novasonic.app.api.model.ItunesItem>,
onPick: (com.novasonic.app.api.model.ItunesItem) -> Unit,
onPinLink: (String) -> Unit,
onClear: () -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var link by remember { mutableStateOf("") }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = NsSurface) {
Column(
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 20.dp)
.padding(bottom = 16.dp),
) {
Text("Richtigen Künstler wählen", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 18.sp, color = NsOnSurface)
Text("Für „$artistName\"", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 2.dp, bottom = 14.dp))
if (pinned != null) {
OutlinedButton(
onClick = onClear,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
) {
Text("Pin entfernen (aktuell: ${pinned.name.ifBlank { "gepinnt" }})",
color = NsError, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
}
// Paste a Deezer link/id → pin directly (reliable when search misses the artist).
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = link, onValueChange = { link = it },
placeholder = { Text("Deezer-Link oder ID", fontFamily = Outfit, fontSize = 13.sp) },
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = Outfit, fontSize = 14.sp, color = NsOnSurface),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = { if (link.isNotBlank()) onPinLink(link) },
shape = RoundedCornerShape(10.dp),
) { Text("Pinnen", fontFamily = Outfit, fontWeight = FontWeight.Bold) }
}
Text("Nicht in der Liste? Künstler auf deezer.com öffnen und Link einfügen.",
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 6.dp, bottom = 14.dp))
when {
searching -> Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(28.dp))
}
candidates.isEmpty() -> Text("Keine Deezer-Künstler gefunden.", fontFamily = Outfit,
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 12.dp))
else -> Column(Modifier.fillMaxWidth()) {
candidates.forEach { c ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { onPick(c) }.padding(vertical = 7.dp),
) {
Box(Modifier.size(48.dp).clip(CircleShape).background(NsSurface2)) {
AsyncImage(model = c.artworkUrl100, contentDescription = null,
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(c.artistName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(c.fanCount?.let { "${fmtFanCount(it)} Fans" } ?: "Deezer",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
}
}
}
}
}
}
}
@@ -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)
}
}
}
}
@@ -0,0 +1,403 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.CompareResult
import com.novasonic.app.api.model.SharedArtist
import com.novasonic.app.api.model.SharedTrack
import com.novasonic.app.api.model.UniqueArtist
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.CompareViewModel
@Composable
fun CompareScreen(
coverUrl: (String?) -> String?,
onBack: () -> Unit,
onArtistClick: (String) -> Unit = {},
onAlbumClick: (String) -> Unit = {},
vm: CompareViewModel = viewModel(),
) {
val state by vm.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize().background(NsBackground)) {
// ── Top bar ──
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
}
Text(
"Musik-Match",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
color = NsOnSurface, modifier = Modifier.weight(1f),
)
// Show "wechseln" once a partner is picked so you can go back to the list.
if (state.partner != null) {
Text(
"↺ Wechseln",
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = NsOnSurface2,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { vm.clearPartner() }
.padding(horizontal = 8.dp, vertical = 6.dp),
)
}
}
when {
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
!state.configured -> CenterMsg(
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
)
state.error != null -> CenterMsg(state.error!!, NsError)
state.partner == null -> PartnerPicker(state.me, state.users, vm::choosePartner)
state.result != null -> CompareContent(state.result!!, coverUrl, onArtistClick, onAlbumClick)
else -> CenterBox { CircularProgressIndicator(color = NsAccent) }
}
}
}
@Composable
private fun PartnerPicker(
me: String,
users: List<com.novasonic.app.api.model.ScrobbleUser>,
onPick: (String) -> Unit,
) {
if (users.isEmpty()) {
CenterMsg("Noch niemand sonst hat Wiedergaben aufgezeichnet.\nSobald eine andere Person NovaSonic nutzt, könnt ihr euch hier vergleichen.")
return
}
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
) {
Text(
"Mit wem willst du dich vergleichen?",
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(vertical = 6.dp),
)
users.forEach { u ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.padding(vertical = 5.dp)
.clip(RoundedCornerShape(12.dp))
.background(NsSurface2)
.clickable { onPick(u.user) }
.padding(12.dp),
) {
Box(
Modifier.size(40.dp).clip(CircleShape).background(NsAccent),
contentAlignment = Alignment.Center,
) {
Text(
(u.user.firstOrNull()?.uppercase() ?: "?"),
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Color.Black,
)
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(u.user, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${u.plays} Plays", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Text("›", fontFamily = Outfit, fontSize = 20.sp, color = NsOnSurface2)
}
}
}
}
@Composable
private fun CompareContent(
d: CompareResult,
coverUrl: (String?) -> String?,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
) {
Scoreboard(d)
Spacer(Modifier.height(20.dp))
Totals(d)
Spacer(Modifier.height(22.dp))
SectionHeader("Gemeinsame Künstler")
if (d.sharedArtists.isEmpty()) {
EmptyLine("Keine gemeinsamen Künstler in diesem Zeitraum.")
} else {
val max = (d.sharedArtists.maxOfOrNull { maxOf(it.playsA, it.playsB) } ?: 1).coerceAtLeast(1)
d.sharedArtists.take(15).forEach { a ->
SharedArtistRow(a, max, a.artistId?.let { id -> { onArtistClick(id) } })
}
}
Spacer(Modifier.height(22.dp))
if (d.sharedTracks.isNotEmpty()) {
SectionHeader("Gemeinsame Songs")
d.sharedTracks.take(12).forEach { t ->
SharedTrackRow(t, coverUrl(t.coverArt ?: t.albumId ?: t.songId),
t.albumId?.let { id -> { onAlbumClick(id) } })
}
Spacer(Modifier.height(22.dp))
}
Streitpunkte(d, onArtistClick)
SectionHeader("Nur bei…")
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f))
UniqueColumn("Nur ${d.userB}", d.onlyB, onArtistClick, Modifier.weight(1f))
}
}
}
@Composable
private fun Streitpunkte(d: CompareResult, onArtistClick: (String) -> Unit) {
// Gemeinsame Kuenstler mit dem groessten Ungleichgewicht — das Gegenstueck zu "Gemeinsame
// Kuenstler", das nach Summe sortiert. Mindestmasse, damit ein einzelner Play gegen null nicht
// als Streit gilt. Identische Schwellen wie im Web, damit beide dasselbe zeigen.
val items = d.sharedArtists
.map { a ->
val summe = a.playsA + a.playsB
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
}
items.forEach { (a, summe, _) ->
val anteilA = a.playsA / summe.toFloat()
Column(
Modifier.fillMaxWidth()
.clickable { a.artistId?.let(onArtistClick) }
.padding(vertical = 6.dp)
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("${a.playsA}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
fontWeight = FontWeight.Bold)
Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp))
Text("${a.playsB}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(4.dp))
Row(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp))) {
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))
}
@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
private fun Totals(d: CompareResult) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TotalCard(d.userA, d.totalsA.plays, d.totalsA.artists, Modifier.weight(1f))
TotalCard(d.userB, d.totalsB.plays, d.totalsB.artists, Modifier.weight(1f))
}
}
@Composable
private fun TotalCard(name: String, plays: Int, artists: Int, modifier: Modifier) {
Column(modifier.clip(RoundedCornerShape(12.dp)).background(NsSurface2).padding(14.dp)) {
Text(name, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(6.dp))
Text("$plays Plays · $artists Künstler", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
}
@Composable
private fun SharedArtistRow(a: SharedArtist, max: Int, onClick: (() -> Unit)?) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(vertical = 7.dp),
) {
Text("${a.playsA}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
color = NsOnSurface2, textAlign = TextAlign.End, modifier = Modifier.width(30.dp))
// left (A) bar grows toward the centre
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.fillMaxWidth(a.playsA.toFloat() / max).height(6.dp)
.clip(RoundedCornerShape(3.dp)).background(NsAccent))
}
Text(
a.name ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
modifier = Modifier.weight(1.6f).padding(horizontal = 6.dp),
)
// right (B) bar grows from the centre
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth(a.playsB.toFloat() / max).height(6.dp)
.clip(RoundedCornerShape(3.dp)).background(NsAccent.copy(alpha = 0.55f)))
}
Text("${a.playsB}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
color = NsOnSurface2, textAlign = TextAlign.Start, modifier = Modifier.width(30.dp))
}
}
@Composable
private fun SharedTrackRow(t: SharedTrack, cover: String?, onClick: (() -> Unit)?) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(vertical = 5.dp),
) {
Box(Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2)) {
AsyncImage(model = cover, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize())
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(t.title ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!t.artist.isNullOrBlank()) {
Text(t.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Text("${t.playsA} · ${t.playsB}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2)
}
}
@Composable
private fun UniqueColumn(title: String, items: List<UniqueArtist>, onArtistClick: (String) -> Unit, modifier: Modifier) {
Column(modifier) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 6.dp))
if (items.isEmpty()) {
Text("—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 4.dp))
} else {
items.take(6).forEach { a ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.then(if (a.artistId != null) Modifier.clickable { onArtistClick(a.artistId) } else Modifier)
.padding(vertical = 4.dp),
) {
Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Text("${a.plays}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 12.sp,
color = NsOnSurface2, modifier = Modifier.padding(start = 6.dp))
}
}
}
}
}
@Composable
private fun SectionHeader(t: String) {
Text(t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp))
}
@Composable
private fun EmptyLine(msg: String) {
Text(msg, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 8.dp))
}
@Composable
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
}
@Composable
private fun CenterMsg(msg: String, color: Color = NsOnSurface2) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color, textAlign = TextAlign.Center)
}
}
@@ -0,0 +1,727 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
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 android.widget.Toast
import androidx.compose.ui.platform.LocalContext
import com.novasonic.app.api.model.*
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.ItunesDownloadSheet
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ExploreTab
import com.novasonic.app.viewmodel.ExploreViewModel
@Composable
fun ExploreScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
onOverlayBackChange: ((() -> Unit)?) -> Unit = {},
onOpenImporter: () -> Unit = {},
) {
val vm: ExploreViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val focusManager = LocalFocusManager.current
val context = LocalContext.current
// Overlay back is handled at the app root (see AppNavigation.exploreOverlayBack): a BackHandler
// inside this NavHost destination loses back priority to the NavHost, which pops Erkunden → Home.
// Report the current overlay's close action up; clear it when this screen leaves composition
// (e.g. drilling into a real album route) so that screen's own back works normally.
val overlayKind = when {
state.artistDetail != null && state.partialAlbum != null -> 5 // artist → partial album
state.artistDetail != null && state.albumTracks != null -> 4 // artist → iTunes album
state.artistDetail != null && state.artistAllAlbums != null -> 3 // artist → all-albums grid
state.artistDetail != null -> 2 // iTunes artist
state.albumTracks != null -> 1 // album straight from search
else -> 0
}
DisposableEffect(overlayKind) {
onOverlayBackChange(
when (overlayKind) {
5 -> vm::dismissPartialAlbum
4 -> vm::dismissAlbum
3 -> vm::closeArtistAllAlbums
2 -> vm::closeArtistDetail
1 -> vm::dismissAlbum
else -> null
}
)
onDispose { onOverlayBackChange(null) }
}
// Download sheet
state.downloadSheetItem?.let { item ->
ItunesDownloadSheet(
item = item,
onDownload = { type ->
vm.dismissDownloadSheet()
vm.downloadItem(item, type) { _, msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
},
onDismiss = vm::dismissDownloadSheet,
)
}
// ── iTunes artist detail — full-screen overlay above tabs ────────────────
state.artistDetail?.let { artistState ->
val partialAlbum = state.partialAlbum
val albumTracks = state.albumTracks
val albumItem = state.albumItem
when {
partialAlbum != null -> {
PartialAlbumScreen(
partialAlbum = partialAlbum,
playerState = playerState,
coverUrl = coverUrl,
previewingId = state.previewingId,
onPlay = onSongPlay,
onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet,
onBack = vm::dismissPartialAlbum,
onShowContextMenu = onShowContextMenu,
)
}
albumTracks != null && albumItem != null -> {
ItunesAlbumDetail(
title = state.albumTitle,
albumItem = albumItem,
tracks = albumTracks,
previewingId = state.previewingId,
onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet,
onArtistClick = vm::openArtistOfAlbum,
onBack = vm::dismissAlbum,
)
}
else -> {
ItunesArtistScreen(
artistState = artistState,
playerState = playerState,
coverUrl = coverUrl,
onBack = vm::closeArtistDetail,
onAlbumClick = onAlbumClick,
onPartialAlbumOpen = vm::loadPartialAlbum,
onSongPlay = onSongPlay,
onDownload = vm::showDownloadSheet,
onItunesAlbumOpen = vm::loadAlbum,
onItunesTrackPreview = vm::togglePreview,
previewingId = state.previewingId,
allAlbumsOpen = state.artistAllAlbums,
onOpenAllAlbums = vm::openArtistAllAlbums,
onCloseAllAlbums = vm::closeArtistAllAlbums,
allAlbumsGrid = vm.allAlbumsGrid,
allAlbumsQuery = vm.allAlbumsQuery.value,
onAllAlbumsQueryChange = { vm.allAlbumsQuery.value = it },
)
}
}
return
}
// Swipe left/right to switch between the Bibliothek and Erkunden sub-tabs, kept in sync with the
// TabRow: a chip tap animates the pager; a settled swipe updates the selected tab.
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { ExploreTab.values().size }
LaunchedEffect(state.tab) {
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
}
LaunchedEffect(pagerState.settledPage) {
val t = ExploreTab.values()[pagerState.settledPage]
if (t != state.tab) { vm.selectTab(t); vm.stopPreview() }
}
Column(Modifier.fillMaxSize()) {
// ── Tabs ─────────────────────────────────────────────────────────────
// Drive the indicator off the pager's current page so it moves WITH the swipe (at the
// half-way point), not only once the swipe settles — that lag read as "not smooth".
TabRow(
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.background,
contentColor = NsAccent,
) {
Tab(
selected = pagerState.currentPage == ExploreTab.LIBRARY.ordinal,
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
text = { Text("Bibliothek", maxLines = 1) },
selectedContentColor = NsAccent,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
Tab(
selected = pagerState.currentPage == ExploreTab.GLOBAL.ordinal,
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
text = { Text("Erkunden", maxLines = 1) },
selectedContentColor = NsAccent,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Search bar ───────────────────────────────────────────────────────
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = state.query,
onValueChange = vm::onQueryChange,
placeholder = {
Text(
if (state.tab == ExploreTab.LIBRARY) "Suche in Bibliothek…" else "Global suchen…",
color = NsOnSurface2,
)
},
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
trailingIcon = if (state.query.isNotEmpty()) { {
IconButton(onClick = { vm.onQueryChange(""); focusManager.clearFocus() }) {
Icon(Icons.Default.Close, "Löschen", tint = NsOnSurface2)
}
} } else null,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp)
.focusRequester(focusRequester),
)
// ── Importer entry (matches PWA "Importer" under Entdecken) ───────────
if (state.tab == ExploreTab.GLOBAL) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(12.dp))
.background(NsSurface)
.clickable(onClick = onOpenImporter)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Icon(Icons.Default.Download, null, tint = NsAccent, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("Importer", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface)
Text("Spotify · YouTube · SoundCloud Link laden",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Icon(Icons.Default.ChevronRight, null, tint = NsOnSurface2)
}
}
// ── Content (swipeable) ────────────────────────────────────────────────
HorizontalPager(
state = pagerState,
// Pre-compose the neighbour page so the swipe doesn't hitch while its (heavy) LazyColumn
// builds on first drag.
beyondViewportPageCount = 1,
modifier = Modifier.weight(1f),
) { page ->
// fillMaxSize so short content (e.g. the Top-Alben charts row) sits at the top, not
// vertically centered in the page.
Box(Modifier.fillMaxSize()) {
when (page) {
ExploreTab.LIBRARY.ordinal -> LibrarySearchContent(
query = state.query,
coverUrl = coverUrl,
playerState = playerState,
onSongPlay = onSongPlay,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onShowContextMenu = onShowContextMenu,
)
else -> GlobalExploreContent(
state = state,
vm = vm,
coverUrl = coverUrl,
playerState = playerState,
onAlbumClick = onAlbumClick,
onSongPlay = onSongPlay,
onDownload = vm::showDownloadSheet,
)
}
}
}
}
}
// ── Library (local Navidrome) search ─────────────────────────────────────────
@Composable
private fun LibrarySearchContent(
query: String,
coverUrl: (String?) -> String?,
playerState: PlayerState,
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
) {
// Reuse SearchScreen's ViewModel
val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(query) { vm.onSearch(query) }
if (query.isBlank()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Search, null, tint = NsOnSurface3, modifier = Modifier.size(48.dp))
Spacer(Modifier.height(12.dp))
Text("Bibliothek durchsuchen", color = NsOnSurface2, fontFamily = Outfit)
}
}
return
}
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
LazyColumn(state = vm.listState) {
val artists = state.searchResult?.artist ?: emptyList()
val albums = state.searchResult?.album ?: emptyList()
val songs = state.searchResult?.song ?: emptyList()
if (artists.isEmpty() && albums.isEmpty() && songs.isEmpty()) {
item {
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Ergebnisse", color = NsOnSurface2)
}
}
return@LazyColumn
}
if (artists.isNotEmpty()) {
item { SectionHeader("Künstler") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(artists, key = { it.id }) { a ->
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image
// is the wrong person). Otherwise: search3 usually omits coverArt → fall back
// to the artist id, which Navidrome's getCoverArt resolves to the artist image.
val pinImg = state.overrides[a.id]?.image?.takeIf { it.isNotBlank() }
ArtistCard(name = a.name, imageUrl = pinImg ?: coverUrl(a.coverArt ?: a.id)) { onArtistClick(a) }
}
}
Spacer(Modifier.height(8.dp))
}
}
if (albums.isNotEmpty()) {
item { SectionHeader("Alben") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(albums, key = { it.id }) { album ->
com.novasonic.app.ui.component.AlbumCard(
album = album, coverUrl = coverUrl(album.coverArt),
onClick = { onAlbumClick(album) },
)
}
}
Spacer(Modifier.height(8.dp))
}
}
if (songs.isNotEmpty()) {
item { SectionHeader("Titel") }
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
// Play only the tapped song; Radio mode (if on) fills the rest.
onPlay = { onSongPlay(listOf(song), 0) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}
// ── Global (iTunes) explore ───────────────────────────────────────────────────
@Composable
private fun GlobalExploreContent(
state: com.novasonic.app.viewmodel.ExploreUiState,
vm: ExploreViewModel,
coverUrl: (String?) -> String? = { null },
playerState: PlayerState = com.novasonic.app.player.PlayerState(),
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit = {},
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit = { _, _ -> },
onDownload: (ItunesItem) -> Unit = {},
) {
// Album drill-down view
if (state.albumTracks != null) {
ItunesAlbumDetail(
title = state.albumTitle,
albumItem = state.albumItem,
tracks = state.albumTracks,
previewingId = state.previewingId,
onPreview = vm::togglePreview,
onDownload = onDownload,
onArtistClick = vm::openArtistOfAlbum,
onBack = vm::dismissAlbum,
)
return
}
if (state.isSearching) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
if (state.query.isBlank()) {
// Show iTunes charts
if (state.chartsLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
LazyColumn {
item { SectionHeader("Top-Alben") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
items(state.charts) { entry ->
ItunesChartCard(entry = entry, onClick = {
val id = entry.id?.attributes?.imId?.toLongOrNull()
if (id != null) vm.loadAlbum(
ItunesItem(
wrapperType = "collection",
collectionId = id,
collectionName = entry.name?.label,
artistName = entry.artist?.label,
artworkUrl100 = entry.artworkHd(),
)
)
})
}
}
Spacer(Modifier.height(16.dp))
}
}
return
}
// Search results
val hasResults = state.artists.isNotEmpty() || state.albums.isNotEmpty() || state.tracks.isNotEmpty()
if (!hasResults && state.error == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Ergebnisse", color = NsOnSurface2, fontFamily = Outfit)
}
return
}
LazyColumn(state = vm.searchListState) {
if (state.error != null) {
item { Text(state.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)) }
}
if (state.artists.isNotEmpty()) {
item { SectionHeader("Künstler") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(state.artists) { a ->
ArtistCard(
name = a.artistName ?: "",
imageUrl = a.artworkUrl100?.replace("100x100bb", "300x300bb"),
subtitle = a.fanCount?.takeIf { it > 0 }?.let { "${fmtFanCount(it)} Fans" },
) { vm.openArtistDetail(a) }
}
}
Spacer(Modifier.height(8.dp))
}
}
if (state.albums.isNotEmpty()) {
item { SectionHeader("Alben") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
items(state.albums) { album ->
ItunesAlbumCard(album = album, onClick = { vm.loadAlbum(album) })
}
}
Spacer(Modifier.height(8.dp))
}
}
if (state.tracks.isNotEmpty()) {
item { SectionHeader("Titel") }
itemsIndexed(state.tracks, key = { _, t -> t.trackId ?: 0 }) { _, track ->
ItunesTrackRow(
track = track,
isPreviewing = state.previewingId == track.trackId,
onPreview = { vm.togglePreview(track) },
onDownload = { onDownload(track) },
)
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}
@Composable
internal fun ItunesAlbumDetail(
title: String,
albumItem: ItunesItem?,
tracks: List<ItunesItem>,
previewingId: Long?,
onPreview: (ItunesItem) -> Unit,
onDownload: (ItunesItem) -> Unit = {},
onArtistClick: ((ItunesItem) -> Unit)? = null,
onBack: () -> Unit,
) {
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, top = 4.dp, bottom = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = MaterialTheme.colorScheme.onSurface)
}
// Anklickbar nur, wenn Deezer eine Künstler-id mitgeliefert hat — /artist/{id}/albums
// lässt das artist-Objekt weg, dort bleibt der Name einfach Text.
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) {
IconButton(onClick = { onDownload(albumItem) }) {
Icon(Icons.Default.Download, "Album herunterladen",
tint = NsAccent, modifier = Modifier.size(22.dp))
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
LazyColumn {
itemsIndexed(tracks, key = { _, t -> t.trackId ?: 0 }) { _, track ->
ItunesTrackRow(
track = track,
isPreviewing = previewingId == track.trackId,
onPreview = { onPreview(track) },
onDownload = { onDownload(track) },
)
}
item { Spacer(Modifier.height(16.dp)) }
}
}
}
// ── Small shared components ───────────────────────────────────────────────────
@Composable
private fun ItunesChartCard(entry: ItunesRssEntry, onClick: () -> Unit) {
Column(
modifier = Modifier.width(130.dp).clickable(onClick = onClick),
) {
AsyncImage(
model = entry.artworkHd(), contentDescription = entry.name?.label,
contentScale = ContentScale.Crop,
modifier = Modifier.size(130.dp).clip(RoundedCornerShape(10.dp))
.background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(entry.name?.label ?: "", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface)
Text(entry.artist?.label ?: "", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun ItunesAlbumCard(album: ItunesItem, onClick: () -> Unit) {
Column(
modifier = Modifier.width(130.dp).clickable(onClick = onClick),
) {
val art = album.artworkUrl100?.replace("100x100bb", "300x300bb")
AsyncImage(
model = art, contentDescription = album.collectionName,
contentScale = ContentScale.Crop,
modifier = Modifier.size(130.dp).clip(RoundedCornerShape(10.dp))
.background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(album.collectionName ?: "", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface)
Text(album.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun ItunesTrackRow(
track: ItunesItem,
isPreviewing: Boolean,
onPreview: () -> Unit,
onDownload: (() -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.clickable(onClick = onPreview)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
val art = track.artworkUrl100?.replace("100x100bb", "60x60bb")
AsyncImage(
model = art, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(4.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(track.trackName ?: "", style = MaterialTheme.typography.bodyMedium,
color = if (isPreviewing) NsAccent else MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(track.artistName, track.collectionName).joinToString(" · "),
style = MaterialTheme.typography.labelSmall,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
// Preview button
if (track.previewUrl != null) {
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
Icon(
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
if (isPreviewing) "Stop" else "Preview",
tint = if (isPreviewing) NsAccent else NsOnSurface2,
modifier = Modifier.size(22.dp),
)
}
}
// Download button
if (onDownload != null) {
IconButton(onClick = onDownload, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Download, "Herunterladen",
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
}
}
}
}
@Composable
private fun ArtistCard(name: String, imageUrl: String?, subtitle: String? = null, onClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(80.dp)
.clickable(onClick = onClick),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(72.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(NsSurface2),
) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
Icons.Default.Person, contentDescription = null,
tint = NsOnSurface2, modifier = Modifier.size(32.dp),
)
}
}
Spacer(Modifier.height(6.dp))
Text(
name, fontFamily = Outfit, fontSize = 12.sp, fontWeight = FontWeight.Medium,
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
if (subtitle != null) {
Text(
subtitle, fontFamily = Outfit, fontSize = 10.sp, fontWeight = FontWeight.Medium,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(start = 20.dp, top = 12.dp, bottom = 10.dp))
}
@@ -0,0 +1,96 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.novasonic.app.api.model.Genre
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.GenreViewModel
@Composable
fun GenreListScreen(
onGenreClick: (Genre) -> Unit,
onGenrePlay: (Genre) -> Unit,
onBack: () -> Unit,
) {
val vm: GenreViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface2)
}
Text(
"Alle Genres",
fontFamily = Outfit,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = NsOnSurface,
)
}
when {
state.isLoading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
}
state.error != null -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize().padding(32.dp),
) {
Text(state.error!!, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.load() },
colors = ButtonDefaults.buttonColors(containerColor = NsAccent)) {
Text("Retry")
}
}
}
else -> {
LazyColumn(
contentPadding = PaddingValues(bottom = 24.dp),
modifier = Modifier.fillMaxSize(),
) {
val chunked = state.genres.chunked(2)
chunked.forEachIndexed { rowIdx, rowGenres ->
item(key = "row_$rowIdx") {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
) {
rowGenres.forEachIndexed { colIdx, genre ->
val colorIdx = (rowIdx * 2 + colIdx) % genreColors.size
GenreChip(
genre = genre,
bgColor = genreColors[colorIdx],
onClick = { onGenreClick(genre) },
onPlay = { onGenrePlay(genre) },
modifier = Modifier.weight(1f),
)
}
if (rowGenres.size == 1) Spacer(Modifier.weight(1f))
}
}
}
}
}
}
}
}
@@ -0,0 +1,108 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.ui.component.Rollbalken
@Composable
fun GenreSongsScreen(
genreName: String,
coverUrl: (String?) -> String?,
playerState: PlayerState,
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onBack: () -> Unit,
) {
val context = LocalContext.current
val repo = context.app.repository
val decodedName = remember(genreName) {
java.net.URLDecoder.decode(genreName, "UTF-8")
}
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(decodedName) {
try {
songs = repo.getSongsByGenre(decodedName)
loading = false
} catch (e: Exception) {
error = e.message
loading = false
}
}
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 8.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back",
tint = MaterialTheme.colorScheme.onSurface)
}
Text(
decodedName,
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 20.sp,
color = MaterialTheme.colorScheme.onSurface,
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
when {
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
error != null -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(error!!, color = MaterialTheme.colorScheme.error)
}
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text("Keine Songs gefunden", color = NsOnSurface2)
}
else -> {
val listState = rememberLazyListState()
Box(Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), state = listState) {
itemsIndexed(songs, 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(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,
)
}
}
}
}
}
@@ -0,0 +1,426 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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.Album
import com.novasonic.app.api.model.Genre
import com.novasonic.app.data.NotificationStore
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.HomeViewModel
import androidx.compose.ui.res.painterResource
import com.novasonic.app.R
private val greetingsWithName = listOf(
"Ready for some beats,",
"What's on the playlist,",
"Good vibes only,",
"Let the music play,",
"Your soundtrack awaits,",
"Turn it up,",
"Time to tune in,",
"Hit play and chill,",
"Music is the answer,",
"Welcome back,",
)
private val greetingsNoName = listOf(
"Ready for some beats?",
"What's on the playlist today?",
"Good vibes only",
"Let the music play",
"Your soundtrack awaits",
"Turn it up",
"Time to tune in",
"Hit play and chill",
"Music is the answer",
"What are we listening to?",
"Time to escape into music",
"Pick something great",
)
internal val genreColors = listOf(
Color(0xFF1a1a3e), Color(0xFF1e2a1a), Color(0xFF2a1a1a),
Color(0xFF1a2a2a), Color(0xFF2a1e1a), Color(0xFF1e1a2a),
Color(0xFF2a2a1a), Color(0xFF1a2a1e), Color(0xFF201520),
Color(0xFF152020), Color(0xFF201515), Color(0xFF151520),
)
@Composable
fun HomeScreen(
coverUrl: (Album) -> String?,
onAlbumClick: (Album) -> Unit,
onAlbumPlay: (Album) -> Unit,
onGenreClick: (Genre) -> Unit,
onGenrePlay: ((Genre) -> Unit)? = null,
onShowAllGenres: (() -> Unit)? = null,
onBellClick: () -> Unit = {},
onSettingsClick: () -> Unit = {},
refreshKey: Int = 0,
) {
val vm: HomeViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val notifs by NotificationStore.items.collectAsStateWithLifecycle()
val unreadCount = notifs.count { !it.read }
LaunchedEffect(refreshKey) { if (refreshKey > 0) vm.load() }
val greeting = remember { greetingsNoName.random() }
val greetingWithName = remember { greetingsWithName.random() }
LazyColumn(
contentPadding = PaddingValues(bottom = 24.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
val name = state.username.replaceFirstChar { it.uppercaseChar() }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 24.dp, bottom = 20.dp),
) {
Text(
text = if (name.isBlank()) greeting else "$greetingWithName $name",
fontFamily = Outfit,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = NsOnSurface,
modifier = Modifier.weight(1f),
)
BadgedBox(
badge = {
if (unreadCount > 0) Badge(containerColor = NsAccent) {
Text(
if (unreadCount > 9) "9+" else "$unreadCount",
fontSize = 9.sp, color = NsBackground,
)
}
}
) {
IconButton(onClick = onBellClick) {
Icon(painterResource(R.drawable.ic_honigtopf_brief), "Benachrichtigungen",
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
}
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, "Einstellungen", tint = NsOnSurface2)
}
}
}
if (state.isLoading) {
item {
Box(Modifier.fillMaxWidth().height(170.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
}
return@LazyColumn
}
if (state.error != null) {
item {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(32.dp),
) {
Text(state.error!!, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.load() },
colors = ButtonDefaults.buttonColors(containerColor = NsAccent)) {
Text("Retry")
}
}
}
return@LazyColumn
}
// Hero card — pool = frequent(5) ∪ recentlyPlayed(5), label from web
state.heroAlbum?.let { hero ->
item {
HeroCard(
album = hero,
label = state.heroLabel,
coverUrl = coverUrl(hero),
onPlay = { onAlbumPlay(hero) },
onClick = { onAlbumClick(hero) },
)
Spacer(Modifier.height(28.dp))
}
}
if (state.genres.isNotEmpty()) {
item { SectionHeader("Genres") }
item {
GenreGrid(genres = state.genres.take(6), onGenreClick = onGenreClick, onGenrePlay = onGenrePlay)
if (onShowAllGenres != null && state.genres.size > 6) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp)
.clip(RoundedCornerShape(10.dp))
.background(NsSurface)
.clickable(onClick = onShowAllGenres)
.padding(vertical = 12.dp),
) {
Text(
"Alle anzeigen · ${state.genres.size}",
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = NsAccent,
)
}
}
Spacer(Modifier.height(20.dp))
}
}
if (state.recentAlbums.isNotEmpty()) {
item { SectionHeader("Zuletzt hinzugefügt") }
item {
AlbumRow(albums = state.recentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(28.dp))
}
}
if (state.recentlyPlayedAlbums.isNotEmpty()) {
item { SectionHeader("Zuletzt gehört") }
item {
AlbumRow(albums = state.recentlyPlayedAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(28.dp))
}
}
if (state.randomAlbums.isNotEmpty()) {
item { SectionHeader("Entdecken") }
item {
AlbumRow(albums = state.randomAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(28.dp))
}
}
if (state.frequentAlbums.isNotEmpty()) {
item { SectionHeader("Meistgespielt") }
item {
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(16.dp))
}
}
}
}
// PWA: .home-hero { height:170px; border-radius:14px }
// background = blur(8px) brightness(.45) + horizontal gradient overlay
// cover = 100x100, border-radius:9px
@Composable
private fun HeroCard(album: Album, label: String, coverUrl: String?, onPlay: () -> Unit, onClick: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(170.dp)
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick),
) {
AsyncImage(
model = coverUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().blur(16.dp),
)
Box(
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(Color(0xE107070F), Color(0x4D07070F)))
)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
) {
AsyncImage(
model = coverUrl, contentDescription = album.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(9.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
Column(Modifier.weight(1f)) {
Text(
text = label,
fontFamily = Outfit,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.sp,
color = NsAccent,
)
Spacer(Modifier.height(4.dp))
Text(
text = album.name,
fontFamily = Outfit,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
letterSpacing = (-0.3).sp,
color = NsOnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Text(
text = album.artist!!,
fontFamily = Outfit,
fontSize = 13.sp,
color = NsOnSurface2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.width(12.dp))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(42.dp)
.clip(RoundedCornerShape(21.dp))
.background(NsAccent)
.clickable(onClick = onPlay),
) {
Icon(Icons.Default.PlayArrow, "Play",
tint = NsBackground, modifier = Modifier.size(22.dp))
}
}
}
}
}
@Composable
internal fun GenreGrid(
genres: List<Genre>,
onGenreClick: (Genre) -> Unit,
onGenrePlay: ((Genre) -> Unit)?,
) {
val rows = genres.chunked(2)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
rows.forEach { rowGenres ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rowGenres.forEachIndexed { _, genre ->
val colorIdx = genres.indexOf(genre) % genreColors.size
GenreChip(
genre = genre,
bgColor = genreColors[colorIdx],
onClick = { onGenreClick(genre) },
onPlay = onGenrePlay?.let { cb -> { cb(genre) } },
modifier = Modifier.weight(1f),
)
}
if (rowGenres.size == 1) Spacer(Modifier.weight(1f))
}
}
}
}
@Composable
internal fun GenreChip(
genre: Genre,
bgColor: Color,
onClick: () -> Unit,
onPlay: (() -> Unit)?,
modifier: Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.height(52.dp)
.clip(RoundedCornerShape(10.dp))
.background(bgColor)
.clickable(onClick = onClick)
.padding(start = 14.dp, end = if (onPlay != null) 8.dp else 14.dp),
) {
Box(
Modifier.width(4.dp).height(24.dp)
.clip(RoundedCornerShape(2.dp))
.background(NsAccent)
)
Spacer(Modifier.width(10.dp))
Text(
text = genre.value,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = NsOnSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (onPlay != null) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(16.dp))
.background(NsAccent.copy(alpha = 0.18f))
.clickable(onClick = onPlay),
) {
Icon(
Icons.Default.PlayArrow, "Play",
tint = NsAccent,
modifier = Modifier.size(18.dp),
)
}
}
}
}
@Composable
private fun AlbumRow(albums: List<Album>, coverUrl: (Album) -> String?, onAlbumClick: (Album) -> Unit) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(albums, key = { it.id }) { album ->
AlbumCard(album = album, coverUrl = coverUrl(album), onClick = { onAlbumClick(album) })
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title.uppercase(),
fontFamily = Outfit,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.5.sp,
color = NsOnSurface2,
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 12.dp),
)
}
@@ -0,0 +1,337 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.Stop
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.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
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.ImportTrack
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ImporterViewModel
// Brand colors for the platform badge
private val Spotify = Color(0xFF1DB954)
private val SoundCloud = Color(0xFFFF5500)
private val YouTube = Color(0xFFFF0000)
@Composable
fun ImporterScreen(onBack: () -> Unit) {
val vm: ImporterViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val focus = LocalFocusManager.current
val brand = when (state.platform) {
"spotify" -> Spotify
"soundcloud" -> SoundCloud
"youtube" -> YouTube
else -> NsAccent
}
val brandLabel = when (state.platform) {
"spotify" -> "Spotify"
"soundcloud" -> "SoundCloud"
"youtube" -> "YouTube"
else -> "URL"
}
Column(Modifier.fillMaxSize()) {
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface)
}
Text("Importer", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 20.sp, color = NsOnSurface)
}
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(
"Spotify-, YouTube-, YouTube-Music- oder SoundCloud-Link einfügen, " +
"um Tracks zu laden.",
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, lineHeight = 19.sp,
)
}
// Link input + badge
item {
OutlinedTextField(
value = state.link,
onValueChange = vm::onLink,
placeholder = { Text("https://open.spotify.com/…", color = NsOnSurface2) },
singleLine = true,
trailingIcon = {
Box(
Modifier.padding(end = 8.dp)
.clip(RoundedCornerShape(8.dp))
.background(brand.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(brandLabel, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 10.sp, color = brand)
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { focus.clearFocus(); vm.resolve() }),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = brand,
unfocusedBorderColor = NsOnSurface3,
cursorColor = brand,
focusedContainerColor = NsSurface,
unfocusedContainerColor = NsSurface,
focusedTextColor = NsOnSurface,
unfocusedTextColor = NsOnSurface,
),
modifier = Modifier.fillMaxWidth(),
)
}
item {
Button(
onClick = { focus.clearFocus(); vm.resolve() },
enabled = !state.isResolving && state.link.isNotBlank(),
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = brand, contentColor = Color.Black),
) {
if (state.isResolving) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.Black)
} else {
Text("Auflösen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
}
}
}
state.error?.let { err ->
item {
Box(
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(NsSurface)
.border(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
.padding(14.dp),
) {
Text(err, fontFamily = Outfit, fontSize = 13.sp,
color = MaterialTheme.colorScheme.error, lineHeight = 19.sp)
}
}
}
// Resolved preview header
state.resolved?.let { r ->
item {
Row(
modifier = Modifier.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(NsSurface)
.border(1.dp, NsBorder, RoundedCornerShape(14.dp))
.padding(16.dp),
) {
AsyncImage(
model = r.artworkUrl?.replace("100x100", "300x300"),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(72.dp).clip(RoundedCornerShape(8.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text((r.type ?: "").uppercase(), fontFamily = Outfit,
fontWeight = FontWeight.Bold, fontSize = 10.sp,
letterSpacing = 1.5.sp, color = brand)
Spacer(Modifier.height(3.dp))
Text(r.name ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 17.sp, color = NsOnSurface,
maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(r.artist, "${r.tracks.size} Tracks").joinToString(" · "),
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
// 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
item {
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
JobProgress(
status = state.jobStatus,
message = state.jobMessage,
completed = state.jobCompleted,
total = state.jobTotal,
brand = brand,
)
} else {
Button(
onClick = vm::downloadAll,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = brand, contentColor = Color.Black),
) {
Icon(Icons.Default.Download, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Alle ${r.tracks.size} Tracks laden",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
}
}
}
itemsIndexed(r.tracks) { idx, track ->
ImportTrackRow(
num = idx + 1,
track = track,
isPreviewing = state.previewingId != null && state.previewingId == vm.previewId(track),
onPreview = { vm.togglePreview(track) },
)
}
}
}
}
}
@Composable
private fun JobProgress(
status: String?, message: String?, completed: Int, total: Int, brand: Color,
) {
Column(
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(NsSurface)
.border(1.dp, NsBorder, RoundedCornerShape(12.dp))
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
when (status) {
"done" -> Icon(Icons.Default.Check, null, tint = brand, modifier = Modifier.size(20.dp))
"error" -> Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp))
else -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = brand)
}
Spacer(Modifier.width(10.dp))
Text(
when (status) {
"done" -> "Fertig — in Navidrome scannen lassen"
"error" -> "Fehler beim Download"
"downloading" -> "Lädt… ${if (total > 0) "$completed/$total" else ""}"
else -> "In Warteschlange…"
},
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = if (status == "error") MaterialTheme.colorScheme.error else NsOnSurface,
)
}
if (total > 0 && status != "done") {
Spacer(Modifier.height(10.dp))
LinearProgressIndicator(
progress = { if (total > 0) completed.toFloat() / total else 0f },
modifier = Modifier.fillMaxWidth(),
color = brand,
trackColor = NsSurface2,
)
}
message?.takeIf { it.isNotBlank() }?.let {
Spacer(Modifier.height(8.dp))
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
}
}
@Composable
private fun ImportTrackRow(
num: Int,
track: ImportTrack,
isPreviewing: Boolean,
onPreview: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
AsyncImage(
model = track.artworkUrl100,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
fontSize = 14.sp, color = if (isPreviewing) NsAccent else NsOnSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!track.artistName.isNullOrBlank()) {
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
// 30s preview — only when the source provided a clip
if (track.previewUrl != null) {
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
Icon(
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
if (isPreviewing) "Stop" else "Preview",
tint = if (isPreviewing) NsAccent else NsOnSurface2,
)
}
}
}
}
@@ -0,0 +1,484 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
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 coil.compose.AsyncImage
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.Song
import com.novasonic.app.api.model.fmtFanCount
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ItunesArtistState
private fun normAlbum(s: String?): String =
// Keep Unicode letters/numbers (\p{L}\p{N}) — stripping to a-z0-9 emptied every Japanese/CJK
// title, so all of an artist's JP singles collapsed into one and "matched" each other.
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}\\s]"), " ").replace(Regex("\\s+"), " ").trim()
data class ArtistAlbumCategories(
val downloaded: List<Pair<Album, ItunesItem?>>,
val partial: List<Triple<Album, ItunesItem, Pair<Int, Int>>>,
val notDownloaded: List<ItunesItem>,
)
fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAlbumCategories {
// Deduplicate iTunes albums by collectionId (iTunes can return the same album multiple times)
val uniqueIAlbums = iAlbums.distinctBy { it.collectionId ?: it.collectionName }
val downloaded = mutableListOf<Pair<Album, ItunesItem?>>()
val partial = mutableListOf<Triple<Album, ItunesItem, Pair<Int, Int>>>()
val notDl = mutableListOf<ItunesItem>()
val usedNdIds = mutableSetOf<String>()
for (ia in uniqueIAlbums) {
val iName = normAlbum(ia.collectionName)
// Deezer's /artist/{id}/albums omits nb_tracks, so trackCount is usually unknown (0) here.
// Only mark "Unvollständig" when we KNOW the album has more tracks than we own — an unknown
// total counts as complete, never a false "1 / 0".
val iCount = ia.trackCount ?: 0
val match = ndAlbums.find { a ->
val n = normAlbum(a.name)
n == iName || (n.length > 3 && iName.startsWith(n)) || (iName.length > 3 && n.startsWith(iName))
}
when {
match == null -> notDl.add(ia)
// Deezer returns the same album as several variants (single/EP/album/regional); distinctBy
// above only catches identical ids, so also skip an owned album we've already classified —
// otherwise it shows 2-3x ("PIXELATED KISSES").
match.id in usedNdIds -> Unit
iCount > match.songCount -> {
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
}
else -> {
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
}
}
}
for (na in ndAlbums) {
if (na.id !in usedNdIds) downloaded.add(Pair(na, null))
}
return ArtistAlbumCategories(
downloaded = downloaded.sortedByDescending { it.first.year ?: 0 },
partial = partial.sortedByDescending { it.first.year ?: 0 },
notDownloaded = notDl.sortedByDescending { it.releaseDate ?: "" },
)
}
@Composable
fun ItunesArtistScreen(
artistState: ItunesArtistState,
playerState: PlayerState,
coverUrl: (String?) -> String?,
onBack: () -> Unit,
onAlbumClick: (Album) -> Unit,
onPartialAlbumOpen: (Album, ItunesItem) -> Unit,
onSongPlay: (List<Song>, Int) -> Unit,
onDownload: (ItunesItem) -> Unit,
onItunesAlbumOpen: (ItunesItem) -> Unit,
onItunesTrackPreview: (ItunesItem) -> Unit,
previewingId: Long?,
allAlbumsOpen: String?,
onOpenAllAlbums: (String) -> Unit,
onCloseAllAlbums: () -> Unit,
allAlbumsGrid: androidx.compose.foundation.lazy.grid.LazyGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState(),
allAlbumsQuery: String = "",
onAllAlbumsQueryChange: (String) -> Unit = {},
) {
val artist = artistState.artist
val cats = if (!artistState.isLoading)
categorizeAlbums(artistState.ndAlbums, artistState.iAlbums)
else null
// "All albums" state is hoisted (ExploreViewModel.artistAllAlbums) so it survives an album
// drill-down and back; entries are re-derived from cats for whichever section is open.
val allAlbumEntries: List<AllAlbumEntry> = when (allAlbumsOpen) {
"In Library" -> cats?.downloaded?.map { (nd, _) ->
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
}.orEmpty()
"Unvollständig" -> cats?.partial?.map { (nd, ia, counts) ->
AllAlbumEntry(
name = nd.name, year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { onPartialAlbumOpen(nd, ia) }, onDownload = { onDownload(ia) },
)
}.orEmpty()
"Nicht heruntergeladen" -> cats?.notDownloaded?.map { ia ->
AllAlbumEntry(
name = ia.collectionName ?: "—", year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"), dimmed = true,
onClick = { onItunesAlbumOpen(ia) }, onDownload = { onDownload(ia) },
)
}.orEmpty()
else -> emptyList()
}
if (allAlbumsOpen != null) {
AllAlbumsOverlay(
title = allAlbumsOpen,
entries = allAlbumEntries,
onBack = onCloseAllAlbums,
gridState = allAlbumsGrid,
query = allAlbumsQuery,
onQueryChange = onAllAlbumsQueryChange,
)
return
}
// Pick backdrop: first Navidrome album cover, else first iTunes album art
val backdropUrl = artistState.ndAlbums.firstOrNull()?.let { coverUrl(it.coverArt) }
?: artistState.iAlbums.firstOrNull()?.artworkUrl100?.replace("100x100bb", "300x300bb")
Column(Modifier.fillMaxSize().background(NsBackground)) {
// ── Hero ──────────────────────────────────────────────────────────────
Box(
Modifier.fillMaxWidth().height(200.dp)
) {
AsyncImage(
model = backdropUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().blur(24.dp),
)
Box(Modifier.fillMaxSize().background(
Brush.verticalGradient(0f to Color.Black.copy(0.5f), 1f to NsBackground)
))
// Back button
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(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))
}
// Artist name
Column(
Modifier.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 16.dp),
) {
Text(
artist.artistName ?: "Unbekannter Künstler",
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 28.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
if (!artist.primaryGenreName.isNullOrBlank()) {
Text(artist.primaryGenreName!!, fontFamily = Outfit,
fontSize = 13.sp, color = Color.White.copy(0.6f))
}
artist.fanCount?.takeIf { it > 0 }?.let {
Text("${fmtFanCount(it)} Fans", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp, color = Color.White.copy(0.55f))
}
}
}
// ── Action row ────────────────────────────────────────────────────────
if (artistState.topSongs.isNotEmpty()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { onSongPlay(artistState.topSongs, 0) },
modifier = Modifier.weight(1f).height(42.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent),
) {
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
}
OutlinedButton(
onClick = { onSongPlay(artistState.topSongs, -1) },
modifier = Modifier.weight(1f).height(42.dp),
shape = RoundedCornerShape(12.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true)
.copy(brush = androidx.compose.ui.graphics.SolidColor(NsAccent.copy(0.5f))),
) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(18.dp), tint = NsAccent)
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 13.sp, color = NsAccent)
}
}
}
if (artistState.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return@Column
}
// ── Scrollable content ────────────────────────────────────────────────
LazyColumn(Modifier.fillMaxSize()) {
// iTunes top tracks
if (artistState.iTracks.isNotEmpty()) {
item { ArtistSectionHeader("Weltweit populär (Deezer)") }
itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
ItunesArtistTrackRow(
track = track,
isPreviewing = previewingId == track.trackId,
onPreview = { onItunesTrackPreview(track) },
onDownload = { onDownload(track) },
)
}
}
// ── Downloaded albums ─────────────────────────────────────────────
cats?.let { c ->
if (c.downloaded.isNotEmpty()) {
item {
ArtistSectionHeader("In Library", c.downloaded.size,
onShowAll = if (c.downloaded.size > 4) ({ onOpenAllAlbums("In Library") }) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(c.downloaded) { (nd, _) ->
AlbumThumb(
name = nd.name,
year = nd.year?.toString(),
artUrl = coverUrl(nd.coverArt),
onClick = { onAlbumClick(nd) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
// ── Partial albums ────────────────────────────────────────────
if (c.partial.isNotEmpty()) {
item {
ArtistSectionHeader("Unvollständig", c.partial.size,
onShowAll = if (c.partial.size > 4) ({ onOpenAllAlbums("Unvollständig") }) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(c.partial) { (nd, ia, counts) ->
AlbumThumb(
name = nd.name,
year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { onPartialAlbumOpen(nd, ia) },
onDownload = { onDownload(ia) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
// ── Not downloaded ────────────────────────────────────────────
if (c.notDownloaded.isNotEmpty()) {
item {
ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size,
onShowAll = if (c.notDownloaded.size > 4) ({ onOpenAllAlbums("Nicht heruntergeladen") }) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(c.notDownloaded, key = { it.collectionId ?: 0L }) { ia ->
AlbumThumb(
name = ia.collectionName ?: "—",
year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
dimmed = true,
onClick = { onItunesAlbumOpen(ia) },
onDownload = { onDownload(ia) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
@Composable
internal fun AlbumThumb(
name: String,
year: String?,
artUrl: String?,
badge: String? = null,
dimmed: Boolean = false,
onClick: () -> Unit,
onDownload: (() -> Unit)? = null,
) {
Column(modifier = Modifier.width(130.dp)) {
Box {
AsyncImage(
model = artUrl, contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(130.dp)
.clip(RoundedCornerShape(10.dp))
.background(NsSurface2)
.clickable(onClick = onClick)
.then(if (dimmed) Modifier.background(Color.Black.copy(0.35f)) else Modifier),
)
// Track count badge for partial
if (badge != null) {
Box(
Modifier
.align(Alignment.BottomStart)
.padding(6.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color.Black.copy(0.7f))
.padding(horizontal = 5.dp, vertical = 2.dp),
) {
Text(badge, fontFamily = JetBrainsMono, fontSize = 10.sp, color = Color.White)
}
}
// Download button (for partial/not-downloaded)
if (onDownload != null) {
Box(
Modifier
.align(Alignment.BottomEnd)
.padding(6.dp)
.size(28.dp)
.clip(CircleShape)
.background(NsAccent)
.clickable(onClick = onDownload),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Download, null,
tint = Color.White, modifier = Modifier.size(14.dp))
}
}
}
Spacer(Modifier.height(5.dp))
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp, color = if (dimmed) NsOnSurface2 else NsOnSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!year.isNullOrBlank()) {
Text(year, fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
}
}
}
@Composable
internal fun ItunesArtistTrackRow(
track: ItunesItem,
isPreviewing: Boolean,
onPreview: () -> Unit,
onDownload: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onPreview)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
val art = track.artworkUrl100?.replace("100x100bb", "60x60bb")
AsyncImage(
model = art, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(4.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(track.trackName ?: "", style = MaterialTheme.typography.bodyMedium,
color = if (isPreviewing) NsAccent else MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(track.collectionName).joinToString(" · "),
style = MaterialTheme.typography.labelSmall,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
if (track.previewUrl != null) {
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
Icon(
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
null, tint = if (isPreviewing) NsAccent else NsOnSurface2,
modifier = Modifier.size(22.dp),
)
}
}
IconButton(onClick = onDownload, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Download, null, tint = NsOnSurface2, modifier = Modifier.size(20.dp))
}
}
}
@Composable
internal fun ArtistSectionHeader(title: String, count: Int? = null, onShowAll: (() -> Unit)? = null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 10.dp, end = 16.dp),
) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.weight(1f))
if (count != null) {
Text("$count", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2.copy(0.5f),
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(NsSurface2)
.padding(horizontal = 7.dp, vertical = 2.dp))
}
if (onShowAll != null) {
Spacer(Modifier.width(10.dp))
Text(
"Alle anzeigen",
fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
modifier = Modifier.clickable(onClick = onShowAll),
)
}
}
}
@@ -0,0 +1,683 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.border
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
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.Album
import com.novasonic.app.api.model.Artist
import com.novasonic.app.api.model.Playlist
import com.novasonic.app.offline.DownloadGroupEntity
import com.novasonic.app.offline.GroupType
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumSort
import com.novasonic.app.viewmodel.LibraryTab
import com.novasonic.app.viewmodel.LibraryViewModel
import com.novasonic.app.viewmodel.OfflineViewModel
import kotlinx.coroutines.launch
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.DownloadForOffline
import androidx.compose.material.icons.filled.PlayArrow
import com.novasonic.app.api.model.Song
@Composable
fun LibraryScreen(
coverUrl: (String?) -> String?,
onAlbumClick: (Album) -> Unit,
onArtistClick: (Artist) -> Unit,
onPlaylistClick: (Playlist) -> Unit,
onPlay: (List<Song>, Int) -> Unit,
onOpenOffline: (DownloadGroupEntity) -> Unit,
) {
val vm: LibraryViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
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()
// Fully-downloaded source IDs per type — drives the "downloaded" badge on cards.
val downloadedAlbumIds = remember(offlineGroups) {
offlineGroups.filter { it.type == GroupType.ALBUM && it.totalTracks > 0 && it.downloadedTracks >= it.totalTracks }
.map { it.sourceId }.toSet()
}
val downloadedPlaylistIds = remember(offlineGroups) {
offlineGroups.filter { it.type == GroupType.PLAYLIST && it.totalTracks > 0 && it.downloadedTracks >= it.totalTracks }
.map { it.sourceId }.toSet()
}
var showCreateDialog by remember { mutableStateOf(false) }
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
// Swipe left/right to switch library tabs, kept in sync with the TabRow (like Erkunden): a chip
// tap animates the pager; a settled swipe updates the selected tab. Indicator is driven off the
// pager's current page so it moves WITH the swipe.
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { LibraryTab.entries.size }
LaunchedEffect(state.tab) {
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
}
LaunchedEffect(pagerState.settledPage) {
val t = LibraryTab.entries[pagerState.settledPage]
if (t != state.tab) vm.selectTab(t)
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
TabRow(
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.background,
contentColor = NsAccent,
) {
LibraryTab.entries.forEach { tab ->
Tab(
selected = pagerState.currentPage == tab.ordinal,
onClick = { vm.selectTab(tab) },
text = {
Text(
when (tab) {
LibraryTab.PLAYLISTS -> "Playlists"
LibraryTab.ALBUMS -> "Alben"
LibraryTab.ARTISTS -> "Künstler"
LibraryTab.OFFLINE -> "Offline"
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
selectedContentColor = NsAccent,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
val pageTab = LibraryTab.entries[page]
// The Offline tab is fed by the local Room DB (offlineGroups) and must stay usable even while
// the online library is still loading or unreachable — only gate the network-backed tabs.
if (state.isLoading && pageTab != LibraryTab.OFFLINE) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
} else when (pageTab) {
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
// Sort chips
androidx.compose.foundation.lazy.LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
items(AlbumSort.entries) { s ->
FilterChip(
selected = state.albumSort == s,
onClick = { vm.setAlbumSort(s) },
label = {
Text(when (s) {
AlbumSort.NEWEST -> "Neueste"
AlbumSort.OLDEST -> "Älteste"
AlbumSort.AZ -> "A-Z"
}, fontSize = 12.sp)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent,
selectedLeadingIconColor = NsAccent,
),
)
}
}
val sortedAlbums = remember(state.albumsByNewest, state.albumsByName, state.albumSort) {
when (state.albumSort) {
AlbumSort.NEWEST -> state.albumsByNewest // already ordered by Navidrome add-date
AlbumSort.AZ -> state.albumsByName // already ordered alphabetically
AlbumSort.OLDEST -> state.albumsByName.sortedWith(
compareBy<com.novasonic.app.api.model.Album> { it.year ?: Int.MAX_VALUE }
.thenBy { it.name.lowercase() }
)
}
}
val albumGridState = vm.albumsGridState // hoisted → survives album drill-in/back
// Kein LaunchedEffect(state.albumSort) zum Hochscrollen: der liefe auch beim ersten
// Compose nach dem Zurückkommen und würde die gemerkte Position sofort verwerfen.
// Das Hochscrollen passiert jetzt in vm.setAlbumSort(), also nur bei echtem Wechsel.
LazyVerticalGrid(
state = albumGridState,
columns = GridCells.Adaptive(minSize = 150.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
items(sortedAlbums, key = { it.id }) { album ->
AlbumCard(
album = album,
coverUrl = coverUrl(album.coverArt),
width = 150.dp,
downloaded = album.id in downloadedAlbumIds,
onClick = { onAlbumClick(album) },
)
}
}
}
LibraryTab.ARTISTS -> {
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
val scope = rememberCoroutineScope()
// "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
val letterToIndex = remember(indizes) {
buildMap<String, Int> {
var flat = 0
indizes.forEach { idx ->
put(idx.name, flat)
flat += 1 + idx.artist.size
}
}
}
var activeLetter by remember { mutableStateOf<String?>(null) }
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(
state = listState,
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
modifier = Modifier.fillMaxSize(),
) {
indizes.forEach { idx ->
item(key = "header_${idx.name}") {
Text(
text = idx.name,
style = MaterialTheme.typography.labelSmall,
color = NsAccent,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
}
items(idx.artist, key = { it.id }) { artist ->
ListItem(
headlineContent = {
Text(artist.name, fontFamily = Outfit,
fontWeight = FontWeight.Medium, fontSize = 15.sp)
},
supportingContent = if (artist.albumCount > 0) { {
Text("${artist.albumCount} Alben",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
} } else null,
leadingContent = {
AsyncImage(
model = coverUrl(artist.coverArt),
contentDescription = artist.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(NsSurface2),
)
},
modifier = Modifier.fillMaxWidth().clickable { onArtistClick(artist) },
colors = ListItemDefaults.colors(containerColor = NsBackground),
)
}
}
}
if (letters.isNotEmpty()) {
// Bubble showing active letter while dragging
activeLetter?.let { letter ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.align(Alignment.CenterEnd)
.offset(x = (-44).dp)
.size(52.dp)
.clip(CircleShape)
.background(NsAccent),
) {
Text(
text = letter,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = NsBackground,
)
}
}
// Vertical letter strip
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.width(28.dp)
.pointerInput(letters) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val y = event.changes.firstOrNull()?.position?.y ?: continue
val idx = ((y / size.height) * letters.size)
.toInt().coerceIn(0, letters.lastIndex)
val letter = letters[idx]
when (event.type) {
PointerEventType.Press,
PointerEventType.Move -> {
event.changes.forEach { it.consume() }
if (letter != activeLetter) {
activeLetter = letter
letterToIndex[letter]?.let { pos ->
scope.launch { listState.scrollToItem(pos) }
}
}
}
else -> activeLetter = null
}
}
}
},
) {
Column(
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(),
) {
letters.forEach { letter ->
Text(
text = letter,
fontSize = 10.sp,
fontWeight = if (letter == activeLetter) FontWeight.Bold else FontWeight.Normal,
color = if (letter == activeLetter) NsAccent else NsOnSurface3,
textAlign = TextAlign.Center,
)
}
}
}
}
}
}
}
LibraryTab.PLAYLISTS -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 80.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
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(
name = pl.name,
subtitle = "${pl.songCount} Titel" + (von?.let { " · von $it" } ?: ""),
coverUrl = coverUrl(pl.coverArt),
downloaded = pl.id in downloadedPlaylistIds,
onClick = { onPlaylistClick(pl) },
)
}
}
LibraryTab.OFFLINE -> {
val groups = offlineGroups
val storage by offlineVm.storageBytes.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
if (groups.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.DownloadDone,
contentDescription = null,
tint = NsOnSurface3,
modifier = Modifier.size(56.dp),
)
Spacer(Modifier.height(12.dp))
Text(
"Noch nichts heruntergeladen",
fontFamily = Outfit,
fontSize = 15.sp,
color = NsOnSurface2,
)
Spacer(Modifier.height(4.dp))
Text(
"Öffne ein Album und tippe auf ↓",
fontFamily = Outfit,
fontSize = 12.sp,
color = NsOnSurface3,
)
}
}
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
Text(
text = "%.1f MB gespeichert".format(storage / 1_000_000.0),
fontFamily = Outfit,
fontSize = 12.sp,
color = NsOnSurface3,
modifier = Modifier.padding(bottom = 12.dp),
)
}
items(groups, key = { it.groupId }) { group ->
OfflineGroupRow(
group = group,
coverUrl = coverUrl(group.coverArt),
onOpen = { onOpenOffline(group) },
onPlay = {
scope.launch {
val songs = offlineVm.songsForGroup(group.groupId)
if (songs.isNotEmpty()) onPlay(songs, 0)
}
},
onDelete = { groupToDelete = group },
)
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
}
} // end HorizontalPager
} // end Column
if (pagerState.currentPage == LibraryTab.PLAYLISTS.ordinal) {
FloatingActionButton(
onClick = { showCreateDialog = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
containerColor = NsAccent,
) {
Icon(Icons.Default.Add, "Neue Playlist", tint = NsBackground)
}
}
} // end Box
// ── Delete offline group dialog ───────────────────────────────────────────
groupToDelete?.let { group ->
AlertDialog(
onDismissRequest = { groupToDelete = null },
title = { Text("Löschen?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = { Text("\"${group.name}\" aus dem Offline-Speicher entfernen?", fontFamily = Outfit) },
confirmButton = {
TextButton(onClick = {
offlineVm.deleteGroup(group.groupId)
groupToDelete = null
}) {
Text("Löschen", color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { groupToDelete = null }) {
Text("Abbrechen", color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
// ── Create playlist dialog ────────────────────────────────────────────────
if (showCreateDialog) {
var nameInput by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showCreateDialog = false },
title = { Text("Neue Playlist", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
OutlinedTextField(
value = nameInput,
onValueChange = { nameInput = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
),
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
vm.createPlaylist(nameInput)
showCreateDialog = false
},
enabled = nameInput.isNotBlank(),
) {
Text("Erstellen", color = NsAccent, fontFamily = Outfit, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showCreateDialog = false }) {
Text("Abbrechen", color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
}
@Composable
private fun OfflineGroupRow(
group: DownloadGroupEntity,
coverUrl: String?,
onOpen: () -> Unit,
onPlay: () -> Unit,
onDelete: () -> Unit,
) {
val isFullyDone = group.totalTracks > 0 && group.downloadedTracks >= group.totalTracks
val subtitle = when {
group.totalTracks == 0 -> "Ausstehend…"
isFullyDone -> "${group.totalTracks} Titel · Vollständig"
else -> "${group.downloadedTracks}/${group.totalTracks} Titel"
}
val typeLabel = when (group.type) {
GroupType.ALBUM -> "Album"
GroupType.PLAYLIST -> "Playlist"
GroupType.SONG -> "Song"
GroupType.FAVORITES -> "Favoriten"
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(NsSurface)
.border(1.dp, NsBorder, RoundedCornerShape(12.dp))
.clickable(onClick = onOpen)
.padding(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
AsyncImage(
model = coverUrl,
contentDescription = group.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(8.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = group.name,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
text = "$typeLabel · $subtitle",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
IconButton(onClick = onPlay, enabled = isFullyDone) {
Icon(Icons.Default.PlayArrow, "Abspielen",
tint = if (isFullyDone) NsAccent else NsOnSurface3,
modifier = Modifier.size(20.dp))
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Löschen", tint = NsOnSurface3, modifier = Modifier.size(20.dp))
}
}
if (!isFullyDone && group.totalTracks > 0) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { group.downloadedTracks.toFloat() / group.totalTracks },
modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)),
color = NsAccent,
trackColor = NsSurface2,
)
}
}
Spacer(Modifier.height(8.dp))
}
@Composable
private fun PlaylistCard(
name: String,
subtitle: String,
coverUrl: String?,
downloaded: Boolean = false,
onClick: () -> Unit,
) {
val shape = RoundedCornerShape(12.dp)
Column(
modifier = Modifier
.clip(shape)
.background(NsSurface)
.border(1.dp, NsBorder, shape)
.clickable(onClick = onClick),
) {
Box {
AsyncImage(
model = coverUrl,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
if (downloaded) DownloadedBadge(Modifier.align(Alignment.BottomEnd).padding(6.dp))
}
Column(Modifier.padding(10.dp)) {
Text(
text = name,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 12.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
text = subtitle,
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
/** Small "downloaded" pill overlaid on a cover — the down-arrow-in-circle, Spotify-style. */
@Composable
fun DownloadedBadge(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(22.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.DownloadForOffline,
contentDescription = "Heruntergeladen",
tint = NsAccent,
modifier = Modifier.size(16.dp),
)
}
}
@@ -0,0 +1,131 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.novasonic.app.ui.theme.NsAccent
import com.novasonic.app.ui.theme.NsOnSurface2
import com.novasonic.app.viewmodel.LoginViewModel
@Composable
fun LoginScreen(onLoggedIn: () -> Unit) {
val vm: LoginViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
// 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 password by remember { mutableStateOf("") }
var showPw by remember { mutableStateOf(false) }
LaunchedEffect(state.success) {
if (state.success) onLoggedIn()
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
) {
Text(
text = "NovaSonic",
color = NsAccent,
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Connect to your Navidrome server",
style = MaterialTheme.typography.bodyMedium,
color = NsOnSurface2,
)
Spacer(Modifier.height(40.dp))
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server URL") },
placeholder = { Text("https://music.example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
colors = fieldColors(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = fieldColors(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
singleLine = true,
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { showPw = !showPw }) {
Icon(
imageVector = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null,
tint = NsOnSurface2,
)
}
},
modifier = Modifier.fillMaxWidth(),
colors = fieldColors(),
)
Spacer(Modifier.height(28.dp))
if (state.error != null) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 12.dp),
)
}
Button(
onClick = { vm.login(serverUrl.trim(), username.trim(), password) },
enabled = !state.isLoading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = MaterialTheme.colorScheme.background),
) {
if (state.isLoading) {
CircularProgressIndicator(Modifier.size(20.dp), color = MaterialTheme.colorScheme.background, strokeWidth = 2.dp)
} else {
Text("Connect", fontWeight = FontWeight.SemiBold)
}
}
}
}
@Composable
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
focusedLabelColor = NsAccent,
unfocusedBorderColor = NsOnSurface2,
unfocusedLabelColor = NsOnSurface2,
cursorColor = NsAccent,
)
@@ -0,0 +1,992 @@
package com.novasonic.app.ui.screen
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import android.graphics.drawable.BitmapDrawable
import androidx.palette.graphics.Palette
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
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.player.PlayerController
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.LyricsViewModel
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 }
// ── Obergrenzen fuer grosse Anzeigen (Fold aufgeklappt, Tablet) ──────────────────────────────
// Bis 1.26 hatte diese Ansicht keine Breitengrenze: das Cover war `fillMaxWidth` mal
// `aspectRatio(1f)`. Auf einem Handy sind das knappe 360 dp und passt; auf dem inneren Display
// eines Fold sind es ueber 800 dp — ein quadratisches Cover dieser Groesse schob Titel,
// Fortschrittsbalken und Knoepfe aus dem Bild, und die ganze Seite wirkte ueberdimensional.
// Die Web-Ansicht deckelt dasselbe seit immer bei 480 px (`.np-inner { max-width: 480px }`).
// Zusaetzlich wird das Cover gegen die HOEHE gedeckelt: bei einem fast quadratischen Display ist
// die Breite kein gutes Mass mehr fuer ein quadratisches Bild.
private val NP_SPALTE_MAX = 480.dp // Player-Seite: Cover, Titel, Fortschritt, Knoepfe
private val NP_LISTE_MAX = 560.dp // Liedtext und Warteschlange — Listen duerfen etwas breiter
private val NP_COVER_MAX = 400.dp
// Was Titel, Album, Kuenstler, Fortschrittsbalken, Zeiten, Knopfreihe und die Abstaende
// dazwischen zusammen brauchen (gemessen an den gesetzten Groessen, mit etwas Luft). Das Cover
// bekommt den Rest — so passt die Seite auch auf kurze Anzeigen, wo bisher die Knopfreihe
// unten abgeschnitten wurde.
private val NP_RESTBEDARF = 260.dp
// Auf kurzen Anzeigen (Flip-Frontdisplay, Querformat) schrumpfen Abstaende und Abspielknopf
// zusaetzlich, der Rest braucht dann weniger — und wenn es trotzdem nicht reicht, laesst sich
// die Seite rollen.
private val NP_RESTBEDARF_KURZ = 200.dp
@Composable
fun NowPlayingScreen(
state: PlayerState,
coverUrl: String?,
queueCoverUrl: ((String?) -> String?)? = null,
controller: PlayerController,
onDismiss: () -> Unit,
onStarToggle: (() -> Unit)? = null,
onShowOptions: (() -> Unit)? = null,
onArtistClick: ((artistId: String) -> Unit)? = null,
onToggleRadio: (() -> Unit)? = null,
) {
val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) }
// Waagerechtes Wischen: standardmaessig wechselt es den TITEL — das ist die Geste, die man hier
// staendig braucht. Wer lieber zwischen Player, Warteschlange und Text blaettert, stellt es in
// 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 }
LaunchedEffect(tab) {
if (pagerState.currentPage != tab.ordinal) pagerState.animateScrollToPage(tab.ordinal)
}
LaunchedEffect(pagerState.settledPage) {
val t = NpTab.entries[pagerState.settledPage]
if (t != tab) tab = t
}
// Preload lyrics as soon as NowPlaying opens — so the Lyrics tab shows instantly
val lyricsVm: LyricsViewModel = viewModel()
LaunchedEffect(song?.id) {
if (song != null) lyricsVm.loadForSong(song)
}
val statusBarTop: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getTop(this).toDp() }
val topPad = maxOf(statusBarTop + 16.dp, 52.dp)
val navBarBottom: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getBottom(this).toDp() }
// Apple Music-style: cover scales up while playing
val coverScale by animateFloatAsState(
targetValue = if (state.isPlaying) 1f else 0.86f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
label = "coverScale",
)
// Extract dominant color from cover art for background tinting
val context = LocalContext.current
var dominantColor by remember(coverUrl) { mutableStateOf<Color?>(null) }
LaunchedEffect(coverUrl) {
dominantColor = null
if (coverUrl.isNullOrBlank()) return@LaunchedEffect
try {
val loader = ImageLoader(context)
val req = ImageRequest.Builder(context).data(coverUrl).allowHardware(false).size(80).build()
val result = loader.execute(req)
if (result is SuccessResult) {
val bitmap = (result.drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect
val palette = withContext(Dispatchers.Default) { Palette.from(bitmap).generate() }
val swatch = palette.vibrantSwatch ?: palette.mutedSwatch ?: palette.dominantSwatch
swatch?.rgb?.let { dominantColor = Color(it) }
}
} catch (_: Exception) {}
}
// Blend vibrant color toward darker for the background without losing the hue
val rawBgColor = dominantColor?.let { c ->
Color(
red = (c.red * 0.55f).coerceIn(0f, 1f),
green = (c.green * 0.55f).coerceIn(0f, 1f),
blue = (c.blue * 0.55f).coerceIn(0f, 1f),
)
} ?: Color(0xFF0D0D1A)
val animatedBgColor by animateColorAsState(
targetValue = rawBgColor,
animationSpec = tween(600),
label = "npBg",
)
Box(Modifier.fillMaxSize().background(NsBackground)) {
// Dominant-color gradient — covers top 70% of screen
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to animatedBgColor,
0.65f to animatedBgColor.copy(alpha = 0.5f),
1f to NsBackground,
)
)
)
// Light scrim for readability
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.15f)))
BoxWithConstraints(Modifier.fillMaxSize()) {
// Zwei Groessenfragen entscheiden das Layout, nicht ein Geraetename:
// schmal — passt die Kopfzeile mit drei Reitern noch in voller Groesse? (Flip-Deckel)
// kurz — bleibt nach Kopfzeile und Raendern ueberhaupt Platz? (Flip-Deckel, Querformat)
// Die Schwelle liegt bewusst UNTER 360 dp: 360 ist noch ein gewoehnliches Handy und
// soll die Kopfzeile unveraendert behalten. Erfasst werden damit der Deckel eines Fold
// (~352 dp) und — ueber `kurz` — das Frontdisplay eines Flip, das bei aehnlicher Breite
// nur ein Drittel der Hoehe hat und wo drei Reiter in voller Groesse nicht hinpassen.
val schmal = maxWidth < 356.dp || maxHeight < 560.dp
val kurz = maxHeight < 560.dp
val obenPad = if (kurz) maxOf(statusBarTop + 4.dp, 20.dp) else topPad
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(top = obenPad, bottom = maxOf(navBarBottom, if (kurz) 8.dp else 20.dp))
.pointerInput(Unit) {
var dragY = 0f
detectVerticalDragGestures(
onDragEnd = { if (dragY > 120f) onDismiss(); dragY = 0f },
onDragCancel = { dragY = 0f },
onVerticalDrag = { _, dy -> if (dy > 0) dragY += dy },
)
},
) {
// ── Top bar ────────────────────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
// Ohne Grenze stehen Schliessen-Knopf und Menue auf einem breiten Display
// ganz aussen und die Reiter einsam in der Mitte.
.widthIn(max = NP_LISTE_MAX)
.fillMaxWidth()
.padding(horizontal = if (schmal) 8.dp else 20.dp)
.padding(bottom = if (kurz) 10.dp else 28.dp),
) {
NpCircleBtn(onClick = onDismiss) {
Icon(Icons.Default.KeyboardArrowDown, "Schließen",
tint = Color.White, modifier = Modifier.size(24.dp))
}
// Tab pills
Row(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(Color.White.copy(alpha = 0.1f))
.padding(3.dp),
) {
NpTab.entries.forEach { t ->
val sel = pagerState.currentPage == t.ordinal
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
.clickable { tab = t }
.padding(
horizontal = if (schmal) 9.dp else 18.dp,
vertical = if (kurz) 6.dp else 9.dp,
),
) {
Text(
when (t) {
NpTab.PLAYER -> "Player"
NpTab.LYRICS -> "Lyrics"
NpTab.QUEUE -> "Queue"
},
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = if (schmal) 11.sp else 13.sp,
color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
)
}
}
}
if (onShowOptions != null) {
NpCircleBtn(onClick = onShowOptions) {
Icon(Icons.Default.MoreVert, "Optionen",
tint = Color.White, modifier = Modifier.size(22.dp))
}
} else {
Spacer(Modifier.size(36.dp))
}
}
// ── Swipeable tab content (Player / Lyrics / Queue) ────────────
HorizontalPager(
state = pagerState,
userScrollEnabled = !wischTitel,
modifier = Modifier.weight(1f).fillMaxWidth(),
) { 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(
horizontalAlignment = Alignment.CenterHorizontally,
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]) {
// ── PLAYER TAB ─────────────────────────────────────────────────
NpTab.PLAYER -> BoxWithConstraints(Modifier.fillMaxSize()) {
var dragX by remember { mutableStateOf(0f) }
// Das Cover nimmt das KLEINSTE aus drei Maszen: verfuegbare Breite (minus der
// bisherigen 24 dp Rand je Seite), die Hoehe abzueglich dessen, was der Rest der
// Seite braucht, und eine feste Obergrenze. Auf einem hohen Handy gewinnt die
// Breite — dort bleibt alles wie bisher. Aufgeklappt greifen die anderen beiden.
val coverKante = minOf(
maxWidth - 48.dp,
(maxHeight - if (kurz) NP_RESTBEDARF_KURZ else NP_RESTBEDARF).coerceAtLeast(120.dp),
NP_COVER_MAX,
)
val spalte = minOf(maxWidth, NP_SPALTE_MAX)
// Vor dem Rollbereich festhalten: innerhalb des Box-Lambdas ist `maxHeight` des
// aeusseren Messrahmens nicht mehr erreichbar (Compose sperrt gleich markierte
// Layout-Bereiche gegeneinander ab).
val seitenHoehe = maxHeight
// Greift ueberhaupt eine der neuen Grenzen? Nur dann weicht das Layout von dem
// ab, was ein hohes Handy bisher zeigte. Solange die BREITE das engste Mass ist
// — also auf jedem gewoehnlichen Handy —, bleibt es beim alten Aufbau: oben
// beginnend mit 28 dp Abstand. Erst wenn Hoehe oder Obergrenze beissen (Fold
// aufgeklappt, Tablet, Querformat), wird senkrecht zentriert; oben klebend mit
// einem Loch darunter saehe dort falsch aus.
val gedeckelt = coverKante < maxWidth - 48.dp || kurz
// Passt es, wird es angeordnet wie oben beschrieben; passt es nicht, wird
// gescrollt. Beides mit einem Aufbau: der Inhalt ist mindestens so hoch wie die
// Seite, darf aber darueber hinauswachsen — dann hat der Rollbereich etwas zu
// rollen. Auf dem Frontdisplay eines Flip bleibt sonst nichts uebrig, was ohne
// Abschneiden darstellbar waere. Passt alles, ist der Rollweg null: dann
// verhaelt sich die Seite wie ohne Rollbereich.
// Die Wischgeste zum Schliessen bleibt erhalten: ein Rollbereich ohne Weg
// verbraucht die Geste nicht, sie erreicht also weiter die Seite darunter.
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = if (gedeckelt) Arrangement.Center else Arrangement.Top,
modifier = Modifier.fillMaxWidth().heightIn(min = seitenHoehe),
) {
if (!gedeckelt) Spacer(Modifier.height(28.dp))
// Cover art — gedeckelt, scale-animates like Apple Music
AsyncImage(
model = coverUrl,
contentDescription = song?.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(coverKante)
.graphicsLayer { scaleX = coverScale; scaleY = coverScale }
.shadow(40.dp, RoundedCornerShape(20.dp))
.clip(RoundedCornerShape(20.dp))
.background(NsSurface2)
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
if (dragX < -80) controller.skipNext()
else if (dragX > 80) controller.skipPrev()
dragX = 0f
},
onDragCancel = { dragX = 0f },
onHorizontalDrag = { _, d -> dragX += d },
)
},
)
Spacer(Modifier.height(if (kurz) 12.dp else 24.dp))
// ── Song info ──────────────────────────────────────────────
Column(Modifier.width(spalte).padding(horizontal = 28.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.weight(1f)) {
Text(
song?.title ?: "Nichts spielt",
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp,
letterSpacing = (-0.4).sp, color = Color.White,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(3.dp))
// Order matches the web player: title → album → artists.
if (!song?.album.isNullOrBlank()) {
Text(
song!!.album!!,
fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.45f),
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(3.dp))
}
if (song != null && !song.artist.isNullOrBlank()) {
// Prefer OpenSubsonic artists array (has IDs per contributor).
// Fall back to splitting artist string by ';' (same as web app).
val raw: List<com.novasonic.app.api.model.SongArtist> =
if (song.artists.size > 1) song.artists
else song.artist!!.split(";").map { part ->
val n = part.trim()
com.novasonic.app.api.model.SongArtist(
id = if (n.equals(song.artist?.trim(), ignoreCase = true)) song.artistId ?: "" else "",
name = n,
)
}.filter { it.name.isNotBlank() }
val artistList = raw
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
artistList.forEachIndexed { idx, a ->
if (idx > 0) {
Text(
" · ",
fontFamily = Outfit,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.35f),
)
}
val navigable = onArtistClick != null && a.id.isNotBlank()
Text(
text = a.name,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
maxLines = 1,
color = if (navigable) NsAccent else NsAccent.copy(alpha = 0.55f),
modifier = if (navigable)
Modifier.pointerInput(a.id) {
detectTapGestures { onArtistClick!!(a.id) }
}
else Modifier,
)
}
}
}
}
if (onStarToggle != null && song != null) {
val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState()
val starred = starOverrides[song.id] ?: (song.starred != null)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(40.dp).clip(CircleShape)
.clickable(onClick = onStarToggle),
) {
Icon(
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
null,
tint = if (starred) NsAccent else Color.White.copy(0.5f),
modifier = Modifier.size(22.dp),
)
}
}
}
}
Spacer(Modifier.height(if (kurz) 10.dp else 20.dp))
// ── Seek bar ───────────────────────────────────────────────
Column(Modifier.width(spalte).padding(horizontal = 24.dp)) {
val progress = if (state.durationMs > 0)
(state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 0f
NpSeekBar(
progress = progress,
onSeek = { controller.seekTo((it * state.durationMs).toLong()) },
modifier = Modifier.fillMaxWidth(),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatMs(state.positionMs), fontFamily = JetBrainsMono, fontSize = 11.sp,
color = Color.White.copy(0.4f))
Text(formatMs(state.durationMs), fontFamily = JetBrainsMono, fontSize = 11.sp,
color = Color.White.copy(0.4f))
}
}
Spacer(Modifier.height(if (kurz) 4.dp else 12.dp))
// ── Controls ───────────────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.width(spalte).padding(horizontal = 16.dp),
) {
NpCircleBtn(onClick = { controller.toggleShuffle() }, aktiv = state.shuffled) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
}
NpCircleBtn(onClick = { controller.skipPrev() }) {
Icon(Icons.Default.SkipPrevious, null, modifier = Modifier.size(34.dp),
tint = Color.White)
}
// Big play/pause
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(if (kurz) 56.dp else 68.dp)
.clip(CircleShape)
.background(Color.White)
.clickable { controller.togglePlay() },
) {
Icon(
if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
null,
tint = Color.Black, modifier = Modifier.size(34.dp),
)
}
NpCircleBtn(onClick = { controller.skipNext() }) {
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
}
val wiederholt = state.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF
NpCircleBtn(onClick = { controller.toggleRepeat() }, aktiv = wiederholt) {
val (icon, tint) = when (state.repeatMode) {
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
else -> Icons.Default.Repeat to Color.White.copy(0.65f)
}
Icon(icon, null, modifier = Modifier.size(22.dp), tint = tint)
}
}
} // end Column (gedeckelte Player-Spalte)
} // end Box (Rollbereich, falls die Seite nicht reicht)
}
// ── LYRICS TAB ─────────────────────────────────────────────────
NpTab.LYRICS -> if (song != null) {
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
val lyricsListState = rememberLazyListState()
val density = LocalDensity.current
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.
val anchorFrac = 0.40f
// 200ms lookahead — same as web app for better sync feel
val activeIdx = if (lyricsState.lines.isNotEmpty())
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0)
else -1
// 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.
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
val info = lyricsListState.layoutInfo
val anchor = info.viewportStartOffset +
((info.viewportEndOffset - info.viewportStartOffset) * anchorFrac).toInt()
val item = info.visibleItemsInfo.firstOrNull { it.index == activeIdx }
if (item != null) {
val delta = (item.offset + item.size / 2 - anchor).toFloat()
lyricsListState.animateScrollBy(
delta,
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
)
} else {
// Big jump (instrumental gap → chorus): approximate-anchor now, refine next tick
lyricsListState.animateScrollToItem(activeIdx, -anchor)
}
}
if (lyricsState.lines.isNotEmpty()) {
Row(
modifier = Modifier.widthIn(max = NP_LISTE_MAX)
.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 {
lyricsState.isLoading -> Box(
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = NsAccent.copy(0.7f),
modifier = Modifier.size(28.dp), strokeWidth = 2.dp)
Spacer(Modifier.height(10.dp))
Text("Lade Lyrics…", fontFamily = Outfit, fontSize = 13.sp,
color = Color.White.copy(0.5f))
}
}
lyricsState.notFound -> Box(
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
) {
Text("Keine Lyrics verfügbar", fontFamily = Outfit, fontSize = 14.sp,
color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
}
lyricsState.lines.isNotEmpty() -> LazyColumn(
state = lyricsListState,
modifier = Modifier
.weight(1f)
// Gedeckelt: eine Textzeile ueber 800 dp liest sich nicht mehr.
.widthIn(max = NP_LISTE_MAX)
.fillMaxWidth()
.onSizeChanged { lyricsViewH = it.height },
// Asymmetric padding so the first/last lines can still reach the anchor point.
contentPadding = if (lyricsViewH > 0) PaddingValues(
top = with(density) { (lyricsViewH * anchorFrac).toDp() },
bottom = with(density) { (lyricsViewH * (1f - anchorFrac)).toDp() },
) else PaddingValues(vertical = 220.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
itemsIndexed(lyricsState.lines, key = { idx, _ -> idx }) { idx, line ->
LyricsLineItem(
text = line.text,
isActive = idx == activeIdx,
isPast = idx < activeIdx,
onSeek = { controller.seekTo(line.timeMs) },
)
}
}
lyricsState.plainText.isNotBlank() -> LazyColumn(
modifier = Modifier.weight(1f)
.widthIn(max = NP_LISTE_MAX).fillMaxWidth(),
contentPadding = PaddingValues(vertical = 24.dp),
) {
item {
Text(
text = lyricsState.plainText,
fontFamily = Outfit, fontSize = 15.sp,
color = Color.White.copy(0.8f),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
)
}
}
}
}
// ── QUEUE TAB ──────────────────────────────────────────────────
NpTab.QUEUE -> {
// Radio mode toggle
if (onToggleRadio != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.widthIn(max = NP_LISTE_MAX)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp)
.clip(RoundedCornerShape(12.dp))
.background(
if (state.radioMode) NsAccent.copy(alpha = 0.18f)
else Color.White.copy(alpha = 0.07f)
)
.clickable(onClick = onToggleRadio)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Icon(
Icons.Default.Radio, null,
tint = if (state.radioMode) NsAccent else Color.White.copy(0.55f),
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
"Radio-Modus",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
color = if (state.radioMode) NsAccent else Color.White.copy(0.85f),
)
Text(
"Lädt ähnliche Songs automatisch nach",
fontFamily = Outfit, fontSize = 11.sp,
color = Color.White.copy(0.4f),
)
}
Switch(
checked = state.radioMode,
onCheckedChange = { onToggleRadio() },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = NsAccent,
uncheckedThumbColor = Color.White.copy(0.6f),
uncheckedTrackColor = Color.White.copy(0.15f),
),
)
}
}
if (state.queue.isEmpty()) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Warteschlange leer", fontFamily = Outfit, fontSize = 14.sp,
color = Color.White.copy(0.4f))
}
} 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(
modifier = Modifier.weight(1f)
.widthIn(max = NP_LISTE_MAX).fillMaxWidth(),
contentPadding = PaddingValues(vertical = 4.dp),
) {
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
val active = state.currentIndex == idx
val wirdGezogen = ziehIdx == idx
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
if (wirdGezogen) { translationY = ziehOffset; shadowElevation = 12f }
}
.clip(RoundedCornerShape(10.dp))
.background(
when {
wirdGezogen -> Color.White.copy(0.16f)
active -> Color.White.copy(0.08f)
else -> Color.Transparent
}
)
.clickable { controller.seekToItem(idx) }
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
AsyncImage(
model = queueCoverUrl?.invoke(qSong.coverArt),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color.White.copy(0.08f)),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(qSong.title, fontFamily = Outfit, fontWeight = FontWeight.Medium,
fontSize = 14.sp, color = if (active) NsAccent else Color.White,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!qSong.artist.isNullOrBlank()) {
Text(qSong.artist!!, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(0.4f),
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (active) {
Icon(Icons.AutoMirrored.Filled.VolumeUp, null,
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
}
}
},
)
}
}
}
}
}
} // end when
} // end inner Column
} // end HorizontalPager
}
} // end BoxWithConstraints (schmal/kurz)
}
}
@Composable
private fun NpSeekBar(
progress: Float,
onSeek: (Float) -> Unit,
modifier: Modifier = Modifier,
) {
var isDragging by remember { mutableStateOf(false) }
var dragProgress by remember { mutableStateOf(0f) }
val displayProgress = if (isDragging) dragProgress else progress
val trackHeight by animateDpAsState(
targetValue = if (isDragging) 5.dp else 3.dp,
animationSpec = tween(120),
label = "seekTrackHeight",
)
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier
.height(32.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
val p = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
onSeek(p)
}
}
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
isDragging = true
dragProgress = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
},
onDragEnd = { onSeek(dragProgress); isDragging = false },
onDragCancel = { isDragging = false },
onDrag = { change, _ ->
dragProgress = (change.position.x / size.width.toFloat()).coerceIn(0f, 1f)
},
)
},
) {
// Background track
Box(
Modifier
.fillMaxWidth()
.height(trackHeight)
.clip(RoundedCornerShape(50))
.background(Color.White.copy(alpha = 0.2f))
)
// Filled portion
Box(
Modifier
.fillMaxWidth(displayProgress.coerceIn(0f, 1f))
.height(trackHeight)
.clip(RoundedCornerShape(50))
.background(Color.White)
.align(Alignment.CenterStart)
)
// Thumb — visible only while dragging
if (isDragging) {
Box(
Modifier
.size(14.dp)
.offset(x = (maxWidth * displayProgress - 7.dp).coerceIn(0.dp, maxWidth - 14.dp))
.align(Alignment.CenterStart)
.clip(CircleShape)
.background(Color.White)
)
}
}
}
@Composable
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(
contentAlignment = Alignment.Center,
modifier = Modifier
.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 {
val s = (ms / 1000).coerceAtLeast(0)
return "%d:%02d".format(s / 60, s % 60)
}
@Composable
private fun LyricsLineItem(
text: String,
isActive: Boolean,
isPast: Boolean,
onSeek: () -> Unit,
) {
val animColor by animateColorAsState(
targetValue = when {
isActive -> Color.White
isPast -> Color.White.copy(alpha = 0.45f)
else -> Color.White.copy(alpha = 0.22f)
},
animationSpec = tween(300),
label = "lyricColor",
)
val animScale by animateFloatAsState(
targetValue = if (isActive) 1.04f else 1f,
animationSpec = tween(300),
label = "lyricScale",
)
Text(
text = text,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 28.sp,
color = animColor,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = animScale; scaleY = animScale }
.clickable { onSeek() }
.padding(horizontal = 20.dp, vertical = 6.dp),
)
}
@@ -0,0 +1,218 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
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.Done
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RemoveCircle
import androidx.compose.material.icons.filled.Shuffle
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
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.Song
import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.toSong
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.OfflineDetailViewModel
import com.novasonic.app.ui.component.Rollbalken
@Composable
fun OfflineDetailScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onPlay: (List<Song>, Int) -> Unit,
onBack: () -> Unit,
) {
val vm: OfflineDetailViewModel = viewModel()
val group by vm.group.collectAsStateWithLifecycle()
val tracks by vm.tracks.collectAsStateWithLifecycle()
var editMode by remember { mutableStateOf(false) }
// The group flow starts as null before Room emits — don't treat that initial null as "deleted".
var everLoaded by remember { mutableStateOf(false) }
LaunchedEffect(group) { if (group != null) everLoaded = true }
// Only leave once the group was loaded and has since disappeared (last song removed).
LaunchedEffect(everLoaded, group) { if (everLoaded && group == null) onBack() }
val g = group ?: run {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
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)
}
val listState = rememberLazyListState()
Box(Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), state = listState) {
item {
// ── Hero ─────────────────────────────────────────────────────────
Box(Modifier.fillMaxWidth()) {
AsyncImage(
model = coverUrl(g.coverArt),
contentDescription = g.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2),
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(
Brush.verticalGradient(
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))
Text(
"Offline · ${tracks.size} Titel",
fontFamily = Outfit, fontSize = 13.sp,
color = Color.White.copy(alpha = 0.65f),
)
}
}
// ── Play / Shuffle / Edit row ─────────────────────────────────────
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, 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 = { 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)
}
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,
// Position in der abspielbaren Liste suchen — die Anzeige enthält auch
// Titel, die noch fehlen, die Indizes decken sich also nicht.
onPlay = {
val i = spielbar.indexOfFirst { it.id == song.id }
if (i >= 0) onPlay(spielbar, i)
else android.widget.Toast.makeText(
ctx, "Dieser Titel ist noch nicht heruntergeladen",
android.widget.Toast.LENGTH_SHORT).show()
},
)
}
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)) }
}
Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
)
}
}
@@ -0,0 +1,225 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
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 coil.compose.AsyncImage
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.PartialAlbumState
private fun normTrack(s: String?) =
// Keep Unicode letters/numbers so Japanese track titles aren't all emptied to "" (which made
// every downloaded JP track "match" every missing one in the partial-album split).
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "").trim()
@Composable
fun PartialAlbumScreen(
partialAlbum: PartialAlbumState,
playerState: PlayerState,
coverUrl: (String?) -> String?,
previewingId: Long?,
onPlay: (List<Song>, Int) -> Unit,
onPreview: (ItunesItem) -> Unit,
onDownload: (ItunesItem) -> Unit,
onBack: () -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
) {
val ia = partialAlbum.itunesAlbum
val nd = partialAlbum.ndAlbum
val artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb")
?: coverUrl(nd.coverArt)
Column(Modifier.fillMaxSize().background(NsBackground)) {
// ── Header ────────────────────────────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = MaterialTheme.colorScheme.onSurface)
}
AsyncImage(
model = artUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(42.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(
nd.name, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
nd.artist ?: ia.artistName ?: "",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = { onDownload(ia) }) {
Icon(Icons.Default.Download, "Album laden", tint = NsAccent, modifier = Modifier.size(20.dp))
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
if (partialAlbum.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return@Column
}
// Cross-reference: which iTunes tracks have no ND match?
val ndNames = partialAlbum.ndSongs.map { normTrack(it.title) }.toSet()
val downloadedSongs = partialAlbum.ndSongs.sortedBy { it.track ?: 999 }
val missingTracks = partialAlbum.iTracks
.filter { normTrack(it.trackName) !in ndNames }
.sortedBy { it.trackNumber ?: 999 }
LazyColumn(Modifier.fillMaxSize()) {
// ── Play / Shuffle the downloaded tracks (you can still enjoy the album even when not
// every track is downloaded yet) ─────────────────────────────────────────────────
if (downloadedSongs.isNotEmpty()) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.background(NsAccent)
.clickable { onPlay(downloadedSongs, 0) }
.padding(vertical = 11.dp),
) {
Icon(Icons.Default.PlayArrow, null, tint = NsBackground, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Abspielen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsBackground)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.background(NsSurface2)
.clickable { onPlay(downloadedSongs, -1) }
.padding(vertical = 11.dp),
) {
Icon(Icons.Default.Shuffle, null, tint = NsOnSurface, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Zufall", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface)
}
}
}
}
// ── Downloaded tracks ─────────────────────────────────────────────
if (downloadedSongs.isNotEmpty()) {
item {
PartialSectionHeader(
title = "Heruntergeladen",
count = downloadedSongs.size,
accent = false,
)
}
itemsIndexed(downloadedSongs, key = { _, s -> "dl_${s.id}" }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(downloadedSongs, idx) },
onStarToggle = {},
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
}
// ── Missing tracks (iTunes only) ──────────────────────────────────
if (missingTracks.isNotEmpty()) {
item {
PartialSectionHeader(
title = "Nicht heruntergeladen",
count = missingTracks.size,
accent = true,
)
}
itemsIndexed(missingTracks, key = { _, t -> "miss_${t.trackId ?: 0L}" }) { _, track ->
ItunesArtistTrackRow(
track = track,
isPreviewing = previewingId == track.trackId,
onPreview = { onPreview(track) },
onDownload = { onDownload(track) },
)
}
}
if (downloadedSongs.isEmpty() && missingTracks.isEmpty()) {
item {
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Tracks gefunden", color = NsOnSurface2, fontFamily = Outfit)
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
@Composable
private fun PartialSectionHeader(title: String, count: Int, accent: Boolean) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp, end = 16.dp),
) {
Text(
title,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = if (accent) NsOnSurface2.copy(0.7f) else NsOnSurface2,
modifier = Modifier.weight(1f),
)
Text(
"$count",
fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2.copy(0.5f),
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(NsSurface2)
.padding(horizontal = 7.dp, vertical = 2.dp),
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.4f),
modifier = Modifier.padding(horizontal = 16.dp))
}
@@ -0,0 +1,405 @@
package com.novasonic.app.ui.screen
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.CircularProgressIndicator
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
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.Song
import com.novasonic.app.offline.DownloadGroupEntity
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.PlaylistViewModel
import com.novasonic.app.ui.component.Rollbalken
import com.novasonic.app.ui.formatGesamtdauer
@Composable
fun PlaylistDetailScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onDeleted: () -> Unit = {},
onBack: () -> Unit,
) {
val vm: PlaylistViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val context = LocalContext.current
var editMode by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
val downloadGroup by vm.downloadGroup.collectAsStateWithLifecycle()
val isDownloading by vm.isDownloading.collectAsStateWithLifecycle()
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri ?: return@rememberLauncherForActivityResult
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
vm.uploadCover(bytes, mimeType)
}
LaunchedEffect(state.deleted) { if (state.deleted) onDeleted() }
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
val pl = state.playlist ?: run {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
state.error ?: "Playlist nicht gefunden",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onBack) { Text("Zurück") }
}
}
return
}
val listState = rememberLazyListState()
Box(Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), state = listState) {
item {
// ── Hero ─────────────────────────────────────────────────────────
Box(Modifier.fillMaxWidth()) {
AsyncImage(
model = coverUrl(pl.coverArt),
contentDescription = pl.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(
Brush.verticalGradient(
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(
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)) }
}
Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
)
}
// ── Rename dialog ─────────────────────────────────────────────────────────
if (showRenameDialog) {
var nameInput by remember { mutableStateOf(pl.name) }
var commentInput by remember { mutableStateOf(pl.comment ?: "") }
AlertDialog(
onDismissRequest = { showRenameDialog = false },
title = { Text("Playlist bearbeiten", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = nameInput,
onValueChange = { nameInput = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = commentInput,
onValueChange = { commentInput = it },
label = { Text("Beschreibung") },
minLines = 2,
maxLines = 4,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
),
modifier = Modifier.fillMaxWidth(),
)
TextButton(
onClick = { showRenameDialog = false; imagePicker.launch("image/*") },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.AddAPhoto, null,
tint = NsAccent, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Cover hochladen", color = NsAccent, fontSize = 13.sp)
}
}
},
confirmButton = {
TextButton(
onClick = { vm.rename(nameInput, commentInput); showRenameDialog = false },
enabled = nameInput.isNotBlank(),
) {
Text("Speichern", color = NsAccent, fontFamily = Outfit, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showRenameDialog = false }) {
Text("Abbrechen", color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
// ── Delete confirmation dialog ────────────────────────────────────────────
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Playlist löschen?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
Text(
"\"${pl.name}\" wird dauerhaft gelöscht.",
fontFamily = Outfit, color = NsOnSurface2,
)
},
confirmButton = {
TextButton(
onClick = { showDeleteDialog = false; vm.delete() },
enabled = !state.isSaving,
) {
Text("Löschen", color = MaterialTheme.colorScheme.error,
fontFamily = Outfit, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Abbrechen", color = NsOnSurface2)
}
},
containerColor = NsSurface,
)
}
}
@@ -0,0 +1,123 @@
package com.novasonic.app.ui.screen
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.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.NsAccent
import com.novasonic.app.ui.theme.NsOnSurface2
import com.novasonic.app.viewmodel.SearchViewModel
@Composable
fun SearchScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onSongPlay: (List<Song>, Int) -> Unit,
onAlbumClick: (Album) -> Unit,
) {
val vm: SearchViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize()) {
SearchBar(
query = state.query,
onQueryChange = vm::onQueryChange,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
when {
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
state.query.isBlank() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Search, contentDescription = null,
tint = NsOnSurface2, modifier = Modifier.size(48.dp))
Spacer(Modifier.height(12.dp))
Text("Search songs, albums, artists",
style = MaterialTheme.typography.bodyMedium, color = NsOnSurface2)
}
}
state.searchResult != null -> {
val r = state.searchResult!!
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
if (r.song.isNotEmpty()) {
item { ResultHeader("Songs") }
items(r.song, key = { it.id }) { song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
// Play only the tapped song; Radio mode (if on) fills the rest.
onPlay = { onSongPlay(listOf(song), 0) },
)
}
}
if (r.album.isNotEmpty()) {
item { ResultHeader("Albums") }
items(r.album, key = { it.id }) { album ->
ListItem(
headlineContent = { Text(album.name) },
supportingContent = { Text(album.artist ?: "",
style = MaterialTheme.typography.labelSmall) },
modifier = Modifier.fillMaxWidth().clickable { onAlbumClick(album) },
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background),
)
}
}
if (r.artist.isNotEmpty()) {
item { ResultHeader("Artists") }
items(r.artist, key = { it.id }) { artist ->
ListItem(
headlineContent = { Text(artist.name) },
supportingContent = { Text("${artist.albumCount} albums",
style = MaterialTheme.typography.labelSmall) },
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background),
)
}
}
}
}
}
}
}
@Composable
private fun SearchBar(query: String, onQueryChange: (String) -> Unit, modifier: Modifier) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search…") },
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
singleLine = true,
modifier = modifier,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface2,
cursorColor = NsAccent,
),
)
}
@Composable
private fun ResultHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = NsAccent,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
@@ -0,0 +1,477 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
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.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ConnStatus
import com.novasonic.app.viewmodel.SettingsViewModel
private val SCROBBLE_OPTIONS = listOf(
"15s" to "15 Sekunden",
"30s" to "30 Sekunden",
"third" to "1/3 der Song-Länge",
"two_thirds" to "2/3 der Song-Länge",
"end" to "Song-Ende",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {}) {
val vm: SettingsViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
var showPassword by remember { mutableStateOf(false) }
var showAbsKey by remember { mutableStateOf(false) }
var showLogoutDialog by remember { mutableStateOf(false) }
var scrobbleExpanded by remember { mutableStateOf(false) }
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("Abmelden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = { Text("Wirklich abmelden? Alle lokalen Einstellungen werden gelöscht.", fontFamily = Outfit) },
confirmButton = {
TextButton(onClick = { vm.logout(); showLogoutDialog = false }) {
Text("Abmelden", color = MaterialTheme.colorScheme.error, fontFamily = Outfit)
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) { Text("Abbrechen", fontFamily = Outfit) }
},
containerColor = NsSurface,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
) {
Text(
"Einstellungen",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp,
color = NsOnSurface,
)
// ── 📊 Statistics (TOP) ──────────────────────────────────────────────
OutlinedButton(
onClick = onOpenStats,
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
OutlinedButton(
onClick = onOpenCompare,
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
Text("🎧 Musik-Match", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
// ── Save button (TOP) ────────────────────────────────────────────────
Spacer(Modifier.height(4.dp))
SaveButton(vm, state.isSaving, state.savedOk, state.error)
// ── 🎨 Theme ─────────────────────────────────────────────────────────
SectionHeader("🎨 Theme")
ThemePicker(currentThemeId = state.theme, onSelect = { id -> vm.onTheme(id); applyNsTheme(id) })
Spacer(Modifier.height(4.dp))
// ── 📊 Bibliothek ────────────────────────────────────────────────────
SectionHeader("📊 Bibliothek")
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.weight(1f)) {
Text("Abspielhäufigkeit anzeigen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text("Zeigt Wiedergabezähler in Song-Reihen an", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Switch(
checked = state.showPlayCount, onCheckedChange = vm::onShowPlayCount,
colors = SwitchDefaults.colors(
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
uncheckedThumbColor = NsOnSurface2,
),
)
}
// ── ▶ 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 }) {
OutlinedTextField(
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
onValueChange = {}, readOnly = true,
label = { Text("Scrobble senden nach", fontFamily = Outfit) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(scrobbleExpanded) },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(),
colors = fieldColors(),
)
ExposedDropdownMenu(expanded = scrobbleExpanded, onDismissRequest = { scrobbleExpanded = false }, containerColor = NsSurface2) {
SCROBBLE_OPTIONS.forEach { (key, label) ->
DropdownMenuItem(
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
onClick = { vm.onScrobbleMode(key); scrobbleExpanded = false },
colors = MenuDefaults.itemColors(textColor = if (key == state.scrobbleMode) NsAccent else NsOnSurface),
)
}
}
}
Spacer(Modifier.height(4.dp))
// ── 📚 Audiobookshelf ────────────────────────────────────────────────
SectionHeader("📚 Audiobookshelf")
NsTextField(
value = state.absUrl, onValueChange = vm::onAbsUrl,
label = "Server URL", placeholder = "https://audiobooks.beispiel.de",
keyboardType = KeyboardType.Uri,
)
NsTextField(
value = state.absApiKey, onValueChange = vm::onAbsApiKey,
label = "API Key",
hint = "Einstellungen → Profil → API Token in Audiobookshelf",
visualTransformation = if (showAbsKey) VisualTransformation.None else PasswordVisualTransformation(),
keyboardType = KeyboardType.Password,
trailingIcon = {
IconButton(onClick = { showAbsKey = !showAbsKey }) {
Icon(
if (showAbsKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null, tint = NsOnSurface2,
)
}
},
)
ConnStatusRow(status = state.absStatus, label = "Nicht konfiguriert", onTest = vm::testAbs)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.weight(1f)) {
Text("Kapitel-Fortschrittsanzeige", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text("Fortschrittsbalken zeigt aktuelles Kapitel statt Gesamtlänge", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Switch(
checked = state.absChapterTrackMode, onCheckedChange = vm::onAbsChapterTrackMode,
colors = SwitchDefaults.colors(
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
uncheckedThumbColor = NsOnSurface2,
),
)
}
Spacer(Modifier.height(4.dp))
// ── 🔑 Discover APIs ─────────────────────────────────────────────────
SectionHeader("🔑 Discover APIs")
NsTextField(
value = state.lastFmKey, onValueChange = vm::onLastFmKey,
label = "Last.fm API Key",
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(
value = state.backendUrl, onValueChange = vm::onBackendUrl,
label = "NovaSonic Backend URL",
hint = "Für Download-to-Server via yt-dlp",
keyboardType = KeyboardType.Uri,
)
ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend)
NsTextField(
value = state.scrobbleUrl, onValueChange = vm::onScrobbleUrl,
label = "NovaSonic Scrobble URL",
hint = "Play-Log für Statistiken (Port 3710)",
keyboardType = KeyboardType.Uri,
)
ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble)
Spacer(Modifier.height(4.dp))
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
SectionHeader("🌐 Navidrome Server")
NsTextField(
value = state.serverUrl, onValueChange = vm::onServerUrl,
label = "Server URL", placeholder = "https://musik.beispiel.de",
keyboardType = KeyboardType.Uri,
)
NsTextField(
value = state.username, onValueChange = vm::onUsername,
label = "Benutzername",
)
NsTextField(
value = state.password, onValueChange = vm::onPassword,
label = "Passwort",
hint = "(unverändert lassen wenn kein Reset gewünscht)",
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardType = KeyboardType.Password,
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null, tint = NsOnSurface2,
)
}
},
)
Spacer(Modifier.height(8.dp))
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
OutlinedButton(
onClick = { showLogoutDialog = true },
modifier = Modifier.fillMaxWidth().height(48.dp),
border = ButtonDefaults.outlinedButtonBorder.copy(
brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error)
),
) {
Text("Abmelden", color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun SaveButton(vm: SettingsViewModel, isSaving: Boolean, savedOk: Boolean, error: String?) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (error != null) {
Text(error, color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontSize = 13.sp)
}
if (savedOk) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Check, null, tint = NsAccent, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Gespeichert", color = NsAccent, fontFamily = Outfit, fontSize = 13.sp)
}
}
Button(
onClick = vm::save,
enabled = !isSaving,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
) {
if (isSaving) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
} else {
Text("Speichern", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
}
}
}
}
@Composable
private fun ConnStatusRow(status: ConnStatus, label: String, onTest: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
when (status) {
ConnStatus.Idle -> Spacer(Modifier.size(10.dp))
ConnStatus.Checking -> CircularProgressIndicator(modifier = Modifier.size(10.dp), strokeWidth = 1.5.dp, color = NsAccent)
is ConnStatus.Ok -> Box(Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF4CAF50)))
is ConnStatus.Err -> Box(Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFE53935)))
}
Spacer(Modifier.width(8.dp))
Text(
text = when (status) {
ConnStatus.Idle -> label
ConnStatus.Checking -> "Verbinde…"
is ConnStatus.Ok -> status.text
is ConnStatus.Err -> status.text
},
fontFamily = Outfit, fontSize = 12.sp,
color = when (status) {
is ConnStatus.Ok -> Color(0xFF4CAF50)
is ConnStatus.Err -> Color(0xFFE53935)
else -> NsOnSurface2
},
modifier = Modifier.weight(1f),
)
TextButton(onClick = onTest, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
Text("Testen", fontFamily = Outfit, fontSize = 12.sp, color = NsAccent)
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
letterSpacing = 0.5.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
HorizontalDivider(color = NsBorder)
Spacer(Modifier.height(4.dp))
}
@Composable
private fun NsTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
placeholder: String = "",
hint: String = "",
keyboardType: KeyboardType = KeyboardType.Text,
visualTransformation: VisualTransformation = VisualTransformation.None,
trailingIcon: (@Composable () -> Unit)? = null,
) {
Column {
OutlinedTextField(
value = value, onValueChange = onValueChange,
label = { Text(label, fontFamily = Outfit) },
placeholder = if (placeholder.isNotBlank()) { { Text(placeholder, color = NsOnSurface2) } } else null,
singleLine = true,
visualTransformation = visualTransformation,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
trailingIcon = trailingIcon,
modifier = Modifier.fillMaxWidth(),
colors = fieldColors(),
)
if (hint.isNotBlank()) {
Text(hint, fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
modifier = Modifier.padding(start = 4.dp, top = 2.dp))
}
}
}
@Composable
private fun ThemePicker(currentThemeId: String, onSelect: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
NS_THEMES.forEach { theme ->
val isActive = theme.id == currentThemeId
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(9.dp))
.background(if (isActive) theme.surface else NsSurface)
.border(
width = if (isActive) 2.dp else 1.dp,
color = if (isActive) theme.accent else NsBorder,
shape = RoundedCornerShape(9.dp),
)
.clickable { onSelect(theme.id) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Box(Modifier.size(22.dp).clip(CircleShape).background(theme.accent))
Spacer(Modifier.width(12.dp))
Text(
text = theme.name, fontFamily = Outfit,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
fontSize = 14.sp, color = if (isActive) theme.onSurface else NsOnSurface2,
modifier = Modifier.weight(1f),
)
if (isActive) Icon(Icons.Default.Check, null, tint = theme.accent, modifier = Modifier.size(18.dp))
}
}
}
}
@Composable
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent, focusedLabelColor = NsAccent, cursorColor = NsAccent,
unfocusedBorderColor = NsOnSurface3, unfocusedLabelColor = NsOnSurface2,
focusedTextColor = NsOnSurface, unfocusedTextColor = NsOnSurface,
)
@@ -0,0 +1,256 @@
package com.novasonic.app.ui.screen
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.rememberLazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.ui.component.Rollbalken
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
fun StarredScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onArtistClick: (String) -> Unit = {},
onAlbumClick: (String) -> Unit = {},
onOpenNews: () -> Unit = {},
onOpenFollowed: () -> Unit = {},
) {
val context = LocalContext.current
val repo = context.app.repository
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 error by remember { mutableStateOf<String?>(null) }
var sort by remember { mutableStateOf(StarredSort.NEWEST) }
LaunchedEffect(Unit) {
try {
val markiert = repo.getStarred()
songs = markiert.song
gefolgteKuenstler = markiert.artist.sortedBy { it.name.lowercase() }
gemerkteAlben = markiert.album.sortedByDescending { it.starred ?: "" }
loading = false
} catch (e: Exception) {
error = e.message
loading = false
}
}
val sorted = remember(songs, sort) {
when (sort) {
StarredSort.NEWEST -> songs.sortedByDescending { it.starred ?: "" }
StarredSort.OLDEST -> songs.sortedBy { it.starred ?: "" }
StarredSort.AZ -> songs.sortedBy { it.title.lowercase() }
StarredSort.BY_ARTIST -> songs.sortedBy { "${it.artist?.lowercase()}${it.title.lowercase()}" }
}
}
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 12.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
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, bottom = 12.dp),
) {
StarredSort.entries.forEach { s ->
FilterChip(
selected = sort == s,
onClick = { sort = s },
label = {
Text(when (s) {
StarredSort.NEWEST -> "Neueste"
StarredSort.OLDEST -> "Älteste"
StarredSort.AZ -> "A-Z"
StarredSort.BY_ARTIST -> "Künstler"
}, fontSize = 12.sp)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent,
selectedLeadingIconColor = NsAccent,
),
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
if (loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
} else if (error != null) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(error!!, color = MaterialTheme.colorScheme.error)
}
} else if (sorted.isEmpty() && gefolgteKuenstler.isEmpty() && gemerkteAlben.isEmpty()) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text("Noch keine Favoriten", color = NsOnSurface2)
}
} else {
val listState = rememberLazyListState()
Box(Modifier.fillMaxSize()) {
LazyColumn(state = listState) {
if (gefolgteKuenstler.isNotEmpty()) {
item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// 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)) }
}
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)
}
}
@@ -0,0 +1,347 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
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.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.ScrobbleStats
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.StatsView
import com.novasonic.app.viewmodel.StatsViewModel
private val MONTHS = listOf(
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
)
@Composable
fun StatsScreen(
coverUrl: (String?) -> String?,
onBack: () -> Unit,
onArtistClick: (String) -> Unit = {},
onAlbumClick: (String) -> Unit = {},
vm: StatsViewModel = viewModel(),
) {
val state by vm.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize().background(NsBackground)) {
// ── Top bar ──
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
}
Text(
"Meine Statistiken",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
color = NsOnSurface,
)
}
// ── Navigator: scope chips + year/month stepper ──
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) {
listOf(StatsView.ALL to "Alles", StatsView.YEAR to "Jahr", StatsView.MONTH to "Monat").forEach { (v, l) ->
val active = v == state.view
Text(
l,
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = if (active) Color.White else NsOnSurface2,
modifier = Modifier
.padding(end = 8.dp)
.clip(RoundedCornerShape(999.dp))
.background(if (active) NsAccent else Color.Transparent)
.clickable { vm.setView(v) }
.padding(horizontal = 14.dp, vertical = 7.dp),
)
}
if (state.view != StatsView.ALL) {
Spacer(Modifier.weight(1f))
StepArrow("‹", enabled = !vm.atStart()) { vm.step(-1) }
Text(
if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}",
fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface,
modifier = Modifier.padding(horizontal = 8.dp),
)
StepArrow("›", enabled = !vm.atCurrent()) { vm.step(1) }
}
}
when {
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
!state.configured -> CenterMessage(
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
)
state.error != null -> CenterMessage(state.error!!, NsError)
(state.stats?.totalPlays ?: 0) == 0 -> CenterMessage(
"Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.",
)
else -> StatsContent(state.stats!!, coverUrl, onArtistClick, onAlbumClick)
}
}
}
@Composable
private fun StatsContent(
d: ScrobbleStats,
coverUrl: (String?) -> String?,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
) {
// Tiles
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(bottom = 18.dp)) {
Tile(d.totalPlays.toString(), "Plays", Modifier.weight(1f))
Tile(fmtTime(d.totalSeconds), "Hörzeit", Modifier.weight(1f))
Tile(if (d.topArtists.size >= 10) "10+" else d.topArtists.size.toString(), "Künstler", Modifier.weight(1f))
}
if (d.series.isNotEmpty()) {
SectionHeader("Verlauf")
SeriesChart(d.series)
Spacer(Modifier.height(20.dp))
}
if (d.topArtists.isNotEmpty()) {
SectionHeader("Top-Künstler")
d.topArtists.forEachIndexed { i, a ->
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true,
onClick = a.artistId?.let { id -> { onArtistClick(id) } })
}
Spacer(Modifier.height(20.dp))
}
if (d.topTracks.isNotEmpty()) {
SectionHeader("Top-Songs")
d.topTracks.forEachIndexed { i, t ->
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays,
onClick = t.albumId?.let { id -> { onAlbumClick(id) } })
}
Spacer(Modifier.height(20.dp))
}
if (d.topAlbums.isNotEmpty()) {
SectionHeader("Top-Alben")
d.topAlbums.forEachIndexed { i, a ->
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays,
onClick = a.albumId?.let { id -> { onAlbumClick(id) } })
}
Spacer(Modifier.height(20.dp))
}
if (d.topGenres.isNotEmpty()) {
SectionHeader("Top-Genres")
d.topGenres.forEachIndexed { i, g -> GenreRow(i + 1, g.name ?: "—", g.plays) }
Spacer(Modifier.height(20.dp))
}
if (d.byHour.any { it.plays > 0 }) {
SectionHeader("Hör-Uhr (Stunde)")
HourClock(d.byHour.map { it.plays })
}
}
}
@Composable
private fun GenreRow(rank: Int, name: String, plays: Int) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
) {
Text(
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
color = NsOnSurface2, modifier = Modifier.width(22.dp),
)
Text(
name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
)
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
}
}
@Composable
private fun StepArrow(glyph: String, enabled: Boolean, onClick: () -> Unit) {
Text(
glyph,
fontFamily = Outfit, fontSize = 20.sp, fontWeight = FontWeight.Bold,
color = if (enabled) NsOnSurface else NsOnSurface2.copy(alpha = 0.35f),
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
@Composable
private fun Tile(value: String, label: String, modifier: Modifier = Modifier) {
Column(
modifier
.clip(RoundedCornerShape(12.dp))
.background(NsSurface2)
.padding(14.dp),
) {
Text(value, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, color = NsOnSurface, maxLines = 1)
Text(label, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, modifier = Modifier.padding(top = 2.dp))
}
}
@Composable
private fun SectionHeader(t: String) {
Text(
t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp),
)
}
@Composable
private fun TopRow(
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
onClick: (() -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(vertical = 5.dp),
) {
Text(
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
color = NsOnSurface2, modifier = Modifier.width(22.dp),
)
Box(
Modifier
.size(44.dp)
.clip(if (circular) CircleShape else RoundedCornerShape(6.dp))
.background(NsSurface2),
) {
AsyncImage(
model = cover, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!subtitle.isNullOrBlank()) {
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
}
}
@Composable
private fun SeriesChart(series: List<com.novasonic.app.api.model.StatBucket>) {
val max = (series.maxOfOrNull { it.plays } ?: 1).coerceAtLeast(1)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth().height(110.dp).horizontalScroll(rememberScrollState()),
) {
series.forEach { s ->
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(30.dp)) {
Box(
Modifier
.width(20.dp)
.height((6 + (84 * s.plays / max)).dp)
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
.background(NsAccent),
)
Text(
seriesLabel(s.bucket), fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
maxLines = 1, modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
@Composable
private fun HourClock(hours: List<Int>) {
val max = (hours.maxOrNull() ?: 1).coerceAtLeast(1)
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth().height(72.dp),
) {
hours.forEachIndexed { h, plays ->
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
Box(
Modifier
.fillMaxWidth()
.height((2 + (48 * plays / max)).dp)
.clip(RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp))
.background(if (plays > 0) NsAccent else NsSurface2),
)
Text(
if (h % 6 == 0) h.toString() else "",
fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 3.dp),
)
}
}
}
}
@Composable
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
}
@Composable
private fun CenterMessage(msg: String, color: Color = NsOnSurface2) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color)
}
}
// "3h 12m" / "12m" / "45s"
private fun fmtTime(sec: Long): String {
val h = sec / 3600
val m = (sec % 3600) / 60
return when {
h > 0 -> "${h}h ${m}m"
m > 0 -> "${m}m"
else -> "${sec}s"
}
}
// "2025-08" -> "08.25", "2025-08-03" -> "03.08", "2025" -> "2025"
private fun seriesLabel(b: String): String = when {
b.length == 7 -> b.substring(5) + "." + b.substring(2, 4)
b.length == 10 -> b.substring(8) + "." + b.substring(5, 7)
else -> b
}
@@ -0,0 +1,116 @@
package com.novasonic.app.ui.theme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
// ── Dynamic theme state — Compose tracks reads automatically ─────────────────
object NsColors {
var themeId by mutableStateOf("dark-ocean") // currently applied theme id (readable)
var accent by mutableStateOf(Color(0xFF4F8EF7))
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
var bg by mutableStateOf(Color(0xFF070D14))
var bg2 by mutableStateOf(Color(0xFF0C1520))
var surface by mutableStateOf(Color(0xFF182435))
var surface2 by mutableStateOf(Color(0xFF1F2F42))
var onSurface by mutableStateOf(Color(0xFFEDF2FF))
var onSurface2 by mutableStateOf(Color(0xFF7088AA))
var onSurface3 by mutableStateOf(Color(0xFF445566))
var border by mutableStateOf(Color(0x12FFFFFF))
var error by mutableStateOf(Color(0xFFCF6679))
}
// Convenience accessors — all reads in composables are state-tracked
val NsBackground get() = NsColors.bg
val NsBg2 get() = NsColors.bg2
val NsSurface get() = NsColors.surface
val NsSurface2 get() = NsColors.surface2
val NsAccent get() = NsColors.accent
val NsAccentDim get() = NsColors.accentDim
val NsOnSurface get() = NsColors.onSurface
val NsOnSurface2 get() = NsColors.onSurface2
val NsOnSurface3 get() = NsColors.onSurface3
val NsBorder get() = NsColors.border
val NsError get() = NsColors.error
// ── Theme definitions — mirrors src/themes.js ────────────────────────────────
data class NsTheme(
val id: String,
val name: String,
val accent: Color,
val accentDim: Color,
val bg: Color,
val bg2: Color,
val surface: Color,
val surface2: Color,
val onSurface: Color,
val onSurface2: Color,
val onSurface3: Color,
)
val NS_THEMES = listOf(
NsTheme(
"dark-ocean", "Dark Ocean",
Color(0xFF4F8EF7), Color(0xFF2A6CD6),
Color(0xFF070D14), Color(0xFF0C1520),
Color(0xFF182435), Color(0xFF1F2F42),
Color(0xFFEDF2FF), Color(0xFF7088AA), Color(0xFF445566),
),
NsTheme(
"dark-amber", "Dark Amber",
Color(0xFFE8A020), Color(0xFFC07010),
Color(0xFF07070F), Color(0xFF0D0D1A),
Color(0xFF1A1A2E), Color(0xFF22223A),
Color(0xFFF0EEFC), Color(0xFF8888AA), Color(0xFF555577),
),
NsTheme(
"forest", "Forest",
Color(0xFF4CAF7D), Color(0xFF2D7A52),
Color(0xFF060D08), Color(0xFF0B1410),
Color(0xFF162218), Color(0xFF1D2E20),
Color(0xFFEDFAF2), Color(0xFF7A9E85), Color(0xFF455C4A),
),
NsTheme(
"rose-pine", "Rosé Pine",
Color(0xFFEB6F92), Color(0xFFB8415E),
Color(0xFF191724), Color(0xFF1F1D2E),
Color(0xFF21202E), Color(0xFF403D52),
Color(0xFFE0DEF4), Color(0xFF908CAA), Color(0xFF6E6A86),
),
NsTheme(
"jade-green", "Jade",
Color(0xFF2ECC71), Color(0xFF1F9E55),
Color(0xFF07100C), Color(0xFF0C1813),
Color(0xFF162A21), Color(0xFF1E3A2D),
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) {
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
NsColors.themeId = t.id
NsColors.accent = t.accent
NsColors.accentDim = t.accentDim
NsColors.bg = t.bg
NsColors.bg2 = t.bg2
NsColors.surface = t.surface
NsColors.surface2 = t.surface2
NsColors.onSurface = t.onSurface
NsColors.onSurface2 = t.onSurface2
NsColors.onSurface3 = t.onSurface3
}
@@ -0,0 +1,29 @@
package com.novasonic.app.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
@Composable
fun NovaSonicTheme(content: @Composable () -> Unit) {
// Reads NsColors mutableState — recomposes automatically when theme changes
val colorScheme = darkColorScheme(
background = NsColors.bg,
surface = NsColors.surface,
surfaceVariant = NsColors.surface2,
primary = NsColors.accent,
onPrimary = NsColors.bg,
secondary = NsColors.accentDim,
onSecondary = NsColors.bg,
onBackground = NsColors.onSurface,
onSurface = NsColors.onSurface,
onSurfaceVariant = NsColors.onSurface2,
outline = NsColors.border,
error = NsColors.error,
)
MaterialTheme(
colorScheme = colorScheme,
typography = NovaSonicTypography,
content = content,
)
}
@@ -0,0 +1,47 @@
package com.novasonic.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.googlefonts.Font
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.unit.sp
import com.novasonic.app.R
private val provider = GoogleFont.Provider(
providerAuthority = "com.google.android.gms.fonts",
providerPackage = "com.google.android.gms",
certificates = R.array.com_google_android_gms_fonts_certs,
)
private val OutfitFont = GoogleFont("Outfit")
private val JetBrainsMonoFont = GoogleFont("JetBrains Mono")
val Outfit = FontFamily(
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Light),
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Normal),
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Medium),
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.SemiBold),
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Bold),
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.ExtraBold),
)
val JetBrainsMono = FontFamily(
Font(JetBrainsMonoFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Normal),
Font(JetBrainsMonoFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Medium),
)
val NovaSonicTypography = Typography(
displayLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 36.sp, letterSpacing = (-0.5).sp),
displayMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 28.sp, letterSpacing = (-0.25).sp),
titleLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, letterSpacing = 0.sp),
titleMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, letterSpacing = 0.15.sp),
titleSmall = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, letterSpacing = 0.1.sp),
bodyLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 16.sp, letterSpacing = 0.5.sp),
bodyMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 14.sp, letterSpacing = 0.25.sp),
bodySmall = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 12.sp, letterSpacing = 0.4.sp),
labelLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, letterSpacing = 0.1.sp),
labelMedium = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 12.sp, letterSpacing = 0.5.sp, color = NsOnSurface2),
labelSmall = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 11.sp, letterSpacing = 0.5.sp, color = NsOnSurface2),
)
@@ -0,0 +1,73 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Album
import com.novasonic.app.app
import com.novasonic.app.offline.DownloadGroupEntity
import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.GroupType
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
data class AlbumUiState(
val album: Album? = null,
val isLoading: Boolean = true,
val error: String? = null,
)
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
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 albumId: String? = savedState["albumId"]
private val _state = MutableStateFlow(AlbumUiState())
val state: StateFlow<AlbumUiState> = _state
val downloadGroup: StateFlow<DownloadGroupEntity?> = offlineRepo.allGroupsFlow
.map { groups -> groups.find { it.groupId == "album:$albumId" } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
/** True while any track in this album's group is queued or actively downloading. */
val isDownloading: StateFlow<Boolean> = offlineRepo.tracksForGroupFlow("album:$albumId")
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
init {
val id = albumId
if (id.isNullOrBlank()) {
_state.value = AlbumUiState(isLoading = false, error = "Keine Album-ID übergeben")
} else {
viewModelScope.launch {
try {
val album = repo.getAlbum(id)
_state.value = AlbumUiState(album = album, isLoading = false)
} catch (e: Exception) {
_state.value = AlbumUiState(error = e.message ?: e.toString(), isLoading = false)
}
}
}
}
fun downloadAlbum() {
val album = _state.value.album ?: return
viewModelScope.launch {
runCatching {
offlineRepo.enqueueGroup(
type = GroupType.ALBUM,
sourceId = album.id,
name = album.name,
coverArt = album.coverArt,
songs = album.song,
)
}.onFailure { android.util.Log.e("AlbumViewModel", "enqueueGroup failed", it) }
}
}
}
@@ -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)}"
}
}
@@ -0,0 +1,450 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.EventsClient
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.ItunesClient
import com.novasonic.app.api.model.Artist
import com.novasonic.app.api.model.ArtistOverride
import com.novasonic.app.api.model.ConcertEvent
import com.novasonic.app.api.model.ArtistInfo
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
data class ArtistUiState(
val artist: Artist? = null,
val info: ArtistInfo? = null,
val topSongs: List<Song> = emptyList(),
val isLoading: Boolean = true,
val error: String? = null,
// iTunes
val iAlbums: List<ItunesItem> = emptyList(),
val iTracks: List<ItunesItem> = emptyList(),
val itunesLoading: Boolean = false,
// Shuffle all
val isShuffling: Boolean = false,
val pendingShufflePlay: List<Song>? = null,
// Sub-album detail (tapped not-downloaded album)
val subAlbumItem: ItunesItem? = null,
val subAlbumTracks: List<ItunesItem>? = null,
val subAlbumTitle: String = "",
// Partial album detail (tapped incomplete album — shows downloaded + missing tracks)
val partialAlbum: PartialAlbumState? = null,
// Concerts
val events: List<ConcertEvent> = emptyList(),
val eventsError: String? = null, // non-null → the fetch failed (vs. genuinely no concerts)
val showEventsSheet: Boolean = false,
// Deezer fan count of the matched artist (shown under the name), null if unknown
val fanCount: Long? = null,
// Preview + download
val previewingId: Long? = null,
val downloadSheetItem: ItunesItem? = null,
// Artist pin (same-name disambiguation): the active pin, the image it overrides the hero with,
// and the picker state (link/id paste + Deezer search candidates).
val pin: ArtistOverride? = null,
val pinnedImage: String? = null,
// When pinned, the Similar-Artists row uses Deezer "related" (correct for the pinned artist)
// instead of Last.fm's list (keyed to the wrong/merged Navidrome artist).
val pinnedRelated: List<ItunesItem> = emptyList(),
val showPinPicker: Boolean = false,
val pinSearching: Boolean = false,
val pinCandidates: List<ItunesItem> = emptyList(),
)
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
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 preview = app.app.previewController
private val artistId: String? = savedState["artistId"]
private val _state = MutableStateFlow(ArtistUiState())
val state: StateFlow<ArtistUiState> = _state
// Scroll of the artist page + the "alle anzeigen" overlay, hoisted so tapping an album/artist and
// coming back returns you to the same spot (the artist route entry, hence this VM, is retained).
val pageListState = androidx.compose.foundation.lazy.LazyListState()
val allAlbumsGrid = androidx.compose.foundation.lazy.grid.LazyGridState()
val allAlbumsQuery = androidx.compose.runtime.mutableStateOf("")
init {
if (artistId.isNullOrBlank()) {
_state.value = ArtistUiState(isLoading = false, error = "Keine Künstler-ID übergeben")
} else {
load()
}
// Mirror the app-level preview so track rows show which clip plays (bar lives in AppNavigation).
viewModelScope.launch {
preview.state.collect { ps ->
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
}
}
}
fun load() {
val artistId = artistId ?: return
viewModelScope.launch {
_state.value = ArtistUiState(isLoading = true, itunesLoading = true)
try {
val artistDef = async { repo.getArtist(artistId) }
val infoDef = async { runCatching { repo.getArtistInfo(artistId) }.getOrNull() }
val artist = artistDef.await()
val info = infoDef.await()
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
// so the Deezer enrichment below uses the pinned artist, not a name-guess.
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() }
// Navidrome can hold the same track twice (single + album track) → duplicate top-song
// rows ("PIXELATED KISSES" x2). Collapse by title+album (keeps the highest play count).
val top = topDef.await().distinctBy { "${it.title}|${it.album}".lowercase() }
val pin = pinDef.await()
_state.value = ArtistUiState(
artist = artist, info = info, topSongs = top,
isLoading = false, itunesLoading = true,
pin = pin, pinnedImage = pin?.image?.takeIf { it.isNotBlank() },
)
// Fetch iTunes + events in parallel without blocking the main screen
launch { loadItunesData(artist.name, pin) }
launch {
val backendUrl = repo.getBackendUrl()
runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }
.onSuccess { _state.value = _state.value.copy(events = it, eventsError = null) }
.onFailure { e ->
android.util.Log.w("NovaSonic", "Konzert-Fetch fehlgeschlagen für ${artist.name}", e)
_state.value = _state.value.copy(events = emptyList(), eventsError = e.message ?: e.toString())
}
}
} catch (e: Exception) {
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
}
}
}
private suspend fun loadItunesData(artistName: String, pin: ArtistOverride? = null) {
// Pinned: skip the name-guess entirely and enrich straight from the pinned Deezer artist id.
val pinnedId = pin?.deezerArtistId?.toLongOrNull()
if (pinnedId != null) {
_state.value = _state.value.copy(
fanCount = pin.fanCount ?: _state.value.fanCount,
pinnedImage = pin.image.takeIf { it.isNotBlank() } ?: _state.value.pinnedImage,
)
coroutineScope {
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(pinnedId) }.getOrDefault(emptyList()) }
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(pinnedId, 10) }.getOrDefault(emptyList()) }
val relatedD = async { runCatching { ItunesClient.lookupRelatedArtists(pinnedId, 12) }.getOrDefault(emptyList()) }
_state.value = _state.value.copy(
iAlbums = iAlbumsD.await(), iTracks = iTracksD.await(),
pinnedRelated = relatedD.await(), itunesLoading = false,
)
}
enrichPartialCounts()
return
}
// Not pinned → clear any stale pinned-related from a previous pin.
_state.value = _state.value.copy(pinnedRelated = emptyList())
val searchResp = runCatching {
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
}.getOrNull()
val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
// artists are fan-sorted (ItunesClient), so firstOrNull gives the most popular match.
// Prefer an EXACT normalized-name match: "Flay!" must not resolve to the far more popular
// "K.FLAY" (its name merely contains "flay"). Only fall back to a loose contains-match.
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
val q = norm(artistName)
val iArtist = (
artists.firstOrNull { norm(it.artistName) == q }
?: artists.firstOrNull {
val n = it.artistName ?: return@firstOrNull false
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
}
?: artists.firstOrNull()
)
val iArtistId = iArtist?.artistId
if (iArtist?.fanCount != null) _state.value = _state.value.copy(fanCount = iArtist.fanCount)
if (iArtistId != null) {
coroutineScope {
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(iArtistId) }.getOrDefault(emptyList()) }
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(iArtistId, 10) }.getOrDefault(emptyList()) }
_state.value = _state.value.copy(
iAlbums = iAlbumsD.await(),
iTracks = iTracksD.await(),
itunesLoading = false,
)
}
enrichPartialCounts()
} else {
_state.value = _state.value.copy(itunesLoading = false)
}
}
/**
* Deezer's /artist/{id}/albums omits nb_tracks, so an owned album's real total is unknown and it
* never got flagged "Unvollständig" (iCount stayed 0). Mirror the web: for MATCHED (owned) albums
* whose Deezer trackCount is unknown, fetch the album's track list once to learn the real total,
* then update state so categorizeAlbums can detect partials. Only matched albums are enriched —
* a couple of calls, not the whole discography.
*/
private suspend fun enrichPartialCounts() {
val ndAlbums = _state.value.artist?.album ?: return
val iAlbums = _state.value.iAlbums
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
val enriched = coroutineScope {
iAlbums.map { ia ->
val cid = ia.collectionId
val known = (ia.trackCount ?: 0) > 0
val matched = ndAlbums.any { a ->
val n = norm(a.name); val i = norm(ia.collectionName)
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
}
if (!known && matched && cid != null) async {
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
if (n > 0) ia.copy(trackCount = n) else ia
} else async { ia }
}.map { it.await() }
}
_state.value = _state.value.copy(iAlbums = enriched)
}
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
fun loadSubAlbum(album: ItunesItem) {
val id = album.collectionId ?: return
_state.value = _state.value.copy(
subAlbumItem = album,
subAlbumTitle = album.collectionName ?: "",
subAlbumTracks = null,
)
viewModelScope.launch {
val tracks = runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
_state.value = _state.value.copy(subAlbumTracks = tracks)
}
}
fun dismissSubAlbum() {
_state.value = _state.value.copy(subAlbumItem = null, subAlbumTracks = null, subAlbumTitle = "")
}
// ── Partial album (incomplete album: shows downloaded + missing iTunes tracks) ──
fun loadPartialAlbum(nd: com.novasonic.app.api.model.Album, ia: ItunesItem) {
_state.value = _state.value.copy(
partialAlbum = PartialAlbumState(ndAlbum = nd, itunesAlbum = ia, isLoading = true),
)
viewModelScope.launch {
coroutineScope {
val ndSongsD = async { runCatching { repo.getAlbum(nd.id).song }.getOrDefault(emptyList()) }
val iTracksD = async {
ia.collectionId?.let { id ->
runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
} ?: emptyList()
}
_state.value = _state.value.copy(
partialAlbum = PartialAlbumState(
ndAlbum = nd,
itunesAlbum = ia,
ndSongs = ndSongsD.await(),
iTracks = iTracksD.await(),
isLoading = false,
),
)
}
}
}
fun dismissPartialAlbum() {
_state.value = _state.value.copy(partialAlbum = null)
}
// ── Download sheet ───────────────────────────────────────────────────────
fun shuffleAll() {
val artist = _state.value.artist ?: return
if (_state.value.isShuffling) return
viewModelScope.launch {
_state.value = _state.value.copy(isShuffling = true)
val allSongs = artist.album.flatMap { album ->
runCatching { repo.getAlbum(album.id).song }.getOrDefault(emptyList())
}
_state.value = _state.value.copy(
isShuffling = false,
pendingShufflePlay = allSongs.ifEmpty { null }, // gemischt wird im Player
)
}
}
fun consumeShufflePlay() {
_state.value = _state.value.copy(pendingShufflePlay = null)
}
fun openEventsSheet() { _state.value = _state.value.copy(showEventsSheet = true) }
fun dismissEventsSheet() { _state.value = _state.value.copy(showEventsSheet = false) }
fun showDownloadSheet(item: ItunesItem) {
_state.value = _state.value.copy(downloadSheetItem = item)
}
fun dismissDownloadSheet() {
_state.value = _state.value.copy(downloadSheetItem = null)
}
fun downloadItem(item: ItunesItem, type: String, onDone: (Boolean, String) -> Unit) {
viewModelScope.launch {
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
if (backendUrl.isBlank()) {
onDone(false, "Kein Backend konfiguriert — bitte in Einstellungen eintragen")
return@launch
}
val jobId = "job_${System.currentTimeMillis()}"
val artwork = item.artworkUrl100?.replace("100x100bb", "600x600bb")
val request = when (type) {
"album" -> DownloadRequest(
jobId = jobId, type = "album",
title = item.collectionName ?: item.artistName ?: "Unknown",
artist = item.artistName, artworkUrl = artwork,
collectionId = item.collectionId, itunesId = item.collectionId,
)
"artist" -> DownloadRequest(
jobId = jobId, type = "artist",
title = item.artistName ?: "Unknown",
artist = item.artistName, artworkUrl = artwork,
)
else -> DownloadRequest(
jobId = jobId, type = "track",
title = item.trackName ?: item.collectionName ?: "Unknown",
artist = item.artistName, album = item.collectionName,
artworkUrl = artwork, itunesId = item.trackId, collectionId = item.collectionId,
)
}
val client = ImporterClient(backendUrl)
runCatching { client.download(request) }
.onSuccess {
NotificationStore.startPoll(backendUrl, request.jobId, request.title, request.artist ?: "")
onDone(true, "✓ Download gestartet")
}
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
client.close()
}
}
// ── 30s preview (app-level PreviewController → global control bar, survives navigation) ──
fun togglePreview(track: ItunesItem) {
val url = track.previewUrl ?: return
val id = track.trackId ?: return
player.pause()
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
}
fun stopPreview() = preview.stop()
// ── Artist pin (same-name disambiguation) ────────────────────────────────
fun openPinPicker() {
_state.value = _state.value.copy(showPinPicker = true, pinCandidates = emptyList(), pinSearching = true)
val name = _state.value.artist?.name ?: return
viewModelScope.launch {
val res = runCatching { ItunesClient.search(name, entity = "musicArtist", limit = 8) }.getOrNull()
val cands = res?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
_state.value = _state.value.copy(pinCandidates = cands, pinSearching = false)
}
}
fun dismissPinPicker() { _state.value = _state.value.copy(showPinPicker = false) }
/** Pin one of the searched candidates. */
fun pinCandidate(item: ItunesItem) {
val dzId = item.artistId ?: return
applyPin(ArtistOverride(
deezerArtistId = dzId.toString(),
name = item.artistName ?: _state.value.artist?.name ?: "",
image = item.artworkUrl100 ?: "",
fanCount = item.fanCount,
))
}
/** Pin by a pasted Deezer link or bare id (the reliable route when search can't surface it). */
fun pinByLink(raw: String, onResult: (Boolean) -> Unit) {
val id = parseDeezerArtistId(raw)?.toLongOrNull()
if (id == null) { onResult(false); return }
viewModelScope.launch {
val a = runCatching { ItunesClient.lookupArtist(id) }.getOrNull()
if (a?.artistId == null) { onResult(false); return@launch }
applyPin(ArtistOverride(
deezerArtistId = a.artistId.toString(),
name = a.artistName ?: _state.value.artist?.name ?: "",
image = a.artworkUrl100 ?: "",
fanCount = a.fanCount,
))
onResult(true)
}
}
/**
* Resolve a (Deezer-related) artist name to a Navidrome library artist id, so a pinned artist's
* "Similar" chips are tappable when that artist is actually in the library. Exact name match only
* (case-insensitive) to avoid navigating to the wrong artist; null if not in the library.
*/
suspend fun libraryArtistId(name: String?): String? {
val n = name?.trim()?.lowercase() ?: return null
if (n.isEmpty()) return null
val res = runCatching { repo.search(n) }.getOrNull() ?: return null
return res.artist.firstOrNull { it.name.trim().lowercase() == n }?.id
}
fun clearPin() {
val id = artistId ?: return
viewModelScope.launch {
runCatching { repo.removeArtistOverride(id) }
_state.value = _state.value.copy(pin = null, pinnedImage = null, showPinPicker = false, itunesLoading = true)
loadItunesData(_state.value.artist?.name ?: "", null)
}
}
private fun applyPin(ov: ArtistOverride) {
val id = artistId ?: return
viewModelScope.launch {
runCatching { repo.setArtistOverride(id, ov) }
_state.value = _state.value.copy(
pin = ov,
pinnedImage = ov.image.takeIf { it.isNotBlank() },
showPinPicker = false,
itunesLoading = true,
)
loadItunesData(_state.value.artist?.name ?: "", ov)
}
}
// Pull a Deezer artist id out of a pasted URL (deezer.com/.../artist/12345) or a bare id.
private fun parseDeezerArtistId(raw: String): String? {
val s = raw.trim()
if (s.isEmpty()) return null
Regex("artist/(\\d+)").find(s)?.let { return it.groupValues[1] }
return if (s.matches(Regex("\\d+"))) s else null
}
// No onCleared stopPreview — the preview is app-level so it keeps playing (controllable via the
// global bar) after leaving this screen.
}
@@ -0,0 +1,68 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.CompareResult
import com.novasonic.app.api.model.ScrobbleUser
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
data class CompareUiState(
val loading: Boolean = true,
val configured: Boolean = true,
val error: String? = null,
val me: String = "",
val users: List<ScrobbleUser> = emptyList(), // candidates (excludes me)
val partner: String? = null, // chosen userB; null → show picker
val result: CompareResult? = null,
)
/** Head-to-Head "Musik-Match": pick a partner from the play-log, then compare. */
class CompareViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(CompareUiState())
val state: StateFlow<CompareUiState> = _state
init { loadUsers() }
fun loadUsers() {
viewModelScope.launch {
_state.value = CompareUiState(loading = true)
val settings = repo.appSettingsFlow().first()
if (settings.scrobbleUrl.isBlank()) {
_state.value = _state.value.copy(loading = false, configured = false)
return@launch
}
val resp = repo.fetchScrobbleUsers()
_state.value = if (resp == null)
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
else
_state.value.copy(
loading = false, configured = true, error = null,
me = resp.me,
users = resp.users.filter { it.user != resp.me },
)
}
}
/** Back to the partner picker. */
fun clearPartner() {
_state.value = _state.value.copy(partner = null, result = null, error = null)
}
fun choosePartner(name: String) {
_state.value = _state.value.copy(partner = name, loading = true, error = null, result = null)
viewModelScope.launch {
val res = repo.fetchScrobbleCompare(name)
_state.value = if (res == null)
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
else
_state.value.copy(loading = false, result = res, error = null)
}
}
}
@@ -0,0 +1,382 @@
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.ItunesClient
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.ItunesRssEntry
import com.novasonic.app.api.model.Song
import androidx.compose.foundation.lazy.LazyListState
import com.novasonic.app.api.model.artworkHd
import com.novasonic.app.app
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
enum class ExploreTab { LIBRARY, GLOBAL }
data class PartialAlbumState(
val ndAlbum: Album,
val itunesAlbum: ItunesItem,
val ndSongs: List<Song> = emptyList(),
val iTracks: List<ItunesItem> = emptyList(),
val isLoading: Boolean = true,
)
data class ItunesArtistState(
val artist: ItunesItem,
val isLoading: Boolean = true,
val ndAlbums: List<Album> = emptyList(),
val iAlbums: List<ItunesItem> = emptyList(),
val iTracks: List<ItunesItem> = emptyList(),
val topSongs: List<Song> = emptyList(),
val error: String? = null,
)
data class ExploreUiState(
val tab: ExploreTab = ExploreTab.LIBRARY,
val query: String = "",
// iTunes search results
val artists: List<ItunesItem> = emptyList(),
val albums: List<ItunesItem> = emptyList(),
val tracks: List<ItunesItem> = emptyList(),
// Album drill-down
val albumTracks: List<ItunesItem>? = null,
val albumTitle: String = "",
val albumItem: ItunesItem? = null,
// Artist detail (iTunes)
val artistDetail: ItunesArtistState? = null,
// Which "all albums" section is expanded inside the iTunes artist overlay (by title), or null.
// Hoisted here so it survives drilling into an album (nav route OR overlay) and coming back.
val artistAllAlbums: String? = null,
// Partial album detail (opened from artist's "Unvollständig" section)
val partialAlbum: PartialAlbumState? = null,
// Download sheet
val downloadSheetItem: ItunesItem? = null,
// Charts (shown when query is empty in GLOBAL tab)
val charts: List<ItunesRssEntry> = emptyList(),
// Loading states
val isSearching: Boolean = false,
val chartsLoading: Boolean = false,
// Preview
val previewingId: Long? = null, // trackId currently playing preview
val error: String? = null,
)
class ExploreViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val player = app.app.playerController
private val preview = app.app.previewController
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
val state: StateFlow<ExploreUiState> = _state
// Global-search results scroll — hoisted so it survives an album/artist drill-in and back.
val searchListState = LazyListState()
// "Alle anzeigen" overlay (Deezer artist albums): scroll + search query, hoisted so an album
// drill-in and back keeps both (the overlay itself leaves composition on drill-in).
val allAlbumsGrid = androidx.compose.foundation.lazy.grid.LazyGridState()
val allAlbumsQuery = androidx.compose.runtime.mutableStateOf("")
private var searchJob: Job? = null
init {
loadCharts()
// Mirror the app-level preview state so the track rows still show which clip is playing.
viewModelScope.launch {
preview.state.collect { ps ->
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
}
}
}
fun selectTab(tab: ExploreTab) {
_state.value = _state.value.copy(tab = tab, albumTracks = null)
}
fun onQueryChange(q: String) {
_state.value = _state.value.copy(query = q, albumTracks = null)
searchJob?.cancel()
if (q.isBlank()) {
_state.value = _state.value.copy(artists = emptyList(), albums = emptyList(), tracks = emptyList())
return
}
searchJob = viewModelScope.launch {
delay(350)
search(q)
}
}
private fun search(q: String) {
// Strip leading aesthetic punctuation (".diedlonely", "†…") so iTunes actually surfaces the
// artist instead of nothing. Falls back to the raw query if it was only symbols.
val term = q.trimStart { !it.isLetterOrDigit() }.trim().ifBlank { q }
viewModelScope.launch {
_state.value = _state.value.copy(isSearching = true, error = null)
try {
val resp = ItunesClient.search(term)
val results = resp.results
val artists = results.filter { it.wrapperType == "artist" }
_state.value = _state.value.copy(
artists = artists,
albums = results.filter { it.wrapperType == "collection" },
tracks = results.filter { it.wrapperType == "track" && it.kind == "song" },
isSearching = false,
)
enrichArtistArt(artists)
} catch (e: Exception) {
_state.value = _state.value.copy(isSearching = false, error = e.message)
}
}
}
/**
* iTunes artist entities carry no artwork, so search results render as grey circles. Mirror the
* web PWA: fetch one album per artist and borrow its cover. Runs after the main results are shown
* (list appears instantly) and only applies if the user is still viewing the same artist set.
*/
private fun enrichArtistArt(artists: List<ItunesItem>) {
if (artists.none { it.artworkUrl100.isNullOrBlank() && it.artistName != null }) return
viewModelScope.launch {
val enriched = artists.map { a ->
async {
if (!a.artworkUrl100.isNullOrBlank()) return@async a
val name = a.artistName ?: return@async a
val art = runCatching {
ItunesClient.search(name, entity = "album", limit = 1).results
.firstOrNull { it.wrapperType == "collection" }?.artworkUrl100
}.getOrNull()
if (art != null) a.copy(artworkUrl100 = art) else a
}
}.awaitAll()
val cur = _state.value.artists
if (cur.map { it.artistId } == artists.map { it.artistId }) {
_state.value = _state.value.copy(artists = enriched)
}
}
}
fun loadAlbum(album: ItunesItem) {
val id = album.collectionId ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isSearching = true, albumTracks = null,
albumTitle = album.collectionName ?: "", albumItem = album)
try {
val tracks = ItunesClient.lookupAlbum(id)
_state.value = _state.value.copy(albumTracks = tracks, isSearching = false)
} catch (e: Exception) {
_state.value = _state.value.copy(isSearching = false, error = e.message)
}
}
}
fun dismissAlbum() {
_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 closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) }
fun openArtistDetail(artist: ItunesItem) {
_state.value = _state.value.copy(
artistDetail = ItunesArtistState(artist = artist, isLoading = true),
artistAllAlbums = null,
)
viewModelScope.launch {
val artistId = artist.artistId
val artistName = artist.artistName ?: return@launch
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 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 ndAlbums = ndAlbumsD.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(
artistDetail = ItunesArtistState(
artist = voll,
isLoading = false,
ndAlbums = ndAlbums,
iAlbums = iAlbums,
iTracks = iTracksD.await(),
topSongs = topSongsD.await(),
),
)
// Deezer's artist-albums list omits nb_tracks, so owned albums never show "Unvollständig".
// Resolve the real total for matched albums (like web) so partials are detected.
val enriched = enrichPartialCounts(ndAlbums, iAlbums)
if (enriched !== iAlbums) {
_state.value.artistDetail?.let { d ->
if (d.artist.artistId == artist.artistId) {
_state.value = _state.value.copy(artistDetail = d.copy(iAlbums = enriched))
}
}
}
}
}
/** For MATCHED (owned) albums whose Deezer trackCount is unknown, fetch the album's track list
* once to learn the real total. Only owned albums are enriched (a couple of calls). */
private suspend fun enrichPartialCounts(
ndAlbums: List<Album>, iAlbums: List<ItunesItem>,
): List<ItunesItem> {
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return iAlbums
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
var changed = false
val out = coroutineScope {
iAlbums.map { ia ->
val cid = ia.collectionId
val known = (ia.trackCount ?: 0) > 0
val matched = ndAlbums.any { a ->
val n = norm(a.name); val i = norm(ia.collectionName)
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
}
if (!known && matched && cid != null) async {
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
if (n > 0) { changed = true; ia.copy(trackCount = n) } else ia
} else async { ia }
}.map { it.await() }
}
return if (changed) out else iAlbums
}
fun closeArtistDetail() {
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null, artistAllAlbums = null)
}
fun loadPartialAlbum(nd: Album, ia: ItunesItem) {
_state.value = _state.value.copy(
partialAlbum = PartialAlbumState(ndAlbum = nd, itunesAlbum = ia, isLoading = true),
)
viewModelScope.launch {
coroutineScope {
val ndSongsD = async { runCatching { repo.getAlbum(nd.id).song }.getOrDefault(emptyList()) }
val iTracksD = async {
ia.collectionId?.let { id ->
runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
} ?: emptyList()
}
_state.value = _state.value.copy(
partialAlbum = PartialAlbumState(
ndAlbum = nd,
itunesAlbum = ia,
ndSongs = ndSongsD.await(),
iTracks = iTracksD.await(),
isLoading = false,
),
)
}
}
}
fun dismissPartialAlbum() {
_state.value = _state.value.copy(partialAlbum = null)
}
fun showDownloadSheet(item: ItunesItem) {
_state.value = _state.value.copy(downloadSheetItem = item)
}
fun dismissDownloadSheet() {
_state.value = _state.value.copy(downloadSheetItem = null)
}
fun downloadItem(item: ItunesItem, type: String, onDone: (Boolean, String) -> Unit) {
viewModelScope.launch {
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
if (backendUrl.isBlank()) {
onDone(false, "Kein Backend konfiguriert — bitte in Einstellungen eintragen")
return@launch
}
val jobId = "job_${System.currentTimeMillis()}"
val artwork = item.artworkUrl100?.replace("100x100bb", "600x600bb")
val request = when (type) {
"album" -> DownloadRequest(
jobId = jobId, type = "album",
title = item.collectionName ?: item.artistName ?: "Unknown",
artist = item.artistName,
artworkUrl = artwork,
collectionId = item.collectionId,
itunesId = item.collectionId,
)
"artist" -> DownloadRequest(
jobId = jobId, type = "artist",
title = item.artistName ?: "Unknown",
artist = item.artistName,
artworkUrl = artwork,
)
else -> DownloadRequest(
jobId = jobId, type = "track",
title = item.trackName ?: item.collectionName ?: "Unknown",
artist = item.artistName,
album = item.collectionName,
artworkUrl = artwork,
itunesId = item.trackId,
collectionId = item.collectionId,
)
}
val client = ImporterClient(backendUrl)
runCatching { client.download(request) }
.onSuccess {
// App-lifetime poll → success/failure notification even after leaving the screen
// (previously only the artist page + importer notified; search/Bibliothek was silent).
NotificationStore.startPoll(
backendUrl, request.jobId, request.title, request.artist ?: "",
navType = type, navAlbum = item.collectionName ?: "", navArtist = item.artistName ?: "",
)
onDone(true, "✓ Download gestartet")
}
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
client.close()
}
}
private fun loadCharts() {
viewModelScope.launch {
try {
val entries = ItunesClient.topAlbums()
_state.value = _state.value.copy(charts = entries, chartsLoading = false)
} catch (e: Exception) {
_state.value = _state.value.copy(chartsLoading = false)
}
}
}
// ── 30s preview ─────────────────────────────────────────────────────────
fun togglePreview(track: ItunesItem) {
val url = track.previewUrl ?: return
val id = track.trackId ?: return
player.pause() // don't overlap the 30s clip with library playback
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
}
fun stopPreview() = preview.stop()
// NOTE: no onCleared stopPreview — the preview is app-level on purpose, so leaving the Explore
// tab keeps it playing with the global control bar (see AppNavigation).
}
@@ -0,0 +1,33 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Genre
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class GenreUiState(
val genres: List<Genre> = emptyList(),
val isLoading: Boolean = true,
val error: String? = null,
)
class GenreViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(GenreUiState())
val state: StateFlow<GenreUiState> = _state
init { load() }
fun load() {
viewModelScope.launch {
_state.value = GenreUiState(isLoading = true)
runCatching { repo.getGenres().filter { it.songCount > 0 }.sortedByDescending { it.songCount } }
.onSuccess { _state.value = GenreUiState(genres = it, isLoading = false) }
.onFailure { _state.value = GenreUiState(error = it.message, isLoading = false) }
}
}
}
@@ -0,0 +1,94 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.Genre
import com.novasonic.app.app
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class HomeUiState(
val heroAlbum: Album? = null,
val heroLabel: String = "",
val recentAlbums: List<Album> = emptyList(),
val recentlyPlayedAlbums: List<Album> = emptyList(),
val frequentAlbums: List<Album> = emptyList(),
val randomAlbums: List<Album> = emptyList(),
val genres: List<Genre> = emptyList(),
val username: String = "",
val isLoading: Boolean = false,
val error: String? = null,
)
class HomeViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(HomeUiState(isLoading = true))
val state: StateFlow<HomeUiState> = _state
init {
viewModelScope.launch {
repo.appSettingsFlow().collect { s ->
_state.value = _state.value.copy(username = s.username)
}
}
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val recentDef = async { runCatching { repo.getRecentAlbums(20) } }
val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) } }
val frequentDef = async { runCatching { repo.getFrequentAlbums(20) } }
val randomDef = async { runCatching { repo.getRandomAlbums(20) } }
val genresDef = async { runCatching { repo.getGenres() } }
val recentResult = recentDef.await()
val recentlyPlayedResult = recentlyPlayedDef.await()
val frequentResult = frequentDef.await()
val randomResult = randomDef.await()
val genresResult = genresDef.await()
if (recentResult.isFailure) {
_state.value = HomeUiState(error = recentResult.exceptionOrNull()?.message)
return@launch
}
val recent = recentResult.getOrDefault(emptyList())
val recentlyPlayed = recentlyPlayedResult.getOrDefault(emptyList())
val frequent = frequentResult.getOrDefault(emptyList())
val random = randomResult.getOrDefault(emptyList())
val genres = genresResult.getOrDefault(emptyList())
// Hero pool = frequent.take(5) ∪ recentlyPlayed.take(5), deduped — matches web
val frequentIds = frequent.take(5).map { it.id }.toSet()
val seen = mutableSetOf<String>()
val pool = (frequent.take(5) + recentlyPlayed.take(5))
.filter { it.id.isNotBlank() && seen.add(it.id) }
val hero = pool.randomOrNull()
val heroLabel = if (hero != null && frequentIds.contains(hero.id)) "MEISTGESPIELT" else "ZULETZT GEHÖRT"
_state.value = _state.value.copy(
heroAlbum = hero,
heroLabel = heroLabel,
recentAlbums = recent,
recentlyPlayedAlbums = recentlyPlayed,
frequentAlbums = frequent,
randomAlbums = random,
genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount },
isLoading = false,
error = null,
)
} catch (e: Exception) {
_state.value = HomeUiState(error = e.message)
}
}
}
}
@@ -0,0 +1,245 @@
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.model.DownloadRequest
import com.novasonic.app.api.model.ImportResolveResponse
import com.novasonic.app.api.model.ImportTrack
import com.novasonic.app.app
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class ImporterUiState(
val backendUrl: String = "",
val link: String = "",
val platform: String? = null, // spotify | soundcloud | youtube
val isResolving: Boolean = false,
val resolved: ImportResolveResponse? = null,
val error: String? = null,
// Download job
val isDownloading: Boolean = false,
val jobStatus: String? = null, // pending | downloading | done | error
val jobMessage: String? = null,
val jobCompleted: Int = 0,
val jobTotal: Int = 0,
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or 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) {
private val repo = app.app.repository
private val player = app.app.playerController
private val preview = app.app.previewController
private val _state = MutableStateFlow(ImporterUiState())
val state: StateFlow<ImporterUiState> = _state
init {
viewModelScope.launch {
repo.appSettingsFlow().collect { s ->
_state.value = _state.value.copy(backendUrl = s.backendUrl)
}
}
// Mirror the app-level preview so the track rows show which clip is playing.
viewModelScope.launch {
preview.state.collect { ps ->
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
}
}
}
// ── 30s preview ─────────────────────────────────────────────────────────
// Import tracks have no numeric id, so key the app-level preview by the previewUrl's hash.
fun previewId(track: ImportTrack): Long = track.previewUrl?.hashCode()?.toLong() ?: 0L
fun togglePreview(track: ImportTrack) {
val url = track.previewUrl ?: return
player.pause() // don't overlap the 30s clip with library playback
preview.toggle(
previewId(track), url,
track.trackName ?: "", track.artistName ?: "", track.artworkUrl100,
)
}
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) {
_state.value = _state.value.copy(
link = v,
platform = ImporterClient.platformFor(v.trim()),
resolved = null,
error = null,
)
}
fun resolve() {
val s = _state.value
val url = s.link.trim()
if (url.isBlank()) return
if (s.backendUrl.isBlank()) {
_state.value = s.copy(error = "Backend-URL in Einstellungen konfigurieren")
return
}
val platform = ImporterClient.platformFor(url)
if (platform == null) {
_state.value = s.copy(error = "Nicht unterstützter Link. Spotify, YouTube oder SoundCloud einfügen.")
return
}
viewModelScope.launch {
_state.value = _state.value.copy(isResolving = true, error = null, resolved = null)
val client = ImporterClient(s.backendUrl)
try {
val resp = client.resolve(platform, url)
if (resp.error != null) {
_state.value = _state.value.copy(isResolving = false, error = mapError(resp.error))
} else {
_state.value = _state.value.copy(isResolving = false, resolved = resp)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
isResolving = false,
error = "Backend nicht erreichbar: ${e.message}",
)
} finally {
client.close()
}
}
}
fun downloadAll() {
val s = _state.value
val resolved = s.resolved ?: return
if (s.backendUrl.isBlank() || resolved.tracks.isEmpty()) return
val jobId = "job_${System.currentTimeMillis()}"
viewModelScope.launch {
_state.value = _state.value.copy(
isDownloading = true, jobStatus = "pending", jobMessage = null,
jobCompleted = 0, jobTotal = resolved.tracks.size,
)
val client = ImporterClient(s.backendUrl)
try {
client.download(
DownloadRequest(
jobId = jobId,
type = if (resolved.type == "track") "track" else "playlist",
title = resolved.name ?: "Import",
artist = resolved.artist,
artworkUrl = resolved.artworkUrl,
tracks = resolved.tracks,
)
)
// App-lifetime poll → notification delivered even if user navigates away
NotificationStore.startPoll(
backendUrl = s.backendUrl,
jobId = jobId,
title = resolved.name ?: "Import",
artist = resolved.artist ?: "",
navType = resolved.type ?: "",
navAlbum = resolved.name ?: "",
navArtist = resolved.artist ?: "",
)
// In-screen progress poll (cancelled on navigation — only for UI)
pollJob(client, jobId)
} catch (e: Exception) {
_state.value = _state.value.copy(
isDownloading = false, jobStatus = "error",
jobMessage = "Download fehlgeschlagen: ${e.message}",
)
client.close()
}
}
}
private suspend fun pollJob(client: ImporterClient, jobId: String) {
val s = _state.value
val title = s.resolved?.name ?: "Import"
val artist = s.resolved?.artist ?: ""
try {
repeat(600) { // up to ~20 min at 2s interval
delay(2000)
val st = runCatching { client.status(jobId) }.getOrNull() ?: return@repeat
_state.value = _state.value.copy(
jobStatus = st.status,
jobMessage = st.message,
jobCompleted = st.completed ?: _state.value.jobCompleted,
jobTotal = st.total ?: _state.value.jobTotal,
)
if (st.status == "done" || st.status == "error") {
_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
}
}
_state.value = _state.value.copy(isDownloading = false)
} finally {
client.close()
}
}
/**
* 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) {
"EDITORIAL_PLAYLIST" ->
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
"RATE_LIMITED" -> "Spotify Rate-Limit erreicht — bitte kurz warten."
else -> code
}
}
@@ -0,0 +1,147 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.ArtistIndex
import com.novasonic.app.api.model.Playlist
import com.novasonic.app.app
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class LibraryTab { PLAYLISTS, OFFLINE, ALBUMS, ARTISTS }
enum class AlbumSort { NEWEST, OLDEST, AZ }
data class LibraryUiState(
val tab: LibraryTab = LibraryTab.PLAYLISTS,
// Two pre-fetched lists — avoids re-fetch when switching sort
val albumsByNewest: List<Album> = emptyList(), // type=newest (Navidrome add-date)
val albumsByName: List<Album> = emptyList(), // type=alphabeticalByName (for AZ + Oldest)
val albumSort: AlbumSort = AlbumSort.NEWEST,
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(),
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
val currentUser: String = "",
val isLoading: Boolean = false,
val error: String? = null,
)
class LibraryViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
val state: StateFlow<LibraryUiState> = _state
// Scroll positions live in the VM so they survive drilling into an album/artist and back (the
// Library back-stack entry, hence this VM, is retained). A full teardown of the tab → fresh VM
// → back at the top, as expected.
val albumsGridState = LazyGridState()
val artistsListState = LazyListState()
init {
loadPlaylists()
viewModelScope.launch {
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
_state.value = _state.value.copy(currentUser = u)
}
}
fun setAlbumSort(sort: AlbumSort) {
if (sort == _state.value.albumSort) return
_state.value = _state.value.copy(albumSort = sort)
// Eine andere Sortierung heißt eine andere Liste — dort oben anfangen. Bewusst hier und
// nicht als LaunchedEffect im Screen: der feuert auch bei jedem Neu-Composen (z. B. nach
// dem Zurück aus einem Album) und hätte die gemerkte Scrollposition jedes Mal verworfen.
viewModelScope.launch { albumsGridState.scrollToItem(0) }
}
fun selectTab(tab: LibraryTab) {
_state.value = _state.value.copy(tab = tab)
when (tab) {
LibraryTab.ALBUMS -> if (_state.value.albumsByNewest.isEmpty() && _state.value.albumsByName.isEmpty()) loadAlbums()
LibraryTab.ARTISTS -> if (_state.value.artistIndexes.isEmpty()) loadArtists()
LibraryTab.PLAYLISTS -> if (_state.value.playlists.isEmpty()) loadPlaylists()
LibraryTab.OFFLINE -> Unit
}
}
private fun loadAlbums() = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val newestDef = async { runCatching { repo.getRecentAlbums(500) } }
val azDef = async { runCatching { repo.getAlphaAlbums(500) } }
val newestResult = newestDef.await()
val azResult = azDef.await()
if (newestResult.isFailure) {
_state.value = _state.value.copy(error = newestResult.exceptionOrNull()?.message, isLoading = false)
return@launch
}
_state.value = _state.value.copy(
albumsByNewest = newestResult.getOrDefault(emptyList()),
albumsByName = azResult.getOrDefault(emptyList()),
isLoading = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message, isLoading = false)
}
}
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 {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val indexes = repo.getArtists()
_state.value = _state.value.copy(artistIndexes = indexes, isLoading = false)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message, isLoading = false)
}
}
private fun loadPlaylists() = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val pls = repo.getPlaylists()
_state.value = _state.value.copy(playlists = pls, isLoading = false)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message, isLoading = false)
}
}
fun createPlaylist(name: String, onSuccess: (String) -> Unit = {}) {
val trimmed = name.trim()
if (trimmed.isBlank()) return
viewModelScope.launch {
try {
val newPl = repo.createPlaylist(trimmed, emptyList())
val pls = runCatching { repo.getPlaylists() }.getOrDefault(_state.value.playlists)
_state.value = _state.value.copy(playlists = pls)
onSuccess(newPl.id)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message)
}
}
}
fun reloadPlaylists() = loadPlaylists()
}
@@ -0,0 +1,44 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.SubsonicClient
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class LoginUiState(
val isLoading: Boolean = false,
val success: Boolean = false,
val error: String? = null,
)
class LoginViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state
fun login(serverUrl: String, username: String, password: String) {
viewModelScope.launch {
_state.value = LoginUiState(isLoading = true)
try {
val client = SubsonicClient(serverUrl, username, password)
client.ping()
client.close()
repo.saveCredentials(serverUrl, username, password)
_state.value = LoginUiState(success = true)
} catch (e: Exception) {
val msg = when {
e.message?.contains("Unable to resolve host") == true -> "Cannot reach server. Check the URL."
e.message?.contains("40") == true -> "Wrong username or password."
else -> e.message ?: "Connection failed."
}
_state.value = LoginUiState(error = msg)
}
}
}
}
@@ -0,0 +1,46 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.LrcLibClient
import com.novasonic.app.api.LrcLine
import com.novasonic.app.api.parseLrc
import com.novasonic.app.api.model.Song
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class LyricsUiState(
val lines: List<LrcLine> = emptyList(), // non-empty = synced
val plainText: String = "", // fallback plain lyrics
val isLoading: Boolean = false,
val notFound: Boolean = false,
val songId: String = "",
)
class LyricsViewModel(app: Application) : AndroidViewModel(app) {
private val _state = MutableStateFlow(LyricsUiState())
val state: StateFlow<LyricsUiState> = _state
fun loadForSong(song: Song) {
if (_state.value.songId == song.id && !_state.value.isLoading) return
_state.value = LyricsUiState(isLoading = true, songId = song.id)
viewModelScope.launch {
val result = LrcLibClient.getLyrics(
artist = song.artist ?: "",
title = song.title,
album = song.album,
durationSecs = song.duration,
)
_state.value = when {
result == null -> LyricsUiState(notFound = true, songId = song.id)
result.instrumental -> LyricsUiState(plainText = "🎵 Instrumentalstück", songId = song.id)
!result.syncedLyrics.isNullOrBlank() -> LyricsUiState(lines = parseLrc(result.syncedLyrics), songId = song.id)
!result.plainLyrics.isNullOrBlank() -> LyricsUiState(plainText = result.plainLyrics, songId = song.id)
else -> LyricsUiState(notFound = true, songId = song.id)
}
}
}
}
@@ -0,0 +1,33 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.offline.DownloadGroupEntity
import com.novasonic.app.offline.DownloadedTrackEntity
import com.novasonic.app.offline.toSong
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class OfflineDetailViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val offlineRepo = app.app.offlineRepository
// groupId is passed URL-encoded in the route; Navigation decodes it back to "album:xyz".
private val groupId: String = savedState["groupId"] ?: ""
val group: StateFlow<DownloadGroupEntity?> = offlineRepo.allGroupsFlow
.map { groups -> groups.find { it.groupId == groupId } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val tracks: StateFlow<List<DownloadedTrackEntity>> = offlineRepo.tracksForGroupFlow(groupId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun songs(): List<Song> = tracks.value.map { it.toSong() }
fun deleteSong(songId: String) {
viewModelScope.launch { offlineRepo.removeSongFromGroup(groupId, songId) }
}
}
@@ -0,0 +1,38 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.offline.DownloadGroupEntity
import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.toSong
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class OfflineViewModel(app: Application) : AndroidViewModel(app) {
private val offlineRepo = app.app.offlineRepository
val groups: StateFlow<List<DownloadGroupEntity>> = offlineRepo.allGroupsFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val storageBytes: StateFlow<Long> = groups
.map { withContext(Dispatchers.IO) { offlineRepo.storageUsedBytes() } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L)
fun deleteGroup(groupId: String) {
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> =
offlineRepo.tracksForGroup(groupId)
.filter { it.state == DownloadState.DONE }
.map { it.toSong() }
}
@@ -0,0 +1,169 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.PlaylistWithSongs
import com.novasonic.app.app
import com.novasonic.app.offline.DownloadGroupEntity
import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.GroupType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class PlaylistUiState(
val playlist: PlaylistWithSongs? = null,
val isLoading: Boolean = true,
val isSaving: Boolean = false,
val deleted: Boolean = false,
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) {
private val repo = app.app.repository
private val offlineRepo = app.app.offlineRepository
private val playlistId: String? = savedState["playlistId"]
private val _state = MutableStateFlow(PlaylistUiState())
val state: StateFlow<PlaylistUiState> = _state
val downloadGroup: StateFlow<DownloadGroupEntity?> = offlineRepo.allGroupsFlow
.map { groups -> groups.find { it.groupId == "playlist:$playlistId" } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
/** True while any track in this playlist's group is queued or actively downloading. */
val isDownloading: StateFlow<Boolean> = offlineRepo.tracksForGroupFlow("playlist:$playlistId")
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
init {
load()
viewModelScope.launch {
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
_state.value = _state.value.copy(currentUser = u)
}
}
fun load() {
val id = playlistId
if (id.isNullOrBlank()) {
_state.value = PlaylistUiState(isLoading = false, error = "Keine Playlist-ID übergeben")
return
}
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val pl = repo.getPlaylist(id)
_state.value = PlaylistUiState(playlist = pl, isLoading = false)
} catch (e: Exception) {
_state.value = PlaylistUiState(isLoading = false, error = e.message ?: e.toString())
}
}
}
fun rename(newName: String, newComment: String? = null) {
val id = playlistId ?: return
val pl = _state.value.playlist ?: return
val trimmed = newName.trim()
if (trimmed.isBlank()) return
val comment = newComment?.trim() ?: pl.comment
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.updatePlaylistMeta(id, trimmed, comment)
_state.value = _state.value.copy(
playlist = pl.copy(name = trimmed, comment = comment),
isSaving = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun delete() {
val id = playlistId ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.deletePlaylist(id)
_state.value = _state.value.copy(deleted = true, isSaving = false)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun uploadCover(imageBytes: ByteArray, mimeType: String) {
val id = playlistId ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.uploadPlaylistCover(id, imageBytes, mimeType)
// Reload to get the updated coverArt ID from Navidrome
val pl = repo.getPlaylist(id)
_state.value = _state.value.copy(playlist = pl, isSaving = false)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun removeCover() {
val id = playlistId ?: return
val pl = _state.value.playlist ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
try {
repo.removePlaylistCover(id)
_state.value = _state.value.copy(
playlist = pl.copy(coverArt = null),
isSaving = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun downloadPlaylist() {
val pl = _state.value.playlist ?: return
viewModelScope.launch {
runCatching {
offlineRepo.enqueueGroup(
type = GroupType.PLAYLIST,
sourceId = pl.id,
name = pl.name,
coverArt = pl.coverArt,
songs = pl.entry,
)
}.onFailure { android.util.Log.e("PlaylistViewModel", "downloadPlaylist failed", it) }
}
}
fun removeSong(index: Int) {
val id = playlistId ?: return
val pl = _state.value.playlist ?: return
if (index < 0 || index >= pl.entry.size) return
viewModelScope.launch {
try {
repo.removeSongsFromPlaylist(id, listOf(index))
val newSongs = pl.entry.toMutableList().also { it.removeAt(index) }
_state.value = _state.value.copy(
playlist = pl.copy(entry = newSongs, songCount = newSongs.size)
)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message)
}
}
}
}
@@ -0,0 +1,64 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.compose.foundation.lazy.LazyListState
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.ArtistOverride
import com.novasonic.app.api.model.SearchResult3
import com.novasonic.app.app
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class SearchUiState(
val query: String = "",
val searchResult: SearchResult3? = null,
val isLoading: Boolean = false,
val error: String? = null,
// Artist pins (by Navidrome artist id) so a pinned artist shows the correct picture in results.
val overrides: Map<String, ArtistOverride> = emptyMap(),
)
class SearchViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(SearchUiState())
val state: StateFlow<SearchUiState> = _state
// Results scroll — hoisted so it survives a drill-in/back within the search.
val listState = LazyListState()
private var searchJob: Job? = null
init {
viewModelScope.launch {
val ov = runCatching { repo.artistOverrides() }.getOrDefault(emptyMap())
if (ov.isNotEmpty()) _state.value = _state.value.copy(overrides = ov)
}
}
fun onQueryChange(q: String) = onSearch(q)
fun onSearch(q: String) {
_state.value = _state.value.copy(query = q)
searchJob?.cancel()
if (q.isBlank()) {
_state.value = _state.value.copy(searchResult = null, isLoading = false)
return
}
searchJob = viewModelScope.launch {
delay(300)
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val results = repo.search(q)
_state.value = _state.value.copy(searchResult = results, isLoading = false)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message, isLoading = false)
}
}
}
}
@@ -0,0 +1,207 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.AbsClient
import com.novasonic.app.app
import com.novasonic.app.data.PrefsStore
import com.novasonic.app.ui.theme.NsColors
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
sealed class ConnStatus {
object Idle : ConnStatus()
object Checking : ConnStatus()
data class Ok(val text: String) : ConnStatus()
data class Err(val text: String) : ConnStatus()
}
data class SettingsUiState(
val serverUrl: String = "",
val username: String = "",
val password: String = "",
val lastFmKey: String = "",
val backendUrl: String = "",
val scrobbleUrl: String = "",
val scrobbleMode: String = "30s",
val showPlayCount: Boolean = false,
val theme: String = "dark-ocean",
val absUrl: String = "",
val absApiKey: String = "",
val absChapterTrackMode: Boolean = false,
val downloadWifiOnly: Boolean = true,
val wischenWechseltTitel: Boolean = true,
val isSaving: Boolean = false,
val savedOk: Boolean = false,
val error: String? = null,
val absStatus: ConnStatus = ConnStatus.Idle,
val backendStatus: ConnStatus = ConnStatus.Idle,
val scrobbleStatus: ConnStatus = ConnStatus.Idle,
)
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
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.
private val _state = MutableStateFlow(SettingsUiState(theme = NsColors.themeId))
val state: StateFlow<SettingsUiState> = _state
init {
viewModelScope.launch {
repo.appSettingsFlow().collect { s ->
_state.value = _state.value.copy(
serverUrl = s.serverUrl,
username = s.username,
password = s.password,
lastFmKey = s.lastFmKey,
backendUrl = s.backendUrl,
scrobbleUrl = s.scrobbleUrl,
scrobbleMode = s.scrobbleMode,
showPlayCount = s.showPlayCount,
theme = s.theme,
absUrl = s.absUrl,
absApiKey = s.absApiKey,
absChapterTrackMode = s.absChapterTrackMode,
downloadWifiOnly = s.downloadWifiOnly,
wischenWechseltTitel = s.wischenWechseltTitel,
)
}
}
}
fun onServerUrl(v: String) = update { it.copy(serverUrl = v, savedOk = false) }
fun onUsername(v: String) = update { it.copy(username = v, savedOk = false) }
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) }
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) }
fun onScrobbleUrl(v: String) = update { it.copy(scrobbleUrl = v, savedOk = false, scrobbleStatus = ConnStatus.Idle) }
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
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 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) {
update { it.copy(theme = id) }
viewModelScope.launch { repo.saveTheme(id) }
}
fun testAbs() {
val url = _state.value.absUrl.trim().trimEnd('/')
val key = _state.value.absApiKey.trim()
if (url.isBlank() || key.isBlank()) {
update { it.copy(absStatus = ConnStatus.Err("URL und API Key eingeben")) }
return
}
update { it.copy(absStatus = ConnStatus.Checking) }
viewModelScope.launch {
val backend = _state.value.backendUrl.trim().trimEnd('/')
val ok = runCatching { AbsClient(url, key, backend).ping() }.getOrDefault(false)
update { it.copy(absStatus = if (ok) ConnStatus.Ok("Verbunden · $url") else ConnStatus.Err("Nicht erreichbar")) }
}
}
fun testBackend() {
val url = _state.value.backendUrl.trim().trimEnd('/')
if (url.isBlank()) {
update { it.copy(backendStatus = ConnStatus.Err("Backend URL eingeben")) }
return
}
update { it.copy(backendStatus = ConnStatus.Checking) }
viewModelScope.launch {
try {
val http = HttpClient(Android) {
install(HttpTimeout) { requestTimeoutMillis = 5_000; connectTimeoutMillis = 4_000 }
}
val resp = http.get("$url/api/health")
http.close()
if (resp.status == HttpStatusCode.OK) {
update { it.copy(backendStatus = ConnStatus.Ok("Verbunden · $url")) }
} else {
update { it.copy(backendStatus = ConnStatus.Err("HTTP ${resp.status.value}")) }
}
} catch (e: Exception) {
update { it.copy(backendStatus = ConnStatus.Err("Nicht erreichbar")) }
}
}
}
fun testScrobble() {
val url = _state.value.scrobbleUrl.trim().trimEnd('/')
if (url.isBlank()) {
update { it.copy(scrobbleStatus = ConnStatus.Err("Scrobble URL eingeben")) }
return
}
update { it.copy(scrobbleStatus = ConnStatus.Checking) }
viewModelScope.launch {
try {
val http = HttpClient(Android) {
install(HttpTimeout) { requestTimeoutMillis = 5_000; connectTimeoutMillis = 4_000 }
}
val resp = http.get("$url/scrobble/health")
http.close()
if (resp.status == HttpStatusCode.OK) {
update { it.copy(scrobbleStatus = ConnStatus.Ok("Verbunden · $url")) }
} else {
update { it.copy(scrobbleStatus = ConnStatus.Err("HTTP ${resp.status.value}")) }
}
} catch (e: Exception) {
update { it.copy(scrobbleStatus = ConnStatus.Err("Nicht erreichbar")) }
}
}
}
fun save() {
val s = _state.value
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
_state.value = s.copy(error = "Server, Benutzername und Passwort sind Pflichtfelder")
return
}
viewModelScope.launch {
_state.value = s.copy(isSaving = true, error = null)
try {
repo.saveAppSettings(PrefsStore.AppSettings(
serverUrl = s.serverUrl,
username = s.username,
password = s.password,
lastFmKey = s.lastFmKey.trim(),
backendUrl = s.backendUrl.trimEnd('/'),
scrobbleUrl = s.scrobbleUrl.trimEnd('/'),
scrobbleMode = s.scrobbleMode,
showPlayCount = s.showPlayCount,
theme = s.theme,
absUrl = s.absUrl.trimEnd('/'),
absApiKey = s.absApiKey.trim(),
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)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun logout() {
viewModelScope.launch { repo.saveCredentials("", "", "") }
}
private inline fun update(block: (SettingsUiState) -> SettingsUiState) {
_state.value = block(_state.value)
}
}
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more