Compare commits
87
Commits
apk-latest
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6e98463ee | ||
|
|
ae357b0eb9 | ||
|
|
17a677a5a5 | ||
|
|
1366ec3832 | ||
|
|
7879e37a7b | ||
|
|
2f87a12f08 | ||
|
|
748b1446a2 | ||
|
|
06b13caea9 | ||
|
|
1432a960fb | ||
|
|
37f8f8acac | ||
|
|
c7fed82736 | ||
|
|
e8582381ec | ||
|
|
899c98977a | ||
|
|
549bec85ce | ||
|
|
2258e5465f | ||
|
|
2641a6df44 | ||
|
|
d5b5c8cf84 | ||
|
|
77a3bb33f0 | ||
|
|
d420123a44 | ||
|
|
f083fad986 | ||
|
|
363eb2b1aa | ||
|
|
1aa218f3e3 | ||
|
|
6652fb4d2b | ||
|
|
073593d963 | ||
|
|
8d25bf0404 | ||
|
|
24558497da | ||
|
|
eb72e5c27d | ||
|
|
66fcdbba05 | ||
|
|
86810b6a51 | ||
|
|
cb43c3375e | ||
|
|
ea7167f851 | ||
|
|
f3d54158d3 | ||
|
|
908488a337 | ||
|
|
19f241e493 | ||
|
|
5f043beb5d | ||
|
|
de674dbe3f | ||
|
|
ffedc7532b | ||
|
|
e2e7b2490f | ||
|
|
8bc03da88e | ||
|
|
35e36067af | ||
|
|
cb5ea26c77 | ||
|
|
568bb5002c | ||
|
|
ea448e109f | ||
|
|
4ee20de359 | ||
|
|
ac69d259d5 | ||
|
|
ec75fdef7b | ||
|
|
fca7cc79c1 | ||
|
|
651f979401 | ||
|
|
0b23a07039 | ||
|
|
8da67e0975 | ||
|
|
e8108b0a7a | ||
|
|
871ecdd72e | ||
|
|
a2211a62d2 | ||
|
|
65fa950043 | ||
|
|
92c3d96936 | ||
|
|
dfae34c706 | ||
|
|
334aa51a09 | ||
|
|
83570b22b5 | ||
|
|
dea0485a54 | ||
|
|
4323d68edc | ||
|
|
91d0d5fd0b | ||
|
|
5a6cff8105 | ||
|
|
5f1650b82a | ||
|
|
94fdc3274e | ||
|
|
a89e2974bc | ||
|
|
430ecdc737 | ||
|
|
3c99778567 | ||
|
|
f62b248bb6 | ||
|
|
aae358c4c7 | ||
|
|
a05c15df93 | ||
|
|
da32d2911a | ||
|
|
39d47b78d4 | ||
|
|
eee98d193f | ||
|
|
33b3df609a | ||
|
|
6d8a8a1d5e | ||
|
|
02b590505a | ||
|
|
9f0d4c3153 | ||
|
|
e91708bc97 | ||
|
|
0029b89de4 | ||
|
|
3e8897b551 | ||
|
|
043ec3a108 | ||
|
|
62391e4aae | ||
|
|
767d088665 | ||
|
|
1d8d8143f2 | ||
|
|
149443e4f7 | ||
|
|
93ee958434 | ||
|
|
a780f6622b |
No files matched your search
@@ -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
|
||||
+31
-15
@@ -1,20 +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
|
||||
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # play-log / stats service (port 3710)
|
||||
# ── 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
|
||||
MUSIC_PATH=/srv/navidrome/music
|
||||
|
||||
AUDIO_FMT=mp3 # mp3 | flac | opus | best (used by yt-dlp)
|
||||
AUDIO_QUALITY=0 # 0 = best quality (used by yt-dlp)
|
||||
# ── 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 Events (optional — free API key)
|
||||
# 1. Sign up at https://developer.ticketmaster.com/
|
||||
# 2. Create an app — copy the Consumer Key
|
||||
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,3 +3,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
scripts/.env.release
|
||||
.env
|
||||
.idea/
|
||||
+863
-10
@@ -3,6 +3,9 @@
|
||||
All notable changes to NovaSonic are documented here.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features and fixes together).
|
||||
Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche das waren, steht in einer
|
||||
**Android:**-Zeile direkt unter der Release-Überschrift — die Einträge darunter beschreiben nur noch,
|
||||
*was* sich geändert hat, nicht in welchem APK.
|
||||
|
||||
---
|
||||
|
||||
@@ -13,13 +16,14 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
||||
| Music playback (Navidrome) | ✅ | ✅ |
|
||||
| Seek bar / scrubbing | ✅ | ✅ |
|
||||
| Queue management | ✅ | ✅ |
|
||||
| Warteschlange bearbeiten (ziehen/löschen) | ✅ | ✅ |
|
||||
| Shuffle / Repeat | ✅ | ✅ |
|
||||
| Radio mode (similar songs) | ✅ | ✅ |
|
||||
| Now Playing overlay | ✅ | ✅ |
|
||||
| Dominant color background | ✅ | ✅ |
|
||||
| Cover art scale animation | ❌ | ✅ |
|
||||
| Synced lyrics (LRCLIB) | ✅ | ✅ |
|
||||
| Lyrics autoscroll | ✅ | ✅ |
|
||||
| Lyrics-Mitlaufen (umschaltbar) | ✅ | ✅ |
|
||||
| Star / favorite song | ✅ | ✅ |
|
||||
| Multi-artist navigation | ✅ | ✅ |
|
||||
| Album detail page | ✅ | ✅ |
|
||||
@@ -35,7 +39,17 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
||||
| Playlist browsing | ✅ | ✅ |
|
||||
| Playlist create/edit | ✅ | ✅ |
|
||||
| Playlist cover upload | ✅ | ✅ |
|
||||
| Besitzer geteilter Playlists | ✅ | ✅ |
|
||||
| Künstler folgen / Album merken | ✅ | ✅ |
|
||||
| Neu von deinen Künstlern | ✅ | ✅ |
|
||||
| Konzerte deiner Künstler | ✅ | ✅ |
|
||||
| Spotify / YouTube importer | ✅ | ✅ |
|
||||
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
|
||||
| Wiedergabe fortsetzen | ✅ | ✅ |
|
||||
| Teilen per Link | ✅ | ✅ |
|
||||
| Album → Künstler (Deezer-Suche) | ✅ | ✅ |
|
||||
| Playlist aus Import anlegen | ✅ | ✅ |
|
||||
| Song von Festplatte löschen | ✅ | ✅ |
|
||||
| Metadata editor (tags + cover) | ✅ | ❌ |
|
||||
| Song delete (from disk) | ✅ | ❌ |
|
||||
| Import notification | ✅ | ✅ |
|
||||
@@ -50,6 +64,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
||||
| ABS notification (modern style) | ❌ | ✅ |
|
||||
| Android Auto | — | ✅ |
|
||||
| Offline download (albums/playlists) | ❌ | ✅ |
|
||||
| Downloads nur über WLAN | — | ✅ |
|
||||
| Offline download (single songs) | ❌ | ✅ |
|
||||
| Offline playback | ❌ | ✅ |
|
||||
| Offline detail / manage downloads | ❌ | ✅ |
|
||||
@@ -64,6 +79,799 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
||||
|
||||
---
|
||||
|
||||
## [3.2] — 2026-10-01
|
||||
|
||||
**Android:** 1.25 (versionCode 28) → 1.26 (versionCode 29).
|
||||
|
||||
### Added
|
||||
- **Lyrics: Mitlaufen ein- und ausschalten (Web + Android)** — im Lyrics-Reiter sitzt jetzt ein
|
||||
kleiner Schalter *Mitlaufen / Folgt*. Aus heißt: der Text bleibt stehen, man scrollt selbst.
|
||||
An heißt: der Text springt mit und hält die laufende Zeile bei 40 % Höhe.
|
||||
- **Vorgabe ist AUS.** Wer mitliest, blättert auch mal zurück oder vor — und genau dann sprang
|
||||
der Text bisher bei jeder neuen Zeile unter dem Finger weg.
|
||||
- **Nur bei synchronen Texten sichtbar.** Ohne Zeitstempel gibt es nichts mitzuführen; bei
|
||||
reinem Text erscheint der Schalter nicht.
|
||||
- Hervorgehoben wird die laufende Zeile **immer**, auch bei ausgeschaltetem Mitlaufen — man will
|
||||
ja sehen, wo man ist. Einschalten richtet sofort aus, statt bis zur nächsten Zeile zu warten.
|
||||
- Gespeichert wird pro Gerät (`ns_lyrics_folgen` im Web, `lyrics_autoscroll` in den Android-
|
||||
Einstellungen). Der Android-Schlüssel wird **einzeln** geschrieben: würde ihn das Speichern im
|
||||
Einstellungsbildschirm mitschreiben, setzte es ihn jedes Mal auf die Vorgabe zurück, weil
|
||||
dieser Bildschirm das Feld gar nicht führt.
|
||||
|
||||
### Fixed
|
||||
- **Mischen war nicht zufällig (Web)** — `liste.sort(() => Math.random() - 0.5)` ist keine
|
||||
Mischung: ein Vergleicher, der bei derselben Paarung mal so und mal anders antwortet, ist für
|
||||
eine Sortierung ungültig, und das Ergebnis ist messbar verzerrt. Gemessen (V8, 3000 Durchläufe)
|
||||
blieb der erste Titel drei- bis fünfmal zu oft vorn — bei 10 Titeln in 18,8 % der Fälle statt
|
||||
10 %, bei 100 in 3,1 % statt 1 %, bei 2000 in 0,23 % statt 0,05 %. Die Grobordnung sah dabei
|
||||
gemischt aus, weshalb es lange nicht auffiel; bevorzugt wurden trotzdem immer dieselben Titel.
|
||||
Ersetzt durch Fisher-Yates in `src/player/queueMath.js` (`mischeListe`), verwendet an allen vier
|
||||
Stellen: Warteschlange starten, Mischen umschalten und die beiden Genre-Knöpfe.
|
||||
- **Der letzte Titel lief mehrfach wieder los (Web/PWA)** — am Ende der Warteschlange ohne Repeat
|
||||
steht nichts mehr an, trotzdem spielte der letzte Titel bei jedem Zurückholen der Seite erneut.
|
||||
Ursache: ein beendetes `<audio>` meldet sich als *pausiert*, und die Wiederaufnahme nach einem
|
||||
unfreiwilligen Hintergrund-Stop rief `play()` — das spult dann auf Anfang zurück. Mit Mischen
|
||||
fiel es besonders auf, weil der letzte Titel dort ein beliebiger ist. Jetzt bricht die
|
||||
Wiederaufnahme bei `audio.ended` ab.
|
||||
- **Shuffle-Knopf und tatsächliche Reihenfolge gingen auseinander (Android)** — jeder
|
||||
Shuffle-Knopf mischte selbst (`liste.shuffled()`) und übergab die fertige Reihenfolge. Damit war
|
||||
die „ursprüngliche" Liste schon die gemischte: das Shuffle-Symbol leuchtete nicht, obwohl
|
||||
gemischt gespielt wurde, und *Shuffle aus* konnte die Albumreihenfolge nie wiederherstellen.
|
||||
Das Mischen macht jetzt der Player (`playSongs(liste, -1)`), der die ungemischte Liste behält.
|
||||
- **Radio-Modus konnte den laufenden Titel direkt hinter sich selbst hängen (Web + Android)** — in
|
||||
der letzten Rückfallebene sind Wiederholungen erlaubt, damit Musik weiterläuft; der gerade
|
||||
laufende Titel war davon nicht ausgenommen und spielte dann zweimal in Folge. Jetzt ist er es.
|
||||
|
||||
## [3.1] — 2026-09-22
|
||||
|
||||
**Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf und die Gesamtdauer, 1.24 die Konzerte, 1.25 sie ohne Zeitfenster.
|
||||
|
||||
### Added
|
||||
- **Konzerte deiner Künstler (Web + Android)** — die Zeitleiste hat einen zweiten Reiter: neben
|
||||
*Neu erschienen* steht jetzt *Konzerte* und zeigt, wann die Künstler spielen, denen du folgst.
|
||||
Neuer Endpunkt `POST /api/artist-concerts`, Gegenstück zu `/api/artist-releases`.
|
||||
- **Zwei Reiter statt zweier Knöpfe** in den Favoriten. Beides ist dasselbe Thema; zwei Knöpfe
|
||||
drängeln sich, und man müsste sich merken, welcher was zeigt.
|
||||
- **Bewusst nicht in eine gemeinsame Liste gemischt.** Veröffentlichungen liegen hinter dir,
|
||||
Konzerte vor dir. Chronologisch gemischt stünde „heute" mittendrin und man wüsste nie, ob man
|
||||
vorwärts oder rückwärts liest. Deshalb läuft die Konzertliste **nach vorn**: das nächste
|
||||
Konzert zuerst, nach Monat gruppiert wie bei den Veröffentlichungen.
|
||||
- **Kein Zeitfenster bei den Konzerten.** Die Chips 90/30/365 waren dort zuerst mit dabei und
|
||||
ergaben keinen Sinn: Ticketmaster liefert ohnehin nur Anstehendes, und bei Terminen will man
|
||||
alles Angekündigte sehen — eine Tagesgrenze schneidet nur willkürlich ab. Die Chip-Reihe
|
||||
erscheint jetzt nur noch beim Reiter *Neu erschienen*; der Endpunkt beachtet ein
|
||||
mitgeschicktes Fenster weiterhin, verlangt es aber nicht mehr.
|
||||
- **Gegenprobe gegen die Stichwortsuche.** Ticketmaster wird über den Namen abgefragt
|
||||
(`keyword=`), nicht über `attractionId` — dieselbe Unschärfe wie bei der Deezer-Zuordnung.
|
||||
Jeder Treffer wird deshalb gegengeprüft: der Künstlername muss im Veranstaltungs- **oder**
|
||||
Attraktionsnamen vorkommen. Ohne das liefert „MORE" oder „TPR" fremde Veranstaltungen.
|
||||
- **Ortszeit, nicht UTC.** Ein Konzert um 20:00 stünde sonst als 08:00 da. Die Zeit wird als
|
||||
zonenlose Ortszeit durchgereicht und unverändert angezeigt.
|
||||
- **Kein Ortsfilter** — die Suche läuft weltweit. Eine Einschränkung per `.env` ist im Backlog
|
||||
notiert; fürs Erste reicht, was die API liefert.
|
||||
- Der Cache liegt im Backend (6 h, höchstens vier Anfragen gleichzeitig), nicht im Client: bei
|
||||
50 gefolgten Künstlern wären das sonst 50 Abfragen pro Gerät und App-Start. Bei einem Fehler
|
||||
kommt der abgelaufene Stand zurück statt einer leeren Liste.
|
||||
- **Neun Prüfungen** in `backend/test/konzerte.test.js` — mit eingeschobener
|
||||
Ticketmaster-Antwort, also ohne Schlüssel und ohne Netz: Ortszeit, Gegenprobe, Fenster,
|
||||
Sortierung nach vorn, Cache-Treffer, abgesagte Termine.
|
||||
- **Zwei neue Designs: Violet und Crimson (Web + Android)** — ein helles Lila auf tiefem
|
||||
Violettschwarz und ein gedämpftes Rot auf Weinrot-Schwarz. Beide mit demselben Variablensatz wie
|
||||
die vorhandenen, geprüft: kein Design vermisst eine Variable.
|
||||
- Crimson trägt bewusst **kein reines Rot** als Akzent — das läge zu nah an der Warnfarbe, und
|
||||
Löschknöpfe sähen dann aus wie gewöhnliche Akzente. Stattdessen ein gedämpftes Rosé-Rot, und
|
||||
die Warnfarbe wurde dort heller gesetzt, damit sie weiterhin absticht.
|
||||
- Beide Plattformen lesen ihre Designliste durch, die Auswahl in den Einstellungen füllt sich
|
||||
also von selbst.
|
||||
- **Wischen wechselt den Titel (Android)** — seitwärts wischen blätterte im Now-Playing zwischen
|
||||
Player, Warteschlange und Text; den Titel wechselte nur ein Wisch **genau über dem Cover**. Das
|
||||
ist verdreht: der Titelwechsel ist die Geste, die man dort ständig braucht. Jetzt nimmt die ganze
|
||||
Player-Seite den Wisch entgegen und springt zum nächsten oder vorherigen Titel.
|
||||
- **Umstellbar** in den Einstellungen unter *Wiedergabe* → „Wischen wechselt den Titel". Aus
|
||||
heißt: wie bisher. Die Reiter oben schalten in beiden Fällen weiter, es geht also nie eine
|
||||
Ansicht verloren.
|
||||
- Auf Warteschlange und Text liegt die Geste **nicht** — dort würde sie mit dem Ziehen in der
|
||||
Liste streiten.
|
||||
- **Die Warteschlange lässt sich bearbeiten (Web + Android)** — bisher war sie eine reine Anzeige:
|
||||
reinlegen ging, umsortieren oder wieder herausnehmen nicht. Jetzt trägt jede Zeile einen Griff
|
||||
zum Ziehen und ein ✕ zum Entfernen, auf beiden Plattformen und in beiden Web-Listen (Seitenleiste
|
||||
und Now-Playing).
|
||||
- **Der Zeiger auf den laufenden Titel wandert mit.** Das ist der Teil, bei dem ein Fehler nicht
|
||||
auffällt, sondern später den falschen Song spielt: fällt ein Titel **vor** dem laufenden weg,
|
||||
rutscht dieser eine Stelle vor; trifft es den laufenden selbst, rückt der nächste nach (am
|
||||
Ende der Liste der davor); beim Verschieben wird der Zeiger über die **Position** des laufenden
|
||||
Titels neu bestimmt statt gerechnet — das stimmt auch, wenn er selbst gezogen wurde.
|
||||
- Diese Rechnung liegt im Web als reines Modul ohne Importe in `src/player/queueMath.js`, damit
|
||||
sie ohne DOM und ohne Wiedergabe prüfbar ist: **12 Fälle, alle grün.**
|
||||
- **Web: Zeiger-Ereignisse statt HTML5-Drag-and-Drop.** Dessen `dragstart` gibt es auf
|
||||
Touch-Geräten gar nicht — die Warteschlange wäre am Handy unbearbeitbar geblieben.
|
||||
`setPointerCapture` bedient Maus und Finger über denselben Weg. Die Zeilen zwischen Start und
|
||||
Ziel weichen aus, damit die Lücke dorthin wandert, wo man loslässt.
|
||||
- **Android: getauscht wird laufend**, sobald der Finger eine Zeilenhöhe zurückgelegt hat — so
|
||||
braucht es kein Geisterbild, die Liste zeigt die neue Reihenfolge sofort. Gezogen wird am
|
||||
Griff, nicht an der Zeile, sonst ließen sich Ziehen und Scrollen nicht auseinanderhalten; und
|
||||
der erste Fingerkontakt wird verbraucht, sonst beansprucht die `LazyColumn` die Geste (dieselbe
|
||||
Falle wie beim Rollbalken).
|
||||
- Im Player laufen die lokale Liste und Media3 im Gleichschritt (`moveMediaItem`,
|
||||
`removeMediaItem`). Liefen sie auseinander, zeigte die Oberfläche den falschen Song an, weil
|
||||
der laufende Index vom Controller kommt, der Titel dazu aber aus der lokalen Liste.
|
||||
|
||||
|
||||
### Changed
|
||||
- **Gesamtdauer mit Stunden (Web + Android)** — im Web stand bei Playlists und Alben nur eine
|
||||
Minutenzahl; „134 Min." muss man selbst umrechnen, und bei einer Playlist mit 2000 Titeln steht
|
||||
dort eine vierstellige Zahl. Ab einer Stunde heißt es jetzt „2 Std. 14 Min.", volle Stunden ohne
|
||||
Anhängsel („2 Std."). **In Android fehlte die Dauer ganz** — Playlist und Album zeigen sie jetzt
|
||||
ebenfalls.
|
||||
- Kostet keinen zusätzlichen Aufruf: Subsonic liefert `duration` beim Playlist- und Album-Objekt
|
||||
mit, in Android wird genau die genommen; im Web wird weiterhin über die geladenen Titel
|
||||
summiert, weil die dort ohnehin schon vorliegen.
|
||||
- Dieselbe Regel und dieselbe Schreibweise auf beiden Plattformen, damit dieselbe Playlist nicht
|
||||
zwei verschiedene Angaben trägt. Neun Fälle geprüft (0, 59 Min., genau 1 Std., 100 Std., leer).
|
||||
- **Benachrichtigungen tragen einen Honigtopf mit Brief statt der Glocke (Web + Android)** —
|
||||
reine Strichzeichnung wie die übrigen Symbole, damit sie die Akzentfarbe annimmt, wenn etwas
|
||||
Ungelesenes anliegt. Im Web als Inline-SVG, in Android als Vektor-Drawable mit denselben
|
||||
Konturen, an beiden Stellen (Kopfzeile und Kopf des Benachrichtigungsfensters).
|
||||
- Bei kleiner Größe zählt jeder Strich: die erste Fassung hatte einen zu großen Brief und einen
|
||||
geraden Topf, das verlief zu einer Fläche. Der Topf hat jetzt einen gewölbten Bauch und der
|
||||
Brief sitzt kleiner in der Mitte — gerendert und in Originalgröße gegengesehen.
|
||||
- **Nennmaß ist nicht gleich Wirkung.** Neben dem Zahnrad wirkte der Topf kleiner, obwohl er
|
||||
mit 23 px nominal *größer* war. Nachgemessen: das Zahnrad füllt sein 24er-Feld vollständig aus,
|
||||
der Topf nur 18,4 × 15,9 — also etwa zwei Drittel. Die Zeichnung wird jetzt auf Feldbreite
|
||||
aufgezogen (Faktor 1,38) und steht bei 19 px; damit stimmen die sichtbaren Höhen überein
|
||||
(17,3 gegen 18,0). Die Strichstärke ist entsprechend zurückgenommen, weil das Aufziehen sie
|
||||
mitvergrößert. Dieselbe Korrektur im Android-Vektor, dort war die Schieflage dieselbe.
|
||||
- **Der Rollbalken läuft nur noch über den Inhalt, nicht über die Suchleiste (Web)** — die
|
||||
Kopfzeile mit Suche, Glocke und Zahnrad lag **im** Rollbereich und blieb per `sticky` oben
|
||||
kleben. Der Balken gehört zum Rollbereich und reichte damit bis ganz nach oben, neben eine
|
||||
Leiste, in der gar nichts rollt. Die Kopfzeile sitzt jetzt außerhalb: eine Spalte rechts der
|
||||
Seitenleiste trägt oben die feste Kopfzeile und darunter den Rollbereich. Das Suchfeld samt
|
||||
Ergebnisfenster ist dabei unverändert geblieben — mit jsdom gegengeprüft, **14 Fälle grün**.
|
||||
- **Die Warteschlange hat einen Schließen-Knopf (Web)** — sie ließ sich nur über denselben Knopf
|
||||
im Player wieder zumachen, mit dem man sie geöffnet hatte. Jetzt steht ein ✕ in ihrer Kopfzeile.
|
||||
- **Der Rollbalken ist greifbar (Web)** — er war 4 px breit. Sichtbar reichte das, mit der Maus war
|
||||
er kaum zu treffen: in einer langen Playlist kam man nicht ans Ende, ohne zu scrollen. Jetzt 12 px
|
||||
**Greiffläche** mit einem schmalen sichtbaren Balken darin (durchsichtiger Rand plus
|
||||
`background-clip:content-box`), heller beim Überfahren und beim Ziehen — wie in Spotify.
|
||||
- `min-height:44px` hält ihn auch bei sehr langen Listen greifbar; bei 1900 Titeln schrumpfte er
|
||||
sonst auf wenige Pixel, also genau dort, wo man ihn am nötigsten hat.
|
||||
- **Firefox bekommt nur die Farbe.** Dessen Standardbalken ist ohnehin breit genug; ein
|
||||
`scrollbar-width:thin` hätte ihn wieder verschmälert. Die Ausnahmen, die bewusst keinen Balken
|
||||
zeigen (waagerechte Kartenreihen, Lyrics, Chips), bleiben unangetastet.
|
||||
- **Rollbalken zum Ziehen (Android)** — Compose bringt keinen mit: eine `LazyColumn` zeigt nichts
|
||||
an, egal wie lang sie ist. Bei einer Playlist mit 1900 Titeln bleibt nur Wischen. Jetzt steht am
|
||||
rechten Rand ein Balken, der sich greifen und ziehen lässt — in Playlist, Album, Favoriten,
|
||||
Genre-Titeln und der Offline-Ansicht.
|
||||
- **Gerechnet wird in Einträgen, nicht in Pixeln.** Die Gesamthöhe einer `LazyColumn` ist
|
||||
unbekannt, solange nicht alles gebaut wurde, und bei unterschiedlich hohen Zeilen auch nicht
|
||||
ausrechenbar. Über den Anteil sichtbarer an allen Einträgen stimmt der Balken bei gleich hohen
|
||||
Zeilen — und genau die haben Song- und Albumlisten.
|
||||
- **Er bleibt dauerhaft sichtbar,** nur gedämpft (0,28), und wird beim Scrollen und Ziehen
|
||||
kräftiger. Ein Balken, der erst beim Scrollen erscheint, lässt sich nicht greifen — und Greifen
|
||||
ist hier der ganze Zweck. Wie im Web gibt es eine Mindesthöhe von 44 dp.
|
||||
- Beim Ziehen läuft die Position als Kommazahl mit. Würde jeder Zeigerschritt aus
|
||||
`firstVisibleItemIndex` neu rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal
|
||||
verloren und der Balken bliebe bei langsamem Ziehen stehen.
|
||||
- **Nachtrag 1.17: greifen ging zuerst gar nicht.** Zwei Fehler auf einmal. Die Greiffläche war
|
||||
so schmal wie der sichtbare Balken (8 dp) — am Bildschirmrand trifft das mit dem Finger niemand,
|
||||
Androids eigene Vorgabe für Bedienelemente liegt bei 48 dp. Und `detectVerticalDragGestures`
|
||||
wartet die Wischschwelle ab, bevor es die Geste beansprucht; die `LazyColumn` startet ihr
|
||||
eigenes Scrollen in genau diesem Moment und gewinnt jedes Mal. Jetzt sind sichtbar 6 dp und
|
||||
anfassbar 28 dp, und der erste Fingerkontakt wird sofort verbraucht, bevor die Liste ihn sieht.
|
||||
- Verbraucht wird nur, wenn der Finger **auf dem Daumen** aufsetzt (16 dp Spiel). Daneben bleibt
|
||||
das Wischen am rechten Rand normales Scrollen — sonst käme dort plötzlich die umgekehrte
|
||||
Richtung heraus, weil Balken ziehen und Inhalt wischen entgegengesetzt laufen.
|
||||
- **Nachtrag 1.18: das Ziehen brach sofort wieder ab.** `pointerInput` war auf die Geometrie
|
||||
geschlüsselt — unter anderem auf die Daumenposition, und die steht in jedem Bild anders da. Ein
|
||||
geänderter Schlüssel setzt die Gestenerkennung neu auf: der Finger lag noch auf dem Schirm, die
|
||||
Erkennung fing wieder bei null an. Je länger die Liste, desto öfter, deshalb war es bei 2000
|
||||
Titeln unbrauchbar. Der Schlüssel ist jetzt `Unit`; die wechselnden Werte liest die Geste über
|
||||
`rememberUpdatedState` zur Laufzeit nach.
|
||||
- Gescrollt wird außerdem nicht mehr im Zeigerereignis, sondern in **einem** Effekt, der die
|
||||
Zielposition verfolgt. Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte
|
||||
konkurrierende Scroll-Aufrufe, die sich gegenseitig überholten.
|
||||
- **Nachtrag 1.21: die Songzeilen halten rechts 24 dp frei.** Dort steht der Rollbalken, und wo
|
||||
sein Daumen gerade liegt, wäre ein ⋮ darunter weiterhin nicht zu treffen gewesen — der Daumen
|
||||
wandert ja mit. Die Zeilen rücken deshalb überall ein, auch dort, wo es gar keinen Balken gibt,
|
||||
damit sie gleich aussehen.
|
||||
- **Nachtrag 1.20: der Balken verschluckte das ⋮ der Songzeilen.** Compose reicht eine Berührung
|
||||
nur an das **oberste** getroffene Element weiter — der Greifstreifen lag über die volle Höhe
|
||||
am rechten Rand und nahm damit jeden Tipper auf das Kontextmenü entgegen, obwohl er ihn gar
|
||||
nicht wollte. Jetzt hört nur noch der **Daumen** zu (mit 10 dp Luft darüber und darunter); der
|
||||
Rahmen darum hat kein `pointerInput` und ist dadurch durchlässig. Der Streifen ist außerdem von
|
||||
28 auf 24 dp geschrumpft.
|
||||
- **„Neu von deinen Künstlern" sitzt jetzt in den Favoriten (Web)** — es stand links unter
|
||||
*Entdecken*, in Android dagegen als Knopf „✨ Neu" in den Favoriten. Zwei Plattformen, zwei Orte
|
||||
für dieselbe Sache. Das Web folgt jetzt Android: der Eintrag in der Seitenleiste ist weg, in der
|
||||
Kopfzeile der Favoriten steht der Knopf — dort, wo auch die gefolgten Künstler stehen.
|
||||
- Der Knopf erscheint nur, wenn man überhaupt jemandem folgt, genau wie in Android.
|
||||
- Die Seite hat dafür einen Zurück-Knopf bekommen (vorher unnötig, weil sie über die Seitenleiste
|
||||
erreichbar war) und zählt für die Seitenleiste jetzt zur Bibliothek statt zu Entdecken.
|
||||
|
||||
### Fixed
|
||||
- **Künstler schlägt Dauer: die Kaskade sucht in anderer Reihenfolge (Backend)** — Nachtrag zum
|
||||
Fehlgriff bei „Dearly Beloved" (TPR). Die Kette war vollständig nachvollziehbar:
|
||||
- Deezer kennt **keinen** Künstler „TPR"; das Album läuft dort unter *Project Destati*, der Titel
|
||||
heißt *Dearly Beloved I* und dauert **230 s**. Von dort kam die erwartete Dauer.
|
||||
- Der richtige Upload dauert **136 s** und fiel damit aus dem Fenster 207–253 s. Eine Rockband,
|
||||
die tatsächlich **DEARLY BELOVED** heißt, liegt mit ihrem Stück „LSD" bei **250 s** mitten
|
||||
drin — und ihr **Bandname** erfüllte die Titelprüfung.
|
||||
- Entschieden hat die Reihenfolge: die Stufe *Dauer behalten, Künstler fallen lassen* lief vor
|
||||
der Stufe *Künstler behalten, Dauer lockern*. Die erste griff zu, die zweite kam nie dran.
|
||||
- **Die beiden sind jetzt vertauscht.** Der Künstler ist das stärkere Merkmal: die erwartete
|
||||
Dauer stammt aus Metadaten und gehört oft zu einer anderen Fassung, während Kanalname und
|
||||
`artist`-Feld den Song selbst benennen. Es fällt **kein** Schritt weg — was vorher gefunden
|
||||
wurde, wird weiter gefunden, gegebenenfalls eine Stufe später.
|
||||
- **Belegt statt behauptet:** `backend/scripts/filter-probe.mjs` holt für bekannte Fälle die
|
||||
echten Suchtreffer — **aus beiden Quellen, YT Music zuerst** — und spielt beide Reihenfolgen
|
||||
durch. Fünf Fälle, **ein** Unterschied (der gemeldete), **null** Regressionen: BTS auf dem
|
||||
Label-Kanal BANGTANTV, der japanische Soundtrack-Titel, Rising Insane und Daft Punk kommen
|
||||
unverändert heraus.
|
||||
- Die erste Fassung der Probe sah **nur** die gewöhnliche YouTube-Suche und meldete deshalb für
|
||||
„Get Lucky" einen Grammy-Mitschnitt und für TPR ein Tutorial-Video. Beides war erfunden: über
|
||||
YT Music steht dort an erster Stelle die offizielle Tonspur („Get Lucky (feat. Pharrell
|
||||
Williams and Nile Rodgers)", 370 s) beziehungsweise TPRs echtes „Dearly Beloved" (138 s) — und
|
||||
genau die lädt die Kaskade. Eine Probe, die eine Stufe auslässt, erfindet Fehler; deshalb fragt
|
||||
sie jetzt beide Quellen ab.
|
||||
- **Der Download-Filter konnte den falschen Titel speichern — zwei Löcher, ein Grund (Backend)** —
|
||||
gemeldet mit „銀髪の少年" (Yoshihisa Hirano, HUNTER×HUNTER) und „Dearly Beloved" (TPR): beide Male
|
||||
kam etwas anderes an.
|
||||
- **Die Kaskadenschritte 3 und 4 lassen die Künstlerprüfung fallen** — bewusst, damit
|
||||
Label-Kanäle durchkommen. Übrig bleibt dann Titel + Dauer. Bei „Dearly Beloved" reicht das
|
||||
nicht: es gibt Rocksongs, die genau so heißen, und einer davon lag im Dauerfenster.
|
||||
- **Für japanische Titel gab es überhaupt keine Titelprüfung.** `buildTitleKeywords` lieferte
|
||||
dafür eine leere Liste (japanische Titel haben keine Wortgrenzen, und Art Tracks sind oft
|
||||
romanisiert). Zusammen mit Schritt 3/4 blieb damit **allein die Dauer** übrig — und auf einem
|
||||
Soundtrack-Album passt fast jedes andere Stück desselben Komponisten in dasselbe Fenster.
|
||||
Genau das ist bei „銀髪の少年" passiert.
|
||||
- **Behoben in drei Schritten:** japanische Titel bekommen ein Muster aus *Originaltitel oder
|
||||
Romaji-Fassung* (Art Tracks tragen üblicherweise das eine oder das andere; Leerzeichen und
|
||||
Bindestriche bleiben frei, weil Uploader sie unterschiedlich setzen). Ein Schritt **ohne**
|
||||
Titelprüfung läuft jetzt immer **mit** Künstlerprüfung, egal was die Kaskade lockern wollte.
|
||||
Und japanische Künstlernamen werden gegen das `artist`-Feld geprüft — der Kanalname steht kaum
|
||||
je in Kanji, das Feld der Art Tracks dagegen schon; vorher hatten sie gar keine Prüfung.
|
||||
- Bleibt am Ende weder ein Titel- noch ein Künstlermerkmal übrig, wird **gar nicht gesucht**:
|
||||
ohne Merkmal landet sonst der erste Treffer passender Länge auf der Platte. Der Direktlink im
|
||||
Importer ist dafür der vorgesehene Weg.
|
||||
- **Die Umschrift ist nur *eine* Schreibweise.** Der Tokenizer liefert „Ginpatsu No Shounen",
|
||||
Uploader schreiben „Ginpatsu no Shonen", setzen Bindestriche oder lassen den
|
||||
Katakana-Längungsstrich weg (der bei „テーゼ" sogar unübersetzt stehenbleibt: „TeーZe"). Das
|
||||
Muster lässt diese Abweichungen zu — langes o/u optional, Längungsstrich weg, Trennzeichen
|
||||
frei. Sonst wäre die Alternative so streng, dass sie praktisch nie greift.
|
||||
- Dieselbe Hilfe für **Kanji-Künstlernamen**: Kanäle und Videotitel schreiben sie meist
|
||||
lateinisch, also zählt die Umschrift jetzt auch als Künstlermerkmal. Vorher blieb dort nur das
|
||||
`artist`-Feld in Originalschrift.
|
||||
- Sieben neue Prüfungen in `backend/test/ytdlp.test.js` (61 grün), darunter „anderes Stück
|
||||
desselben Albums darf nicht passen" und die Schreibvarianten der Umschrift.
|
||||
- **Das Mischen-Symbol zeigte nie an, dass es an ist (Android)** — und der Grund lag nicht in der
|
||||
Anzeige. `toggleShuffle` baute den Rest der Warteschlange als Schleife aus `removeMediaItem` +
|
||||
`addMediaItem` um; dazwischen hatte der Controller vorübergehend weniger Titel als unsere Liste.
|
||||
Genau darauf reagiert `onTimelineChanged` mit „die Titel kamen von außen", baute die
|
||||
Warteschlange neu auf — und setzte dabei `isManuallyShuffled` **zurück**. Gemischt wurde also,
|
||||
aber der Zustand war eine Zehntelsekunde später wieder weg. Jetzt wird der Rest in **einem** Zug
|
||||
ersetzt (`replaceMediaItems`), der Zwischenzustand entfällt.
|
||||
- **Mischen und Wiederholen sind jetzt auch zu sehen (Android)** — ein eingefärbtes Symbol allein
|
||||
reicht auf einem dunklen Cover nicht, das liest sich wie eine Schattierung. Ist der Schalter an,
|
||||
liegt ein getönter Kreis darunter und ein Punkt darunter.
|
||||
- **Der Rollbalken lief über die Player-Leiste (Web)** — sofort nach dem Verbreitern gemeldet. Der
|
||||
Balken gehört zum Scrollbereich und läuft über dessen ganze Höhe, und `.main` reichte per
|
||||
`padding-bottom` bis an den Fensterboden, also bis **unter** die Leiste. Bei 4 px fiel das nie
|
||||
auf, bei 12 px sofort. Der Scrollbereich endet jetzt per `margin-bottom` über der Leiste — auf
|
||||
dem Handy über Leiste **und** unterer Navigation. Die Platzreserve für den Inhalt, die das
|
||||
`padding-bottom` war, ist damit überflüssig geworden und entfällt.
|
||||
|
||||
---
|
||||
|
||||
## [3.0] — 2026-09-19
|
||||
|
||||
**Android:** 1.13 (versionCode 16) → 1.15 (versionCode 18). 1.13 brachte Folgen, Zeitleiste und
|
||||
den Gefolgt-Filter, 1.14 die begrenzte Reihe in den Favoriten, 1.15 die Knopfleiste im Album.
|
||||
|
||||
Erster Sprung auf eine neue Hauptversion seit 2.0: „Künstlern folgen" ist kein weiteres Fenster,
|
||||
sondern ein neuer Umgang mit der Bibliothek — die App erzählt jetzt, was **draußen** neu ist, statt
|
||||
nur zu zeigen, was drinsteht.
|
||||
|
||||
### Added
|
||||
- **Künstlern folgen und Alben merken (Web + Android)** — Grundlage für die geplanten Meldungen zu
|
||||
neuen Veröffentlichungen und Konzerten. „Folgen" ist **kein eigener Speicher**, sondern Navidromes
|
||||
Stern auf dem Künstler: `star&id=<ndArtistId>`. Damit ist der Zustand sofort auf allen Geräten da,
|
||||
überlebt Neuinstallationen und taucht sogar in Navidromes eigener Oberfläche auf.
|
||||
- Möglich ist das, weil Navidrome `id`, `albumId` und `artistId` beim Markieren in **eine** Liste
|
||||
wirft und gleich behandelt (`media_annotation.go`) — unser vorhandener Aufruf trug also ohne
|
||||
Änderung. Den Zustand liefert `starred` aus `getArtist`/`getAlbum`.
|
||||
- Das Herz sitzt **im Banner** des Künstlers, direkt neben dem ⚙ für den Pin — auf beiden
|
||||
Plattformen gleich. Die Aktionsleiste darunter ist „was ich jetzt tue" (abspielen, mischen,
|
||||
Konzerte); Folgen ist dagegen ein Zustand und gehört an den Künstler selbst. Beim Album bleibt
|
||||
das Herz in der Aktionsleiste, dort gibt es kein Banner.
|
||||
- **Übersicht, wem man folgt:** die Favoritenseite zeigt jetzt Abschnitte *Künstler, denen du
|
||||
folgst*, *Gemerkte Alben* und *Songs*. Das kostet **keinen zusätzlichen Aufruf** — `getStarred2`
|
||||
lieferte Künstler und Alben die ganze Zeit im selben Paket mit, wir haben sie nur weggeworfen.
|
||||
- Beide Plattformen schalten **optimistisch** um und fallen bei einem Fehlschlag zurück — Android
|
||||
über den vorhandenen `StarState`, der ohnehin über IDs arbeitet und deshalb unverändert taugt.
|
||||
- Wer ohne Künstler-Pin folgt, bekommt einen Hinweis: ohne Pinnung läuft die Deezer-Zuordnung über
|
||||
den Namen, und bei gleichnamigen Künstlern kämen dauerhaft die Neuheiten des Falschen.
|
||||
|
||||
- **Filter „❤ Gefolgt" auf der Künstlerseite (Web)** — die Künstlerseite hat jetzt zwei Chips
|
||||
(*Alle* / *❤ Gefolgt (n)*) und ein Suchfeld. Damit ist die Liste, wem man folgt, dort, wo man
|
||||
Künstler ohnehin sucht — statt nur als waagerechte Reihe unter den Favoriten. `getArtists` liefert
|
||||
kein `starred` mit, die Markierungen kommen deshalb aus `getStarred2` und werden 30 s gehalten,
|
||||
damit das Umschalten der Chips nicht jedes Mal nachfragt. Ein Herz-Klick verwirft diesen Zwischen-
|
||||
speicher sofort — sonst zeigte der Filter nach dem Entfolgen noch eine halbe Minute den alten Stand.
|
||||
- **Die Reihe der gefolgten Künstler ist begrenzt, mit „Alle anzeigen" (Web + Android)** — vorher
|
||||
landeten **alle** gefolgten Künstler in der waagerechten Reihe der Favoriten. Bei 200 Gefolgten
|
||||
sind das 200 Karten samt Bildern, von denen man vier sieht — und zum Durchsehen taugt eine endlose
|
||||
Reihe ohnehin nicht. Am Ende steht jetzt eine Karte „Alle anzeigen (+n weitere)", die in die
|
||||
Künstlerliste mit aktivem Gefolgt-Filter springt.
|
||||
- **Im Web richtet sich die Anzahl nach der Fensterbreite** statt nach einer festen Zahl: es wird
|
||||
gerechnet, wie viele Karten nebeneinander passen (Kartenbreite plus Abstand), minus eine, damit
|
||||
„Alle anzeigen" nicht angeschnitten am Rand klebt. Untergrenze zwei. Bei einer Größenänderung
|
||||
wird neu aufgeteilt — sonst bliebe die Zahl auf dem Stand des ersten Aufbaus.
|
||||
- Android zeigt zehn und dann den Knopf; die `LazyRow` baut ohnehin nur Sichtbares, dort ging es
|
||||
allein um die Durchsehbarkeit. Weil das `LibraryViewModel` am Navigationseintrag hängt und einen
|
||||
Zielwechsel nicht überlebt, reicht `LibraryIntent` die Absicht einmalig durch.
|
||||
- **Die Knopfleiste im Album sieht aus wie die der Playlist (Android)** — im Album trugen *Play*
|
||||
und *Shuffle* noch Beschriftungen und teilten sich die Breite, während Herz und Download auf feste
|
||||
46 dp gesetzt waren. Vier Knöpfe in einer Reihe lassen dafür keinen Platz: die Schrift wurde
|
||||
abgeschnitten, und die Reihe war ungleich geteilt. Jetzt vier gleich breite reine Symbolknöpfe wie
|
||||
im Playlist-Detail — Play gefüllt als Hauptaktion, der Rest umrandet und erst accent gefärbt, wenn
|
||||
er aktiv ist (gemerkt, vollständig geladen). Die Symbole tragen jetzt Beschriftungen für die
|
||||
Vorlesefunktion, vorher stand dort bei Play und Shuffle `null`.
|
||||
- **Zeitfenster-Chips der Zeitleiste beginnen mit 90 Tagen (Android)** — das ist die Vorgabe, also
|
||||
steht der aktive Chip jetzt vorn statt in der Mitte. Web war schon so.
|
||||
- **Filter „❤ Gefolgt" im Künstler-Reiter (Android)** — zwei Chips über der Liste, gleiche Stelle
|
||||
wie im Web. Bewusst ein Filter und kein eigener Reiter: die Bibliotheksleiste trägt schon
|
||||
Playlists, Alben, Künstler und Offline. Die markierten IDs werden bei jedem Einschalten frisch
|
||||
geholt, damit ein eben entfolgter Künstler nicht noch dasteht.
|
||||
- **Zeitleiste „Neu von deinen Künstlern" (Android)** — erreichbar über „✨ Neu" in den Favoriten,
|
||||
dort, wo auch die gefolgten Künstler stehen. Gleiche Darstellung wie im Web: nach Monat gruppiert,
|
||||
neueste zuerst, Vorhandenes mit Haken statt Download-Knopf, Künstlername anklickbar. Der
|
||||
Download-Knopf hängt am bekannten Weg samt Benachrichtigung über den Fortschritt.
|
||||
- **Zeitleiste „Neu von deinen Künstlern" (Web)** — eigener Punkt in der Seitenleiste. Zeigt die
|
||||
jüngsten Veröffentlichungen aller Künstler, denen du folgst: nach Monat gruppiert, neueste zuerst,
|
||||
pro Eintrag Cover, Künstler, Art (Album/Single/EP), Titelzahl und Datum. Umschaltbar auf 30 Tage,
|
||||
90 Tage oder ein Jahr.
|
||||
- **Was du schon hast, ist markiert.** Verglichen wird über Künstler + Albumname (akzent- und
|
||||
zeichenunabhängig) — Deezers Album-Id hat mit Navidromes nichts zu tun. Alles andere bekommt
|
||||
direkt einen Laden-Knopf, der den vertrauten Download-Dialog öffnet.
|
||||
- **Neuer Backend-Endpunkt `POST /api/artist-releases`** (`backend/lib/releases.js`). Die
|
||||
Deezer-Zuordnung passiert dort, weil die Künstler-Pins ohnehin serverseitig liegen: der Client
|
||||
schickt nur seine Navidrome-Künstler und muss über Deezer nichts wissen. Gepinnte Künstler nutzen
|
||||
die feste ID, ungepinnte werden über den Namen gesucht — sortiert nach Fan-Zahl, weil Deezers
|
||||
Suche nicht nach Bekanntheit ordnet. Künstler ohne Treffer kommen als `ohneZuordnung` zurück und
|
||||
die Oberfläche rät zum Pinnen, statt still die Neuheiten des Falschen anzuzeigen.
|
||||
- **Der Cache liegt im Backend, nicht im Client.** Bei 30 gefolgten Künstlern wären das sonst 30
|
||||
Deezer-Aufrufe pro Gerät und App-Start. 6 h für Alben, 24 h für die Namenszuordnung, höchstens
|
||||
vier Anfragen gleichzeitig; bei einem Deezer-Fehler wird der abgelaufene Stand ausgeliefert
|
||||
statt einer leeren Liste.
|
||||
|
||||
### Fixed
|
||||
- **Favoriten überleben ein Umbenennen (Web)** — Umbenennen und Verschieben geben dem Song beim
|
||||
nächsten Scan eine **neue** Navidrome-ID. Für Playlists wurde die Zugehörigkeit längst umgehängt,
|
||||
für den Stern nicht: wer einen markierten Song bearbeitete, verlor die Markierung stillschweigend.
|
||||
Der Zustand wird jetzt vor dem Schreiben gelesen (`starred` aus `getSong`) und nach dem Wiederfinden
|
||||
auf die neue ID gesetzt. Schlägt das fehl, steht es als Warnung im Fenster statt unbemerkt zu
|
||||
verschwinden.
|
||||
**Grenze:** der Stern liegt in Navidrome **pro Nutzer**. Übertragen lässt sich nur der des
|
||||
bearbeitenden Nutzers — fremde Markierungen sind über die Subsonic-API weder lesbar noch setzbar.
|
||||
- **Dubletten lassen sich im Editor selbst auflösen (Web)** — wollte das Speichern die Datei in den
|
||||
Ordner der neuen Tags verschieben und lag dort schon eine gleichnamige, blieb sie liegen und der
|
||||
Hinweis schickte einen auf die Konsole. Derselbe Song lag danach doppelt in der Bibliothek — genau
|
||||
das, was die ganze Dateizuordnung durcheinanderbringt.
|
||||
- Das Fenster stellt jetzt **beide Kopien gegenüber** (Pfad, Künstler, Titel, Länge, Größe) und
|
||||
sagt, ob es dieselbe Aufnahme ist: gleiche Länge (± 2 s) **und** gleicher Titel-Tag. Darunter
|
||||
steht ein Knopf, der die überzählige löscht — mit derselben Rückfrage wie beim Song-Löschen.
|
||||
- **Neuer Endpunkt `POST /api/resolve-duplicate`.** Er nimmt Pfade entgegen und prüft deshalb
|
||||
strenger als das Löschen per ID: beide müssen im Musikordner liegen, Audiodateien sein und
|
||||
existieren, und dieselbe Länge und denselben Titel tragen. Sonst 409 und es wird nichts gelöscht.
|
||||
Geprüft mit echten Dateien: sechs Fälle, von „fehlende Felder" über einen Pfad außerhalb des
|
||||
Musikordners bis zum anderen Song mit ähnlichem Namen — nur die echte Dublette fiel.
|
||||
|
||||
- **Ein „/" im Künstler- oder Albumnamen führte den Metadaten-Editor auf die falsche Datei** —
|
||||
beim Bearbeiten von „K/DA – VILLAIN" meldete das Backend, die gefundene Datei enthalte
|
||||
„Bad Omens – Like A Villain". Zwei Fehler trafen aufeinander:
|
||||
- **Der gemeldete Pfad kann es gar nicht geben.** Navidrome baut ihn aus den Tags
|
||||
(`fakePath()`): `{AlbumArtist}/{Album}/{DD}-{TT} - {Titel}`. Steht ein Schrägstrich **im**
|
||||
Namen, wird daraus eine zusätzliche Verzeichnisebene — „K/DA/ALL OUT/…" statt des Ordners
|
||||
`K_DA`, den unser `sanitize()` beim Herunterladen angelegt hat. Dasselbe bei „POP/STARS" →
|
||||
`POP_STARS`. Der Pfad zeigt also ins Leere, und die Suche fällt auf Ähnlichkeit zurück.
|
||||
- **Die Ähnlichkeitssuche war zu großzügig.** Sie nahm jede Datei, deren Stamm auf den Titel
|
||||
**endet** — und „bad omens - like a villain" endet nun einmal auf „villain". Getroffen wird
|
||||
jetzt nur noch ein vollständiges Feld des Dateinamens: der ganze Stamm, oder der Teil hinter
|
||||
einem „ - ". Die Endsilben-Regel ist ersatzlos weg.
|
||||
- Neu davor: der Ordner wird **aus den Tags nachgebaut**, genau so wie der Downloader ihn
|
||||
angelegt hätte (Schrägstrich → `_`, erster Künstler), und zuerst dort gesucht. Das ist gezielt
|
||||
statt geraten und macht die Ähnlichkeitssuche für diesen Fall überflüssig.
|
||||
- Dass der Fehler überhaupt sichtbar wurde, lag am Tag-Abgleich aus 2.3: er hat den Fehlgriff
|
||||
erkannt und abgebrochen, statt die falsche Datei zu bearbeiten.
|
||||
- **Das Löschen hatte diesen Abgleich nicht** — es nahm den Treffer der Ähnlichkeitssuche ohne
|
||||
Gegenprobe und löschte. Bei „VILLAIN" hätte es die Datei von Bad Omens erwischt, unwiderruflich
|
||||
und ohne Hinweis. Jetzt prüft auch `/api/delete-song` die Tags der gefundenen Datei gegen das,
|
||||
was Navidrome für diesen Song meldet, und bricht im Zweifel mit 409 ab. Als letzten Suchschritt
|
||||
bekommt es zusätzlich `findExistingTrack` — der Weg, der bei „K/DA" von sich aus richtig liegt,
|
||||
weil er `/` und `_` beim Vergleich gleich behandelt.
|
||||
- Die Suchhelfer liegen jetzt in [metadata.js](backend/lib/metadata.js) statt im Server, damit sie
|
||||
geprüft werden können: sieben neue Fälle in `backend/test/metadata.test.js` (49 Prüfungen grün).
|
||||
|
||||
### Changed
|
||||
- **Nur noch ein Künstler im Dateinamen (Backend + Editor)** — bisher stand dort die vollständige
|
||||
Gästeliste: `01 - K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine,League of Legends - MORE.mp3`.
|
||||
Üblich ist das nirgends — Picard, beets und Lidarr schreiben höchstens **einen** Künstler in den
|
||||
Namen —, und es hatte zwei handfeste Nachteile: `sanitize()` schneidet bei 100 Zeichen hart ab,
|
||||
also mitten im letzten Namen, und mit dem Dateisystemlimit von 255 Byte wird es bei mehrbytigen
|
||||
Namen eng, was Umbenennen und Verschieben scheitern lässt. Jetzt `01 - K_DA - MORE.mp3`.
|
||||
- Genommen wird der **erste Künstler des Titels**, nicht der des Albums — so trägt ein
|
||||
Soundtrack-Titel im Namen weiterhin seinen eigenen Interpreten, auch wenn der Ordner am
|
||||
Album-Künstler hängt. Die vollständige Liste steht unverändert im `artist`-Tag, und nur den
|
||||
liest Navidrome.
|
||||
- Gilt für **neue** Downloads und für Dateien, die bearbeitet werden; Bestehendes bleibt liegen.
|
||||
Beide Namensformen stehen also eine Weile nebeneinander. Die Dublettenprüfung erkennt beide —
|
||||
eigener Testfall, damit die alten Dateien nicht ein zweites Mal geladen werden.
|
||||
|
||||
- **Künstlerbilder laden endlich zügig (Web)** — im Künstlerraster lief **pro Künstler ein eigener
|
||||
`getArtistInfo2`-Aufruf**, nur um die Bildadresse zu erfahren. Bei 200 Künstlern sind das 200
|
||||
API-Aufrufe auf einen Schlag, die der Browser zu sechst abarbeitet (mehr Verbindungen erlaubt er
|
||||
pro Host nicht) — und jeder davon konnte Navidrome zu einer **externen** Abfrage bei Last.fm
|
||||
zwingen. Dass manche Bilder gar nicht kamen, lag an derselben Quelle: Last.fm liefert für viele
|
||||
Künstler nur noch einen Platzhalter, den der Code als „kein Bild" erkennt.
|
||||
Dabei liefert `getArtists` das Bild längst mit: `toArtistID3` setzt `coverArt`, und Navidrome löst
|
||||
Künstlerbilder selbst auf (`ArtistArtPriority` = `artist.*, album/artist.*, external`) und legt sie
|
||||
im eigenen Cache ab. Jetzt geht pro Künstler nur noch das Bild selbst über die Leitung, kein
|
||||
API-Aufruf davor; ist `coverArt` leer, hat der Künstler kein Bild und wir sparen uns den Versuch.
|
||||
Gemessen im Test: **0 statt 120 Zusatzaufrufe** bei 120 Künstlern. Gleiches gilt jetzt für die
|
||||
Künstlerkarten in der lokalen Suche und die gefolgten Künstler in den Favoriten.
|
||||
**Android war nie betroffen** — dort lief es immer schon über `coverArt`.
|
||||
- **Top Songs fragen jetzt über die Künstler-ID (Web + Android)** — Navidrome 0.64 nimmt bei
|
||||
`getTopSongs` zusätzlich `id`. Bisher lief die Abfrage nur über den **Namen**, und der Namensweg
|
||||
sucht mit `LIKE` und `Max: 1` (`core/external/provider.go`): bei zwei gleichnamigen Künstlern in
|
||||
der Bibliothek gewann schlicht der erste Treffer. Wir schicken jetzt `id` **und** `artist` — ältere
|
||||
Navidrome-Versionen ignorieren `id`, neuere fallen bei unbekannter ID von selbst auf den Namen
|
||||
zurück. In Erkunden bleibt es bewusst beim Namen: die dortige `artistId` ist **Deezers**, nicht
|
||||
Navidromes.
|
||||
|
||||
---
|
||||
|
||||
## [2.6] — 2026-09-12
|
||||
|
||||
**Android:** 1.9 (versionCode 12) → 1.12 (versionCode 15). 1.9 brachte den Besitzer geteilter
|
||||
Playlists und die Offline-Korrekturen, 1.10 den Zählfehler im Downloader, 1.11 den WLAN-Schalter,
|
||||
1.12 das vollständige Löschen.
|
||||
|
||||
### Fixed
|
||||
- **Zurück und Vorwärts gingen im installierten Fenster am PC gar nicht (Web)** — im Browser-Tab
|
||||
funktionierten sie, im PWA-Fenster nicht. Das Manifest setzt `"display": "standalone"`: dort gibt
|
||||
es keine Zurück-/Vorwärts-Knöpfe, und die Seitentasten der Maus werden nicht auf den Verlauf
|
||||
umgelenkt. Die App übernimmt die Maustasten X1/X2 jetzt selbst — **nur** im Standalone-Fenster,
|
||||
denn im Tab macht das der Browser und wir würden doppelt navigieren (beides mitgetestet).
|
||||
Zurück geht über denselben Weg wie der In-App-Knopf, schließt also erst ein offenes
|
||||
Now-Playing-Fenster. `Alt`+`←`/`→` bleibt dem Browser überlassen, das kann er auch im Fenster.
|
||||
- **Zwei Node-Dienste hießen in Navidromes Player-Liste fast gleich** — das Backend meldete sich als
|
||||
`novasonic`, der Scrobble-Dienst als `NovaSonic`; da Navidrome den Namen als `client [User-Agent]`
|
||||
bildet, standen dort zwei Zeilen `novasonic [node]` und `NovaSonic [node]`, unterscheidbar nur an
|
||||
einem Großbuchstaben. Genau die Zeile, an der „Report Real Path" hängt, war so nicht auffindbar.
|
||||
Jetzt `NovaSonic-Backend` und `NovaSonic-Scrobble`. Navidrome legt dafür **neue** Player-Zeilen an;
|
||||
die alten können weg. Eine Player-Zeile hält nur Client, User-Agent, IP, Transcoding, Bitratengrenze
|
||||
und die beiden Schalter — keine Wiedergabezahlen, keine Historie. Löschen ist also gefahrlos, und
|
||||
die neuen Zeilen erben `ND_SUBSONIC_DEFAULTREPORTREALPATH`.
|
||||
- **„Pfad (Navidrome)" im Info-Fenster wieder zu „Pfad"** — der Zusatz war doppelt gemoppelt: der
|
||||
Wert kommt ohnehin aus der Subsonic-API. Was er bedeutet, steht als Kommentar im Code und in den
|
||||
Known Bugs, nicht als Etikett im Fenster.
|
||||
- **Der Zurück-Knopf tat oft nichts (Web)** — `navigate()` prüfte zwar, ob die Ansicht sich
|
||||
überhaupt geändert hat, und ließ den Verlaufseintrag dann weg — **rief `history.pushState` aber
|
||||
trotzdem**. Zweimal auf denselben Tab geklickt hieß also zwei Browser-Einträge auf dieselbe
|
||||
Ansicht, und jeder davon kostete einen Zurück-Druck, bei dem sichtbar nichts passierte. Gemessen
|
||||
im Test: dreimal „Alben" erzeugte **drei** Browser-Einträge bei **einem** Stack-Eintrag, danach
|
||||
bewegten vier Zurück-Drücke die Ansicht genau einmal. Jetzt bekommt nur eine wirklich neue Ansicht
|
||||
einen Verlaufseintrag, sonst wird der bestehende ersetzt. Der allererste Aufruf ersetzt ebenfalls,
|
||||
weil `main.js` die schon offene Seite bereits auf `nsIdx 0` gesetzt hatte — auch das war ein
|
||||
toter Zurück-Druck gleich zu Beginn. Vorwärts funktioniert unverändert (mitgetestet).
|
||||
|
||||
### Fixed
|
||||
- **Offline-Downloads löschen ließ Dateien zurück (Android)** — „komplett löschen und neu laden" ist
|
||||
der naheliegende Ausweg, wenn eine Gruppe durcheinandergeraten ist; dafür muss Löschen aber wirklich
|
||||
alles erwischen. Es fehlte an drei Stellen:
|
||||
- **Cover blieben liegen.** Entfernt wurde nur die Audiodatei. Bei einer Playlist über viele Alben
|
||||
sammeln sich so hunderte JPEGs an. Jetzt werden sie mitgelöscht — aber nur, wenn kein anderer
|
||||
Titel dasselbe Cover mehr benutzt, denn Cover werden geteilt gespeichert.
|
||||
- **Unfertige Titel behielten ihre Datei.** Der Pfad kam aus der Karte der *fertigen* Titel; ein
|
||||
abgebrochener oder fehlgeschlagener Download tauchte darin nicht auf und blieb unberührt.
|
||||
Der Pfad kommt jetzt aus der Datenbankzeile selbst, dazu die angefangene `.tmp`.
|
||||
- **Laufende Downloadarbeit lief weiter.** Nach dem Löschen lud der Worker noch minutenlang ins
|
||||
Leere; jetzt wird die Arbeit der Gruppe abgebrochen.
|
||||
- **Ein zu hoch gezählter Fortschritt konnte sich nie mehr korrigieren (Android)** — Gruppen aus der
|
||||
Zeit vor 1.10 tragen womöglich einen Wert über `totalTracks` und gelten damit als „Vollständig":
|
||||
der Herunterladen-Knopf ist unantastbar, und das Fortsetzen beim Start sucht nur Gruppen *unter*
|
||||
dem Sollwert — die Gruppe fiel also durch jedes Raster. Beim App-Start wird der Fortschritt aller
|
||||
Gruppen jetzt einmal aus dem tatsächlichen Zustand der Titel neu berechnet (eine SQL-Anweisung),
|
||||
danach greifen Knopf und Fortsetzen wieder.
|
||||
- **Halb geladene Gruppen meldeten sich als „Vollständig" (Android)** — der Worker startete seinen
|
||||
Zähler bei `group.downloadedTracks` und zählte dieselben fertigen Titel beim Überspringen **gleich
|
||||
noch einmal**: nach einem Fortsetzen mit 412 fertigen Titeln meldete die Gruppe 824. Überschreitet
|
||||
der Wert `totalTracks`, gilt sie als vollständig — der Fortschrittsbalken verschwindet und der
|
||||
Herunterladen-Knopf wird unantastbar, obwohl Titel fehlen. Der Fehler steckte schon länger drin,
|
||||
fiel aber kaum auf, solange Fortsetzen nur von Hand passierte; mit der automatischen Wiederholung
|
||||
hätte er bei jedem Lauf zugeschlagen. Der Zähler beginnt jetzt bei 0 und zählt jeden fertigen Titel
|
||||
genau einmal.
|
||||
- Nebenbei: beim Überspringen wurde der Fortschritt in die Datenbank geschrieben, obwohl er sich
|
||||
nicht ändert — 1900 überflüssige Schreibzugriffe je Lauf. Geschrieben wird jetzt nur nach einem
|
||||
tatsächlichen Versuch und einmal beim Abbruch.
|
||||
- **Große Offline-Downloads liefen nie zu Ende (Android)** — WorkManager räumt einem
|
||||
Hintergrund-Worker rund **10 Minuten** ein und stoppt ihn dann. Ein Vordergrunddienst ist keine
|
||||
Option, `FOREGROUND_SERVICE_TYPE_DATA_SYNC` bringt die App unter Android 15 zum Absturz. Der
|
||||
Downloader prüfte aber nie `isStopped`, lief nach dem Stoppsignal blind weiter bis zum harten
|
||||
Abbruch, und meldete am Ende `Result.failure()` statt `Result.retry()` — ohne Backoff. WorkManager
|
||||
startete deshalb **nichts** von allein neu: bei 1900 Titeln musste man immer wieder von Hand auf
|
||||
Herunterladen tippen. Jetzt:
|
||||
- die Schleife steigt bei `isStopped` sauber aus und fordert den nächsten Lauf an (`Result.retry()`,
|
||||
linearer Backoff 30 s). Fertige Titel überspringt sie ohnehin, es wird also fortgesetzt statt neu
|
||||
geladen.
|
||||
- bleiben danach noch Titel offen, fordert der Worker selbst weitere Läufe an — begrenzt auf 20
|
||||
Versuche, damit dauerhaft kaputte Titel (auf dem Server gelöscht, immer 404) keine Endlosschleife
|
||||
auslösen. Danach steht in der Gruppenzeile weiter `x/y Titel`, und ein Tipp startet die Kette neu.
|
||||
- ein einzelner Netzfehler beendete bisher die **ganze** Kette endgültig (`Result.failure()` im
|
||||
äußeren `catch`); jetzt wird auch das wiederholt.
|
||||
- beim App-Start werden unvollständige Gruppen weitergeführt (`setzeUnvollstaendigeFort()`), falls
|
||||
die Kette doch einmal reißt — Geräteneustart, aufgebrauchte Versuche.
|
||||
- Nebenbei: die Zahl der offenen Titel kam aus einer Abfrage **pro Titel** am Ende jedes Laufs
|
||||
(1900 Stück); jetzt eine einzige `COUNT`-Abfrage mit JOIN.
|
||||
- **Große Offline-Playlists ließen sich nicht abspielen (Android)** — bei ~100 Titeln ging alles, bei
|
||||
1900 nicht. Ursache war nicht die Größe an sich: die Warteschlange wurde aus **allen** Titeln der
|
||||
Gruppe gebaut, ohne Rücksicht auf den Download-Zustand. Die Karte, aus der die lokale Datei kommt,
|
||||
kennt dagegen nur fertige (`WHERE state = 'DONE'`) — für wartende oder fehlgeschlagene Titel
|
||||
lieferte `streamUrl()` deshalb die **Netz-Adresse**, und die tut offline nichts. Bei 100 Titeln ist
|
||||
der Download längst durch, bei 1900 praktisch nie; liegt so ein Titel vorn, scheitert die
|
||||
Wiedergabe sofort und sieht aus, als ginge die ganze Playlist nicht. Play und Shuffle nehmen jetzt
|
||||
nur fertige Titel, ein Hinweis nennt die Zahl der übersprungenen. Die Liste zeigt weiterhin alle,
|
||||
damit man sieht, was noch fehlt; ein Tipp auf einen unfertigen Titel sagt das auch.
|
||||
- Verworfen: die naheliegende Vermutung, Media3 stoße bei 1900 Einträgen an die 1-MB-Grenze einer
|
||||
Binder-Transaktion. Tut es nicht — `MediaControllerImplBase` reicht die Liste als
|
||||
`BundleListRetriever` durch, den die Session seitenweise abholt. Die Größe ist unschuldig.
|
||||
|
||||
### Added
|
||||
- **„Nur über WLAN herunterladen" (Android)** — die Downloadarbeit lief bisher mit
|
||||
`NetworkType.CONNECTED`, also über **jede** Verbindung. Das war selbstbegrenzend, solange nach zehn
|
||||
Minuten Schluss war; mit der automatischen Wiederholung hätte sich eine Playlist unterwegs
|
||||
ununterbrochen weitergeladen — bei 1900 Titeln grob 9–10 GB über Mobilfunk. Neuer Schalter in den
|
||||
Einstellungen unter **📥 Offline-Downloads**, **standardmäßig an**: dann gilt `UNMETERED`, der
|
||||
Download pausiert ohne WLAN und läuft von selbst weiter, sobald es zurück ist. Abschalten geht nur
|
||||
über eine Rückfrage, die das Datenvolumen ausdrücklich benennt.
|
||||
- Beim Speichern werden laufende Gruppen neu eingereiht (`REPLACE`): WorkManager ändert die
|
||||
Bedingungen einer bereits eingereihten Arbeit nicht mehr, der Schalter hätte sonst erst beim
|
||||
nächsten App-Start gewirkt. Unbedenklich, weil der Worker fortsetzt statt neu zu laden.
|
||||
- **Bei geteilten Playlists steht jetzt dabei, von wem sie sind (Web + Android)** — Navidrome
|
||||
liefert `owner` in `getPlaylists`/`getPlaylist` mit, im Web wurde das Feld nie benutzt. Übersicht
|
||||
und Detailseite zeigen es als `· von <Name>`, und zwar **nur bei fremden** Listen — bei den
|
||||
eigenen wäre „von mir" nur Rauschen. Android zeigte den Besitzer auf der Detailseite bisher bei
|
||||
**jeder** Liste und unbeschriftet, sodass der eigene Name wie ein Teil des Titels aussah; jetzt
|
||||
verhalten sich beide Plattformen gleich, und die Playlist-Übersicht in der Bibliothek zeigt es
|
||||
ebenfalls.
|
||||
|
||||
---
|
||||
|
||||
## [2.5] — 2026-09-10
|
||||
|
||||
**Android:** 1.8 (versionCode 11) — Sprung vom Album zum Künstler.
|
||||
|
||||
### Added
|
||||
- **Vom Album zum Künstler springen (Web + Android)** — in der globalen Suche führte ein Album
|
||||
bisher in eine Sackgasse: der Künstlername darunter war reiner Text. Wer von dort zum Künstler
|
||||
wollte, musste zurück und neu suchen. Der Name ist jetzt anklickbar und öffnet dieselbe
|
||||
Deezer-Künstlerseite wie eine Künstler-Kachel. Betrifft im Web die Inline-Albumansicht in der
|
||||
globalen Suche und die Vollbild-Albumseite, in Android die Album-Detailansicht in Erkunden —
|
||||
dort stand vorher **gar kein** Künstler, nur der Albumtitel.
|
||||
- **Kostet keinen zusätzlichen API-Aufruf.** Deezer legt `/search/album` und `/album/{id}` das
|
||||
vollständige `artist`-Objekt samt `id` bei (in 120 von 120 Treffern über zwölf Suchen geprüft);
|
||||
beide Mappings (`_dzAlbum` im Web, `DzAlbum.toItunesItem()` in Android) übernahmen die id schon,
|
||||
sie wurde nur nirgends benutzt.
|
||||
- **Ausnahme mit Absicht:** `/artist/{id}/albums` lässt das `artist`-Objekt weg. Alben aus dieser
|
||||
Liste haben keine id, dort bleibt der Name deshalb reiner Text statt ins Leere zu zeigen — man
|
||||
kommt ja gerade von der Künstlerseite. Ein Ersatz-Lookup wäre ein Aufruf für einen Sprung, den
|
||||
niemand macht.
|
||||
- Deezer nennt nur den **Haupt**künstler: bei „A feat. B" landet man auf A.
|
||||
- Android lädt Bild und Fanzahl nach, wenn der Künstler aus einem Album konstruiert wurde — sonst
|
||||
sähe die Kopfzeile anders aus als beim Weg über die Künstlersuche.
|
||||
- **Der Künstler in den Albumansichten ist überhaupt erst zu sehen (Web)** — er stand in `--text2`
|
||||
(`#8888aa`) und 14px unter einem 28px starken Albumtitel und ging darin schlicht unter; dass man
|
||||
ihn anklicken kann, war nirgends erkennbar. Jetzt hell (`--text`), 15px und halbfett, und wo
|
||||
wirklich ein Ziel dahintersteht, zusätzlich dezent unterstrichen (beim Überfahren in Akzentfarbe).
|
||||
Gilt für die lokale Albumseite, die Deezer-Albumseite und die Inline-Albumansicht in der globalen
|
||||
Suche. Die Teil-Album-Seite bleibt bewusst ohne Link — dorthin kommt man nur von der Künstlerseite.
|
||||
- Nebenbei behoben: `.album-artist-name` trug `cursor:pointer` unabhängig davon, ob der Name
|
||||
irgendwohin führte — der Zeiger versprach auf der Deezer- und der Teil-Album-Seite einen Klick,
|
||||
den es nicht gab. Jetzt nur noch dort, wo wirklich ein Ziel dahintersteht.
|
||||
|
||||
---
|
||||
|
||||
## [2.4] — 2026-09-06
|
||||
|
||||
**Android:** 1.4 (versionCode 7) → 1.7 (versionCode 10). 1.4 brachte Warteschlangen-Wiederherstellung
|
||||
und die Streitpunkte im Duell, 1.6 machte das Wiederherstellen rein lokal und damit sofort, 1.7 leert
|
||||
den Cache nach einem Update selbst.
|
||||
|
||||
### Added
|
||||
- **Cache räumt sich nach einem Update selbst auf (Android)** — im `cacheDir`
|
||||
liegen Coils Bildspeicher (Cover) und heruntergeladene APK-Updates. Blieb dort nach einer neuen
|
||||
Version etwas Altes liegen, half nur „Cache leeren" von Hand in den Systemeinstellungen — nichts,
|
||||
das man einem normalen Nutzer erklären möchte. Beim ersten Start nach einem Versionswechsel wird
|
||||
der Cache jetzt einmalig geleert; im laufenden Betrieb passiert nichts, sonst wären die Cover bei
|
||||
jedem Start neu zu laden. **Offline-Downloads sind nicht betroffen**, die liegen unter
|
||||
`filesDir/offline` und damit außerhalb des Caches.
|
||||
- **Service-Worker-Cache versioniert sich beim Bauen selbst** — die Cache-Version stand handgepflegt
|
||||
als `ns-v78` in `sw.js` und wurde beim Ausliefern zuverlässig vergessen. Da die Aktivierung nur
|
||||
Caches mit *abweichendem* Namen löscht, blieb der alte bestehen und Nutzer mussten den Browsercache
|
||||
von Hand leeren. Ein Vite-Plugin setzt die Version jetzt aus der Bauzeit (`ns-JJJJMMTThhmmss`), der
|
||||
alte Cache räumt sich beim ersten Start selbst ab. Die Quelldatei bleibt unverändert.
|
||||
- **Zuletzt Gehörtes und Warteschlange überleben den Neustart (Web + Android)** — Android sicherte
|
||||
bisher nur den letzten Song, ohne Warteschlange: die Now-Playing-Leiste blieb nach dem Öffnen
|
||||
**leer, bis man einmal auf Play drückte**. Jetzt merken sich beide Plattformen Warteschlange und
|
||||
aktuellen Titel und stellen sie beim Start wieder her — Android **vorbereitet, aber nicht
|
||||
abgespielt**, damit einen die App beim Öffnen nicht überfällt; läuft schon etwas, passiert nichts.
|
||||
Der Titel beginnt von vorn: die Sekunde im Titel wird bewusst **nicht** gespeichert, das spart
|
||||
dauerndes Schreiben während der Wiedergabe.
|
||||
- Der erste Anlauf (1.4) sicherte nur Song-Ids und lud sie beim Start **einzeln nacheinander über
|
||||
das Netz** nach: das dauerte Sekunden, klappte mal und mal nicht und ohne Verbindung gar nicht.
|
||||
Seit 1.6 liegen die vollständigen Songs als JSON im Gerätespeicher, das Wiederherstellen ist ein
|
||||
rein lokaler Lesevorgang wie im Web. Preis: zwischenzeitlich geänderte Titel oder Cover zeigen
|
||||
bis zum nächsten Abspielen den alten Stand.
|
||||
- `MusicService.onTaskRemoved` sichert die Warteschlange auch, wenn die App aus den letzten Apps
|
||||
gewischt wird — dabei feuert `onIsPlayingChanged` nicht zuverlässig, genau dieser Fall ging
|
||||
vorher verloren. Protokoll unter dem Tag `NsQueue` (`adb logcat -s NsQueue`).
|
||||
- Hinweis fürs Testen: der gespeicherte Zustand entsteht erst ab dieser Version, direkt nach der
|
||||
Installation ist die Leiste also noch leer — einmal abspielen, App schließen, wieder öffnen.
|
||||
|
||||
- **Streitpunkte im Musik-Duell (Web + Android)** — gemeinsame Künstler, sortiert nach dem größten
|
||||
**Ungleichgewicht** statt nach der Summe: wo einer 120 Plays hat und der andere 3. Ein Balken
|
||||
zeigt das Kräfteverhältnis. Mindestens vier gemeinsame Plays und 50 % Unwucht, damit ein einzelner
|
||||
Play gegen null nicht als Streit gilt. Beide Plattformen nutzen dieselben Schwellen.
|
||||
|
||||
---
|
||||
|
||||
## [2.3] — 2026-09-04
|
||||
|
||||
**Android:** versionCode 3 → 1.3 (versionCode 6). versionCode 3 lieferte den Alben-Tab-Fix aus und
|
||||
erstmals das Scroll- und Suchgedächtnis der Künstlerseite (2.2 enthielt den Code, das damalige APK
|
||||
war älter); 1.1 brachte Teilen-Link, Songlöschen, Playlist aus dem Import und die Duell-Anzeigetafel;
|
||||
1.2 und 1.3 nahmen die Adressen aus den Einstellungen in den Build. Alle mit demselben Debug-Schlüssel
|
||||
signiert, Updates laufen also in-place und App-Daten bleiben.
|
||||
|
||||
### Fixed
|
||||
- **YT-Music-Suche war praktisch tot** — `getYtmVideoUrls` verwarf die komplette Trefferliste, sobald
|
||||
irgendwo ein Kanal-Eintrag (`browse/UC…`) auftauchte, samt der bereits gesammelten Song-URLs. An
|
||||
acht echten Suchen gemessen passierte das in **sieben** Fällen: Schritt 1 der Kaskade — die Stufe
|
||||
mit den offiziellen Art Tracks — lieferte nie etwas, faktisch lief jeder Download über den
|
||||
YouTube-Fallback. Das erklärt eine ganze Serie falscher Treffer (u. a. „Easy" als Mixtape- statt
|
||||
Albumversion). Kanal- und Album-Einträge werden jetzt übersprungen statt abzubrechen; über 20 000
|
||||
Zufallsausgaben geprüft ist die neue Auswahl eine Obermenge der alten, kann also nur Kandidaten
|
||||
hinzufügen.
|
||||
- **Zurück im Album führte in die Albenliste** — statt dorthin, wo man das Album angeklickt hatte.
|
||||
Der Knopf navigierte vorwärts, legte dabei einen History-Eintrag an und brachte so auch den
|
||||
Browser-Zurück-Knopf durcheinander. Jetzt geht er einen Schritt im Verlauf zurück wie jede andere
|
||||
Detailseite.
|
||||
- **Metadaten bearbeiten zerlegte die Hörstatistik** — Top-Titel gruppierten nach `song_id`, und beim
|
||||
Bearbeiten umbenannte Dateien bekommen von Navidrome eine neue ID: der Titel erschien doppelt mit
|
||||
geteilten Zählern, Künstler ebenso bei geänderter Schreibweise. Gruppiert wird jetzt über einen
|
||||
normalisierten Schlüssel (Titel + Künstler), Anzeigenamen kommen aus dem jüngsten Play. Playlists
|
||||
waren nie betroffen, die werden beim Bearbeiten ohnehin auf die neue ID übertragen.
|
||||
- **Downloader grabbed a same-shaped title with a different number** — searching DAT ADAM's
|
||||
"700 Main Street" saved **"777 Main Street" by an unrelated artist**. `buildTitleKeywords` required
|
||||
only the two longest words (Street, Main); "700" was dropped for being short, and the cascade step
|
||||
that widens to the top 10 no longer checks the artist channel, so nothing else caught it. Numbers
|
||||
are now always required keywords. In the same pass, a small abbreviation map (street/st, saint/st,
|
||||
mount/mt, doctor/dr, versus/vs, number/no) lets a candidate spelled "700 Main St" match a release
|
||||
titled "700 Main Street" — that mismatch had rejected the correct official Art Track in every step,
|
||||
which is why the search fell through to the wrong video in the first place. A re-release year next
|
||||
to a qualifier ("… - 2020 Remaster") is exempt from the number rule: it appears on the release but
|
||||
never in the YouTube title. Measured against a 29-title sample, 24 titles keep their previous
|
||||
behaviour exactly.
|
||||
- **Downloader saved a concert recording as the album track** — searching DAT ADAM's "UFO" stored
|
||||
"Dat Adam - Ufo Video Days 2015 Live". It passed every gate legitimately: right channel, 209 s
|
||||
inside the duration window, "Ufo" in the title, and `!is_live` rejects only *running streams*, not
|
||||
live recordings. The variant blocklist (remix/cover/acoustic/…) now includes `live`. As with the
|
||||
other rules it switches itself off when the searched title says live, so "Live Forever" and
|
||||
deliberately requested live versions are unaffected.
|
||||
- **Album-Position im Web wurde doch nicht wiederhergestellt** — die gespeicherte Scrollposition wurde
|
||||
in genau einem `requestAnimationFrame` gesetzt. Solange Cover und Listeneinträge noch nachluden, war
|
||||
`scrollHeight` kleiner als das Ziel, und der Browser kappte den Wert stillschweigend: man landete
|
||||
wieder oben. Der Wert wird jetzt über bis zu ~0,5 s erneut gesetzt, bis er sitzt.
|
||||
|
||||
- **Metadaten-Änderungen „sprangen zurück" — bei manchen Songs immer, bei anderen nie.** Findet das
|
||||
Backend die von Navidrome gemeldete Datei nicht am angegebenen Pfad (Navidrome liefert oft einen
|
||||
aus Tags gebauten *virtuellen* Pfad), sucht es über Dateiname bzw. Künstler+Titel weiter. Liegt
|
||||
derselbe Song mehrfach auf der Platte — etwa ein Fehlgriff des Downloaders neben der richtigen
|
||||
Datei —, konnte das die **falsche Kopie** treffen: die wurde getaggt, der bearbeitete Song blieb
|
||||
unverändert. Genau deshalb ging es bei Songs ohne Dublette immer und bei anderen nie. Der über
|
||||
Ähnlichkeit gefundene Kandidat wird jetzt gegen die Tags geprüft, die Navidrome für den Song
|
||||
meldet, und bei Widerspruch mit einer erklärenden Meldung abgebrochen, statt zu raten.
|
||||
- **Dieselbe Playlist zweimal geladen ⇒ Songs doppelt auf der Platte.** Die Dublettenprüfung
|
||||
verglich Künstlernamen zeichengenau, sodass „Cécile Corbel" und „Cecile Corbel" als zwei
|
||||
verschiedene Personen galten — welche Schreibweise ankommt, hängt davon ab, ob die Quelle Deezer
|
||||
oder Spotify war. Der Vergleich ignoriert jetzt Akzente (nur der **Vergleich** — Tags und
|
||||
Dateinamen behalten die Originalschreibweise) und ebenso typografische Apostrophe, an denen
|
||||
`Arrietty’s Song` und `Arrietty's Song` bisher auseinanderfielen.
|
||||
- **Soundtracks landeten doppelt** — die Prüfung durchsucht nur den Ordner des Album-Künstlers.
|
||||
Bei Soundtracks und Compilations weicht der vom Titel-Künstler ab: als Album geladen landet der
|
||||
Song unter `Arcane/`, einzeln geladen unter `Bea Miller/` — die Prüfung sah jeweils im falschen
|
||||
Ordner nach. Weicht der Titel-Künstler ab, wird jetzt auch dessen Ordner geprüft. Gemessen an
|
||||
28.801 Dateien kostet der zweite Durchlauf ~0,6 ms, gegenüber 3–9 s pro Download.
|
||||
- **Stumm übersprungene Umbenennungen** — lag am Zielnamen schon eine Datei, blieb der Song unter
|
||||
altem Namen bzw. im alten Ordner liegen, ohne Hinweis. Solche Fälle kommen jetzt als Warnung
|
||||
zurück; der Editor zeigt sie samt bearbeitetem Dateipfad an und bleibt offen.
|
||||
|
||||
### Added
|
||||
- **Musik-Match wird zum Duell (Web + Android)** — statt eines Prozentrings zeigt die Ansicht jetzt
|
||||
eine Anzeigetafel mit fünf Kategorien, die je einen Sieger haben: Hörzeit, Vielfalt, Tiefe
|
||||
(Plays je Künstler), Nachteule (Anteil vor 5 Uhr) und Entdeckerdrang (Anteil exklusiver Künstler).
|
||||
Bewusst Kennzahlen zur *Hörweise* statt zur schieren Menge, sonst gewinnt immer, wer länger dabei
|
||||
ist. Unentschieden zählt für niemanden.
|
||||
- **Vergleichsansicht im Web zerfiel am Desktop** — sie lebte ausschließlich von Inline-Stilen mit
|
||||
festen Flex-Verhältnissen. Jetzt ein echtes Raster (eine Spalte am Handy, zwei ab 780 px) mit
|
||||
eigenen CSS-Klassen; das Fenster ist von 720 auf 940 px verbreitert, sonst hätte die zweite Spalte
|
||||
nie ausgelöst.
|
||||
- **Gemeinsame Songs wurden über `song_id` abgeglichen** — hatten beide denselben Song aus
|
||||
verschiedenen Dateien gehört oder wurde eine Datei zwischendurch umbenannt, galt er als nicht
|
||||
gemeinsam. Jetzt derselbe normalisierte Schlüssel wie in der Statistik.
|
||||
- **Titelwechsel erreichte den Linux-Desktop nicht** — die Media-Session-Metadaten wurden feldweise
|
||||
mutiert, um unter Windows das Neuaufbauen der Medienkachel zu vermeiden. Über MPRIS kam davon
|
||||
nichts an: Plasma-Widgets zeigten dauerhaft den zuerst gespielten Titel. Bei echtem Titelwechsel
|
||||
wird jetzt ein neues `MediaMetadata` zugewiesen, sonst weiter in-place (Cover-Nachladen).
|
||||
- **Keine Adressen mehr eintippen (Android)** — Navidrome-, Backend- und
|
||||
Scrobble-Adresse kommen als Vorgabe aus dem Build (`-PnovasonicNavidrome=` / `-PnovasonicBackend=`
|
||||
/ `-PnovasonicScrobble=`, gepflegt in `scripts/.env.release`). Sie greifen, wenn im Gerätespeicher
|
||||
nichts steht; die Felder in den Einstellungen bleiben sichtbar und überschreibbar, damit ein Umzug
|
||||
am Gerät korrigierbar ist statt per neuem APK. Beim ersten Start ist nur noch **Benutzer und
|
||||
Passwort** einzugeben — Zugangsdaten werden bewusst *nicht* eingebacken, die gehören pro Person.
|
||||
- **Die Web-Adresse ist keine Einstellung mehr (Android)** — sie kommt aus
|
||||
`BuildConfig.SHARE_HOST`, also aus **derselben** Angabe (`-PnovasonicHost=…`), die ohnehin für die
|
||||
App-Links ins Manifest wandert. Damit gibt es keine vierte Adresse zu pflegen, „Teilen" ist immer
|
||||
sichtbar, und ein- und ausgehende Links können nicht mehr auseinanderlaufen.
|
||||
- **Falle beim Veröffentlichen** — der In-App-Updater vergleicht den `versionCode` aus
|
||||
der Gitea-Release-Beschreibung mit dem der installierten App. Wird die Version nicht angehoben
|
||||
**oder** die Beschreibung nicht mitgepflegt (etwa beim Hochladen von Hand statt über
|
||||
`npm run publish-apk`), meldet die App korrekt „kein Update" — obwohl ein neues APK bereitliegt.
|
||||
- **Teilen per Link (Web + Android)** — ein Song oder Album lässt sich per `https://…?song=<id>`
|
||||
an jemanden auf derselben Instanz schicken. Der Link trägt **nur die Id, keine Zugangsdaten**:
|
||||
der Empfänger sieht den Inhalt mit seinem eigenen Navidrome-Konto. Web teilt über
|
||||
`navigator.share()` und fällt sonst auf die Zwischenablage zurück; beim Öffnen wird das Album
|
||||
angezeigt, der Song gestartet und der Parameter aus der Adresszeile entfernt, damit ein Neuladen
|
||||
nicht erneut losspielt. Als installierte PWA landet der Link dank `launch_handler` im bereits
|
||||
offenen Fenster. Android teilt über den Systemdialog (`ACTION_SEND`) und empfängt Links per
|
||||
App-Link-Intent-Filter; der Host kommt beim Bauen aus `-PnovasonicHost=…`, die Web-Adresse für
|
||||
ausgehende Links aus derselben Angabe (seit 1.2, vorher eine eigene Einstellung). Für das Öffnen ohne Nachfrage muss der SHA-256 des
|
||||
Release-Schlüssels in `public/.well-known/assetlinks.json` stehen — Anleitung in
|
||||
`docs/share-links.md`. Ohne diesen Schritt öffnet der Link die Web-App im Browser.
|
||||
- **Android: Songs von der Festplatte löschen** — im Song-Kontextmenü, unten abgesetzt und rot, mit
|
||||
derselben Sicherheitsabfrage wie im Web. Nutzt denselben Backend-Endpunkt (`/api/delete-song`) und
|
||||
erscheint nur, wenn ein Backend konfiguriert ist — Navidrome allein kann keine Dateien entfernen.
|
||||
- **Android: Playlist direkt aus dem Import anlegen** — der Importer hat jetzt wie das Web ein
|
||||
Häkchen „Playlist daraus anlegen" samt Namensfeld (ab zwei Titeln). Nach dem Download stößt er
|
||||
Navidromes Scan an, sucht jeden Titel und legt die Playlist aus den gefundenen an; nicht
|
||||
gefundene werden gezählt statt die Erstellung scheitern zu lassen.
|
||||
|
||||
Zusätzlich zum Auslieferungsteil dieser Version: production **pulls pre-built images** instead of
|
||||
compiling on the server, the **APK download works again** (it had been broken by Gitea's own link
|
||||
host), and the scroll memory from 2.2 finally behaves on the Alben tab.
|
||||
|
||||
### Added (Auslieferung)
|
||||
- **Pull-based production stack** — `docker-compose.yml` now references
|
||||
`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}` and builds nothing on the host:
|
||||
`docker compose pull && docker compose up -d`. Building locally from the Dockerfiles moved to
|
||||
`docker-compose.dev.yml` (no registry needed there). Images are pushed from the dev machine with
|
||||
`npm run push-images`. A `.dockerignore` keeps the frontend build context to what `vite build`
|
||||
needs, so `android/`, `backend/` and `.git` no longer travel into the image.
|
||||
|
||||
### Fixed
|
||||
- **APK download failed with "Keine APK hinterlegt"** — Gitea builds `browser_download_url` from its
|
||||
own `ROOT_URL`, which pointed at a host the backend cannot reach (a leftover from an earlier
|
||||
network). The API call succeeded, so the download button appeared, but fetching the asset ran into
|
||||
the dead address and fell back silently to a local file that does not exist. Asset and redirect
|
||||
URLs are now rewritten onto `GITEA_URL`; if the Gitea path still fails, a warning log and the 404
|
||||
body name both sources that were tried instead of claiming no APK exists.
|
||||
- **Alben tab jumped back to the top on Back (Android)** — `LaunchedEffect(state.albumSort)` scrolled
|
||||
to item 0. That effect runs on first composition too, not only on a sort change, so returning from
|
||||
an album discarded the position the ViewModel had correctly kept. Scrolling to the top now happens
|
||||
in `LibraryViewModel.setAlbumSort()`, on a real sort change only.
|
||||
|
||||
---
|
||||
|
||||
## [2.2] — 2026-08-22
|
||||
|
||||
A cleanup-and-parity release: the **Song Importer moves into the Global Search field** (one field,
|
||||
@@ -567,7 +1375,6 @@ Polish release: Android navigation and offline resilience, download accuracy for
|
||||
## Planned / Backlog
|
||||
|
||||
### Features
|
||||
- **Web + Android** — **Share by link** — share a song/album to another user of the same instance via one `https://…` link that deep-links into the **PWA on desktop** and the **Android app on phone** (like Spotify/YouTube links). Android **App Links** (`intent-filter` + hosted `/.well-known/assetlinks.json` with the *release* signing SHA-256) open the app directly; PWA `launch_handler`/`handle_links` capture links on desktop. Link format leaning query param `?song=<id>` (no server SPA-rewrite; PWA reads it on load). Carries only the song id — no secrets; playback uses the recipient's own Navidrome creds. Songs first → albums/artists/playlists. Web: `navigator.share()`; Android: `ACTION_SEND`.
|
||||
- **Web + Android** — **Yearly Wrapped** — a yearly story (`/scrobble/wrapped` + web story page + Android pager), built on the existing stats aggregations.
|
||||
- **Web + Android** — **Household leaderboard (>2 users)** — "who listened most this month" + top artists across all users; the broader multi-user version of the already-built 2-user Musik-Match. Needs wider cross-user reads (admin/household mode, or an aggregate endpoint exposing per-user totals only, never raw histories).
|
||||
- **Backend (optional)** — **ListenBrainz relay** — mirror counted plays to ListenBrainz.
|
||||
@@ -577,23 +1384,69 @@ Polish release: Android navigation and offline resilience, download accuracy for
|
||||
- **Keep what you like:** per-track (or whole-mix) option to **promote into the permanent library** instead of letting it expire.
|
||||
- Goal: completely random/unfamiliar music you don't already know — like Spotify's curated/discovery playlists, but self-hosted.
|
||||
- Open questions: where downloaded-but-temporary tracks live vs. the main library (separate folder + retention job), how Navidrome should/shouldn't index them, and dedupe against tracks you already own.
|
||||
- **Web + Android** — **Artist following + new-release & concert notifications** — follow artists and get notified when they drop something new or play nearby. Idea:
|
||||
- A **follow list** of artists (stored per user, e.g. in app settings / a small backend table).
|
||||
- A **"Following" view** listing all artists you follow (grid of artist photos), as an entry point to their pages — a dedicated overview so you can see and manage who you follow, plus a follow/unfollow toggle on the artist page.
|
||||
- **New-release alerts:** periodic check for new albums/singles by followed artists (sources: iTunes lookup / MusicBrainz / Last.fm), surfaced in the existing in-app notification bell (and optionally push).
|
||||
- **Concert alerts:** reuse the existing **Ticketmaster** events integration to notify when a followed artist has nearby/upcoming shows.
|
||||
- Ties into the importer: a "download this new release" action straight from the notification.
|
||||
- **Web + Android** — **Meldungen zu gefolgten Künstlern** — der Rest des Künstler-folgen-Vorhabens.
|
||||
Folgen selbst, die Übersicht „wem folge ich" und die Zeitleiste neuer Veröffentlichungen sind
|
||||
**seit 3.0 gebaut** (siehe dort); offen sind nur noch die beiden Meldewege:
|
||||
- **Glocke für neue Veröffentlichungen:** die Zeitleiste muss man heute selbst aufschlagen. Die
|
||||
Daten liegen schon vor (`POST /api/artist-releases`), es fehlt der Vergleich „was ist seit
|
||||
deinem letzten Besuch dazugekommen" und der Eintrag in der vorhandenen Benachrichtigungsglocke
|
||||
(wahlweise Push).
|
||||
- **Konzert-Meldungen:** die vorhandene **Ticketmaster**-Anbindung wiederverwenden und zeigen,
|
||||
wann gefolgte Künstler spielen. Bewusst zurückgestellt — erst die Neuheiten. Der Entwurf steht
|
||||
aber schon (25.9.2026):
|
||||
- **Ein Einstieg, zwei Reiter**, kein zweiter Knopf in der Favoriten-Kopfzeile. Neuheiten und
|
||||
Konzerte sind dasselbe Thema; zwei Knöpfe drängeln sich, und man muss sich merken, welcher
|
||||
was zeigt. Die Zeitleiste bekommt oben *Neu erschienen* / *Konzerte*, die Tages-Chips
|
||||
(90/30/365) gelten nur für die Neuheiten.
|
||||
- **Nicht in eine gemeinsame Liste mischen.** Veröffentlichungen liegen in der Vergangenheit,
|
||||
Konzerte in der Zukunft. Chronologisch gemischt stünde „heute" mittendrin und man wüsste nie,
|
||||
ob man gerade vorwärts oder rückwärts liest.
|
||||
- **Ort erst später, dann per `.env` im Backend** (Land, optional Stadt und Umkreis). Fürs
|
||||
Erste reicht, was die API von sich aus liefert — die Abfrage sucht heute weltweit
|
||||
(`/api/events/:artist`, `keyword=`, `size=20`, nach Datum sortiert).
|
||||
- **Offene Schwachstelle: die Abfrage läuft über den Namen, nicht über eine ID.** Dieselbe
|
||||
Falle wie bei der Deezer-Zuordnung — „TPR" oder „MORE" treffen als Stichwort alles Mögliche.
|
||||
Ticketmaster hat dafür `attractionId`; die einmal pro Künstler auflösen und zwischenspeichern,
|
||||
so wie die Deezer-IDs.
|
||||
- Aufwandsseite unkritisch: eine Anfrage pro Künstler, im Backend gecacht wie die Releases. Bei
|
||||
50 Künstlern und sechs Stunden Haltbarkeit bleibt das weit unter Ticketmasters Tagesgrenze.
|
||||
- **Android** — Playlist drag-and-drop / sort order
|
||||
- **Android** — Artist page iTunes fallback (for artists not in Navidrome)
|
||||
- **Android** — Romaji / Japanese sort support
|
||||
- **Web + Android** — Backend settings UI rework (move, reorganize)
|
||||
- **Web + Android** — yt-dlp backend update / maintenance
|
||||
- **Web + Andoird** - Safe last played Queue, where it stopped and show last played song loaded (rn only on web kinda with the last song played loaded)
|
||||
- **Android** - Andorid Auto Sync last played and queue too with Phone then, rn its not really syncing or safin what last played on Android Auto, also check if selecting a song to play on Android Auto works that song is getting played and in a playlist it will play the queue a this point then like it works already in Android and Web
|
||||
|
||||
### Ideen / Backlog
|
||||
- (Der Punkt „Künstler und Alben liken, daraus ‚folgen' bauen" ist mit 3.0 umgesetzt und dort
|
||||
beschrieben.)
|
||||
|
||||
### Known Bugs
|
||||
- **Android (lyrics)** — Synced-lyrics auto-scroll is **not smooth**: it follows playback and highlights the right line, but the list jumps to it rather than gliding. Web autoscroll is smooth. Not yet fixed.
|
||||
- **Web (PWA, desktop)** — The Windows media tile (SMTC) briefly closes and reopens when skipping tracks. Swapping the `<audio>` element's source leaves a momentary gap with no active media output, so Windows tears down and rebuilds the tile. A real fix needs one continuous source (MSE), which requires streaming an MSE-compatible format. The native Android app is unaffected. (The two-element gapless handoff added in 1.1 makes skips effectively instant but does **not** fix this — switching elements still ends and restarts the OS session.)
|
||||
- **Backend (download)** — The duplicate check requires at least a `firstArtist` folder match. If the same song was previously stored under a very different artist name (e.g. before an iTunes-name fix), it will not be detected as a duplicate and will be re-downloaded.
|
||||
- **Backend (download)** — Die Dublettenprüfung sucht in den Ordnern von Album- **und** Titel-Künstler
|
||||
und ignoriert Akzente sowie Apostrophe (2.3). Ein Rest bleibt: steht derselbe Song unter einem
|
||||
**deutlich anderen** Künstlernamen (etwa vor einer Namenskorrektur), wird er weiterhin nicht
|
||||
erkannt und erneut geladen.
|
||||
- ~~**Backend (Dateinamen)** — `NN-NN - {Titel}` statt `{Tracknummer} - {Künstler} - {Titel}`~~
|
||||
**Kein Bug — am 11.9.2026 widerlegt.** Der Pfad `Rising Insane/DEATHRACE/01-02 - DEATHRACE.mp3`
|
||||
steht so **nirgends auf der Platte**. Navidrome gibt über die Subsonic-API standardmäßig gar nicht
|
||||
den echten Pfad heraus, sondern baut einen aus den Tags zusammen
|
||||
([`fakePath()` in `server/subsonic/helpers.go`](https://github.com/navidrome/navidrome/blob/master/server/subsonic/helpers.go)):
|
||||
`{AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{Endung}` — `01` ist die **Disc**, `02` die
|
||||
**Tracknummer**. An der Stelle des Künstlers steht also nichts Kaputtes, dort steht überhaupt kein
|
||||
Künstler. Der Info-Dialog zeigte diesen Pfad nur unbeschriftet als „Pfad" an (`s.path` aus
|
||||
`getSong`), was wie ein falscher Dateiname aussah.
|
||||
**Folge fürs Backend:** dass Navidrome den echten Pfad verschweigt, ist die Wurzel der ganzen
|
||||
Datei-Ratearbeit in `metadata.js` — und damit auch der „Metadaten springen zurück"-Fehler (2.3),
|
||||
bei dem die Suche nach Ähnlichkeit die falsche Kopie traf. Navidrome hat dafür einen Schalter:
|
||||
**Einstellungen → Players → Zeile `novasonic` → „Report Real Path"**. Ist er an, liefert `getSong`
|
||||
den echten absoluten Pfad; das Backend nimmt den bereits ohne Codeänderung direkt
|
||||
([novasonic-server.js:1638](../backend/novasonic-server.js)), und das Raten entfällt. Es gibt zwei
|
||||
Player-Zeilen, weil Web/Android als `NovaSonic` und das Backend als `novasonic` anfragen —
|
||||
entscheidend ist die des **Backends**.
|
||||
- **Backend (download)** — Der Variantenfilter nutzt `\b`-Wortgrenzen, und die zählen den Unterstrich
|
||||
als Wortzeichen. Titel wie `song_instrumental_cover` oder `Easy_live` rutschen deshalb durch,
|
||||
während `Easy (Instrumental)` korrekt abgewiesen wird.
|
||||
- **Backend (metadata/sorting)** — MusicBrainz `release['artist-credit']` is often absent in the standard recording search response (requires `inc=artist-credits` which can slow the endpoint). The albumArtist field from MusicBrainz autofill may fall back to the recording artist rather than the release artist.
|
||||
- **Backend (metadata/sorting)** — MBIDs are not embedded in downloaded tracks; Navidrome cannot use them for authoritative album grouping (low priority — album splitting is mostly solved via consistent `album_artist`).
|
||||
@@ -41,9 +41,22 @@ npm run build # production build → dist/
|
||||
npm run preview # preview the production build
|
||||
```
|
||||
|
||||
Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives at
|
||||
`/opt/navidrome/music`. Restart after backend changes: `docker compose restart novasonic-backend`
|
||||
(this also re-pulls the latest yt-dlp).
|
||||
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)
|
||||
|
||||
@@ -80,6 +93,31 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
|
||||
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
|
||||
|
||||
@@ -87,7 +87,8 @@ NovaSonic/
|
||||
├── styles.css
|
||||
├── vite.config.js
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── 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 NAVIDROME_URL / BACKEND_URL / SCROBBLE_URL at runtime
|
||||
```
|
||||
@@ -123,9 +124,25 @@ TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
|
||||
### 2. Start
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -14,8 +14,35 @@ android {
|
||||
applicationId = "com.novasonic.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 29
|
||||
versionName = "1.26"
|
||||
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
|
||||
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
|
||||
// Platzhalter stehen und der App-Link greift schlicht nicht.
|
||||
val host1 = (project.findProperty("novasonicHost") as String?) ?: "music.example.com"
|
||||
// Zweiter Host fuer einen Domainwechsel: solange beide Adressen erreichbar sind, oeffnen
|
||||
// Links von der alten UND der neuen in der App. Ohne Angabe wird host1 wiederholt — ein
|
||||
// doppelter Eintrag ist harmlos, ein Platzhalter-Host waere es NICHT: auf Android 11 und
|
||||
// aelter verifiziert das System nur, wenn ALLE angegebenen Hosts eine gueltige
|
||||
// assetlinks.json ausliefern, ein toter Host wuerde also alle blockieren.
|
||||
val host2 = (project.findProperty("novasonicHost2") as String?) ?: host1
|
||||
manifestPlaceholders["shareHost"] = host1
|
||||
manifestPlaceholders["shareHost2"] = host2
|
||||
// Derselbe Host auch fuer AUSGEHENDE Links. Er steht ohnehin schon fest im Manifest, eine
|
||||
// zusaetzliche Einstellung in der App waere also nur eine weitere Adresse zum Pflegen —
|
||||
// und eine, die vom Manifest abweichen koennte, ohne dass es jemand merkt.
|
||||
buildConfigField("String", "SHARE_HOST", "\"$host1\"")
|
||||
|
||||
// Vorgabe-Adressen fuer Backend und Scrobble-Dienst. Sie stehen ohnehin in der
|
||||
// docker-compose.yml — sie zusaetzlich auf jedem Geraet einzutippen ist Arbeit ohne Nutzen.
|
||||
// Leer gelassen verhaelt sich die App wie bisher (Felder sichtbar, nichts vorbelegt).
|
||||
// Zugangsdaten bleiben bewusst aussen vor: die gehoeren pro Person, nicht ins APK.
|
||||
val backendUrl = (project.findProperty("novasonicBackend") as String?) ?: ""
|
||||
val scrobbleUrl = (project.findProperty("novasonicScrobble") as String?) ?: ""
|
||||
val navidromeUrl= (project.findProperty("novasonicNavidrome") as String?) ?: ""
|
||||
buildConfigField("String", "DEFAULT_BACKEND_URL", "\"$backendUrl\"")
|
||||
buildConfigField("String", "DEFAULT_SCROBBLE_URL", "\"$scrobbleUrl\"")
|
||||
buildConfigField("String", "DEFAULT_ND_URL", "\"$navidromeUrl\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
|
||||
Vendored
+6
@@ -6,3 +6,9 @@
|
||||
# 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.**
|
||||
@@ -33,6 +33,19 @@
|
||||
<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
|
||||
|
||||
@@ -24,13 +24,41 @@ class NovaSonicApp : Application() {
|
||||
// 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() }
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
|
||||
@@ -54,5 +55,18 @@ class ImporterClient(private val backendUrl: String) {
|
||||
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,51 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistReleasesRequest
|
||||
import com.novasonic.app.api.model.ArtistKonzerteRequest
|
||||
import com.novasonic.app.api.model.ArtistKonzerteResponse
|
||||
import com.novasonic.app.api.model.ArtistReleasesResponse
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Neue Veröffentlichungen der gefolgten Künstler. Die Deezer-Auflösung macht das Backend — dort
|
||||
* liegen auch die Künstler-Pins, und der Cache gilt dann für alle Geräte gemeinsam statt pro App.
|
||||
*/
|
||||
object ReleasesClient {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
expectSuccess = true
|
||||
// Der erste Aufruf kann dauern: das Backend holt pro Künstler die Deezer-Diskografie.
|
||||
// Danach liegt es dort im Cache.
|
||||
install(HttpTimeout) { requestTimeoutMillis = 60_000; connectTimeoutMillis = 10_000 }
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetch(backendUrl: String, artists: List<ArtistRef>, days: Int): ArtistReleasesResponse {
|
||||
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistReleasesResponse()
|
||||
return http.post("${backendUrl.trimEnd('/')}/api/artist-releases") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ArtistReleasesRequest(artists, days))
|
||||
}.body()
|
||||
}
|
||||
|
||||
/** Anstehende Konzerte derselben Kuenstler — gleicher Weg, anderer Endpunkt. */
|
||||
suspend fun konzerte(backendUrl: String, artists: List<ArtistRef>): ArtistKonzerteResponse {
|
||||
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistKonzerteResponse()
|
||||
// Ohne `days`: das Backend liefert alles Angekuendigte.
|
||||
return http.post("${backendUrl.trimEnd('/')}/api/artist-concerts") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ArtistKonzerteRequest(artists))
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
@@ -149,9 +149,16 @@ class SubsonicClient(
|
||||
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
|
||||
?: com.novasonic.app.api.model.ArtistInfo()
|
||||
|
||||
suspend fun getTopSongs(artist: String, count: Int = 10): List<com.novasonic.app.api.model.Song> =
|
||||
get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song
|
||||
?: emptyList()
|
||||
/**
|
||||
* 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()
|
||||
@@ -163,6 +170,9 @@ class SubsonicClient(
|
||||
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 }
|
||||
|
||||
@@ -42,6 +42,15 @@ data class DownloadRequest(
|
||||
@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(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Eine Veröffentlichung eines gefolgten Künstlers, wie sie das Backend liefert. */
|
||||
@Serializable
|
||||
data class ArtistRelease(
|
||||
val artistId: String = "",
|
||||
val artistName: String = "",
|
||||
val deezerArtistId: String? = null,
|
||||
val albumId: String = "",
|
||||
val title: String = "",
|
||||
val cover: String = "",
|
||||
val releaseDate: String = "",
|
||||
val recordType: String = "album",
|
||||
val trackCount: Int? = null,
|
||||
val explicit: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistReleasesResponse(
|
||||
val releases: List<ArtistRelease> = emptyList(),
|
||||
/** Künstler, zu denen kein Deezer-Profil gefunden wurde — dort hilft nur ein Pin. */
|
||||
val ohneZuordnung: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistRef(val id: String, val name: String)
|
||||
|
||||
@Serializable
|
||||
data class ArtistReleasesRequest(val artists: List<ArtistRef>, val days: Int)
|
||||
|
||||
/** Ein anstehendes Konzert eines gefolgten Künstlers. */
|
||||
@Serializable
|
||||
data class ArtistKonzert(
|
||||
val artistId: String = "",
|
||||
val artistName: String = "",
|
||||
val eventId: String = "",
|
||||
val titel: String = "",
|
||||
/** Ortszeit ohne Zone, z. B. "2026-11-04T20:00:00" — bewusst nicht UTC. */
|
||||
val datum: String = "",
|
||||
val ort: String = "",
|
||||
val stadt: String = "",
|
||||
val land: String = "",
|
||||
val url: String = "",
|
||||
val abgesagt: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistKonzerteResponse(
|
||||
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||
/** Künstler ohne anstehende Termine — nur zur Erklärung einer leeren Liste. */
|
||||
val ohneTermine: List<String> = emptyList(),
|
||||
/** Im Backend ist kein Ticketmaster-Schlüssel hinterlegt. */
|
||||
val keinSchluessel: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistKonzerteRequest(val artists: List<ArtistRef>)
|
||||
@@ -85,6 +85,7 @@ data class CompareResult(
|
||||
val userA: String = "",
|
||||
val userB: String = "",
|
||||
val matchScore: Int = 0,
|
||||
val battle: Battle? = null,
|
||||
val totalsA: CompareTotals = CompareTotals(),
|
||||
val totalsB: CompareTotals = CompareTotals(),
|
||||
val sharedArtists: List<SharedArtist> = emptyList(),
|
||||
@@ -93,6 +94,23 @@ data class CompareResult(
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Uebergabepunkt fuer geteilte Links (https://<host>/?song=… bzw. ?album=…).
|
||||
*
|
||||
* Die Activity kann nicht selbst navigieren — der NavController lebt in der Compose-Hierarchie.
|
||||
* Deshalb legt MainActivity den eingehenden Link hier ab, und AppNavigation holt ihn sich, sobald
|
||||
* es bereit ist. Ein einzelner Slot genuegt: der letzte geoeffnete Link gewinnt.
|
||||
*/
|
||||
object DeepLinkState {
|
||||
val pending = MutableStateFlow<Uri?>(null)
|
||||
|
||||
fun offer(uri: Uri?) {
|
||||
// Nur Links mit einer fuer uns verwertbaren Kennung annehmen — ein blosser Start ueber das
|
||||
// App-Symbol darf keine Navigation ausloesen.
|
||||
if (uri == null) return
|
||||
if (uri.getQueryParameter("song") == null && uri.getQueryParameter("album") == null) return
|
||||
pending.value = uri
|
||||
}
|
||||
|
||||
/** Holt den Link und leert den Slot, damit er bei Drehung o. ae. nicht erneut greift. */
|
||||
fun consume(): Uri? = pending.value.also { pending.value = null }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
/**
|
||||
* Einmalige Absicht beim Öffnen der Bibliothek. Das LibraryViewModel hängt am Navigationseintrag,
|
||||
* überlebt also keinen Wechsel des Ziels — ein Zustand, den ein anderer Screen setzen will, muss
|
||||
* deshalb hier durch. Wird beim Verarbeiten sofort zurückgesetzt, damit er nicht nachwirkt.
|
||||
*/
|
||||
object LibraryIntent {
|
||||
/** Direkt in den Künstler-Reiter mit aktivem "Gefolgt"-Filter springen. */
|
||||
var oeffneGefolgte: Boolean = false
|
||||
}
|
||||
@@ -101,11 +101,15 @@ class MusicRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
|
||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
||||
/** artistId ist die NAVIDROME-Kuenstler-ID (nicht Deezers) — ohne sie sucht Navidrome
|
||||
* ueber den Namen und nimmt bei Gleichnamigen den ersten Treffer. */
|
||||
suspend fun getTopSongs(artistName: String, artistId: String? = null) =
|
||||
api().getTopSongs(artistName, artistId = artistId)
|
||||
suspend fun getGenres() = api().getGenres()
|
||||
suspend fun 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)
|
||||
@@ -168,6 +172,9 @@ class MusicRepository(private val context: Context) {
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ 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")
|
||||
@@ -25,10 +27,19 @@ class PrefsStore(private val context: Context) {
|
||||
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) {
|
||||
@@ -48,6 +59,16 @@ class PrefsStore(private val context: Context) {
|
||||
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(
|
||||
@@ -59,7 +80,9 @@ class PrefsStore(private val context: Context) {
|
||||
|
||||
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
|
||||
Credentials(
|
||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
||||
// Adresse vorbelegt, Zugangsdaten bewusst nicht — die gehoeren pro Person.
|
||||
serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
|
||||
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
|
||||
username = prefs[KEY_USERNAME] ?: "",
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
)
|
||||
@@ -67,18 +90,26 @@ class PrefsStore(private val context: Context) {
|
||||
|
||||
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
||||
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] ?: "",
|
||||
scrobbleUrl = prefs[KEY_SCROBBLE_URL] ?: "",
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,9 +145,21 @@ class PrefsStore(private val context: Context) {
|
||||
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 }
|
||||
}
|
||||
@@ -130,6 +173,39 @@ class PrefsStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private val queueJson = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Sichert die Warteschlange als VOLLSTAENDIGE Songs, nicht nur als Ids. Mit Ids musste beim
|
||||
* Start jeder Titel einzeln ueber das Netz nachgeladen werden — das dauerte Sekunden und ging
|
||||
* ohne Verbindung gar nicht. So ist das Wiederherstellen ein rein lokaler Lesevorgang.
|
||||
* Der Preis: zwischenzeitlich geaenderte Titel oder Cover zeigen bis zum naechsten Abspielen
|
||||
* den alten Stand. Das ist die deutlich kleinere Unschoenheit.
|
||||
*/
|
||||
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) {
|
||||
val kodiert = runCatching {
|
||||
queueJson.encodeToString(kotlinx.serialization.builtins.ListSerializer(
|
||||
com.novasonic.app.api.model.Song.serializer()), songs)
|
||||
}.getOrNull() ?: return
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_QUEUE_JSON] = kodiert
|
||||
prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
|
||||
data class QueueState(val songs: List<com.novasonic.app.api.model.Song>, val index: Int)
|
||||
|
||||
suspend fun readQueueState(): QueueState? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val roh = prefs[KEY_QUEUE_JSON]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val songs = runCatching {
|
||||
queueJson.decodeFromString(kotlinx.serialization.builtins.ListSerializer(
|
||||
com.novasonic.app.api.model.Song.serializer()), roh)
|
||||
}.getOrNull().orEmpty()
|
||||
if (songs.isEmpty()) return null
|
||||
return QueueState(songs, (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, songs.lastIndex))
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
@@ -21,13 +21,18 @@ class DownloadWorker(
|
||||
|
||||
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()
|
||||
val group = offlineRepo.getGroup(groupId) ?: return Result.failure()
|
||||
// Nur die Existenz zaehlt — der Fortschritt wird unten neu gezaehlt, nicht fortgeschrieben.
|
||||
offlineRepo.getGroup(groupId) ?: return Result.failure()
|
||||
val songIds = offlineRepo.songIdsForGroup(groupId)
|
||||
if (songIds.isEmpty()) return Result.success()
|
||||
|
||||
@@ -35,14 +40,26 @@ class DownloadWorker(
|
||||
if (!creds.isValid) return Result.failure()
|
||||
|
||||
val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||
var done = group.downloadedTracks
|
||||
// Bei 0 anfangen und JEDEN fertigen Titel zaehlen — auch die aus frueheren Laeufen, die
|
||||
// gleich uebersprungen werden. Frueher stand hier group.downloadedTracks, und die Schleife
|
||||
// zaehlte dieselben Titel gleich nochmal: nach einem Fortsetzen mit 412 fertigen Titeln
|
||||
// meldete die Gruppe 824. Ueberschreitet der Wert totalTracks, gilt die Gruppe als
|
||||
// "Vollstaendig" und der Herunterladen-Knopf wird unantastbar, obwohl Titel fehlen.
|
||||
var done = 0
|
||||
val dao = OfflineDatabase.get(applicationContext).offlineDao()
|
||||
|
||||
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++
|
||||
dao.updateGroupProgress(groupId, done)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -69,17 +86,41 @@ class DownloadWorker(
|
||||
offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
|
||||
done++
|
||||
} catch (e: Exception) {
|
||||
Log.e("DownloadWorker", "Failed to download song $songId", e)
|
||||
Log.e(TAG, "Titel $songId fehlgeschlagen", e)
|
||||
audioFile.delete()
|
||||
offlineRepo.markFailed(songId)
|
||||
}
|
||||
|
||||
dao.updateGroupProgress(groupId, done)
|
||||
}
|
||||
Result.success()
|
||||
// Beim Abbruch ist der letzte Stand sonst nicht gesichert.
|
||||
if (gestoppt) dao.updateGroupProgress(groupId, done)
|
||||
|
||||
val offen = dao.offeneTitel(groupId)
|
||||
when {
|
||||
gestoppt -> {
|
||||
Log.i(TAG, "Gestoppt bei $done/${songIds.size} — fordere naechsten Lauf an")
|
||||
Result.retry()
|
||||
}
|
||||
offen == 0 -> {
|
||||
Log.i(TAG, "Gruppe $groupId vollstaendig (${songIds.size} Titel)")
|
||||
Result.success()
|
||||
}
|
||||
runAttemptCount < MAX_VERSUCHE -> {
|
||||
Log.i(TAG, "$offen Titel offen (Versuch $runAttemptCount) — noch ein Lauf")
|
||||
Result.retry()
|
||||
}
|
||||
else -> {
|
||||
// Aufgeben, aber nicht stillschweigend: die Gruppenzeile zeigt weiterhin
|
||||
// "x/y Titel", und ein Tipp auf Herunterladen startet die Kette neu.
|
||||
Log.w(TAG, "$offen Titel bleiben offen — nach $runAttemptCount Versuchen aufgegeben")
|
||||
Result.success()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("DownloadWorker", "Worker failed for group $groupId", e)
|
||||
Result.failure()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,26 @@ interface OfflineDao {
|
||||
@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)
|
||||
|
||||
@@ -84,9 +104,21 @@ interface OfflineDao {
|
||||
""")
|
||||
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?
|
||||
|
||||
@@ -154,18 +154,98 @@ class OfflineRepository(context: Context) {
|
||||
}
|
||||
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(NetworkType.CONNECTED)
|
||||
.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, ExistingWorkPolicy.KEEP, request)
|
||||
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 ──────────────────────────────
|
||||
@@ -173,22 +253,18 @@ class OfflineRepository(context: Context) {
|
||||
/** 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) {
|
||||
_audioMap.value[songId]?.let { File(it).delete() }
|
||||
dao.deleteTrackById(songId)
|
||||
}
|
||||
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) {
|
||||
_audioMap.value[songId]?.let { File(it).delete() }
|
||||
dao.deleteTrackById(songId)
|
||||
}
|
||||
if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
|
||||
recountGroup(groupId)
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +238,14 @@ class MusicService : MediaLibraryService() {
|
||||
|
||||
override fun onGetSession(info: MediaSession.ControllerInfo) = session
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) { player.stop(); stopSelf() }
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
// Wischt jemand die App aus den letzten Apps, wird der Prozess gleich beendet — hier ist die
|
||||
// letzte Gelegenheit, die Warteschlange festzuhalten. Ohne das ginge sie genau in dem Fall
|
||||
// verloren, in dem man sie beim naechsten Oeffnen am ehesten erwartet.
|
||||
runCatching { (application as? com.novasonic.app.NovaSonicApp)?.playerController?.saveQueueNow() }
|
||||
player.stop()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
|
||||
@@ -37,6 +37,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
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) }
|
||||
@@ -56,24 +58,95 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
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
|
||||
queue = songs
|
||||
originalQueue = songs
|
||||
isManuallyShuffled = false
|
||||
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 = songs.map { it.toMediaItem(repo) }
|
||||
ctrl.setMediaItems(items, startIndex, 0L)
|
||||
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()
|
||||
@@ -142,6 +215,40 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
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) {
|
||||
@@ -155,22 +262,22 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
val ctrl = controller ?: return
|
||||
val currentIdx = ctrl.currentMediaItemIndex
|
||||
|
||||
if (!isManuallyShuffled) {
|
||||
// Der Rest hinter dem laufenden Titel wird in EINEM Zug ersetzt. Vorher lief das als
|
||||
// Schleife aus removeMediaItem + addMediaItem, und dazwischen hatte der Controller
|
||||
// voruebergehend weniger Titel als unsere Liste. Genau darauf reagiert onTimelineChanged
|
||||
// mit "die Titel kamen von aussen" — es baute die Warteschlange neu auf und setzte dabei
|
||||
// isManuallyShuffled ZURUECK. Das Mischen lief, aber das Symbol leuchtete nie.
|
||||
val neuerRest = if (!isManuallyShuffled) {
|
||||
isManuallyShuffled = true
|
||||
val played = queue.take(currentIdx + 1)
|
||||
val remaining = queue.drop(currentIdx + 1).shuffled()
|
||||
queue = played + remaining
|
||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
||||
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
||||
queue.drop(currentIdx + 1).shuffled()
|
||||
} else {
|
||||
isManuallyShuffled = false
|
||||
val currentSong = queue.getOrNull(currentIdx)
|
||||
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
|
||||
val restOfOriginal = originalQueue.drop(origIdx + 1)
|
||||
queue = originalQueue.take(origIdx + 1) + restOfOriginal
|
||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
||||
restOfOriginal.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
||||
originalQueue.drop(origIdx + 1)
|
||||
}
|
||||
queue = queue.take(currentIdx + 1) + neuerRest
|
||||
ctrl.replaceMediaItems(currentIdx + 1, ctrl.mediaItemCount, neuerRest.map { it.toMediaItem(repo) })
|
||||
syncState()
|
||||
}
|
||||
|
||||
@@ -207,7 +314,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
}
|
||||
|
||||
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
|
||||
@@ -317,6 +427,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
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()
|
||||
@@ -336,6 +448,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0)
|
||||
scheduleScrobble(song, dur)
|
||||
scope.launch { runCatching { repo.saveLastSong(song) } }
|
||||
persistQueueState()
|
||||
prefetchLyrics(song)
|
||||
}
|
||||
|
||||
|
||||
@@ -123,11 +123,37 @@ fun AppNavigation() {
|
||||
}
|
||||
|
||||
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) }
|
||||
@@ -350,7 +376,7 @@ fun AppNavigation() {
|
||||
onGenrePlay = { genre ->
|
||||
scope.launch { runCatching {
|
||||
val songs = repo.getSongsByGenre(genre.value)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
|
||||
}}
|
||||
},
|
||||
onShowAllGenres = { navController.navigate("genres") },
|
||||
@@ -389,6 +415,19 @@ fun AppNavigation() {
|
||||
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) {
|
||||
@@ -508,7 +547,7 @@ fun AppNavigation() {
|
||||
onGenrePlay = { genre ->
|
||||
scope.launch { runCatching {
|
||||
val songs = repo.getSongsByGenre(genre.value)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
|
||||
}}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
@@ -582,6 +621,58 @@ fun AppNavigation() {
|
||||
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") } },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.novasonic.app.ui
|
||||
|
||||
/**
|
||||
* Gesamtdauer einer Playlist oder eines Albums, in Sekunden hereingereicht.
|
||||
*
|
||||
* Ab einer Stunde werden Stunden ausgewiesen: "134 Min." muss man sonst selbst umrechnen, und bei
|
||||
* einer Playlist mit 2000 Titeln staende dort eine vierstellige Minutenzahl. Gleiche Regel und
|
||||
* gleiche Schreibweise wie `formatGesamtdauer` im Web (src/utils.js).
|
||||
*/
|
||||
fun formatGesamtdauer(sekunden: Int): String {
|
||||
val min = Math.round((sekunden.coerceAtLeast(0)) / 60.0).toInt()
|
||||
if (min < 60) return "$min Min."
|
||||
val std = min / 60
|
||||
val rest = min % 60
|
||||
return if (rest > 0) "$std Std. $rest Min." else "$std Std."
|
||||
}
|
||||
@@ -19,6 +19,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Geteilter Link, mit dem die App gestartet wurde.
|
||||
com.novasonic.app.data.DeepLinkState.offer(intent?.data)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
NovaSonicTheme {
|
||||
@@ -28,6 +30,14 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// launchMode=singleTask: laeuft die App bereits, kommt ein weiterer Link hier an statt in
|
||||
// onCreate. Ohne das passierte beim zweiten geteilten Link schlicht nichts.
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
com.novasonic.app.data.DeepLinkState.offer(intent.data)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -9,7 +9,6 @@ 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.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -26,6 +25,8 @@ 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
|
||||
@@ -49,7 +50,7 @@ fun NotificationSheet(
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 8.dp, top = 18.dp, bottom = 12.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Notifications, null,
|
||||
Icon(painterResource(R.drawable.ic_honigtopf_brief), null,
|
||||
tint = NsAccent, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Rollbalken zum Ziehen fuer eine [LazyListState] — das Gegenstueck zum verbreiterten Balken im Web.
|
||||
*
|
||||
* Compose bringt keinen mit: eine LazyColumn zeigt gar nichts an, egal wie lang sie ist. Bei einer
|
||||
* Playlist mit 2000 Titeln bleibt so nur Wischen, bis der Daumen glueht.
|
||||
*
|
||||
* Gerechnet wird in ITEMS, nicht in Pixeln: die tatsaechliche Gesamthoehe einer LazyColumn ist
|
||||
* unbekannt, solange nicht alles gebaut wurde, und bei unterschiedlich hohen Zeilen laesst sie sich
|
||||
* auch nicht ausrechnen. Ueber den Anteil sichtbarer an allen Eintraegen stimmt der Balken bei
|
||||
* gleich hohen Zeilen — und genau die haben Song- und Albumlisten.
|
||||
*
|
||||
* Drei Fallen steckten hier drin, alle beim Ziehen:
|
||||
*
|
||||
* 1. **Die Greifflaeche muss breiter sein als der sichtbare Balken.** Sichtbar 6 dp, anfassbar
|
||||
* [griffBreite]. 6 dp am Bildschirmrand trifft mit dem Finger niemand.
|
||||
* 2. **Der erste Fingerkontakt muss verbraucht werden**, sonst startet die LazyColumn ihre eigene
|
||||
* Scrollgeste, sobald die Wischschwelle faellt, und der Balken sieht die Bewegung nie.
|
||||
* 3. **`pointerInput` darf NICHT auf die Geometrie geschluesselt sein.** Genau das war der Grund,
|
||||
* warum das Ziehen unbrauchbar blieb: die Daumenposition steht in jedem Bild anders da, ein
|
||||
* Schluessel darauf setzt die Gestenerkennung also mitten in der Geste neu auf — der Finger
|
||||
* liegt noch auf dem Schirm, die Erkennung faengt aber wieder bei null an. Der Schluessel ist
|
||||
* deshalb `Unit`; die wechselnden Werte liest die Geste ueber [rememberUpdatedState] zur
|
||||
* Laufzeit nach.
|
||||
*
|
||||
* Gescrollt wird nicht im Zeigerereignis, sondern in einem eigenen Effekt, der [zielPos] verfolgt.
|
||||
* Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte konkurrierende Scroll-Aufrufe.
|
||||
*
|
||||
* Und eine vierte, die erst nach dem Ausliefern auffiel: **nur der Daumen nimmt Beruehrungen an,
|
||||
* nicht die ganze Spur.** Compose reicht eine Beruehrung nur an das oberste getroffene Element
|
||||
* weiter — ein Streifen ueber die volle Hoehe verschluckte damit das ⋮ jeder Songzeile, obwohl er
|
||||
* die Beruehrung gar nicht wollte. Der aeussere Rahmen hat deshalb KEIN `pointerInput` und ist
|
||||
* dadurch durchlaessig; nur der Daumen selbst hoert zu, mit etwas Luft nach oben und unten.
|
||||
*/
|
||||
@Composable
|
||||
fun Rollbalken(
|
||||
state: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
breite: Dp = 6.dp,
|
||||
griffBreite: Dp = 24.dp,
|
||||
minHoehe: Dp = 44.dp,
|
||||
farbe: Color = Color.White,
|
||||
) {
|
||||
val dichte = LocalDensity.current
|
||||
var spurPx by remember { mutableFloatStateOf(0f) }
|
||||
var ziehen by remember { mutableStateOf(false) }
|
||||
// Beim Ziehen fuehrt diese Kommazahl. Wuerde jeder Zeigerschritt aus firstVisibleItemIndex neu
|
||||
// rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal verloren — der Balken
|
||||
// bliebe bei langsamem Ziehen stehen.
|
||||
var zielPos by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val info = state.layoutInfo
|
||||
val gesamt = info.totalItemsCount
|
||||
val zuSehen = info.visibleItemsInfo.size
|
||||
if (gesamt == 0 || zuSehen == 0 || gesamt <= zuSehen) return
|
||||
|
||||
val aktiv = state.isScrollInProgress || ziehen
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (aktiv) 0.9f else 0.3f,
|
||||
animationSpec = tween(if (aktiv) 120 else 450),
|
||||
label = "rollbalken-alpha",
|
||||
)
|
||||
|
||||
val maxIndex = (gesamt - zuSehen).coerceAtLeast(1)
|
||||
val minHoehePx = with(dichte) { minHoehe.toPx() }
|
||||
val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx)
|
||||
val frei = (spurPx - daumenPx).coerceAtLeast(0f)
|
||||
val anteil = if (ziehen) zielPos / maxIndex
|
||||
else state.firstVisibleItemIndex.toFloat() / maxIndex
|
||||
val daumenY = frei * anteil.coerceIn(0f, 1f)
|
||||
val spielPx = with(dichte) { 10.dp.toPx() } // Luft ober- und unterhalb des Daumens
|
||||
|
||||
// Was die Geste zur Laufzeit braucht — nicht als pointerInput-Schluessel, siehe oben.
|
||||
val geo = rememberUpdatedState(Geometrie(maxIndex.toFloat(), frei, daumenY, daumenPx, spielPx))
|
||||
|
||||
// Die Liste folgt zielPos in EINER Koroutine statt in einer je Fingerbewegung.
|
||||
LaunchedEffect(ziehen) {
|
||||
if (!ziehen) return@LaunchedEffect
|
||||
snapshotFlow { zielPos.roundToInt() }.collect { state.scrollToItem(it) }
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.width(griffBreite)
|
||||
.fillMaxHeight()
|
||||
.onSizeChanged { spurPx = it.height.toFloat() }
|
||||
) {
|
||||
val luftDp = with(dichte) { spielPx.toDp() }
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
// Der Greifbereich beginnt eine Spielbreite ueber dem Daumen und ist entsprechend
|
||||
// hoeher; der sichtbare Balken sitzt durch das Innenmass wieder genau auf daumenY.
|
||||
.offset { IntOffset(0, (daumenY - spielPx).roundToInt()) }
|
||||
.width(griffBreite)
|
||||
.height(with(dichte) { (daumenPx + 2 * spielPx).toDp() })
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val start = awaitFirstDown(requireUnconsumed = false)
|
||||
if (geo.value.frei <= 0f) return@awaitEachGesture
|
||||
start.consume()
|
||||
zielPos = state.firstVisibleItemIndex.toFloat()
|
||||
ziehen = true
|
||||
try {
|
||||
while (true) {
|
||||
val ereignis = awaitPointerEvent()
|
||||
val punkt = ereignis.changes.firstOrNull { it.id == start.id } ?: break
|
||||
if (!punkt.pressed) break
|
||||
val delta = punkt.positionChange().y
|
||||
punkt.consume()
|
||||
if (delta != 0f) {
|
||||
val jetzt = geo.value
|
||||
if (jetzt.frei > 0f) {
|
||||
zielPos = (zielPos + delta * jetzt.maxIndex / jetzt.frei)
|
||||
.coerceIn(0f, jetzt.maxIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ziehen = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(vertical = luftDp, horizontal = 0.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(end = 2.dp)
|
||||
.width(breite)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(farbe.copy(alpha = alpha))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Nur ein Buendel der Werte, die die Geste zur Laufzeit nachliest. */
|
||||
private data class Geometrie(
|
||||
val maxIndex: Float,
|
||||
val frei: Float,
|
||||
val daumenY: Float,
|
||||
val daumenPx: Float,
|
||||
val spiel: Float,
|
||||
)
|
||||
@@ -33,6 +33,8 @@ fun SongContextSheet(
|
||||
onGoToAlbum: (() -> Unit)? = null,
|
||||
onShowInfo: (() -> Unit)? = null,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
onShare: (() -> Unit)? = null,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -77,6 +79,16 @@ fun SongContextSheet(
|
||||
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))
|
||||
|
||||
@@ -45,7 +45,10 @@ fun SongRow(
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPlay)
|
||||
.background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent)
|
||||
.padding(start = if (isPlaying) 0.dp else 4.dp),
|
||||
// Rechts 24 dp frei halten: genau dort liegt der Rollbalken, und ein ⋮ darunter
|
||||
// waere nicht mehr zu treffen — Compose gibt eine Beruehrung nur an das oberste
|
||||
// getroffene Element. Gilt ueberall, damit die Zeilen gleich aussehen.
|
||||
.padding(start = if (isPlaying) 0.dp else 4.dp, end = 24.dp),
|
||||
) {
|
||||
// Accent left bar when playing
|
||||
if (isPlaying) {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -11,6 +12,9 @@ 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.*
|
||||
@@ -34,6 +38,8 @@ 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(
|
||||
@@ -72,152 +78,178 @@ fun AlbumScreen(
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
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,
|
||||
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) } },
|
||||
)
|
||||
// back button — frosted circle
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
// album info overlaid at bottom
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
album.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!album.artist.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
album.artist!!, fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
|
||||
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
|
||||
Modifier.clickable { onArtistClick(album.artistId!!) }
|
||||
else Modifier,
|
||||
)
|
||||
}
|
||||
val meta = listOfNotNull(
|
||||
album.year?.toString(),
|
||||
album.genre?.takeIf { it.isNotBlank() },
|
||||
if (album.songCount > 0) "${album.songCount} Songs" else null,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
meta, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle row ────────────────────────────────────────────
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onPlay(album.song, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onPlay(album.song.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
val isFullyDownloaded = downloadGroup != null &&
|
||||
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
|
||||
downloadGroup!!.totalTracks > 0
|
||||
OutlinedButton(
|
||||
// Tappable unless fully done — resumes / re-downloads any missing songs.
|
||||
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
|
||||
modifier = Modifier.height(46.dp).width(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(
|
||||
when {
|
||||
isFullyDownloaded -> MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
else -> NsAccent.copy(alpha = 0.55f)
|
||||
}
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
when {
|
||||
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Downloaded", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
|
||||
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ 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
|
||||
@@ -82,7 +85,7 @@ fun ArtistDetailScreen(
|
||||
|
||||
LaunchedEffect(state.pendingShufflePlay) {
|
||||
state.pendingShufflePlay?.let { songs ->
|
||||
onPlay(songs, 0)
|
||||
onPlay(songs, -1) // -1 = gemischt starten, der Player mischt
|
||||
vm.consumeShufflePlay()
|
||||
}
|
||||
}
|
||||
@@ -215,11 +218,14 @@ fun ArtistDetailScreen(
|
||||
title = openAllAlbums ?: "",
|
||||
entries = allAlbumEntries,
|
||||
onBack = { openAllAlbums = null },
|
||||
gridState = vm.allAlbumsGrid,
|
||||
query = vm.allAlbumsQuery.value,
|
||||
onQueryChange = { vm.allAlbumsQuery.value = it },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
LazyColumn(state = vm.pageListState, modifier = Modifier.fillMaxSize()) {
|
||||
// Back link
|
||||
item {
|
||||
Row(
|
||||
@@ -257,6 +263,24 @@ fun ArtistDetailScreen(
|
||||
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() },
|
||||
@@ -624,8 +648,10 @@ 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 = {},
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
val filtered = remember(query, entries) {
|
||||
if (query.isBlank()) entries
|
||||
else entries.filter { it.name.contains(query, ignoreCase = true) }
|
||||
@@ -660,11 +686,11 @@ internal fun AllAlbumsOverlay(
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Suchen…", color = NsOnSurface2) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||
trailingIcon = if (query.isNotEmpty()) { {
|
||||
IconButton(onClick = { query = "" }) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(Icons.Default.Clear, null, tint = NsOnSurface2)
|
||||
}
|
||||
} } else null,
|
||||
@@ -684,6 +710,7 @@ internal fun AllAlbumsOverlay(
|
||||
)
|
||||
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ArtistNewsViewModel
|
||||
import com.novasonic.app.viewmodel.NewsReiter
|
||||
import com.novasonic.app.api.model.ArtistKonzert
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
/**
|
||||
* Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. Nach Monat gruppiert,
|
||||
* neueste zuerst. Was schon in der Bibliothek liegt, ist markiert statt anklickbar.
|
||||
*/
|
||||
@Composable
|
||||
fun ArtistNewsScreen(
|
||||
onBack: () -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
val vm: ArtistNewsViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val ctx = LocalContext.current
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.statusBarsPadding().padding(start = 4.dp, top = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
val konzerte = state.reiter == NewsReiter.KONZERTE
|
||||
Text(
|
||||
if (konzerte) "Konzerte deiner Künstler" else "Neu von deinen Künstlern",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind dasselbe
|
||||
// Thema. Bewusst NICHT in eine gemeinsame Liste gemischt — Veroeffentlichungen liegen
|
||||
// hinter, Konzerte vor dem heutigen Tag.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
|
||||
) {
|
||||
listOf(
|
||||
NewsReiter.RELEASES to "Neu erschienen",
|
||||
NewsReiter.KONZERTE to "Konzerte",
|
||||
).forEach { (r, label) ->
|
||||
FilterChip(
|
||||
selected = state.reiter == r,
|
||||
onClick = { vm.zeigeReiter(r) },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Das Zeitfenster gilt nur fuer die Veroeffentlichungen. Konzerte sind ohnehin Zukunft,
|
||||
// dort will man alles Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
|
||||
if (state.reiter == NewsReiter.RELEASES) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 10.dp),
|
||||
) {
|
||||
// 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn.
|
||||
listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) ->
|
||||
FilterChip(
|
||||
selected = state.tage == tage,
|
||||
onClick = { vm.setzeTage(tage) },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
when {
|
||||
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
state.keinBackend -> Hinweis("🔌", "Kein Backend konfiguriert",
|
||||
"Neue Veröffentlichungen kommen über das Backend von Deezer.")
|
||||
state.keineGefolgten -> Hinweis("❤", "Du folgst noch niemandem",
|
||||
"Auf einer Künstlerseite oben rechts aufs Herz tippen.")
|
||||
state.error != null -> Hinweis("⚠", "Laden fehlgeschlagen", state.error!!)
|
||||
|
||||
state.reiter == NewsReiter.KONZERTE && state.keinSchluessel -> Hinweis("🎟",
|
||||
"Kein Ticketmaster-Schlüssel hinterlegt", "Im Backend TICKETMASTER_API_KEY setzen.")
|
||||
state.reiter == NewsReiter.KONZERTE && state.konzerte.isEmpty() -> Hinweis("🎪",
|
||||
"Keine anstehenden Termine",
|
||||
"Gesucht wird weltweit über den Künstlernamen.")
|
||||
state.reiter == NewsReiter.KONZERTE -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||
var letzterMonat = ""
|
||||
state.konzerte.forEach { k ->
|
||||
val monat = monatVon(k.datum)
|
||||
if (monat != letzterMonat) {
|
||||
letzterMonat = monat
|
||||
item(key = "km_$monat") {
|
||||
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
|
||||
}
|
||||
}
|
||||
item(key = "k_${k.eventId}_${k.artistId}") {
|
||||
KonzertZeile(k) { onArtistClick(k.artistId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.releases.isEmpty() -> Hinweis("🌙",
|
||||
"Nichts Neues in den letzten ${state.tage} Tagen", "")
|
||||
else -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||
if (state.ohneZuordnung.isNotEmpty()) {
|
||||
item { ZuordnungsHinweis(state.ohneZuordnung) }
|
||||
}
|
||||
var letzterMonat = ""
|
||||
state.releases.forEach { rel ->
|
||||
val monat = monatVon(rel.releaseDate)
|
||||
if (monat != letzterMonat) {
|
||||
letzterMonat = monat
|
||||
item(key = "m_$monat") {
|
||||
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
|
||||
}
|
||||
}
|
||||
item(key = "${rel.artistId}_${rel.albumId}") {
|
||||
ReleaseZeile(
|
||||
rel = rel,
|
||||
schonDa = vm.hatSchon(rel),
|
||||
onArtist = { onArtistClick(rel.artistId) },
|
||||
onDownload = {
|
||||
vm.herunterladen(rel) { _, msg ->
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Hinweis(symbol: String, titel: String, text: String) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(symbol, fontSize = 34.sp)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(titel, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
if (text.isNotBlank()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(text, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZuordnungsHinweis(namen: List<String>) {
|
||||
Box(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsAccent.copy(alpha = 0.08f))
|
||||
.border(1.dp, NsAccent.copy(alpha = 0.28f), RoundedCornerShape(10.dp))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"⚠ Für ${namen.size} Künstler wurde kein Deezer-Profil gefunden: " +
|
||||
namen.take(4).joinToString(", ") + (if (namen.size > 4) " …" else "") +
|
||||
". Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, lineHeight = 17.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val TYP = mapOf("album" to "Album", "single" to "Single", "ep" to "EP",
|
||||
"compile" to "Compilation")
|
||||
|
||||
@Composable
|
||||
private fun ReleaseZeile(
|
||||
rel: ArtistRelease,
|
||||
schonDa: Boolean,
|
||||
onArtist: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 7.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = rel.cover.takeIf { it.isNotBlank() },
|
||||
contentDescription = rel.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(rel.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(rel.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtist))
|
||||
Text(
|
||||
" · " + (TYP[rel.recordType] ?: rel.recordType) +
|
||||
(rel.trackCount?.let { " · $it Titel" } ?: ""),
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(datumVon(rel.releaseDate), fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface3, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (schonDa) {
|
||||
Icon(Icons.Default.CheckCircle, "In deiner Bibliothek",
|
||||
tint = NsOnSurface3, modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
IconButton(onClick = onDownload) {
|
||||
Icon(Icons.Default.Download, "Herunterladen", tint = NsAccent,
|
||||
modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deezer liefert "JJJJ-MM-TT".
|
||||
private val EIN = SimpleDateFormat("yyyy-MM-dd", Locale.GERMAN)
|
||||
private val MONAT = SimpleDateFormat("LLLL yyyy", Locale.GERMAN)
|
||||
private val DATUM = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN)
|
||||
|
||||
private fun monatVon(s: String) = runCatching { MONAT.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
private fun datumVon(s: String) = runCatching { DATUM.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
|
||||
// Ticketmaster liefert die ORTSZEIT ohne Zone: "2026-11-04T20:00:00".
|
||||
private val EIN_ZEIT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMAN)
|
||||
private val TAG_KURZ = SimpleDateFormat("d", Locale.GERMAN)
|
||||
private val MON_KURZ = SimpleDateFormat("MMM", Locale.GERMAN)
|
||||
private val UHR = SimpleDateFormat("HH:mm", Locale.GERMAN)
|
||||
|
||||
private fun zeitVon(s: String) = runCatching { EIN_ZEIT.parse(s) }.getOrNull()
|
||||
|
||||
/**
|
||||
* Eine Konzertzeile. Statt eines Covers steht links ein Kalenderblatt — ein Konzert hat kein Bild,
|
||||
* aber ein Datum, und danach sucht das Auge in einer Liste, die nach vorn läuft.
|
||||
*/
|
||||
@Composable
|
||||
private fun KonzertZeile(k: ArtistKonzert, onArtist: () -> Unit) {
|
||||
val uri = LocalUriHandler.current
|
||||
val d = zeitVon(k.datum)
|
||||
val ort = listOf(k.ort, k.stadt, k.land).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
val uhr = d?.let { UHR.format(it) }?.takeIf { it != "00:00" }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(10.dp))
|
||||
.alpha(if (k.abgesagt) 0.45f else 1f),
|
||||
) {
|
||||
Text(d?.let { TAG_KURZ.format(it) } ?: "?", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold, fontSize = 17.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(d?.let { MON_KURZ.format(it) }?.uppercase() ?: "", fontFamily = Outfit,
|
||||
fontSize = 10.sp, color = NsOnSurface2)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
k.titel.ifBlank { k.artistName },
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = if (k.abgesagt) NsOnSurface2 else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (k.abgesagt) TextDecoration.LineThrough else null,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(k.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtist))
|
||||
val rest = listOfNotNull(ort.ifBlank { null }, uhr?.let { "$it Uhr" })
|
||||
if (rest.isNotEmpty()) {
|
||||
Text(" · " + rest.joinToString(" · "), fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (k.abgesagt) {
|
||||
Text("abgesagt", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
} else if (k.url.isNotBlank()) {
|
||||
TextButton(onClick = { uri.openUri(k.url) }) {
|
||||
Text("Tickets", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ private fun CompareContent(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||
) {
|
||||
ScoreRing(d)
|
||||
Scoreboard(d)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Totals(d)
|
||||
Spacer(Modifier.height(22.dp))
|
||||
@@ -173,6 +173,8 @@ private fun CompareContent(
|
||||
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))
|
||||
@@ -182,46 +184,102 @@ private fun CompareContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScoreRing(d: CompareResult) {
|
||||
val score = d.matchScore.coerceIn(0, 100)
|
||||
val verdict = when {
|
||||
score >= 80 -> "Seelenverwandte 🎯"
|
||||
score >= 60 -> "Starkes Match 🔥"
|
||||
score >= 40 -> "Solide Überschneidung 👍"
|
||||
score >= 20 -> "Ein paar Gemeinsamkeiten 🙂"
|
||||
else -> "Ziemlich unterschiedlich 🌗"
|
||||
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
|
||||
}
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(Modifier.size(132.dp), contentAlignment = Alignment.Center) {
|
||||
val accent = NsAccent
|
||||
val track = NsSurface2
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val stroke = 14.dp.toPx()
|
||||
val inset = stroke / 2
|
||||
val arcSize = Size(size.width - stroke, size.height - stroke)
|
||||
drawArc(
|
||||
color = track, startAngle = 0f, sweepAngle = 360f, useCenter = false,
|
||||
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize,
|
||||
style = Stroke(width = stroke),
|
||||
)
|
||||
drawArc(
|
||||
color = accent, startAngle = -90f, sweepAngle = score * 3.6f, useCenter = false,
|
||||
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize,
|
||||
style = Stroke(width = stroke),
|
||||
)
|
||||
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)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("$score%", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 30.sp, color = NsOnSurface)
|
||||
Text("Match", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
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))
|
||||
Text(verdict, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsOnSurface)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("${d.userA} ⟷ ${d.userB}", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Scoreboard(d: CompareResult) {
|
||||
val b = d.battle
|
||||
if (b == null || b.kategorien.isEmpty()) return
|
||||
val urteil = when {
|
||||
b.punkteA == b.punkteB -> "Kopf-an-Kopf — kein Sieger."
|
||||
b.punkteA > b.punkteB -> "${d.userA} gewinnt ${b.punkteA}:${b.punkteB}."
|
||||
else -> "${d.userB} gewinnt ${b.punkteB}:${b.punkteA}."
|
||||
}
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(d.userA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
|
||||
Text(" ${b.punkteA} : ${b.punkteB} ", fontFamily = Outfit, fontWeight = FontWeight.Black,
|
||||
fontSize = 30.sp, color = NsOnSurface)
|
||||
Text(d.userB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
|
||||
}
|
||||
Text(urteil, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp),
|
||||
textAlign = TextAlign.Center)
|
||||
|
||||
// Eine Zeile je Kategorie: Wert · Bezeichnung · Wert. Der Sieger wird farblich
|
||||
// hervorgehoben, der Verlierer zurueckgenommen — identisch zum Web.
|
||||
b.kategorien.forEach { k ->
|
||||
val gewinnA = k.sieger == "A"
|
||||
val gewinnB = k.sieger == "B"
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(k.textA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = if (gewinnA) NsAccent else if (gewinnB) NsOnSurface3 else NsOnSurface,
|
||||
textAlign = TextAlign.End, modifier = Modifier.weight(1f))
|
||||
Text(k.label.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 11.sp, color = NsOnSurface2, textAlign = TextAlign.Center,
|
||||
maxLines = 1, modifier = Modifier.weight(1.3f))
|
||||
Text(k.textB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = if (gewinnB) NsAccent else if (gewinnA) NsOnSurface3 else NsOnSurface,
|
||||
modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun Totals(d: CompareResult) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
|
||||
@@ -126,6 +126,7 @@ fun ExploreScreen(
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onArtistClick = vm::openArtistOfAlbum,
|
||||
onBack = vm::dismissAlbum,
|
||||
)
|
||||
}
|
||||
@@ -145,6 +146,9 @@ fun ExploreScreen(
|
||||
allAlbumsOpen = state.artistAllAlbums,
|
||||
onOpenAllAlbums = vm::openArtistAllAlbums,
|
||||
onCloseAllAlbums = vm::closeArtistAllAlbums,
|
||||
allAlbumsGrid = vm.allAlbumsGrid,
|
||||
allAlbumsQuery = vm.allAlbumsQuery.value,
|
||||
onAllAlbumsQueryChange = { vm.allAlbumsQuery.value = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -404,6 +408,7 @@ private fun GlobalExploreContent(
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = onDownload,
|
||||
onArtistClick = vm::openArtistOfAlbum,
|
||||
onBack = vm::dismissAlbum,
|
||||
)
|
||||
return
|
||||
@@ -521,6 +526,7 @@ internal fun ItunesAlbumDetail(
|
||||
previewingId: Long?,
|
||||
onPreview: (ItunesItem) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit = {},
|
||||
onArtistClick: ((ItunesItem) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
@@ -532,9 +538,25 @@ internal fun ItunesAlbumDetail(
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
|
||||
// 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",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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
|
||||
@@ -18,6 +19,7 @@ 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(
|
||||
@@ -77,19 +79,29 @@ fun GenreSongsScreen(
|
||||
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Songs gefunden", color = NsOnSurface2)
|
||||
}
|
||||
else -> LazyColumn {
|
||||
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) } },
|
||||
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,
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
@@ -33,6 +32,8 @@ import com.novasonic.app.data.NotificationStore
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.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,",
|
||||
@@ -120,7 +121,7 @@ fun HomeScreen(
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = onBellClick) {
|
||||
Icon(Icons.Default.Notifications, "Benachrichtigungen",
|
||||
Icon(painterResource(R.drawable.ic_honigtopf_brief), "Benachrichtigungen",
|
||||
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,38 @@ fun ImporterScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist aus dem Import anlegen — wie im Web-Importer. Nur ab zwei Titeln sinnvoll;
|
||||
// ein einzelner Track ergibt keine Playlist.
|
||||
if (r.tracks.size > 1) {
|
||||
item {
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = state.createPlaylist,
|
||||
onCheckedChange = vm::setCreatePlaylist,
|
||||
colors = CheckboxDefaults.colors(checkedColor = brand,
|
||||
checkmarkColor = Color.Black),
|
||||
)
|
||||
Text("Playlist daraus anlegen", fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = NsOnSurface)
|
||||
}
|
||||
if (state.createPlaylist) {
|
||||
OutlinedTextField(
|
||||
value = state.playlistName.ifBlank { r.name ?: "" },
|
||||
onValueChange = vm::setPlaylistName,
|
||||
label = { Text("Playlist-Name", fontFamily = Outfit) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
state.playlistStatus?.let {
|
||||
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download button / job progress
|
||||
item {
|
||||
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
|
||||
|
||||
@@ -109,6 +109,9 @@ fun ItunesArtistScreen(
|
||||
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)
|
||||
@@ -144,6 +147,9 @@ fun ItunesArtistScreen(
|
||||
title = allAlbumsOpen,
|
||||
entries = allAlbumEntries,
|
||||
onBack = onCloseAllAlbums,
|
||||
gridState = allAlbumsGrid,
|
||||
query = allAlbumsQuery,
|
||||
onQueryChange = onAllAlbumsQueryChange,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -219,7 +225,7 @@ fun ItunesArtistScreen(
|
||||
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onSongPlay(artistState.topSongs.shuffled(), 0) },
|
||||
onClick = { onSongPlay(artistState.topSongs, -1) },
|
||||
modifier = Modifier.weight(1f).height(42.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true)
|
||||
|
||||
@@ -7,9 +7,7 @@ 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.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -66,6 +64,15 @@ fun LibraryScreen(
|
||||
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.
|
||||
@@ -167,7 +174,9 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
val albumGridState = vm.albumsGridState // hoisted → survives album drill-in/back
|
||||
LaunchedEffect(state.albumSort) { albumGridState.scrollToItem(0) }
|
||||
// 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),
|
||||
@@ -191,12 +200,20 @@ fun LibraryScreen(
|
||||
LibraryTab.ARTISTS -> {
|
||||
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
|
||||
val scope = rememberCoroutineScope()
|
||||
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
|
||||
// "Gefolgt" filtert die vorhandene Liste — ein eigener Reiter wäre in einer Leiste,
|
||||
// die schon vier Kacheln trägt, nur Gedränge.
|
||||
val indizes = remember(state.artistIndexes, state.nurGefolgte, state.gefolgteIds) {
|
||||
if (!state.nurGefolgte) state.artistIndexes
|
||||
else state.artistIndexes
|
||||
.map { idx -> idx.copy(artist = idx.artist.filter { it.id in state.gefolgteIds }) }
|
||||
.filter { it.artist.isNotEmpty() }
|
||||
}
|
||||
val letters = remember(indizes) { indizes.map { it.name } }
|
||||
// Map letter → flat LazyColumn index of its header item
|
||||
val letterToIndex = remember(state.artistIndexes) {
|
||||
val letterToIndex = remember(indizes) {
|
||||
buildMap<String, Int> {
|
||||
var flat = 0
|
||||
state.artistIndexes.forEach { idx ->
|
||||
indizes.forEach { idx ->
|
||||
put(idx.name, flat)
|
||||
flat += 1 + idx.artist.size
|
||||
}
|
||||
@@ -204,13 +221,54 @@ fun LibraryScreen(
|
||||
}
|
||||
var activeLetter by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 6.dp, bottom = 6.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !state.nurGefolgte,
|
||||
onClick = { vm.nurGefolgteUmschalten(false) },
|
||||
label = { Text("Alle", fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent),
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.nurGefolgte,
|
||||
onClick = { vm.nurGefolgteUmschalten(true) },
|
||||
label = {
|
||||
val n = state.gefolgteIds.size
|
||||
Text("❤ Gefolgt" + if (state.nurGefolgte && n > 0) " ($n)" else "",
|
||||
fontSize = 12.sp)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent),
|
||||
)
|
||||
}
|
||||
if (state.nurGefolgte && indizes.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("❤", fontSize = 30.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Du folgst noch niemandem", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Auf einer Künstlerseite oben rechts aufs Herz tippen.",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
state.artistIndexes.forEach { idx ->
|
||||
indizes.forEach { idx ->
|
||||
item(key = "header_${idx.name}") {
|
||||
Text(
|
||||
text = idx.name,
|
||||
@@ -316,6 +374,7 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,9 +386,11 @@ fun LibraryScreen(
|
||||
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",
|
||||
subtitle = "${pl.songCount} Titel" + (von?.let { " · von $it" } ?: ""),
|
||||
coverUrl = coverUrl(pl.coverArt),
|
||||
downloaded = pl.id in downloadedPlaylistIds,
|
||||
onClick = { onPlaylistClick(pl) },
|
||||
|
||||
@@ -24,7 +24,8 @@ fun LoginScreen(onLoggedIn: () -> Unit) {
|
||||
val vm: LoginViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
var serverUrl by remember { mutableStateOf("") }
|
||||
// Serveradresse vorbelegen, falls im Build hinterlegt — dann bleiben nur Benutzer und Passwort.
|
||||
var serverUrl by remember { mutableStateOf(com.novasonic.app.BuildConfig.DEFAULT_ND_URL) }
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPw by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -60,6 +60,11 @@ import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.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 }
|
||||
|
||||
@@ -78,9 +83,14 @@ fun NowPlayingScreen(
|
||||
val song = state.currentSong
|
||||
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
||||
|
||||
// Swipe left/right between Player / Lyrics / Queue, kept in sync with the tab pills (like
|
||||
// Erkunden). A horizontal drag that starts on the cover still skips tracks — that gesture is a
|
||||
// child pointerInput and consumes the drag before the pager sees it.
|
||||
// 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)
|
||||
@@ -227,11 +237,31 @@ fun NowPlayingScreen(
|
||||
// ── 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(),
|
||||
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 ─────────────────────────────────────────────────
|
||||
@@ -386,7 +416,7 @@ fun NowPlayingScreen(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
NpCircleBtn(onClick = { controller.toggleShuffle() }) {
|
||||
NpCircleBtn(onClick = { controller.toggleShuffle() }, aktiv = state.shuffled) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
|
||||
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
|
||||
}
|
||||
@@ -412,7 +442,8 @@ fun NowPlayingScreen(
|
||||
NpCircleBtn(onClick = { controller.skipNext() }) {
|
||||
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
|
||||
}
|
||||
NpCircleBtn(onClick = { controller.toggleRepeat() }) {
|
||||
val wiederholt = state.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF
|
||||
NpCircleBtn(onClick = { controller.toggleRepeat() }, aktiv = wiederholt) {
|
||||
val (icon, tint) = when (state.repeatMode) {
|
||||
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
|
||||
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
|
||||
@@ -430,6 +461,13 @@ fun NowPlayingScreen(
|
||||
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
|
||||
|
||||
@@ -440,7 +478,10 @@ fun NowPlayingScreen(
|
||||
|
||||
// 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) {
|
||||
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 +
|
||||
@@ -458,6 +499,41 @@ fun NowPlayingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (lyricsState.lines.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
if (folgtMit) NsAccent.copy(0.18f) else Color.White.copy(0.08f)
|
||||
)
|
||||
.clickable {
|
||||
folgenScope.launch {
|
||||
com.novasonic.app.data.PrefsStore(prefsCtx)
|
||||
.saveLyricsFolgen(!folgtMit)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 11.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowDown, null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = if (folgtMit) NsAccent else Color.White.copy(0.55f),
|
||||
)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text(
|
||||
if (folgtMit) "Folgt" else "Mitlaufen",
|
||||
fontFamily = Outfit, fontSize = 11.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (folgtMit) NsAccent else Color.White.copy(0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
lyricsState.isLoading -> Box(
|
||||
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
|
||||
@@ -573,18 +649,38 @@ fun NowPlayingScreen(
|
||||
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).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(if (active) Color.White.copy(0.08f) else Color.Transparent)
|
||||
.background(
|
||||
when {
|
||||
wirdGezogen -> Color.White.copy(0.16f)
|
||||
active -> Color.White.copy(0.08f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
)
|
||||
.clickable { controller.seekToItem(idx) }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
@@ -612,6 +708,53 @@ fun NowPlayingScreen(
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,11 +839,34 @@ private fun NpSeekBar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NpCircleBtn(onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
private fun NpCircleBtn(
|
||||
onClick: () -> Unit,
|
||||
aktiv: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
// Ein eingefaerbtes Symbol allein ist als "an" schwer zu erkennen — auf dem dunklen Cover
|
||||
// sieht Akzentfarbe wie eine Schattierung aus. Ist der Schalter an, liegt deshalb zusaetzlich
|
||||
// ein getoenter Kreis darunter und ein Punkt darunter, wie in gaengigen Playern.
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(onClick = onClick),
|
||||
) { content() }
|
||||
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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -31,11 +32,13 @@ 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(
|
||||
@@ -62,124 +65,154 @@ fun OfflineDetailScreen(
|
||||
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)
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
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,
|
||||
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,
|
||||
)
|
||||
)
|
||||
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))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Offline · ${tracks.size} Titel",
|
||||
fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
|
||||
// ── Play / Shuffle / Edit row ─────────────────────────────────────
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
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),
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
// ── Play / Shuffle / Edit row ─────────────────────────────────────
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { if (songs.isNotEmpty()) onPlay(songs, 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 (songs.isNotEmpty()) onPlay(songs.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
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,
|
||||
onPlay = { onPlay(songs, index) },
|
||||
)
|
||||
}
|
||||
if (editMode) {
|
||||
IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) {
|
||||
Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen",
|
||||
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp))
|
||||
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)) }
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ fun PartialAlbumScreen(
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable { onPlay(downloadedSongs.shuffled(), 0) }
|
||||
.clickable { onPlay(downloadedSongs, -1) }
|
||||
.padding(vertical = 11.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, tint = NsOnSurface, modifier = Modifier.size(18.dp))
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.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
|
||||
@@ -39,6 +40,8 @@ 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(
|
||||
@@ -91,205 +94,218 @@ fun PlaylistDetailScreen(
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
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,
|
||||
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
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable { showRenameDialog = true },
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Edit, "Bearbeiten",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable { showDeleteDialog = true },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Delete, "Löschen",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
pl.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
pl.owner?.takeIf { it.isNotBlank() },
|
||||
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
meta, fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
)
|
||||
}
|
||||
if (!pl.comment.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle / Edit / Download ──────────────────────────────
|
||||
// Four buttons in one row left no room for labels (the Play/Shuffle text was being
|
||||
// clipped away), so all four are equal-width and icon-only. Play stays filled as the
|
||||
// primary action; the rest are outlined and tint accent when active/done.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
}
|
||||
// Toggle edit mode for removing songs
|
||||
OutlinedButton(
|
||||
onClick = { editMode = !editMode },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (editMode) Icons.Default.Done else Icons.Default.Edit,
|
||||
if (editMode) "Bearbeiten beenden" else "Titel entfernen",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (editMode) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
// Download button — tappable unless fully done; resumes / re-downloads missing songs
|
||||
val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false
|
||||
OutlinedButton(
|
||||
onClick = { if (!dlDone) vm.downloadPlaylist() },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
when {
|
||||
dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent)
|
||||
else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(pl.entry, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
|
||||
)
|
||||
}
|
||||
if (editMode) {
|
||||
IconButton(
|
||||
onClick = { vm.removeSong(idx) },
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.RemoveCircleOutline,
|
||||
"Entfernen",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(22.dp),
|
||||
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)) }
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Rename dialog ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,6 +122,76 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {})
|
||||
)
|
||||
}
|
||||
|
||||
// ── ▶ Wiedergabe ────────────────────────────────────────────────────
|
||||
SectionHeader("▶ Wiedergabe")
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Wischen wechselt den Titel", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text(
|
||||
if (state.wischenWechseltTitel)
|
||||
"Im Now-Playing: seitwärts wischen springt zum nächsten oder vorherigen Titel"
|
||||
else
|
||||
"Im Now-Playing: seitwärts wischen blättert zwischen Player, Warteschlange und Text",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.wischenWechseltTitel,
|
||||
onCheckedChange = { vm.onWischenWechseltTitel(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ── 📥 Downloads ─────────────────────────────────────────────────────
|
||||
SectionHeader("📥 Offline-Downloads")
|
||||
|
||||
var wlanWarnung by remember { mutableStateOf(false) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Nur über WLAN herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Pausiert unterwegs und macht im WLAN von selbst weiter", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Switch(
|
||||
// Ausschalten nur mit Rueckfrage: eine grosse Playlist sind schnell mehrere Gigabyte,
|
||||
// und der Downloader arbeitet sich ohne Zutun durch, bis alles da ist.
|
||||
checked = state.downloadWifiOnly,
|
||||
onCheckedChange = { an -> if (an) vm.onDownloadWifiOnly(true) else wlanWarnung = true },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (wlanWarnung) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { wlanWarnung = false },
|
||||
title = { Text("Über mobile Daten laden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Text(
|
||||
"Downloads laufen dann auch ohne WLAN weiter — und zwar so lange, bis alles " +
|
||||
"geladen ist. Eine Playlist mit 1000 Titeln sind leicht mehrere Gigabyte.",
|
||||
fontFamily = Outfit, fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { vm.onDownloadWifiOnly(false); wlanWarnung = false }) {
|
||||
Text("Trotzdem erlauben", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { wlanWarnung = false }) {
|
||||
Text("Abbrechen", fontFamily = Outfit, color = NsAccent)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
|
||||
@@ -194,6 +264,9 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {})
|
||||
label = "Last.fm API Key",
|
||||
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",
|
||||
@@ -210,6 +283,7 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {})
|
||||
)
|
||||
ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble)
|
||||
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
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
|
||||
@@ -18,9 +29,13 @@ 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?,
|
||||
@@ -28,18 +43,29 @@ fun StarredScreen(
|
||||
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 {
|
||||
songs = repo.getStarred().song
|
||||
val markiert = repo.getStarred()
|
||||
songs = markiert.song
|
||||
gefolgteKuenstler = markiert.artist.sortedBy { it.name.lowercase() }
|
||||
gemerkteAlben = markiert.album.sortedByDescending { it.starred ?: "" }
|
||||
loading = false
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
@@ -57,12 +83,25 @@ fun StarredScreen(
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
"Favoriten",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 24.dp, bottom = 8.dp),
|
||||
)
|
||||
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(
|
||||
@@ -100,25 +139,118 @@ fun StarredScreen(
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(error!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
} else if (sorted.isEmpty()) {
|
||||
} else if (sorted.isEmpty() && gefolgteKuenstler.isEmpty() && gemerkteAlben.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Noch keine Favoriten", color = NsOnSurface2)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
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) } },
|
||||
)
|
||||
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)) }
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FavoritenLabel(titel: String) {
|
||||
Text(
|
||||
titel,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GefolgterKuenstler(name: String, bild: String?, albumCount: Int, onClick: () -> Unit) {
|
||||
Column(Modifier.width(110.dp).clickable(onClick = onClick)) {
|
||||
AsyncImage(
|
||||
model = bild, contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text("$albumCount Alben", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlleAnzeigen(weitere: Int, onClick: () -> Unit) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(110.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("→", fontSize = 26.sp, color = NsAccent)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Alle anzeigen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
maxLines = 1, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text("+$weitere weitere", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2, maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,20 @@ val NS_THEMES = listOf(
|
||||
Color(0xFF162A21), Color(0xFF1E3A2D),
|
||||
Color(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) {
|
||||
|
||||
@@ -21,6 +21,10 @@ data class AlbumUiState(
|
||||
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"]
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ReleasesClient
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistKonzert
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Welcher Reiter gezeigt wird. Getrennte Listen, weil die Zeit in verschiedene Richtungen läuft. */
|
||||
enum class NewsReiter { RELEASES, KONZERTE }
|
||||
|
||||
data class ArtistNewsUiState(
|
||||
val reiter: NewsReiter = NewsReiter.RELEASES,
|
||||
val releases: List<ArtistRelease> = emptyList(),
|
||||
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||
val keinSchluessel: Boolean = false,
|
||||
/** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */
|
||||
val vorhanden: Set<String> = emptySet(),
|
||||
val ohneZuordnung: List<String> = emptyList(),
|
||||
val tage: Int = 90,
|
||||
val isLoading: Boolean = true,
|
||||
val keineGefolgten: Boolean = false,
|
||||
val keinBackend: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* "Neu von deinen Künstlern". Gefolgt = Navidromes Stern, die Künstlerliste kommt also aus
|
||||
* getStarred2. Die Deezer-Auflösung macht das Backend — dort liegen die Pins und der gemeinsame
|
||||
* Cache, sonst würde jedes Gerät bei jedem Öffnen dieselben Abfragen wiederholen.
|
||||
*/
|
||||
class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(ArtistNewsUiState())
|
||||
val state: StateFlow<ArtistNewsUiState> = _state
|
||||
|
||||
init { laden(90) }
|
||||
|
||||
/** Reiter wechseln und, falls noch nichts geladen ist, nachladen. */
|
||||
fun zeigeReiter(r: NewsReiter) {
|
||||
_state.value = _state.value.copy(reiter = r)
|
||||
if (r == NewsReiter.KONZERTE && _state.value.konzerte.isEmpty()) ladeKonzerte()
|
||||
}
|
||||
|
||||
/** Zeitfenster setzen — gibt es nur bei den Veroeffentlichungen. */
|
||||
fun setzeTage(tage: Int) = laden(tage)
|
||||
|
||||
// Kein Zeitfenster: Ticketmaster liefert ohnehin nur Anstehendes, und dort will man alles
|
||||
// Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
|
||||
fun ladeKonzerte() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null,
|
||||
keineGefolgten = false, keinBackend = false, keinSchluessel = false)
|
||||
try {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
|
||||
return@launch
|
||||
}
|
||||
val kuenstler = repo.getStarred().artist.map { ArtistRef(it.id, it.name) }
|
||||
if (kuenstler.isEmpty()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
|
||||
return@launch
|
||||
}
|
||||
val antwort = ReleasesClient.konzerte(backendUrl, kuenstler)
|
||||
_state.value = _state.value.copy(
|
||||
konzerte = antwort.konzerte,
|
||||
keinSchluessel = antwort.keinSchluessel,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun laden(tage: Int = _state.value.tage) = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, tage = tage, error = null,
|
||||
reiter = NewsReiter.RELEASES, keineGefolgten = false, keinBackend = false)
|
||||
try {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
|
||||
return@launch
|
||||
}
|
||||
val markiert = repo.getStarred()
|
||||
val kuenstler = markiert.artist.map { ArtistRef(it.id, it.name) }
|
||||
if (kuenstler.isEmpty()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val antwort = ReleasesClient.fetch(backendUrl, kuenstler, tage)
|
||||
|
||||
// Was liegt schon in der Bibliothek? Verglichen wird über Künstler + Albumname —
|
||||
// Deezers Album-Id hat mit Navidromes nichts zu tun.
|
||||
val vorhanden = withContext(Dispatchers.IO) {
|
||||
val ausFavoriten = markiert.album.map { schluessel(it.artist ?: "", it.name) }
|
||||
val ausBibliothek = kuenstler.chunked(6).flatMap { haeppchen ->
|
||||
haeppchen.map { k ->
|
||||
async {
|
||||
runCatching { repo.getArtist(k.id).album.map { schluessel(k.name, it.name) } }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
}.awaitAll().flatten()
|
||||
}
|
||||
(ausFavoriten + ausBibliothek).toSet()
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
releases = antwort.releases,
|
||||
ohneZuordnung = antwort.ohneZuordnung,
|
||||
vorhanden = vorhanden,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun hatSchon(rel: ArtistRelease): Boolean =
|
||||
schluessel(rel.artistName, rel.title) in _state.value.vorhanden
|
||||
|
||||
fun herunterladen(rel: ArtistRelease, onDone: (Boolean, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) { onDone(false, "Kein Backend konfiguriert"); return@launch }
|
||||
val request = DownloadRequest(
|
||||
jobId = "job_${System.currentTimeMillis()}",
|
||||
type = "album",
|
||||
title = rel.title,
|
||||
artist = rel.artistName,
|
||||
artworkUrl = rel.cover.takeIf { it.isNotBlank() },
|
||||
collectionId = rel.albumId.toLongOrNull(),
|
||||
itunesId = rel.albumId.toLongOrNull(),
|
||||
)
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess {
|
||||
NotificationStore.startPoll(
|
||||
backendUrl, request.jobId, request.title, request.artist ?: "",
|
||||
navType = "album", navAlbum = rel.title, navArtist = rel.artistName,
|
||||
)
|
||||
onDone(true, "✓ Download gestartet")
|
||||
}
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun norm(s: String) = s.lowercase()
|
||||
.replace(Regex("[^a-z0-9]"), "")
|
||||
fun schluessel(kuenstler: String, album: String) = "${norm(kuenstler)}~~${norm(album)}"
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,12 @@ data class ArtistUiState(
|
||||
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
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"]
|
||||
@@ -70,6 +76,12 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
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")
|
||||
@@ -95,7 +107,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
val artist = artistDef.await()
|
||||
val info = infoDef.await()
|
||||
|
||||
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
|
||||
val topDef = async { runCatching { repo.getTopSongs(artist.name, artist.id) }.getOrDefault(emptyList()) }
|
||||
// A manual pin (shared, server-side) fixes same-name collisions — resolve it up front
|
||||
// so the Deezer enrichment below uses the pinned artist, not a name-guess.
|
||||
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() }
|
||||
@@ -278,7 +290,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
isShuffling = false,
|
||||
pendingShufflePlay = allSongs.shuffled().ifEmpty { null },
|
||||
pendingShufflePlay = allSongs.ifEmpty { null }, // gemischt wird im Player
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
// 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
|
||||
|
||||
@@ -176,6 +180,16 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_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) }
|
||||
|
||||
@@ -189,15 +203,22 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
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 = artist,
|
||||
artist = voll,
|
||||
isLoading = false,
|
||||
ndAlbums = ndAlbums,
|
||||
iAlbums = iAlbums,
|
||||
|
||||
@@ -29,6 +29,11 @@ data class ImporterUiState(
|
||||
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) {
|
||||
@@ -69,6 +74,9 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
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,
|
||||
@@ -173,6 +181,11 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -182,6 +195,47 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt nach dem Import eine Navidrome-Playlist aus den importierten Titeln an — dieselbe Abfolge
|
||||
* wie im Web: Scan anstossen, kurz warten, jeden Titel suchen, gefundene IDs in eine neue
|
||||
* Playlist. Titel, die Navidrome (noch) nicht kennt, werden gezaehlt statt zu scheitern.
|
||||
*/
|
||||
private suspend fun buildPlaylist(name: String, tracks: List<ImportTrack>) {
|
||||
_state.value = _state.value.copy(playlistStatus = "Navidrome scannt…")
|
||||
runCatching { repo.startScan() }
|
||||
delay(4000)
|
||||
|
||||
val norm = { s: String -> s.lowercase().replace(Regex("[^\\p{L}\\p{N}]+"), "") }
|
||||
val ids = mutableListOf<String>()
|
||||
var fehlend = 0
|
||||
for (t in tracks) {
|
||||
val titel = t.trackName ?: continue
|
||||
val treffer = runCatching {
|
||||
val q = listOfNotNull(t.artistName, titel).joinToString(" ")
|
||||
val gefunden = repo.search(q).song
|
||||
gefunden.firstOrNull { norm(it.title) == norm(titel) }
|
||||
?: repo.search(titel).song.firstOrNull { norm(it.title) == norm(titel) }
|
||||
}.getOrNull()
|
||||
if (treffer != null) ids += treffer.id else fehlend++
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = "Playlist: ${ids.size}/${tracks.size} gefunden…")
|
||||
}
|
||||
|
||||
if (ids.isEmpty()) {
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = "Keine Titel in Navidrome gefunden — Playlist nicht angelegt.")
|
||||
return
|
||||
}
|
||||
val res = runCatching { repo.createPlaylist(name, ids) }
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = res.fold(
|
||||
{ "Playlist \"$name\" angelegt: ${ids.size}/${tracks.size} Titel" +
|
||||
if (fehlend > 0) " ($fehlend nicht gefunden)" else "" },
|
||||
{ "Playlist fehlgeschlagen: ${it.message}" },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapError(code: String): String = when (code) {
|
||||
"EDITORIAL_PLAYLIST" ->
|
||||
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -24,7 +25,12 @@ data class LibraryUiState(
|
||||
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,
|
||||
)
|
||||
@@ -42,10 +48,21 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val albumsGridState = LazyGridState()
|
||||
val artistsListState = LazyListState()
|
||||
|
||||
init { loadPlaylists() }
|
||||
init {
|
||||
loadPlaylists()
|
||||
viewModelScope.launch {
|
||||
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
|
||||
_state.value = _state.value.copy(currentUser = u)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAlbumSort(sort: AlbumSort) {
|
||||
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) {
|
||||
@@ -79,6 +96,18 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
fun nurGefolgteUmschalten(an: Boolean) {
|
||||
_state.value = _state.value.copy(nurGefolgte = an)
|
||||
if (an) ladeGefolgte()
|
||||
}
|
||||
|
||||
/** Markierte Künstler nachladen. Frisch bei jedem Einschalten, damit ein eben entfolgter
|
||||
* Künstler nicht noch in der Liste steht. */
|
||||
private fun ladeGefolgte() = viewModelScope.launch {
|
||||
runCatching { repo.getStarred().artist.map { it.id }.toSet() }
|
||||
.onSuccess { _state.value = _state.value.copy(gefolgteIds = it) }
|
||||
}
|
||||
|
||||
private fun loadArtists() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.*
|
||||
@@ -27,6 +28,11 @@ class OfflineViewModel(app: Application) : AndroidViewModel(app) {
|
||||
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).map { it.toSong() }
|
||||
offlineRepo.tracksForGroup(groupId)
|
||||
.filter { it.state == DownloadState.DONE }
|
||||
.map { it.toSong() }
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import com.novasonic.app.offline.DownloadState
|
||||
import com.novasonic.app.offline.GroupType
|
||||
import 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
|
||||
@@ -22,6 +23,8 @@ data class PlaylistUiState(
|
||||
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) {
|
||||
@@ -42,7 +45,13 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi
|
||||
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
init { load() }
|
||||
init {
|
||||
load()
|
||||
viewModelScope.launch {
|
||||
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
|
||||
_state.value = _state.value.copy(currentUser = u)
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
val id = playlistId
|
||||
|
||||
@@ -36,6 +36,8 @@ data class SettingsUiState(
|
||||
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,
|
||||
@@ -46,7 +48,8 @@ data class SettingsUiState(
|
||||
|
||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val repo = app.app.repository
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
|
||||
// Seed the theme with the already-applied one so the picker doesn't jump before DataStore emits.
|
||||
private val _state = MutableStateFlow(SettingsUiState(theme = NsColors.themeId))
|
||||
@@ -68,6 +71,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
absUrl = s.absUrl,
|
||||
absApiKey = s.absApiKey,
|
||||
absChapterTrackMode = s.absChapterTrackMode,
|
||||
downloadWifiOnly = s.downloadWifiOnly,
|
||||
wischenWechseltTitel = s.wischenWechseltTitel,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -84,6 +89,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
||||
fun 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) }
|
||||
@@ -177,7 +184,12 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<!-- Benachrichtigungen: Honigtopf mit Brief statt der ueblichen Glocke.
|
||||
Reine Strichzeichnung wie die uebrigen Symbole, damit Icon(tint = …) sie einfaerben kann. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- Die Zeichnung fuellt ihr Feld nur zu etwa zwei Dritteln, Material-Symbole fuellen es ganz.
|
||||
Ohne dieses Aufziehen wirkt der Topf neben dem Zahnrad kleiner, obwohl beide 24 dp sind. -->
|
||||
<group
|
||||
android:pivotX="12"
|
||||
android:pivotY="11.95"
|
||||
android:scaleX="1.38"
|
||||
android:scaleY="1.38">
|
||||
|
||||
<!-- Deckelrand -->
|
||||
<path
|
||||
android:pathData="M4.7,4.6 L19.3,4.6 A1.1,1.1 0 0 1 20.4,5.7 L20.4,6.5 A1.1,1.1 0 0 1 19.3,7.6 L4.7,7.6 A1.1,1.1 0 0 1 3.6,6.5 L3.6,5.7 A1.1,1.1 0 0 1 4.7,4.6 Z"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="1.35"
|
||||
android:strokeLineJoin="round" />
|
||||
|
||||
<!-- Topfkoerper, nach unten leicht verjuengt -->
|
||||
<path
|
||||
android:pathData="M5.5,7.6 c-1.1,3.9 -0.7,8.2 0.7,10.4 a2,2 0 0 0 1.7,0.9 h8.2 a2,2 0 0 0 1.7,-0.9 c1.4,-2.2 1.8,-6.5 0.7,-10.4"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="1.35"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
|
||||
<!-- Brief -->
|
||||
<path
|
||||
android:pathData="M9,11.7 L15,11.7 A0.7,0.7 0 0 1 15.7,12.4 L15.7,15.6 A0.7,0.7 0 0 1 15,16.3 L9,16.3 A0.7,0.7 0 0 1 8.3,15.6 L8.3,12.4 A0.7,0.7 0 0 1 9,11.7 Z"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="1.25"
|
||||
android:strokeLineJoin="round" />
|
||||
|
||||
<!-- Brieflasche -->
|
||||
<path
|
||||
android:pathData="M8.6,12.1 L12,14.4 L15.4,12.1"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="1.25"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</group>
|
||||
</vector>
|
||||
File mode changed.
@@ -0,0 +1,32 @@
|
||||
# NovaSonic download backend — bakes the heavy deps (ffmpeg, Deno, yt-dlp, node libs) so restarts are
|
||||
# instant instead of re-installing everything on every boot. Build context = ./backend.
|
||||
FROM node:22-slim
|
||||
|
||||
# System deps: python (yt-dlp), ffmpeg (tagging/convert), curl/git/unzip (installers), ca-certs.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip ffmpeg curl git unzip ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# yt-dlp (nightly) + the Spotify helper tooling.
|
||||
RUN pip install --break-system-packages -U --pre 'yt-dlp[default]' \
|
||||
&& pip install --break-system-packages spotapi 'git+https://github.com/TzurSoffer/spotify-downloader'
|
||||
|
||||
# Deno — yt-dlp uses it to solve YouTube's JS signature/nsig challenge.
|
||||
RUN curl -fsSL https://deno.land/install.sh | sh
|
||||
ENV PATH="/root/.deno/bin:${PATH}"
|
||||
|
||||
# Node libs the server loads (express, kuromoji) — kept out of the app dir like the compose setup did.
|
||||
WORKDIR /deps
|
||||
RUN npm install express kuromoji
|
||||
ENV NODE_PATH=/deps/node_modules
|
||||
|
||||
# App code.
|
||||
WORKDIR /app
|
||||
COPY . /app/
|
||||
|
||||
ENV PORT=3700 DATA_PATH=/data
|
||||
EXPOSE 3700
|
||||
|
||||
# Refresh yt-dlp on start (heavy deps already baked, so this is a quick pull) — non-fatal if offline,
|
||||
# the baked version still works. Keeps the "always-latest yt-dlp" behaviour of the old compose command.
|
||||
CMD ["sh","-c","pip install --break-system-packages -U --pre 'yt-dlp[default]' >/dev/null 2>&1 || true; node novasonic-server.js"]
|
||||
@@ -0,0 +1,130 @@
|
||||
// ─── KONZERTE GEFOLGTER KÜNSTLER ──────────────────────────────────────────────
|
||||
// Liefert zu einer Liste von Navidrome-Künstlern die anstehenden Termine, damit die App den
|
||||
// Reiter "Konzerte" in der Zeitleiste füllen kann. Gegenstück zu releases.js.
|
||||
//
|
||||
// Warum serverseitig: bei 50 gefolgten Künstlern wären das 50 Ticketmaster-Aufrufe — pro Gerät und
|
||||
// pro App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam.
|
||||
//
|
||||
// Bewusste Einschränkung (25.9.2026): gesucht wird über den NAMEN (`keyword=`), nicht über
|
||||
// Ticketmasters `attractionId`. Das ist dieselbe Unschärfe wie bei der Deezer-Zuordnung — ein
|
||||
// kurzer Künstlername trifft als Stichwort auch Fremdes. Deshalb wird jeder Treffer gegengeprüft:
|
||||
// der Name muss im Veranstaltungs- oder Attraktionsnamen vorkommen. Wer es genauer braucht, löst
|
||||
// später die attractionId einmal pro Künstler auf (siehe Backlog im CHANGELOG).
|
||||
const https = require('https');
|
||||
|
||||
const _cache = new Map(); // nameLower → { ts, termine }
|
||||
const TTL = 6 * 60 * 60 * 1000; // 6 h — Termine ändern sich nicht minütlich
|
||||
const GLEICHZEITIG = 4; // höflich gegenüber Ticketmaster, trotzdem zügig
|
||||
|
||||
const norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
||||
|
||||
function holen(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { headers: { Accept: 'application/json' } }, up => {
|
||||
let body = '';
|
||||
up.on('data', d => body += d);
|
||||
up.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (up.statusCode !== 200 || data.fault || data.errors) {
|
||||
return reject(new Error(`Ticketmaster ${up.statusCode}`));
|
||||
}
|
||||
resolve(data);
|
||||
} catch (e) { reject(e); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Anstehende Termine eines Künstlers, gecacht. Bei einem Fehler kommt der alte Stand zurück. */
|
||||
async function termineFuer(name, apiKey) {
|
||||
const key = norm(name);
|
||||
if (!key) return [];
|
||||
const c = _cache.get(key);
|
||||
if (c && Date.now() - c.ts < TTL) return c.termine;
|
||||
|
||||
const ab = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const url = 'https://app.ticketmaster.com/discovery/v2/events.json' +
|
||||
`?keyword=${encodeURIComponent(name)}&classificationName=music` +
|
||||
`&startDateTime=${ab}&size=20&sort=date,asc&apikey=${apiKey}`;
|
||||
|
||||
try {
|
||||
const data = await holen(url);
|
||||
const roh = data?._embedded?.events || [];
|
||||
const termine = roh.map(e => {
|
||||
const ort = e._embedded?.venues?.[0] || {};
|
||||
const start = e.dates?.start || {};
|
||||
// Die ORTSZEIT nehmen, nicht die UTC-Zeit: ein Konzert um 20:00 sonst als 08:00Z angezeigt.
|
||||
// Als naive lokale Zeichenkette ohne Zone weitergereicht, die Clients zeigen sie unverändert.
|
||||
const zeit = (start.localDate
|
||||
? `${start.localDate}T${start.localTime || '00:00:00'}`
|
||||
: start.dateTime) || null;
|
||||
const aus = e.dates?.status?.code === 'offsale' || e.dates?.status?.code === 'cancelled';
|
||||
const attraktionen = (e._embedded?.attractions || []).map(a => a.name || '');
|
||||
return {
|
||||
eventId: e.id || '',
|
||||
titel: e.name || '',
|
||||
datum: zeit,
|
||||
ort: ort.name || '',
|
||||
stadt: ort.city?.name || '',
|
||||
land: ort.country?.name || '',
|
||||
url: e.url || '',
|
||||
abgesagt: aus,
|
||||
_namen: [e.name || '', ...attraktionen],
|
||||
};
|
||||
}).filter(t => t.datum);
|
||||
|
||||
// Gegenprobe zur Stichwortsuche: der Künstlername muss irgendwo im Veranstaltungs- oder
|
||||
// Attraktionsnamen stehen. Ohne das liefert "MORE" oder "TPR" fremde Veranstaltungen.
|
||||
const gesucht = key;
|
||||
const gefiltert = termine.filter(t => t._namen.some(n => norm(n).includes(gesucht)));
|
||||
const sauber = gefiltert.map(({ _namen, ...rest }) => rest);
|
||||
|
||||
_cache.set(key, { ts: Date.now(), termine: sauber });
|
||||
return sauber;
|
||||
} catch (e) {
|
||||
// Bei einem Fehler den abgelaufenen Stand ausliefern statt einer leeren Liste — aber den
|
||||
// Zeitstempel NICHT auffrischen, damit er beim nächsten Mal wieder frisch geholt wird.
|
||||
if (c) return c.termine;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function inHaeppchen(items, groesse, fn) {
|
||||
const raus = [];
|
||||
for (let i = 0; i < items.length; i += groesse) {
|
||||
raus.push(...await Promise.all(items.slice(i, i + groesse).map(fn)));
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{id,name}>} kuenstler Navidrome-Künstler
|
||||
* @param {string} apiKey Ticketmaster-Schlüssel
|
||||
* @param {number} tage Zeitfenster nach vorn; 0 = kein Fenster (Vorgabe)
|
||||
* @returns {{konzerte:Array, ohneTermine:Array<string>}}
|
||||
*/
|
||||
async function kommendeKonzerte(kuenstler, apiKey, tage = 0) {
|
||||
// Kein Fenster als Vorgabe: Ticketmaster liefert ohnehin nur Anstehendes, und bei Konzerten
|
||||
// will man ALLES sehen, was angekuendigt ist — eine Tagesgrenze schneidet nur willkuerlich ab.
|
||||
const grenze = tage > 0 ? Date.now() + tage * 24 * 60 * 60 * 1000 : Infinity;
|
||||
const ohneTermine = [];
|
||||
|
||||
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
|
||||
let termine = [];
|
||||
try { termine = await termineFuer(k.name, apiKey); } catch { return []; }
|
||||
if (!termine.length) { ohneTermine.push(k.name); return []; }
|
||||
return termine
|
||||
.filter(t => new Date(t.datum).getTime() <= grenze)
|
||||
.map(t => ({ artistId: k.id, artistName: k.name, ...t }));
|
||||
});
|
||||
|
||||
// Nach vorn sortiert: das naechste Konzert zuerst — anders als bei den Veroeffentlichungen,
|
||||
// die rueckwaerts laufen.
|
||||
const konzerte = proKuenstler.flat()
|
||||
.sort((a, b) => new Date(a.datum) - new Date(b.datum));
|
||||
return { konzerte, ohneTermine };
|
||||
}
|
||||
|
||||
module.exports = { kommendeKonzerte, termineFuer, _cache };
|
||||
+88
-3
@@ -6,6 +6,19 @@ function sanitize(s) {
|
||||
return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100);
|
||||
}
|
||||
|
||||
// Welcher Kuenstler in den DATEINAMEN gehoert. Bisher stand dort die vollstaendige Gaesteliste
|
||||
// ("K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine,League of Legends"). Das ist nirgends
|
||||
// ueblich — Picard, beets und Lidarr schreiben hoechstens einen Kuenstler in den Namen —, und es
|
||||
// hatte zwei handfeste Nachteile: sanitize() schneidet bei 100 Zeichen hart ab, also mitten im
|
||||
// letzten Namen, und mit dem Dateisystemlimit von 255 Byte wird es bei mehrbytigen Namen eng.
|
||||
// Die vollstaendige Liste steht ohnehin im artist-Tag, und nur den liest Navidrome.
|
||||
// Genommen wird der ERSTE Kuenstler — bei gemischten Alben der des Titels, nicht der des Albums,
|
||||
// damit ein Soundtrack-Titel im Namen weiterhin seinen eigenen Interpreten traegt.
|
||||
function dateinamensKuenstler(artist, fallback) {
|
||||
const erster = (s) => String(s || '').split(/[;,]|\s+&\s+/)[0].trim();
|
||||
return erster(artist) || erster(fallback) || '';
|
||||
}
|
||||
|
||||
function navidromeArtist(name) {
|
||||
if (!name) return name;
|
||||
return name
|
||||
@@ -61,14 +74,21 @@ const _VERSION_KW = /\b(?:remix|live|acoustic|instrumental|version|vip|flip|boot
|
||||
// release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
|
||||
// "recognize - Single.mp3" vs "recognize.mp3" (drift between runs).
|
||||
function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
||||
const norm = s => (s || '').toLowerCase()
|
||||
const norm = s => (s || '')
|
||||
// Akzente abtrennen und verwerfen: dieselbe Kuenstlerin kommt je nach Quelle als
|
||||
// "Cécile Corbel" oder "Cecile Corbel" — ohne das galt sie als zwei Personen und derselbe
|
||||
// Song wurde ein zweites Mal geladen. Betrifft nur den VERGLEICH, nicht Tags oder Dateinamen.
|
||||
.normalize('NFD').replace(/\p{M}+/gu, '')
|
||||
.toLowerCase()
|
||||
// Strip bracketed noise (feat. X, Official Video, Prod. by …) but KEEP version markers
|
||||
// (Remix/Live/Acoustic/…) so distinct recordings aren't collapsed onto the base track.
|
||||
.replace(/[\[((]([^\]))]*)[\]))]/g, (m, inner) => _VERSION_KW.test(inner) ? m : '')
|
||||
// Filesystem-sanitized chars (/ \ : * ? " < > |) become "_" on disk — map those AND "_"
|
||||
// to a space so a title tag "Kwik Flip | YTPMV" matches the file "Kwik Flip _ YTPMV".
|
||||
.replace(/[/\\:*?"<>|_]/g, ' ')
|
||||
.replace(/[,.'!]/g, '')
|
||||
// Auch die typografischen Apostrophe: Anbieter schreiben "Arrietty’s Song" (U+2019), eine
|
||||
// spaetere Anfrage kommt mit geradem "'" — ohne das galten beide als verschiedene Titel.
|
||||
.replace(/[,.!'\u2018\u2019\u02bc\u00b4`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const normArtist = norm(firstArtist);
|
||||
@@ -111,6 +131,70 @@ function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const AUDIO_EXT_RE = /\.(mp3|flac|opus|m4a|ogg|wav)$/i;
|
||||
|
||||
// Vereinheitlicht einen Dateinamen-Stamm fuer den lockeren Vergleich:
|
||||
// 1. fuehrende Track-Praefixe wie "01-15 – " oder "01 - " abschneiden
|
||||
// 2. alle Strichvarianten (en/em dash) auf " - " bringen
|
||||
function normStem(s) {
|
||||
return s
|
||||
.replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '')
|
||||
.replace(/\s*[–—]\s*/g, ' - ');
|
||||
}
|
||||
|
||||
// Tiefensuche nach einer Audiodatei, deren Stamm zum Titel passt. Letzter Ausweg, wenn der von
|
||||
// Navidrome gemeldete Pfad nicht existiert (Ordner umbenannt, Tag-Pfad statt echtem Pfad).
|
||||
//
|
||||
// WICHTIG: der Titel muss ein VOLLSTAENDIGES Feld des Dateinamens sein, nie nur dessen Ende.
|
||||
// Ein blosses endsWith(titel) liess "Bad Omens - Like A Villain" als Treffer fuer den Titel
|
||||
// "VILLAIN" durchgehen — ein voellig anderer Song derselben Endsilbe. Genau solche Fehlgriffe
|
||||
// landen sonst im Metadaten-Editor oder, schlimmer, im Loeschen-Endpunkt.
|
||||
function findFileByStem(dir, titleStem, depth = 0) {
|
||||
if (depth > 4) return null;
|
||||
const normTitle = normStem(titleStem);
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
const found = findFileByStem(path.join(dir, entry.name), titleStem, depth + 1);
|
||||
if (found) return found;
|
||||
} else if (AUDIO_EXT_RE.test(entry.name)) {
|
||||
const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '');
|
||||
const bareStem = normStem(bare.toLowerCase());
|
||||
// Treffer: exakt, "Kuenstler - Titel", oder Release-Suffix-Versatz ("… - Single").
|
||||
if (bareStem === normTitle ||
|
||||
bareStem.endsWith(' - ' + normTitle) ||
|
||||
bareStem.startsWith(normTitle + ' - ') ||
|
||||
normTitle.startsWith(bareStem + ' - ')) {
|
||||
return path.join(dir, entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Navidrome gibt ueber die Subsonic-API standardmaessig keinen echten Pfad heraus, sondern baut
|
||||
// einen aus den Tags: {AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{Endung}. Steht ein "/" IM Namen
|
||||
// — "K/DA", "POP/STARS" —, wird daraus eine zusaetzliche Verzeichnisebene, waehrend auf der Platte
|
||||
// sanitize() ein "_" geschrieben hat ("K_DA/POP_STARS"). Der gemeldete Pfad existiert dann nicht,
|
||||
// und die Suche faellt auf Aehnlichkeit zurueck. Diese Funktion baut den Ordner stattdessen genau
|
||||
// so zusammen, wie der Downloader ihn angelegt haette.
|
||||
function albumDirFromTags(musicRoot, albumArtist, album) {
|
||||
if (!musicRoot || !albumArtist) return null;
|
||||
// Der Downloader legt den Ordner unter dem ERSTEN Kuenstler an (mainArtist.split(/[,;&]/)[0]).
|
||||
const ersterKuenstler = String(albumArtist).split(/[,;&]/)[0].trim();
|
||||
const kandidaten = [];
|
||||
for (const k of new Set([ersterKuenstler, String(albumArtist).trim()])) {
|
||||
if (!k) continue;
|
||||
if (album) kandidaten.push(path.join(musicRoot, sanitize(k), sanitize(album)));
|
||||
kandidaten.push(path.join(musicRoot, sanitize(k)));
|
||||
}
|
||||
for (const c of kandidaten) {
|
||||
try { if (fs.statSync(c).isDirectory()) return c; } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch plain + synced lyrics from LRCLIB
|
||||
async function fetchLyrics(artist, title, album) {
|
||||
try {
|
||||
@@ -131,6 +215,7 @@ async function fetchLyrics(artist, title, album) {
|
||||
// fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums.
|
||||
|
||||
module.exports = {
|
||||
sanitize, navidromeArtist,
|
||||
sanitize, navidromeArtist, dateinamensKuenstler,
|
||||
downloadArtwork, findExistingTrack,
|
||||
normStem, findFileByStem, albumDirFromTags,
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ───────────────────────────────
|
||||
// Liefert zu einer Liste von Navidrome-Künstlern die jüngsten Deezer-Releases, damit die App eine
|
||||
// Zeitleiste "Neu von deinen Künstlern" zeigen kann.
|
||||
//
|
||||
// Warum serverseitig: bei 30 gefolgten Künstlern wären das 30 Deezer-Aufrufe — pro Gerät und pro
|
||||
// App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam.
|
||||
const { dzGet } = require('./deezer');
|
||||
|
||||
const _cache = new Map(); // deezerArtistId → { ts, alben }
|
||||
const _idCache = new Map(); // nameLower → { ts, id }
|
||||
const TTL = 6 * 60 * 60 * 1000; // 6 h — Alben erscheinen nicht minütlich
|
||||
const ID_TTL = 24 * 60 * 60 * 1000; // 24 h — die Zuordnung Name → Deezer ändert sich kaum
|
||||
const GLEICHZEITIG = 4; // höflich gegenüber Deezer, trotzdem zügig
|
||||
|
||||
const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || '';
|
||||
// Künstler stilisieren ihren Namen mit führenden Zeichen (".diedlonely", "†…") — ohne das Abschneiden
|
||||
// findet Deezers Suche sie nicht. Gleiche Regel wie in deezer.js.
|
||||
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
|
||||
|
||||
/** Deezer-ID zu einem Künstler: erst die gepinnte, sonst über den Namen gesucht (und gemerkt). */
|
||||
async function deezerIdFuer(name, pinId) {
|
||||
if (pinId) return String(pinId);
|
||||
const key = stripLeadSym(name).toLowerCase();
|
||||
if (!key) return null;
|
||||
const c = _idCache.get(key);
|
||||
if (c && Date.now() - c.ts < ID_TTL) return c.id;
|
||||
try {
|
||||
const treffer = (await dzGet(`search/artist?q=${encodeURIComponent(stripLeadSym(name))}&limit=5`)).data || [];
|
||||
// Deezer sortiert nicht nach Bekanntheit — die Fan-Zahl trifft den gemeinten Künstler deutlich
|
||||
// öfter als der erste Treffer. Ein Pin schlägt das trotzdem, siehe oben.
|
||||
const best = treffer.slice().sort((a, b) => (b.nb_fan || 0) - (a.nb_fan || 0))[0];
|
||||
const id = best ? String(best.id) : null;
|
||||
_idCache.set(key, { ts: Date.now(), id });
|
||||
return id;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Albumliste eines Deezer-Künstlers, gecacht. */
|
||||
async function albenFuer(deezerId) {
|
||||
const c = _cache.get(deezerId);
|
||||
if (c && Date.now() - c.ts < TTL) return c.alben;
|
||||
try {
|
||||
const alben = (await dzGet(`artist/${deezerId}/albums?limit=50`)).data || [];
|
||||
_cache.set(deezerId, { ts: Date.now(), alben });
|
||||
return alben;
|
||||
} catch {
|
||||
// Bei einem Fehler lieber den alten Stand als gar nichts — abgelaufen, aber nicht falsch.
|
||||
return c ? c.alben : [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Führt eine Aufgabenliste mit begrenzter Gleichzeitigkeit aus. */
|
||||
async function inHaeppchen(items, groesse, fn) {
|
||||
const raus = [];
|
||||
for (let i = 0; i < items.length; i += groesse) {
|
||||
raus.push(...await Promise.all(items.slice(i, i + groesse).map(fn)));
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{id,name,deezerArtistId?}>} kuenstler Navidrome-Künstler (id + name)
|
||||
* @param {number} tage Zeitfenster
|
||||
* @returns {{releases:Array, ohneZuordnung:Array<string>}}
|
||||
*/
|
||||
async function neueReleases(kuenstler, tage = 90) {
|
||||
const grenze = Date.now() - tage * 24 * 60 * 60 * 1000;
|
||||
const ohneZuordnung = [];
|
||||
|
||||
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
|
||||
const dzId = await deezerIdFuer(k.name, k.deezerArtistId);
|
||||
if (!dzId) { ohneZuordnung.push(k.name); return []; }
|
||||
const alben = await albenFuer(dzId);
|
||||
return alben
|
||||
.filter(a => a.release_date && new Date(a.release_date).getTime() >= grenze)
|
||||
.map(a => ({
|
||||
artistId: k.id,
|
||||
artistName: k.name,
|
||||
deezerArtistId: dzId,
|
||||
albumId: String(a.id),
|
||||
title: a.title || '',
|
||||
cover: coverXl(a),
|
||||
releaseDate: a.release_date,
|
||||
recordType: a.record_type || 'album',
|
||||
trackCount: a.nb_tracks ?? null,
|
||||
explicit: !!a.explicit_lyrics,
|
||||
}));
|
||||
});
|
||||
|
||||
const releases = proKuenstler.flat()
|
||||
.sort((a, b) => new Date(b.releaseDate) - new Date(a.releaseDate));
|
||||
return { releases, ohneZuordnung };
|
||||
}
|
||||
|
||||
module.exports = { neueReleases, deezerIdFuer, albenFuer, _cache, _idCache };
|
||||
@@ -115,7 +115,7 @@ const ROMAJI_TTL = 20 * 60 * 1000; // 20 min
|
||||
async function _fetchNdSongs(ndUrl, user, pass) {
|
||||
const salt = Math.random().toString(36).slice(2, 10);
|
||||
const token = createHash('md5').update(pass + salt).digest('hex');
|
||||
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json&albumCount=0&artistCount=0`;
|
||||
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json&albumCount=0&artistCount=0`;
|
||||
const songs = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
|
||||
+173
-58
@@ -4,7 +4,7 @@ const fs = require('fs');
|
||||
const CONFIG = require('./config');
|
||||
const { log } = require('./jobs');
|
||||
const {
|
||||
sanitize, navidromeArtist,
|
||||
sanitize, navidromeArtist, dateinamensKuenstler,
|
||||
downloadArtwork, findExistingTrack,
|
||||
} = require('./metadata');
|
||||
const { romajiForStorage, isTokenizerReady } = require('./romaji');
|
||||
@@ -12,11 +12,37 @@ const { romajiForStorage, isTokenizerReady } = require('./romaji');
|
||||
// Matches hiragana, katakana, CJK — used to detect Japanese content
|
||||
const JP_RE = /[ぁ-んァ-ン一-龯]/;
|
||||
|
||||
function buildTitleKeywords(title) {
|
||||
// For Japanese titles: Art Tracks on YTM/YouTube are often romanized
|
||||
// ("Souiunatsu" for "そういう夏") — a Japanese keyword filter would always
|
||||
// reject the correct video. Channel + duration are the quality gates instead.
|
||||
if (JP_RE.test(title)) return [];
|
||||
function buildTitleKeywords(title, romaji = '') {
|
||||
// Japanische Titel haben keine Wortgrenzen, die Zwei-laengste-Woerter-Regel greift also nicht.
|
||||
// Frueher lieferte diese Funktion dafuer eine LEERE Liste — und damit hatte der Filter bei einem
|
||||
// japanischen Titel gar keine Titelpruefung mehr. Zusammen mit den Kaskadenschritten ohne
|
||||
// Kuenstlerpruefung blieb allein die Dauer uebrig, und bei einem Soundtrack-Album passt fast
|
||||
// jeder andere Titel desselben Komponisten in dasselbe Dauerfenster. Genau so kam am 24.9.2026
|
||||
// ein falsches Stueck aus "HUNTER×HUNTER" herein.
|
||||
//
|
||||
// Stattdessen wird jetzt eine Alternative gefordert: der ORIGINALTITEL oder seine Romaji-Fassung.
|
||||
// Art Tracks tragen ueblicherweise das eine oder das andere. Leerzeichen und Bindestriche der
|
||||
// Romaji-Fassung bleiben dabei frei, weil Uploader sie unterschiedlich setzen
|
||||
// ("Ginpatsu no Shounen" / "Ginpatsu-no-Shounen").
|
||||
if (JP_RE.test(title)) {
|
||||
const escRe = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const alternativen = [escRe(title.trim())];
|
||||
const r = (romaji || '').trim();
|
||||
if (r && r.toLowerCase() !== title.trim().toLowerCase() && !JP_RE.test(r)) {
|
||||
// Die Umschrift des Tokenizers ist EINE Schreibweise von mehreren. Uploader schreiben
|
||||
// "Shonen" statt "Shounen", setzen Bindestriche oder lassen den Katakana-Laengungsstrich
|
||||
// weg. Das Muster laesst diese Abweichungen zu — sonst waere die Alternative so streng,
|
||||
// dass sie praktisch nie greift.
|
||||
alternativen.push(
|
||||
escRe(r)
|
||||
.replace(/ー/g, '') // kommt in lateinischen Titeln nie vor
|
||||
.replace(/\s+/g, '[ -]?')
|
||||
.replace(/ou/gi, '(?:ou|o|\u014d|oh)')
|
||||
.replace(/uu/gi, '(?:uu|u|\u016b)')
|
||||
);
|
||||
}
|
||||
return [`(?:${alternativen.join('|')})`];
|
||||
}
|
||||
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
|
||||
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with','single','ep','album','remaster','remastered','deluxe','edition','version','live','bonus']);
|
||||
const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
|
||||
@@ -26,14 +52,57 @@ function buildTitleKeywords(title) {
|
||||
.replace(/[^\w\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length >= 3 && !VARIANT_WORDS.has(w.toLowerCase()) && !STOPWORDS.has(w.toLowerCase()));
|
||||
const top2 = words.sort((a, b) => b.length - a.length).slice(0, 2);
|
||||
const toPattern = w => (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
|
||||
const result = top2.map(toPattern);
|
||||
// Numbers are the most distinctive token a title has and they are usually SHORT, so the
|
||||
// longest-two rule threw them away: "700 Main Street" kept only Street+Main and happily matched
|
||||
// "777 Main Street" by a different artist. Numbers are therefore always required, on top of the
|
||||
// two longest words.
|
||||
// Ausnahme: die Jahreszahl einer Neuauflage ("… - 2020 Remaster") steht auf der Veroeffentlichung,
|
||||
// aber nie im YouTube-Titel — als Pflicht-Keyword wuerde sie den richtigen Upload ablehnen. Nur
|
||||
// wenn ein solches Qualifier-Wort daneben steht; ein Titel, der SELBST eine Jahreszahl ist
|
||||
// ("1999", "Berlin 2020"), behaelt sie.
|
||||
const RELEASE_QUALIFIER = /\b(remaster|remastered|version|edition|anniversary|mono|stereo)\b/i;
|
||||
const isEditionYear = w => /^(19|20)\d{2}$/.test(w) && RELEASE_QUALIFIER.test(title || '');
|
||||
const isRequiredNum = w => /^\d+$/.test(w) && !isEditionYear(w);
|
||||
const numbers = words.filter(isRequiredNum);
|
||||
const wordsNoNum = words.filter(w => !isRequiredNum(w));
|
||||
const top2 = wordsNoNum.sort((a, b) => b.length - a.length).slice(0, 2);
|
||||
// Uploaders abbreviate the same word the release spells out ("700 Main St" vs "700 Main Street"),
|
||||
// which rejected the official Art Track in every cascade step. Accept either spelling; \b keeps
|
||||
// the short form from matching inside another word.
|
||||
const ABBREV = { street: 'st', saint: 'st', mount: 'mt', doctor: 'dr', versus: 'vs', number: 'no' };
|
||||
const toPattern = w => {
|
||||
const abbr = ABBREV[w.toLowerCase()];
|
||||
if (abbr) return `\\b(?:${w}|${abbr})\\b`;
|
||||
return (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
|
||||
};
|
||||
const result = [...numbers.map(n => `\\b${n}\\b`), ...top2.map(toPattern)];
|
||||
const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null;
|
||||
if (qualifier && !top2.some(w => w.toLowerCase() === qualifier)) result.push(qualifier);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Aus der YTM-Suchausgabe (--flat-playlist --print webpage_url) die Song-URLs herausziehen.
|
||||
// Eine Suche liefert drei Sorten Zeilen: watch?v=… (ein Song), browse/MPREb_… (ein Album) und
|
||||
// browse/UC… (ein Kuenstlerkanal). Nur die erste ist ein Kandidat; die anderen beiden sind
|
||||
// Listeneintraege und werden uebersprungen.
|
||||
//
|
||||
// Frueher galt eine browse/UC-Zeile als "yt-dlp hat einen Kanal aufgeklappt" und die GESAMTE
|
||||
// Trefferliste wurde verworfen — auch die bereits gesammelten Songs davor. Bei "DAT ADAM UFO" stand
|
||||
// der Kanal auf Platz 9 und riss sieben gute Treffer mit, darunter den offiziellen Art Track auf
|
||||
// Platz 1; uebrig blieb der YouTube-Fallback, der einen Live-Mitschnitt zog. Aufklappen kann hier
|
||||
// ohnehin nicht passieren: mit --flat-playlist druckt yt-dlp einen Kanal als EINE Zeile.
|
||||
function pickYtmVideoUrls(stdout) {
|
||||
const urls = [];
|
||||
let skipped = 0;
|
||||
for (const line of (stdout || '').split('\n')) {
|
||||
const u = line.trim();
|
||||
if (!u) continue;
|
||||
if (u.includes('watch?v=')) urls.push(u);
|
||||
else if (u.includes('/browse/')) skipped++;
|
||||
}
|
||||
return { urls, skipped };
|
||||
}
|
||||
|
||||
function buildVariantFilter(title) {
|
||||
const t = (title || '').toLowerCase();
|
||||
// !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that
|
||||
@@ -50,8 +119,16 @@ function buildVariantFilter(title) {
|
||||
{ key: 'cover', expr: 'title !~= (?i)\\bcover\\b' },
|
||||
{ key: 'acoustic', expr: 'title !~= (?i)\\bacoustic\\b' },
|
||||
{ key: 'instrumental', expr: 'title !~= (?i)\\binstrumental\\b' },
|
||||
// Konzertmitschnitte: `!is_live` heisst nur "laeuft nicht gerade als Stream" und laesst eine
|
||||
// Live-AUFNAHME durch. Auf dem echten Kuenstlerkanal ist die sonst nicht vom Studiotrack zu
|
||||
// unterscheiden — so landete "Dat Adam - Ufo Video Days 2015 Live" in der Bibliothek.
|
||||
// skipRe statt includes(): "deliver"/"delivery" enthalten "live" als Teilstring und wuerden
|
||||
// die Regel sonst faelschlich abschalten.
|
||||
{ key: 'live', expr: 'title !~= (?i)\\blive\\b', skipRe: /\blive\b/i },
|
||||
];
|
||||
const parts = RULES.filter(r => !t.includes(r.key)).map(r => r.expr);
|
||||
// Eine Regel entfaellt, wenn der GESUCHTE Titel das Wort selbst traegt — wer "Live Forever" oder
|
||||
// bewusst eine Live-Version sucht, soll sie auch bekommen.
|
||||
const parts = RULES.filter(r => r.skipRe ? !r.skipRe.test(t) : !t.includes(r.key)).map(r => r.expr);
|
||||
return parts.length ? parts.join(' & ') : '';
|
||||
}
|
||||
|
||||
@@ -89,11 +166,22 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
|
||||
const safeTitle = sanitize(cleanTitle);
|
||||
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||
// Filename format: "{01 - }Artist1, Artist2 - Title.ext"
|
||||
const safeArtistForFile = sanitize((artist || '').replace(/;/g, ','));
|
||||
// Filename format: "{01 - }Artist - Title.ext" — nur der erste Kuenstler, siehe
|
||||
// dateinamensKuenstler() in lib/metadata.js. Die volle Liste steht im artist-Tag.
|
||||
const safeArtistForFile = sanitize(dateinamensKuenstler(artist, mainArtist));
|
||||
const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
|
||||
|
||||
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle);
|
||||
// Dublettenpruefung: findExistingTrack durchsucht NUR den Ordner des uebergebenen Kuenstlers.
|
||||
// Bei Soundtracks und Compilations weicht der Album-Kuenstler vom Titel-Kuenstler ab, und je
|
||||
// nachdem, ob der Song als Album oder einzeln geladen wird, landet er unter dem einen ODER dem
|
||||
// anderen. Die Pruefung sah dann jeweils im falschen Ordner nach und lud ihn erneut. Deshalb wird
|
||||
// bei abweichendem Titel-Kuenstler auch dessen Ordner geprueft — zweiter Durchlauf nur in genau
|
||||
// diesem Fall, gemessen ~0,6 ms zusaetzlich bei knapp 29.000 Dateien.
|
||||
const trackArtist = sanitize((artist || '').split(/[,;&]/)[0].trim());
|
||||
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle)
|
||||
|| (trackArtist && trackArtist !== safeArtist
|
||||
? findExistingTrack(outputDir || CONFIG.musicPath, trackArtist, cleanTitle)
|
||||
: null);
|
||||
if (existing) {
|
||||
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
|
||||
return { success: true, dir, skipped: true };
|
||||
@@ -111,7 +199,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
const searchTitle = cleanTitle;
|
||||
|
||||
const variantFilter = buildVariantFilter(searchTitle);
|
||||
const titleKws = buildTitleKeywords(searchTitle);
|
||||
// Romaji nur, wenn der Tokenizer bereit ist — sonst liefert romajiForStorage den Text
|
||||
// unveraendert zurueck und die Alternative waere doppelt.
|
||||
const titleRomaji = isTokenizerReady() ? romajiForStorage(searchTitle) : '';
|
||||
const titleKws = buildTitleKeywords(searchTitle, titleRomaji);
|
||||
const variantPart = variantFilter ? ` & ${variantFilter}` : '';
|
||||
const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : '';
|
||||
|
||||
@@ -126,8 +217,24 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
// audio — even when the channel doesn't contain the artist name (BANGTANTV/HYBE/…). Verified with
|
||||
// "BTS Pied Piper": ONLY the official upload had artist=BTS; every fan/live/lyric reupload had
|
||||
// none. Used as an OR alternative to the channel check so those official uploads still pass strict.
|
||||
const artistMetaPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord))
|
||||
// Fuer japanische Namen wird der KANAL nicht geprueft (Kanal-Namen stehen kaum je in Kanji),
|
||||
// das Feld `artist` der Art Tracks dagegen sehr wohl — dort steht der Originalname. Ohne diese
|
||||
// Ausnahme hatte ein japanischer Kuenstler ueberhaupt keine Identitaetspruefung.
|
||||
const artistMetaPart = (artistFirstWord.length >= 3 || (JP_RE.test(artistFirstWord) && artistFirstWord.length >= 2))
|
||||
? ` & artist ~= (?i)${escRe(artistFirstWord)}` : '';
|
||||
// Kanji-Kuenstler: Kanaele und Videotitel schreiben den Namen meist lateinisch. Ohne diese
|
||||
// Alternative bliebe fuer sie nur das artist-Feld in Originalschrift — und steht dort die
|
||||
// lateinische Schreibweise, faellt der richtige Upload durch.
|
||||
const artistRomajiWord = (JP_RE.test(artistFirstWord) && isTokenizerReady())
|
||||
? ((romajiForStorage(artistFirstWord) || '').split(/\s+/)
|
||||
.filter(w => w.length >= 3 && !JP_RE.test(w))
|
||||
.sort((a, b) => b.length - a.length)[0] || '')
|
||||
: '';
|
||||
const artistRomajiParts = artistRomajiWord
|
||||
? [` & channel ~= (?i)${escRe(artistRomajiWord)}`, ` & artist ~= (?i)${escRe(artistRomajiWord)}`]
|
||||
: [];
|
||||
// Alle Wege, den Kuenstler zu erkennen. yt-dlp verodert mehrere --match-filter.
|
||||
const identitaet = [artistChannelPart, artistMetaPart, ...artistRomajiParts].filter(Boolean);
|
||||
|
||||
// Use a space (not " - ") between artist and title: this matches how users type
|
||||
// in the YTM search box and ranks the exact official track first. A dash skews
|
||||
@@ -202,14 +309,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
|
||||
// result and treating its uploads as the search result set.
|
||||
//
|
||||
// When YTM returns an artist channel as the top hit, yt-dlp prints the channel's
|
||||
// browse URL (/browse/UC…) instead of video URLs. We detect this on stdout and
|
||||
// return [] so the caller falls through to the ytsearch fallback.
|
||||
// NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search
|
||||
// page processing, not just channel expansion, and would wrongly return [] always.
|
||||
const getYtmVideoUrls = (maxItems) => new Promise(resolve => {
|
||||
const urls = [];
|
||||
let channelExpanded = false;
|
||||
let out = '';
|
||||
const proc = spawn(CONFIG.ytdlpBin, [
|
||||
ytmUrl,
|
||||
'--flat-playlist',
|
||||
@@ -221,21 +324,13 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
// native Songs shelf (where the actual artist track lives).
|
||||
'--extractor-args', 'youtube:player_client=web_music,default',
|
||||
], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
|
||||
proc.stdout.on('data', d => {
|
||||
const text = d.toString();
|
||||
if (text.includes('/browse/UC')) { channelExpanded = true; return; }
|
||||
if (!channelExpanded) {
|
||||
text.split('\n').forEach(line => {
|
||||
const u = line.trim();
|
||||
if (u.includes('watch?v=')) urls.push(u);
|
||||
});
|
||||
}
|
||||
});
|
||||
proc.stdout.on('data', d => { out += d.toString(); });
|
||||
proc.on('error', () => resolve([]));
|
||||
proc.on('close', () => {
|
||||
if (channelExpanded) log(jobId, `⚠️ YTM: Artist-Channel in Suchergebnissen — überspringe`);
|
||||
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden`);
|
||||
resolve(channelExpanded ? [] : urls);
|
||||
const { urls, skipped } = pickYtmVideoUrls(out);
|
||||
if (!urls.length) log(jobId, `⚠️ YTM: keine Song-Treffer (${skipped} Listeneintrag/-eintraege)`);
|
||||
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden${skipped ? ` (${skipped} Album/Kanal uebersprungen)` : ''}`);
|
||||
resolve(urls);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,16 +345,24 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
durPart = '';
|
||||
}
|
||||
const base = `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`;
|
||||
// Non-strict, or no artist constraint (short / Japanese name): single filter.
|
||||
if (!strict || !artistChannelPart) return ['--match-filter', base];
|
||||
// Strict: pass if the channel name matches the artist OR yt-dlp's music `artist` metadata does
|
||||
// (catches official label-channel uploads like BANGTANTV). Two --match-filter args = OR in yt-dlp.
|
||||
return [
|
||||
'--match-filter', `${base}${artistChannelPart}`,
|
||||
'--match-filter', `${base}${artistMetaPart}`,
|
||||
];
|
||||
// Ohne Titelpruefung bliebe ohne Kuenstlerpruefung nur die Dauer — das ist keine Pruefung,
|
||||
// sondern ein Muenzwurf. Solche Schritte laufen deshalb IMMER mit Kuenstlerpruefung, auch
|
||||
// wenn die Kaskade sie eigentlich lockern wollte.
|
||||
const mitKuenstler = strict || !titleKwPart;
|
||||
if (!mitKuenstler || !identitaet.length) return ['--match-filter', base];
|
||||
// Mehrere --match-filter verodert yt-dlp: Kanalname ODER `artist`-Metadatum (faengt offizielle
|
||||
// Label-Uploads wie BANGTANTV, deren Kanal den Kuenstler nicht im Namen traegt).
|
||||
return identitaet.flatMap(teil => ['--match-filter', `${base}${teil}`]);
|
||||
};
|
||||
|
||||
// Weder Titel- noch Kuenstlermerkmal pruefbar: dann gaebe es nichts, woran sich der richtige
|
||||
// Treffer erkennen liesse, und der erste Suchtreffer passender Laenge wuerde gespeichert.
|
||||
// Lieber sauber scheitern — der Direktlink im Importer ist dafuer der vorgesehene Weg.
|
||||
if (!titleKwPart && !identitaet.length) {
|
||||
log(jobId, '⚠️ Weder Titel- noch Künstlermerkmal prüfbar — bitte Direktlink im Importer nutzen');
|
||||
return { success: false, notFound: true, dir };
|
||||
}
|
||||
|
||||
const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1'];
|
||||
const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1'];
|
||||
const PI_UNFILTERED = ['--playlist-items', '1'];
|
||||
@@ -287,29 +390,41 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
||||
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
|
||||
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]);
|
||||
}
|
||||
// Findet sich im Dauerfenster nichts, wird ZUERST die DAUER gelockert und der Kuenstler
|
||||
// BEHALTEN — nicht umgekehrt. Der Kuenstler ist das staerkere Merkmal: die erwartete Dauer
|
||||
// stammt aus den Metadaten und gehoert oft zu einer anderen Fassung, waehrend Kanalname und
|
||||
// artist-Feld den Song tatsaechlich benennen.
|
||||
//
|
||||
// Vorher lief es andersherum, und genau daran ist am 24.9.2026 "Dearly Beloved" von TPR
|
||||
// gescheitert: erwartet wurden 230 s (Deezer kennt TPR nicht und lieferte "Dearly Beloved I"
|
||||
// von Project Destati), der richtige Upload dauert 136 s und fiel aus dem Fenster — waehrend
|
||||
// eine Rockband namens DEARLY BELOVED mit ihrem Stueck "LSD" bei 250 s mitten hinein passte.
|
||||
// Deren BANDNAME erfuellte die Titelpruefung. Der Schritt ohne Kanalpruefung griff zuerst zu.
|
||||
//
|
||||
// Es faellt KEIN Schritt weg, nur die Reihenfolge dreht sich: was vorher gefunden wurde, wird
|
||||
// weiter gefunden, nur gegebenenfalls eine Stufe spaeter (geprueft, siehe scripts/filter-probe.mjs).
|
||||
if (!hasAudio()) {
|
||||
// 3. YTM all-10 — no channel check (label channels, etc.)
|
||||
log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…');
|
||||
await tryYtm(0, mf(dur, true));
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 4. YT top-10 — no channel check
|
||||
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
|
||||
}
|
||||
// No duration match. "Mittelweg": relax the duration constraint but REQUIRE the artist
|
||||
// channel (+ title). This catches legit tracks whose YT upload duration drifts from the
|
||||
// iTunes value, while never grabbing a same-titled video by someone else. If nothing
|
||||
// matches title+channel we download NOTHING and the job fails cleanly (see end of fn) —
|
||||
// far better than silently saving the wrong song (e.g. a "recognize" fan clip).
|
||||
if (!hasAudio()) {
|
||||
// 5. YTM all — no duration, but channel + title required
|
||||
// 3. YTM all — ohne Dauer, aber Kuenstler + Titel verlangt
|
||||
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…');
|
||||
await tryYtm(0, mf(null, true, true));
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 6. YT top-10 — no duration, but channel + title required
|
||||
// 4. YT top-10 — ohne Dauer, aber Kuenstler + Titel verlangt
|
||||
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(null, true, true), ...baseArgs]);
|
||||
}
|
||||
// Letzter Ausweg: Kanalpruefung fallen lassen, dafuer die Dauer wieder verlangen. Das faengt
|
||||
// offizielle Uploads von Label-Kanaelen, die den Kuenstler weder im Kanalnamen noch im
|
||||
// artist-Feld tragen. Bleibt auch das ohne Treffer, wird NICHTS geladen und der Auftrag
|
||||
// scheitert sauber — besser als still den falschen Song zu speichern.
|
||||
if (!hasAudio()) {
|
||||
// 5. YTM all-10 — ohne Kanalpruefung, dafuer im Dauerfenster
|
||||
log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…');
|
||||
await tryYtm(0, mf(dur, true));
|
||||
}
|
||||
if (!hasAudio()) {
|
||||
// 6. YT top-10 — ohne Kanalpruefung, dafuer im Dauerfenster
|
||||
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
|
||||
}
|
||||
} else {
|
||||
log(jobId, `🔍 Suche YT Music: "${query}"`);
|
||||
// 1. YTM top-5 — strict: title + variant + channel check
|
||||
@@ -424,4 +539,4 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
|
||||
return { success: true, dir };
|
||||
}
|
||||
|
||||
module.exports = { downloadTrack, embedMetadata };
|
||||
module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter, pickYtmVideoUrls };
|
||||
+337
-76
@@ -18,7 +18,7 @@ const crypto = require('crypto');
|
||||
|
||||
const CONFIG = require('./lib/config');
|
||||
const { jobs, updateJob, log } = require('./lib/jobs');
|
||||
const { sanitize, findExistingTrack } = require('./lib/metadata');
|
||||
const { sanitize, findExistingTrack, findFileByStem, albumDirFromTags, dateinamensKuenstler } = require('./lib/metadata');
|
||||
const dz = require('./lib/deezer');
|
||||
const tm = require('./lib/trackmeta'); // Deezer → MusicBrainz → clean fallback (single tracks)
|
||||
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
|
||||
@@ -94,57 +94,17 @@ function _findFileInDir(dir, basename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Last-resort: deep recursive search by title stem (handles folder renames after metadata edit).
|
||||
// Convention: MusicRoot/AlbumArtist/Album/{01 - }Artist - Title.ext
|
||||
// The title stem is everything after the optional "NN - Artist - " prefix.
|
||||
// We match any audio file whose stem ends with the same title words.
|
||||
// Normalize a file stem for loose matching:
|
||||
// 1. Strip compound track prefixes like "01-15 – " or "01 - " (handles en-dash, em-dash, hyphen)
|
||||
// 2. Normalize all dash variants (en-dash, em-dash) to " - "
|
||||
function _normStem(s) {
|
||||
return s
|
||||
.replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '') // strip leading "01-15 – " or "01 - "
|
||||
.replace(/\s*[–—]\s*/g, ' - '); // normalize remaining en/em-dash to " - "
|
||||
}
|
||||
|
||||
function _findFileByStem(dir, titleStem, depth = 0) {
|
||||
if (depth > 4) return null;
|
||||
const normTitle = _normStem(titleStem);
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
const found = _findFileByStem(path.join(dir, entry.name), titleStem, depth + 1);
|
||||
if (found) return found;
|
||||
} else if (AUDIO_EXT_RE.test(entry.name)) {
|
||||
// Strip extension + leading track prefix, then normalize dashes
|
||||
const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '');
|
||||
const bareStem = _normStem(bare.toLowerCase());
|
||||
// Match: exact, or "Artist - Title" ending with our title stem
|
||||
if (bareStem === normTitle ||
|
||||
bareStem.endsWith(' - ' + normTitle) ||
|
||||
bareStem.endsWith(normTitle) ||
|
||||
// Release-suffix desync: Navidrome's title-derived stem ("… - recognize")
|
||||
// may lack the "- Single"/"- EP" suffix the on-disk filename still carries
|
||||
// ("… - recognize - single"), or vice-versa. Match on the structured prefix.
|
||||
bareStem.startsWith(normTitle + ' - ') ||
|
||||
normTitle.startsWith(bareStem + ' - ')) {
|
||||
return path.join(dir, entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Like _findFileByStem but also tries a romaji-converted stem.
|
||||
// Die Tiefensuche nach Titelstamm liegt jetzt in lib/metadata.js (dort getestet) — sie ist der
|
||||
// letzte Ausweg, wenn Navidromes Pfad nicht existiert, und darf deshalb nicht raten.
|
||||
// Wie findFileByStem, versucht zusaetzlich einen Romaji-Stamm.
|
||||
// Handles the case where Navidrome has a Japanese path in its DB but the file
|
||||
// on disk was stored with a romaji filename by the download pipeline.
|
||||
function _findFileByStemOrRomaji(dir, stem) {
|
||||
const direct = _findFileByStem(dir, stem);
|
||||
const direct = findFileByStem(dir, stem);
|
||||
if (direct) return direct;
|
||||
try {
|
||||
const rt = (romajiForStorage(stem) || '').toLowerCase().trim();
|
||||
if (rt && rt !== stem) return _findFileByStem(dir, rt);
|
||||
if (rt && rt !== stem) return findFileByStem(dir, rt);
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
@@ -266,6 +226,65 @@ app.post('/api/artist-overrides', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ─────────────────────────────
|
||||
// POST /api/artist-releases { artists: [{id, name}], days }
|
||||
// Die Deezer-Zuordnung passiert HIER, weil die Pins ohnehin serverseitig liegen — der Client
|
||||
// schickt also nur seine Navidrome-Künstler und muss nichts über Deezer wissen.
|
||||
app.post('/api/artist-releases', async (req, res) => {
|
||||
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
|
||||
const days = Math.min(Math.max(parseInt(req.body?.days, 10) || 90, 1), 365);
|
||||
if (!artists.length) return res.json({ releases: [], ohneZuordnung: [] });
|
||||
// Obergrenze, damit ein kaputter Client nicht hunderte Deezer-Aufrufe auslöst.
|
||||
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
|
||||
|
||||
const pins = _loadArtistOverrides();
|
||||
const mitPin = artists
|
||||
.filter(a => a && a.id && a.name)
|
||||
.map(a => ({
|
||||
id: String(a.id),
|
||||
name: String(a.name),
|
||||
deezerArtistId: pins[String(a.id)]?.deezerArtistId || null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const { neueReleases } = require('./lib/releases');
|
||||
const ergebnis = await neueReleases(mitPin, days);
|
||||
res.json(ergebnis);
|
||||
} catch (e) {
|
||||
console.error('[releases]', e.message);
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/artist-concerts { artists: [{id, name}], days }
|
||||
// Gegenstueck zu /api/artist-releases: anstehende Termine aller gefolgten Kuenstler.
|
||||
// Ohne Ortsfilter — Ticketmaster sucht weltweit. Das ist bewusst so (siehe CHANGELOG-Backlog):
|
||||
// eine Einschraenkung per .env kommt spaeter, fuers Erste reicht, was die API liefert.
|
||||
app.post('/api/artist-concerts', async (req, res) => {
|
||||
const key = CONFIG.ticketmasterKey;
|
||||
if (!key) return res.json({ konzerte: [], ohneTermine: [], keinSchluessel: true });
|
||||
|
||||
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
|
||||
// Ohne `days` gibt es KEIN Fenster — Konzerte sind ohnehin nur Zukunft, und dort will man
|
||||
// alles Angekuendigte sehen. Ein mitgeschicktes Fenster wird weiterhin beachtet.
|
||||
const roh = parseInt(req.body?.days, 10);
|
||||
const days = Number.isFinite(roh) && roh > 0 ? Math.min(roh, 3650) : 0;
|
||||
if (!artists.length) return res.json({ konzerte: [], ohneTermine: [] });
|
||||
// Obergrenze, damit ein kaputter Client nicht hunderte Ticketmaster-Aufrufe ausloest.
|
||||
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
|
||||
|
||||
const sauber = artists.filter(a => a && a.id && a.name)
|
||||
.map(a => ({ id: String(a.id), name: String(a.name) }));
|
||||
|
||||
try {
|
||||
const { kommendeKonzerte } = require('./lib/konzerte');
|
||||
res.json(await kommendeKonzerte(sauber, key, days));
|
||||
} catch (e) {
|
||||
console.error('[concerts]', e.message);
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/artist-overrides/:ndArtistId — remove a pin
|
||||
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
|
||||
const id = decodeURIComponent(req.params.ndArtistId);
|
||||
@@ -503,6 +522,19 @@ function _giteaApiJson(pathAndQuery) {
|
||||
});
|
||||
}
|
||||
|
||||
// Gitea baut seine browser_download_url aus der eigenen ROOT_URL — die kann eine ganz andere Adresse
|
||||
// sein als die, unter der das Backend Gitea erreicht (interne IP vs. Reverse-Proxy-Domain). Deshalb
|
||||
// Schema+Host+Port immer durch GITEA_URL ersetzen und nur den Pfad übernehmen.
|
||||
function _viaGiteaHost(urlStr) {
|
||||
if (!urlStr || !GITEA_URL) return urlStr;
|
||||
try {
|
||||
const base = new URL(GITEA_URL);
|
||||
const u = new URL(urlStr);
|
||||
if (u.origin === base.origin) return urlStr;
|
||||
return new URL(u.pathname + u.search, base).toString();
|
||||
} catch { return urlStr; }
|
||||
}
|
||||
|
||||
// The .apk asset of the fixed release, or null if unavailable.
|
||||
async function _giteaApkAsset() {
|
||||
const rel = await _giteaApiJson(`/releases/tags/${encodeURIComponent(GITEA_APK_TAG)}`);
|
||||
@@ -513,7 +545,7 @@ async function _giteaApkAsset() {
|
||||
const vcMatch = /versionCode\s*[=:]\s*(\d+)/i.exec(rel.body || '');
|
||||
return {
|
||||
size: asset.size,
|
||||
url: asset.browser_download_url,
|
||||
url: _viaGiteaHost(asset.browser_download_url),
|
||||
version: rel.name || rel.tag_name || null,
|
||||
versionCode: vcMatch ? parseInt(vcMatch[1], 10) : null,
|
||||
updatedAt: new Date(asset.created_at || rel.published_at || Date.now()).getTime(),
|
||||
@@ -523,7 +555,13 @@ async function _giteaApkAsset() {
|
||||
function _serveLocalApk(res) {
|
||||
if (res.headersSent) return;
|
||||
fs.stat(APK_PATH, (err) => {
|
||||
if (err) return res.status(404).json({ error: 'Keine APK hinterlegt' });
|
||||
// Ohne lokale Datei UND ohne erreichbares Gitea-Asset: sagen, welcher der beiden Wege fehlt,
|
||||
// sonst sucht man die APK an der falschen Stelle (die Logs oben nennen den Grund).
|
||||
if (err) return res.status(404).json({
|
||||
error: (GITEA_URL && GITEA_REPO)
|
||||
? `APK-Release nicht erreichbar (${GITEA_URL}, Tag ${GITEA_APK_TAG}) und keine lokale Datei unter ${APK_PATH}`
|
||||
: 'Keine APK hinterlegt',
|
||||
});
|
||||
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"');
|
||||
res.sendFile(APK_PATH);
|
||||
@@ -539,14 +577,21 @@ function _streamApkFromUrl(urlStr, res, redirectsLeft = 3) {
|
||||
lib.get(u, { headers }, up => {
|
||||
if ([301, 302, 303, 307, 308].includes(up.statusCode) && up.headers.location && redirectsLeft > 0) {
|
||||
up.resume();
|
||||
return _streamApkFromUrl(new URL(up.headers.location, u).toString(), res, redirectsLeft - 1);
|
||||
return _streamApkFromUrl(_viaGiteaHost(new URL(up.headers.location, u).toString()), res, redirectsLeft - 1);
|
||||
}
|
||||
if (up.statusCode !== 200) {
|
||||
up.resume();
|
||||
console.warn(`[apk] Gitea-Asset lieferte HTTP ${up.statusCode} (${u.origin}) — fallback auf ${APK_PATH}`);
|
||||
return _serveLocalApk(res);
|
||||
}
|
||||
if (up.statusCode !== 200) { up.resume(); return _serveLocalApk(res); }
|
||||
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"');
|
||||
if (up.headers['content-length']) res.setHeader('Content-Length', up.headers['content-length']);
|
||||
up.pipe(res);
|
||||
}).on('error', () => _serveLocalApk(res));
|
||||
}).on('error', (e) => {
|
||||
console.warn(`[apk] Gitea-Asset nicht erreichbar (${u.origin}): ${e.message} — fallback auf ${APK_PATH}`);
|
||||
_serveLocalApk(res);
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/api/apk/info', async (req, res) => {
|
||||
@@ -956,7 +1001,7 @@ app.post('/api/fix-artist-sort', async (req, res) => {
|
||||
try {
|
||||
const { s, t } = mkTok();
|
||||
const r = await fetch(
|
||||
`${ndUrl}/rest/search3?query=${encodeURIComponent(artistKanji)}&artistCount=0&albumCount=0&songCount=500&u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`,
|
||||
`${ndUrl}/rest/search3?query=${encodeURIComponent(artistKanji)}&artistCount=0&albumCount=0&songCount=500&u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json`,
|
||||
{ signal: AbortSignal.timeout(15000) }
|
||||
);
|
||||
const d = await r.json();
|
||||
@@ -1078,7 +1123,7 @@ app.post('/api/fix-artist-sort', async (req, res) => {
|
||||
// Trigger Navidrome rescan
|
||||
try {
|
||||
const { s, t } = mkTok();
|
||||
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) });
|
||||
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(5000) });
|
||||
} catch {}
|
||||
|
||||
const firstErr = errorDetails[0] ? ` (${errorDetails[0]})` : '';
|
||||
@@ -1217,13 +1262,14 @@ app.get('/api/musicbrainz/lookup', async (req, res) => {
|
||||
app.get('/api/song-sorttags', async (req, res) => {
|
||||
const { ndUrl, user, pass, songId } = req.query;
|
||||
if (!ndUrl || !user || !pass || !songId) return res.status(400).json({ error: 'ndUrl, user, pass, songId required' });
|
||||
let filePath;
|
||||
let filePath, ndSong;
|
||||
try {
|
||||
const salt = Math.random().toString(36).slice(2, 10);
|
||||
const token = crypto.createHash('md5').update(pass + salt).digest('hex');
|
||||
const ndRes = await fetch(`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(8000) });
|
||||
const ndRes = await fetch(`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(8000) });
|
||||
const nd = await ndRes.json();
|
||||
filePath = nd?.['subsonic-response']?.song?.path;
|
||||
ndSong = nd?.['subsonic-response']?.song;
|
||||
filePath = ndSong?.path;
|
||||
if (!filePath) return res.status(404).json({ error: 'Song not found' });
|
||||
} catch (e) { return res.status(502).json({ error: e.message }); }
|
||||
if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath);
|
||||
@@ -1232,9 +1278,11 @@ app.get('/api/song-sorttags', async (req, res) => {
|
||||
const rebased = _rebasePath(filePath, CONFIG.musicPath);
|
||||
const rebasedDir = rebased ? path.dirname(rebased) : null;
|
||||
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
|
||||
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
|
||||
const found = rebased
|
||||
|| _findFileRecursive(CONFIG.musicPath, fileName)
|
||||
|| _findFileInDir(rebasedDir || path.dirname(filePath), fileName)
|
||||
|| (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
|
||||
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
|
||||
if (found) filePath = found; else return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
@@ -1264,7 +1312,7 @@ async function _ndApi(ndUrl, user, pass, method, extra = '') {
|
||||
const s = Math.random().toString(36).slice(2, 10);
|
||||
const t = crypto.createHash('md5').update(pass + s).digest('hex');
|
||||
const r = await fetch(
|
||||
`${ndUrl}/rest/${method}?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json${extra}`,
|
||||
`${ndUrl}/rest/${method}?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json${extra}`,
|
||||
{ signal: AbortSignal.timeout(8000) }
|
||||
);
|
||||
return r.json();
|
||||
@@ -1316,6 +1364,43 @@ async function _migratePlaylists(ndUrl, user, pass, plIds, oldId, newId) {
|
||||
}
|
||||
|
||||
// ─── METADATA EDIT ───────────────────────────────────────────────────────────
|
||||
// Liest die vorhandenen Tags einer Datei. Wird gebraucht, um einen ueber Namensaehnlichkeit
|
||||
// gefundenen Kandidaten zu PRUEFEN, bevor er ueberschrieben wird.
|
||||
async function _readTags(file) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format_tags', file]);
|
||||
let buf = '';
|
||||
proc.stdout.on('data', d => buf += d);
|
||||
proc.on('error', () => resolve({}));
|
||||
proc.on('close', () => { try { resolve(JSON.parse(buf)?.format?.tags || {}); } catch { resolve({}); } });
|
||||
});
|
||||
}
|
||||
const _tagNorm = (v) => (v || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
||||
|
||||
// Steckbrief einer Datei fuer den Dublettenvergleich: Groesse, Laenge, Titel/Kuenstler.
|
||||
// Zwei Kopien desselben Songs unterscheiden sich fast immer nur in der Groesse (andere Bitrate),
|
||||
// nie nennenswert in der Laenge — deshalb ist die Laenge das aussagekraeftige Feld.
|
||||
async function _dateiSteckbrief(file) {
|
||||
let groesse = null;
|
||||
try { groesse = fs.statSync(file).size; } catch {}
|
||||
const dauer = await new Promise((resolve) => {
|
||||
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format=duration', file]);
|
||||
let buf = '';
|
||||
proc.stdout.on('data', d => buf += d);
|
||||
proc.on('error', () => resolve(null));
|
||||
proc.on('close', () => {
|
||||
try { resolve(Math.round(parseFloat(JSON.parse(buf)?.format?.duration)) || null); } catch { resolve(null); }
|
||||
});
|
||||
});
|
||||
const tags = await _readTags(file);
|
||||
return {
|
||||
pfad: file, groesse, dauer,
|
||||
titel: tags.title || tags.TITLE || null,
|
||||
artist: tags.artist || tags.ARTIST || null,
|
||||
album: tags.album || tags.ALBUM || null,
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/edit-metadata', async (req, res) => {
|
||||
const { ndUrl, user, pass, songId, title, artist, albumArtist, album, year, trackNum, totalTracks,
|
||||
discNum, discTotal, genre, artworkBase64, artworkMime, artworkUrl: newArtworkUrl,
|
||||
@@ -1338,7 +1423,7 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
try {
|
||||
const salt = Math.random().toString(36).slice(2, 10);
|
||||
const token = crypto.createHash('md5').update(pass + salt).digest('hex');
|
||||
const ndApi = `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`;
|
||||
const ndApi = `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`;
|
||||
const ndRes = await fetch(ndApi, { signal: AbortSignal.timeout(8000) });
|
||||
const ndData = await ndRes.json();
|
||||
ndSong = ndData?.['subsonic-response']?.song;
|
||||
@@ -1367,16 +1452,45 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
// but Navidrome's index is stale and still points to the old name.
|
||||
const originalDir = path.dirname(filePath);
|
||||
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
|
||||
// Schraegstrich im Kuenstler-/Albumnamen ("K/DA", "POP/STARS"): Navidromes Tag-Pfad macht
|
||||
// daraus eine Verzeichnisebene, auf der Platte steht "_". Den Ordner deshalb aus den Tags
|
||||
// nachbauen und DORT suchen — das ist gezielt und geht jeder Aehnlichkeitssuche vor.
|
||||
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
|
||||
const found = rebased
|
||||
|| _findFileRecursive(CONFIG.musicPath, fileName)
|
||||
|| _findFileInDir(rebasedDir || originalDir, fileName)
|
||||
|| (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
|
||||
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem)
|
||||
// Last resort: Subsonic getSong returns a *virtual* (tag-built) path, so the folder
|
||||
// can be "[Unknown Album]" while the file actually sits in "Singles" with an artist
|
||||
// prefix. findExistingTrack matches by normalized artist+title across all album folders.
|
||||
|| findExistingTrack(CONFIG.musicPath, ndSong.albumArtist || ndSong.artist || '', ndSong.title || '');
|
||||
if (found) { filePath = found; }
|
||||
else return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
|
||||
if (!found) {
|
||||
return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
|
||||
}
|
||||
// Der Treffer stammt aus einer Aehnlichkeitssuche ueber Dateiname bzw. Kuenstler+Titel. Liegt
|
||||
// derselbe Song mehrfach auf der Platte — etwa ein Fehlgriff des Downloaders neben der richtigen
|
||||
// Datei —, kann das die FALSCHE Kopie sein. Die wurde dann getaggt, waehrend der bearbeitete Song
|
||||
// unveraendert blieb: die Aenderung "sprang zurueck". Deshalb wird der Kandidat gegen die Tags
|
||||
// geprueft, die Navidrome fuer diesen Song meldet, und im Zweifel abgebrochen statt geraten.
|
||||
const kandidatTags = await _readTags(found);
|
||||
const istTitel = _tagNorm(kandidatTags.title || kandidatTags.TITLE);
|
||||
const istArtist = _tagNorm(kandidatTags.artist || kandidatTags.ARTIST);
|
||||
const sollTitel = _tagNorm(ndSong?.title);
|
||||
const sollArtist = _tagNorm(ndSong?.artist);
|
||||
const titelPasst = !sollTitel || !istTitel || istTitel === sollTitel;
|
||||
const artistPasst = !sollArtist || !istArtist ||
|
||||
istArtist.includes(sollArtist) || sollArtist.includes(istArtist);
|
||||
if (!titelPasst || !artistPasst) {
|
||||
return res.status(409).json({
|
||||
error: `Datei nicht eindeutig zuzuordnen. Navidrome meldet "${ndSong?.artist || '?'} – ` +
|
||||
`${ndSong?.title || '?'}", die gefundene Datei enthaelt aber "` +
|
||||
`${kandidatTags.artist || '?'} – ${kandidatTags.title || '?'}" (${found}). ` +
|
||||
`Vermutlich liegt derselbe Song mehrfach in der Bibliothek — bitte die Dubletten ` +
|
||||
`aufraeumen, sonst wuerde die falsche Datei bearbeitet.`,
|
||||
});
|
||||
}
|
||||
filePath = found;
|
||||
}
|
||||
|
||||
// Resolve artwork: base64 > URL
|
||||
@@ -1455,19 +1569,33 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
|
||||
// ── Post-save: rename file and/or move to correct artist/album folder ──
|
||||
let currentFile = filePath;
|
||||
// Umbenennen und Verschieben werden uebersprungen, wenn am Ziel schon eine Datei liegt. Das war
|
||||
// bisher stumm — der Song blieb dann unter dem alten Namen bzw. im alten Ordner liegen, ohne dass
|
||||
// es jemand erfuhr. Solche Faelle kommen jetzt als Warnung mit in die Antwort.
|
||||
const warnungen = [];
|
||||
// Faellt beim Verschieben eine gleichnamige Datei am Ziel auf, ist das bereits der
|
||||
// Dublettenfall. Statt nur zu melden, werden beide Dateien verglichen und die Angaben
|
||||
// mitgeschickt, damit die Oberflaeche das Aufraeumen direkt anbieten kann.
|
||||
let dublette = null;
|
||||
|
||||
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
|
||||
// Use original names (may be Japanese); romaji goes only into sort tags.
|
||||
if (title) {
|
||||
const safeTitle = sanitize(title);
|
||||
const fileArtist = (normalArtist || ndSong?.artist || '').replace(/;/g, ',');
|
||||
const fileArtist = dateinamensKuenstler(normalArtist, ndSong?.artist);
|
||||
const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : '';
|
||||
const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||
const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`;
|
||||
const newPath = path.join(path.dirname(currentFile), newName);
|
||||
if (newPath !== currentFile && !fs.existsSync(newPath)) {
|
||||
if (newPath !== currentFile && fs.existsSync(newPath)) {
|
||||
warnungen.push(`Datei nicht umbenannt: "${path.basename(newPath)}" existiert bereits im ` +
|
||||
`selben Ordner. Die Tags wurden geschrieben, der Dateiname bleibt alt.`);
|
||||
} else if (newPath !== currentFile) {
|
||||
try { fs.renameSync(currentFile, newPath); currentFile = newPath; }
|
||||
catch (e) { console.error('[edit-metadata] file rename:', e.message); }
|
||||
catch (e) {
|
||||
console.error('[edit-metadata] file rename:', e.message);
|
||||
warnungen.push(`Umbenennen fehlgeschlagen: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1482,7 +1610,23 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
try {
|
||||
fs.mkdirSync(newDir, { recursive: true });
|
||||
const destFile = path.join(newDir, path.basename(currentFile));
|
||||
if (!fs.existsSync(destFile)) {
|
||||
if (fs.existsSync(destFile)) {
|
||||
const [ziel, hier] = await Promise.all([
|
||||
_dateiSteckbrief(destFile), _dateiSteckbrief(currentFile),
|
||||
]);
|
||||
// Gleiche Laenge (± 2 s) UND gleicher Titel: dann sind es zwei Kopien desselben Songs
|
||||
// und die ueberzaehlige kann weg. Sonst nur melden — Loeschen ist unwiderruflich.
|
||||
const laengeGleich = ziel.dauer != null && hier.dauer != null &&
|
||||
Math.abs(ziel.dauer - hier.dauer) <= 2;
|
||||
const titelGleich = _tagNorm(ziel.titel) === _tagNorm(hier.titel);
|
||||
dublette = {
|
||||
behalten: ziel, // liegt schon am richtigen Platz
|
||||
ueberzaehlig: hier, // die gerade bearbeitete Datei im alten Ordner
|
||||
gleich: !!(laengeGleich && titelGleich),
|
||||
};
|
||||
warnungen.push(`Nicht verschoben: "${destFile}" existiert bereits. Die Datei bleibt in ` +
|
||||
`"${oldAlbumDir}" — dieselbe Aufnahme liegt doppelt in der Bibliothek.`);
|
||||
} else {
|
||||
fs.renameSync(currentFile, destFile);
|
||||
currentFile = destFile;
|
||||
}
|
||||
@@ -1509,20 +1653,42 @@ app.post('/api/edit-metadata', async (req, res) => {
|
||||
try {
|
||||
const salt2 = Math.random().toString(36).slice(2, 10);
|
||||
const tok2 = crypto.createHash('md5').update(pass + salt2).digest('hex');
|
||||
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${tok2}&s=${salt2}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) });
|
||||
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${tok2}&s=${salt2}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(5000) });
|
||||
} catch {}
|
||||
|
||||
// Restore playlist membership after rename/move — the rescan gives the renamed song a new ID
|
||||
if (affectedPlaylists.length > 0) {
|
||||
// Umbenennen und Verschieben geben dem Song beim naechsten Scan eine NEUE Navidrome-ID. Alles,
|
||||
// was an der alten ID hing, waere sonst weg: die Playlist-Zugehoerigkeit — und der Stern, der
|
||||
// bisher stillschweigend verlorenging. Beides wird auf die neue ID uebertragen.
|
||||
const warStarred = !!ndSong?.starred;
|
||||
if (affectedPlaylists.length > 0 || warStarred) {
|
||||
const effectiveTitle = title || ndSong?.title || '';
|
||||
const effectiveArtist = (artist || ndSong?.artist || '').split(';')[0].trim();
|
||||
const newSong = await _findSongAfterRename(ndUrl, user, pass, effectiveTitle, effectiveArtist, songId);
|
||||
if (newSong && newSong.id !== songId) {
|
||||
await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id);
|
||||
if (affectedPlaylists.length > 0) {
|
||||
await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id);
|
||||
}
|
||||
if (warStarred) {
|
||||
// Der Stern ist in Navidrome pro Nutzer gespeichert; uebertragen laesst sich nur der des
|
||||
// bearbeitenden Nutzers — fremde Markierungen sind ueber die Subsonic-API weder lesbar
|
||||
// noch setzbar. Schlaegt es fehl, bleibt es bei einer Warnung statt eines Abbruchs.
|
||||
try {
|
||||
const st = await _ndApi(ndUrl, user, pass, 'star', `&id=${encodeURIComponent(newSong.id)}`);
|
||||
if (st?.['subsonic-response']?.status !== 'ok') throw new Error('star abgelehnt');
|
||||
} catch (e) {
|
||||
warnungen.push(`Favorit konnte nicht uebertragen werden (${e.message}) — der Song muss ` +
|
||||
`erneut markiert werden.`);
|
||||
}
|
||||
}
|
||||
} else if (warStarred && !newSong) {
|
||||
warnungen.push('Der Song wurde nach dem Umbenennen nicht wiedergefunden — die Markierung ' +
|
||||
'als Favorit konnte nicht uebertragen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
// Den tatsaechlich bearbeiteten Pfad mitgeben: wer eine Aenderung "zurueckspringen" sieht, kann
|
||||
// damit sofort erkennen, ob die richtige Datei erwischt wurde.
|
||||
res.json({ ok: true, file: currentFile, warnings: warnungen, duplicate: dublette });
|
||||
} catch (e) {
|
||||
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}
|
||||
if (artFile) try { fs.unlinkSync(artFile); } catch {}
|
||||
@@ -1541,16 +1707,17 @@ app.post('/api/delete-song', async (req, res) => {
|
||||
return res.status(400).json({ error: 'ndUrl, user, pass, songId required' });
|
||||
|
||||
// Resolve the file path from Navidrome
|
||||
let filePath;
|
||||
let filePath, ndSong;
|
||||
try {
|
||||
const salt = Math.random().toString(36).slice(2, 10);
|
||||
const token = crypto.createHash('md5').update(pass + salt).digest('hex');
|
||||
const ndRes = await fetch(
|
||||
`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`,
|
||||
`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`,
|
||||
{ signal: AbortSignal.timeout(8000) }
|
||||
);
|
||||
const ndData = await ndRes.json();
|
||||
filePath = ndData?.['subsonic-response']?.song?.path;
|
||||
ndSong = ndData?.['subsonic-response']?.song;
|
||||
filePath = ndSong?.path;
|
||||
if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' });
|
||||
} catch (e) {
|
||||
return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` });
|
||||
@@ -1561,14 +1728,36 @@ app.post('/api/delete-song', async (req, res) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const fileName = path.basename(filePath);
|
||||
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
|
||||
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
|
||||
const found = _rebasePath(filePath, CONFIG.musicPath)
|
||||
|| _findFileRecursive(CONFIG.musicPath, fileName)
|
||||
|| _findFileInDir(path.dirname(filePath), fileName)
|
||||
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
|
||||
if (found) filePath = found;
|
||||
else return res.status(404).json({
|
||||
|| (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
|
||||
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem)
|
||||
|| findExistingTrack(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist || '', ndSong?.title || '');
|
||||
if (!found) return res.status(404).json({
|
||||
error: `File not found on disk. Navidrome path: "${ndPath}" — music root: "${CONFIG.musicPath}" — searched for: "${fileName}"`,
|
||||
});
|
||||
// Derselbe Abgleich wie im Metadaten-Editor, und hier WICHTIGER: der Treffer stammt aus einer
|
||||
// Aehnlichkeitssuche, und geloescht wird unwiderruflich. Passen die Tags der gefundenen Datei
|
||||
// nicht zu dem, was Navidrome fuer diesen Song meldet, wird abgebrochen statt geraten.
|
||||
const delTags = await _readTags(found);
|
||||
const istTitel = _tagNorm(delTags.title || delTags.TITLE);
|
||||
const istArtist = _tagNorm(delTags.artist || delTags.ARTIST);
|
||||
const sollTitel = _tagNorm(ndSong?.title);
|
||||
const sollArtist = _tagNorm(ndSong?.artist);
|
||||
const titelPasst = !sollTitel || !istTitel || istTitel === sollTitel;
|
||||
const artistPasst = !sollArtist || !istArtist ||
|
||||
istArtist.includes(sollArtist) || sollArtist.includes(istArtist);
|
||||
if (!titelPasst || !artistPasst) {
|
||||
return res.status(409).json({
|
||||
error: `Datei nicht eindeutig zuzuordnen. Navidrome meldet "${ndSong?.artist || '?'} – ` +
|
||||
`${ndSong?.title || '?'}", die gefundene Datei enthaelt aber "` +
|
||||
`${delTags.artist || '?'} – ${delTags.title || '?'}" (${found}). ` +
|
||||
`Es wird nichts geloescht — bitte die Dubletten aufraeumen.`,
|
||||
});
|
||||
}
|
||||
filePath = found;
|
||||
}
|
||||
|
||||
// Safety: file must be inside the music root and must be an audio file
|
||||
@@ -1610,7 +1799,7 @@ app.post('/api/delete-song', async (req, res) => {
|
||||
const s2 = Math.random().toString(36).slice(2, 10);
|
||||
const t2 = crypto.createHash('md5').update(pass + s2).digest('hex');
|
||||
await fetch(
|
||||
`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=novasonic&f=json`,
|
||||
`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=NovaSonic-Backend&f=json`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
} catch {}
|
||||
@@ -1618,6 +1807,78 @@ app.post('/api/delete-song', async (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── DUBLETTE AUFLOESEN ──────────────────────────────────────────────────────
|
||||
// Nach einem abgelehnten Verschieben ("am Ziel liegt schon eine Datei") liegt derselbe Song
|
||||
// zweimal auf der Platte. Dieser Endpunkt loescht die ueberzaehlige Kopie — aber nur, wenn er
|
||||
// selbst nachgeprueft hat, dass es wirklich eine Dublette ist. Er nimmt Pfade entgegen, deshalb
|
||||
// prueft er strenger als das Loeschen per songId:
|
||||
// 1. beide Pfade liegen im Musikordner und sind Audiodateien
|
||||
// 2. beide existieren
|
||||
// 3. gleiche Laenge (± 2 s) UND gleicher Titel-Tag — sonst sind es zwei verschiedene Aufnahmen
|
||||
// Erst dann faellt die zu loeschende Datei.
|
||||
app.post('/api/resolve-duplicate', async (req, res) => {
|
||||
const { ndUrl, user, pass, behalten, ueberzaehlig } = req.body;
|
||||
if (!ndUrl || !user || !pass || !behalten || !ueberzaehlig)
|
||||
return res.status(400).json({ error: 'ndUrl, user, pass, behalten, ueberzaehlig required' });
|
||||
if (behalten === ueberzaehlig)
|
||||
return res.status(400).json({ error: 'Beide Pfade sind identisch' });
|
||||
|
||||
const realRoot = path.resolve(CONFIG.musicPath);
|
||||
const rootSlash = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
|
||||
const pruefe = (pfad, name) => {
|
||||
const abs = path.resolve(pfad);
|
||||
if (!abs.startsWith(rootSlash)) return `${name}: Pfad liegt ausserhalb des Musikordners`;
|
||||
if (!AUDIO_EXT_RE.test(path.extname(abs))) return `${name}: keine Audiodatei`;
|
||||
if (!fs.existsSync(abs)) return `${name}: existiert nicht mehr`;
|
||||
return null;
|
||||
};
|
||||
const fehler = pruefe(behalten, 'behalten') || pruefe(ueberzaehlig, 'ueberzaehlig');
|
||||
if (fehler) return res.status(400).json({ error: fehler });
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
_dateiSteckbrief(path.resolve(behalten)), _dateiSteckbrief(path.resolve(ueberzaehlig)),
|
||||
]);
|
||||
const laengeGleich = a.dauer != null && b.dauer != null && Math.abs(a.dauer - b.dauer) <= 2;
|
||||
const titelGleich = _tagNorm(a.titel) === _tagNorm(b.titel);
|
||||
if (!laengeGleich || !titelGleich) {
|
||||
return res.status(409).json({
|
||||
error: `Das sind keine zwei Kopien desselben Songs — "${a.titel || '?'}" (${a.dauer ?? '?'} s) ` +
|
||||
`gegen "${b.titel || '?'}" (${b.dauer ?? '?'} s). Es wird nichts geloescht.`,
|
||||
});
|
||||
}
|
||||
|
||||
const ziel = path.resolve(ueberzaehlig);
|
||||
try { fs.unlinkSync(ziel); }
|
||||
catch (e) { return res.status(500).json({ error: `Loeschen fehlgeschlagen: ${e.message}` }); }
|
||||
|
||||
// Leere Album- und Kuenstlerordner aufraeumen — gleiche Tiefenbegrenzung wie beim Song-Loeschen.
|
||||
const albumDir = path.dirname(ziel);
|
||||
const artistDir = path.dirname(albumDir);
|
||||
if (albumDir !== realRoot && albumDir.startsWith(rootSlash)) {
|
||||
try {
|
||||
if (fs.readdirSync(albumDir).filter(f => !f.startsWith('.')).length === 0) {
|
||||
fs.rmdirSync(albumDir);
|
||||
if (artistDir !== realRoot && artistDir.startsWith(rootSlash)) {
|
||||
try {
|
||||
if (fs.readdirSync(artistDir).filter(f => !f.startsWith('.')).length === 0) fs.rmdirSync(artistDir);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const s2 = Math.random().toString(36).slice(2, 10);
|
||||
const t2 = crypto.createHash('md5').update(pass + s2).digest('hex');
|
||||
await fetch(
|
||||
`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=NovaSonic-Backend&f=json`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
} catch {}
|
||||
|
||||
res.json({ ok: true, geloescht: ziel, verbleibt: path.resolve(behalten) });
|
||||
});
|
||||
|
||||
// ─── ABS PROXY ───────────────────────────────────────────────────────────────
|
||||
// Forwards API calls to Audiobookshelf to avoid CORS restrictions in the browser.
|
||||
// The browser sends X-ABS-URL and X-ABS-Token headers; audio streaming uses
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
// Regressionsprobe fuer den Download-Filter.
|
||||
//
|
||||
// Warum es das gibt: jede Verschaerfung am Filter, die einen Fehlgriff verhindert, kann eine ganze
|
||||
// KLASSE richtiger Treffer abweisen — und der naechste Bericht kommt dann aus der anderen Richtung.
|
||||
// Damit der Umbau nicht im Kreis laeuft, wird vor jeder Aenderung gegen bekannte Faelle geprueft.
|
||||
//
|
||||
// Was es tut: holt fuer jeden Fall EINMAL die echten Suchtreffer (yt-dlp, mit Metadaten, damit auch
|
||||
// das `artist`-Feld der Art Tracks vorliegt) und spielt dann die Kaskadenstufen lokal durch —
|
||||
// in der ALTEN und der NEUEN Reihenfolge. Gemeldet wird, welcher Treffer jeweils gewinnt.
|
||||
//
|
||||
// WICHTIG: beide Quellen werden abgefragt, YT MUSIC ZUERST. Die erste Fassung dieser Probe sah nur
|
||||
// die gewoehnliche YouTube-Suche und meldete deshalb fuer "Get Lucky" einen Grammy-Mitschnitt als
|
||||
// Treffer — in Wahrheit liefert YT Music dort die offizielle Tonspur an erster Stelle, und genau
|
||||
// die laedt die Kaskade. Eine Probe, die eine Stufe auslaesst, erfindet Fehler.
|
||||
//
|
||||
// docker run --rm --network host --entrypoint node <backend-image> /app/scripts/filter-probe.mjs
|
||||
//
|
||||
// Die Filterbedingungen sind hier nachgebaut, nicht importiert: geprueft werden soll, was yt-dlp
|
||||
// mit --match-filter tut, und das ist ein Ausdruck ueber dieselben Felder.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildTitleKeywords, buildVariantFilter } = require('../lib/ytdlp.js');
|
||||
|
||||
const JP_RE = /[ぁ-んァ-ン一-龯]/;
|
||||
|
||||
// ── Faelle ────────────────────────────────────────────────────────────────────
|
||||
// Jeder Fall nennt, was herauskommen MUSS (erwartetFragment) — ein Stueck des Kanalnamens oder
|
||||
// Titels des richtigen Uploads. Faelle mit `darfNicht` nennen zusaetzlich einen bekannten Fehlgriff.
|
||||
const FAELLE = [
|
||||
{
|
||||
name: 'TPR — Dearly Beloved (Deezer kennt TPR nicht, Dauer gehoert zu anderer Fassung)',
|
||||
artist: 'TPR', titel: 'Dearly Beloved', dauer: 230,
|
||||
erwartetFragment: 'TPR', darfNicht: 'LSD',
|
||||
},
|
||||
{
|
||||
name: 'BTS — Pied Piper (Label-Kanal BANGTANTV, Kuenstler nur im artist-Feld)',
|
||||
artist: 'BTS', titel: 'Pied Piper', dauer: 246,
|
||||
erwartetFragment: 'Pied Piper',
|
||||
},
|
||||
{
|
||||
name: 'Yoshihisa Hirano — 銀髪の少年 (japanischer Titel, Soundtrack-Album)',
|
||||
artist: 'Yoshihisa Hirano', titel: '銀髪の少年', dauer: 129,
|
||||
// Der richtige Upload heisst romanisiert "Ginpatsu no Shonen" — ohne das lange o.
|
||||
// Genau dafuer ist das tolerante Umschrift-Muster da.
|
||||
romaji: 'Ginpatsu No Shounen', erwartetFragment: 'Hirano',
|
||||
},
|
||||
{
|
||||
name: 'Rising Insane — DEATHRACE (normaler westlicher Fall)',
|
||||
artist: 'Rising Insane', titel: 'DEATHRACE', dauer: 216,
|
||||
erwartetFragment: 'Rising Insane',
|
||||
},
|
||||
{
|
||||
name: 'Daft Punk — Get Lucky (Klassiker, viele Coverversionen)',
|
||||
artist: 'Daft Punk', titel: 'Get Lucky', dauer: 369,
|
||||
erwartetFragment: 'Daft Punk',
|
||||
},
|
||||
];
|
||||
|
||||
// ── Suchtreffer holen ─────────────────────────────────────────────────────────
|
||||
const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
|
||||
|
||||
/** YT-Music-Suche: erst die URLs der Songs-Leiste, dann deren Metadaten. */
|
||||
async function sucheYtm(query, n) {
|
||||
const url = `https://music.youtube.com/search?q=${encodeURIComponent(query)}¶ms=${YTM_SONGS_PARAM}`;
|
||||
const urls = await new Promise(resolve => {
|
||||
const p = spawn('yt-dlp', [
|
||||
url, '--flat-playlist', '--playlist-items', `1-${n}`, '--print', 'webpage_url',
|
||||
'--no-warnings', '--extractor-args', 'youtube:player_client=web_music,default',
|
||||
]);
|
||||
let aus = '';
|
||||
p.stdout.on('data', d => aus += d.toString());
|
||||
p.on('error', () => resolve([]));
|
||||
p.on('close', () => resolve(aus.split('\n').map(z => z.trim()).filter(z => z.includes('watch?v='))));
|
||||
});
|
||||
if (!urls.length) return [];
|
||||
return metadaten(urls);
|
||||
}
|
||||
|
||||
function suche(query, n) {
|
||||
return metadaten([`ytsearch${n}:${query}`]);
|
||||
}
|
||||
|
||||
/** Metadaten zu einer Liste von URLs (oder einer Suchangabe) holen. */
|
||||
function metadaten(ziele) {
|
||||
return new Promise(resolve => {
|
||||
const felder = '%(duration)s%(channel)s%(artist)s%(title)s';
|
||||
const p = spawn('yt-dlp', [
|
||||
...ziele, '--simulate', '--no-warnings', '--ignore-errors',
|
||||
'--print', felder,
|
||||
]);
|
||||
let aus = '';
|
||||
p.stdout.on('data', d => aus += d.toString());
|
||||
p.on('error', () => resolve([]));
|
||||
p.on('close', () => resolve(
|
||||
aus.split('\n').filter(Boolean).map(z => {
|
||||
const [dauer, kanal, kuenstler, titel] = z.split('');
|
||||
return {
|
||||
dauer: Number(dauer) || 0,
|
||||
kanal: kanal === 'NA' ? '' : kanal,
|
||||
kuenstler: kuenstler === 'NA' ? '' : kuenstler,
|
||||
titel: titel || '',
|
||||
};
|
||||
})
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filterbedingungen, wie mf() sie baut ──────────────────────────────────────
|
||||
function pruefer(fall) {
|
||||
const kws = buildTitleKeywords(fall.titel, fall.romaji || '');
|
||||
const variant = buildVariantFilter(fall.titel);
|
||||
const ersterK = fall.artist.split(/[\s,/]+/)[0];
|
||||
const rand = Math.max(10, Math.round(fall.dauer * 0.10));
|
||||
const fenster = { min: Math.max(30, fall.dauer - rand), max: fall.dauer + rand };
|
||||
|
||||
const titelPasst = k =>
|
||||
kws.every(muster => new RegExp(muster, 'i').test(k.titel));
|
||||
// buildVariantFilter liefert einen yt-dlp-Ausdruck: mehrere "title !~= (?i)MUSTER", mit & verknuepft.
|
||||
// Hier werden die Muster herausgeloest und als "darf auf KEINES passen" geprueft.
|
||||
const verboten = [...(variant || '').matchAll(/title !~= \(\?i\)([^&]+)/g)]
|
||||
.map(m => m[1].trim()).filter(Boolean);
|
||||
const variantePasst = k => !verboten.some(m => new RegExp(m, 'i').test(k.titel));
|
||||
const kuenstlerPasst = k => {
|
||||
if (ersterK.length < 3 && !JP_RE.test(ersterK)) return true; // kein Merkmal -> keine Huerde
|
||||
const re = new RegExp(ersterK.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
||||
return re.test(k.kanal) || re.test(k.kuenstler);
|
||||
};
|
||||
const imFenster = k => k.dauer >= fenster.min && k.dauer <= fenster.max;
|
||||
const unterKappe = k => k.dauer > 0 && k.dauer < fall.dauer * 2;
|
||||
|
||||
return {
|
||||
fenster,
|
||||
// streng: Dauer + Titel + Variante + Kuenstler
|
||||
streng: k => imFenster(k) && titelPasst(k) && variantePasst(k) && kuenstlerPasst(k),
|
||||
// Dauer behalten, Kuenstler fallen lassen
|
||||
ohneKuenstler: k => imFenster(k) && titelPasst(k) && variantePasst(k),
|
||||
// Kuenstler behalten, Dauer lockern (2x-Kappe bleibt)
|
||||
ohneDauer: k => unterKappe(k) && titelPasst(k) && variantePasst(k) && kuenstlerPasst(k),
|
||||
};
|
||||
}
|
||||
|
||||
const ersterTreffer = (kandidaten, pruefung) => kandidaten.find(pruefung) || null;
|
||||
|
||||
function kaskade(ytm, yt, p, reihenfolge) {
|
||||
const stufen = reihenfolge === 'neu'
|
||||
? [['1/2 streng', p.streng], ['3/4 ohne Dauer, mit Künstler', p.ohneDauer], ['5/6 mit Dauer, ohne Künstler', p.ohneKuenstler]]
|
||||
: [['1/2 streng', p.streng], ['3/4 mit Dauer, ohne Künstler', p.ohneKuenstler], ['5/6 ohne Dauer, mit Künstler', p.ohneDauer]];
|
||||
for (const [stufe, pruefung] of stufen) {
|
||||
// Jede Stufe fragt erst YT Music, dann die gewoehnliche YouTube-Suche — so laeuft die Kaskade.
|
||||
const t = ersterTreffer(ytm, pruefung) || ersterTreffer(yt, pruefung);
|
||||
if (t) return { stufe: `${stufe}${ytm.includes(t) ? ' · YTM' : ' · YT'}`, treffer: t };
|
||||
}
|
||||
return { stufe: 'kein Treffer', treffer: null };
|
||||
}
|
||||
|
||||
const zeig = t => t ? `${String(t.dauer).padStart(4)}s | ${t.kanal || '?'} | ${t.titel}` : '— nichts —';
|
||||
|
||||
(async () => {
|
||||
let abweichungen = 0, fehler = 0;
|
||||
for (const fall of FAELLE) {
|
||||
const query = `${fall.artist} ${fall.titel}`;
|
||||
process.stdout.write(`\n▶ ${fall.name}\n Suche: "${query}" `);
|
||||
const ytm = await sucheYtm(query, 10);
|
||||
const yt = await suche(query, 10);
|
||||
console.log(`(YTM ${ytm.length}, YouTube ${yt.length})`);
|
||||
if (!ytm.length && !yt.length) { console.log(' ⚠️ keine Suchtreffer — übersprungen'); continue; }
|
||||
|
||||
const p = pruefer(fall);
|
||||
console.log(` Dauerfenster ${p.fenster.min}–${p.fenster.max}s`);
|
||||
const alt = kaskade(ytm, yt, p, 'alt');
|
||||
const neu = kaskade(ytm, yt, p, 'neu');
|
||||
console.log(` ALT [${alt.stufe}] ${zeig(alt.treffer)}`);
|
||||
console.log(` NEU [${neu.stufe}] ${zeig(neu.treffer)}`);
|
||||
|
||||
const passt = t => t && (t.kanal + ' ' + t.titel).toLowerCase().includes(fall.erwartetFragment.toLowerCase());
|
||||
const schlecht = t => fall.darfNicht && t && (t.kanal + ' ' + t.titel).toLowerCase().includes(fall.darfNicht.toLowerCase());
|
||||
|
||||
if (!neu.treffer && alt.treffer) { console.log(' ❌ REGRESSION: neu findet nichts, alt schon'); fehler++; }
|
||||
else if (schlecht(neu.treffer)) { console.log(' ❌ neu trifft weiterhin den bekannten Fehlgriff'); fehler++; }
|
||||
else if (!passt(neu.treffer)) { console.log(` ⚠️ neu trifft nicht "${fall.erwartetFragment}"`); fehler++; }
|
||||
else { console.log(' ✅ neu in Ordnung'); }
|
||||
if (zeig(alt.treffer) !== zeig(neu.treffer)) abweichungen++;
|
||||
}
|
||||
console.log(`\n${FAELLE.length} Fälle, ${abweichungen} Unterschied(e) zwischen alt und neu, ${fehler} Problem(e)`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
})();
|
||||
@@ -0,0 +1,117 @@
|
||||
const { test, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const https = require('https');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const { kommendeKonzerte, _cache } = require('../lib/konzerte');
|
||||
|
||||
// ─── Ticketmaster-Antwort einschieben ────────────────────────────────────────
|
||||
// Kein Schluessel, kein Netz: https.get wird ersetzt und liefert eine feste Antwort. Geprueft
|
||||
// wird damit genau das, was uns gehoert — Ortszeit, Gegenprobe des Namens, Fenster, Sortierung.
|
||||
let _antworten = {};
|
||||
let _aufrufe = [];
|
||||
const echtesGet = https.get;
|
||||
|
||||
function stub() {
|
||||
https.get = (url, _opts, cb) => {
|
||||
_aufrufe.push(url);
|
||||
const name = decodeURIComponent((url.match(/keyword=([^&]*)/) || [])[1] || '');
|
||||
const res = new EventEmitter();
|
||||
res.statusCode = 200;
|
||||
process.nextTick(() => {
|
||||
res.emit('data', JSON.stringify(_antworten[name] ?? { _embedded: { events: [] } }));
|
||||
res.emit('end');
|
||||
});
|
||||
cb(res);
|
||||
return new EventEmitter(); // das Request-Objekt, auf dem .on('error') gesetzt wird
|
||||
};
|
||||
}
|
||||
stub();
|
||||
process.on('exit', () => { https.get = echtesGet; });
|
||||
|
||||
const inTagen = n => {
|
||||
const d = new Date(Date.now() + n * 864e5);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
beforeEach(() => { _cache.clear(); _aufrufe = []; _antworten = {}; });
|
||||
|
||||
const ereignis = (name, tage, extra = {}) => ({
|
||||
id: 'e' + tage,
|
||||
name,
|
||||
dates: { start: { localDate: inTagen(tage), localTime: '20:00:00', dateTime: `${inTagen(tage)}T18:00:00Z` }, ...extra.dates },
|
||||
_embedded: { venues: [{ name: 'Halle', city: { name: 'Köln' }, country: { name: 'Germany' } }] },
|
||||
url: 'https://tm.example/' + tage,
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('Ortszeit schlaegt UTC — 20:00 bleibt 20:00', async () => {
|
||||
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 1);
|
||||
assert.ok(konzerte[0].datum.endsWith('T20:00:00'), `unerwartet: ${konzerte[0].datum}`);
|
||||
});
|
||||
|
||||
test('Gegenprobe: fremde Veranstaltung mit demselben Stichwort faellt raus', async () => {
|
||||
_antworten['MORE'] = { _embedded: { events: [
|
||||
ereignis('MORE Tour 2026', 10),
|
||||
ereignis('Sing Something MORE Festival', 20), // enthaelt das Wort → bleibt
|
||||
ereignis('Ganz was anderes', 30), // enthaelt es nicht → faellt raus
|
||||
] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'MORE' }], 'K', 365);
|
||||
assert.deepEqual(konzerte.map(k => k.titel), ['MORE Tour 2026', 'Sing Something MORE Festival']);
|
||||
});
|
||||
|
||||
test('Name im Attraktionsnamen zaehlt auch', async () => {
|
||||
_antworten['K/DA'] = { _embedded: { events: [
|
||||
{ ...ereignis('League Music Night', 15), _embedded: {
|
||||
venues: [{ name: 'Arena', city: { name: 'Berlin' }, country: { name: 'Germany' } }],
|
||||
attractions: [{ name: 'K/DA' }],
|
||||
} },
|
||||
] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'K/DA' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 1, 'ueber die Attraktion erkannt');
|
||||
});
|
||||
|
||||
test('Termine hinter dem Fenster fallen weg', async () => {
|
||||
_antworten['Coldplay'] = { _embedded: { events: [ereignis('Coldplay Live', 20), ereignis('Coldplay Later', 200)] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Coldplay' }], 'K', 30);
|
||||
assert.equal(konzerte.length, 1);
|
||||
assert.equal(konzerte[0].titel, 'Coldplay Live');
|
||||
});
|
||||
|
||||
test('Sortierung laeuft NACH VORN — naechstes Konzert zuerst', async () => {
|
||||
_antworten['A'] = { _embedded: { events: [ereignis('A spaet', 90)] } };
|
||||
_antworten['B'] = { _embedded: { events: [ereignis('B frueh', 5)] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'A' }, { id: '2', name: 'B' }], 'K', 365);
|
||||
assert.deepEqual(konzerte.map(k => k.titel), ['B frueh', 'A spaet']);
|
||||
});
|
||||
|
||||
test('Kuenstler ohne Termine werden gemeldet, nicht verschwiegen', async () => {
|
||||
_antworten['Leer'] = { _embedded: { events: [] } };
|
||||
const { konzerte, ohneTermine } = await kommendeKonzerte([{ id: '1', name: 'Leer' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 0);
|
||||
assert.deepEqual(ohneTermine, ['Leer']);
|
||||
});
|
||||
|
||||
test('Zweiter Aufruf kommt aus dem Cache — kein weiterer Abruf', async () => {
|
||||
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
|
||||
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
|
||||
const nachErstem = _aufrufe.length;
|
||||
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
|
||||
assert.equal(_aufrufe.length, nachErstem, 'zweiter Aufruf darf nicht erneut abrufen');
|
||||
});
|
||||
|
||||
test('Abgesagt wird durchgereicht statt stillschweigend gezeigt', async () => {
|
||||
_antworten['X'] = { _embedded: { events: [
|
||||
{ ...ereignis('X Tour', 40), dates: { start: { localDate: inTagen(40), localTime: '20:00:00' }, status: { code: 'cancelled' } } },
|
||||
] } };
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'X' }], 'K', 365);
|
||||
assert.equal(konzerte[0].abgesagt, true);
|
||||
});
|
||||
|
||||
test('Ein Kuenstlername ohne Buchstaben loest keinen Abruf aus', async () => {
|
||||
const { konzerte } = await kommendeKonzerte([{ id: '1', name: '???' }], 'K', 365);
|
||||
assert.equal(konzerte.length, 0);
|
||||
assert.equal(_aufrufe.length, 0);
|
||||
});
|
||||
@@ -22,6 +22,15 @@ before(() => {
|
||||
'01-01 - Kingdom Hearts 2 - Dearly Beloved - Main Theme.mp3');
|
||||
// Normal "Artist - Title" file
|
||||
touch('Some Artist', 'Therapy - Single', '01 - Some Artist - Therapy.mp3');
|
||||
// Akzent-Schreibweise: liegt MIT Akzent auf der Platte
|
||||
touch('Cécile Corbel', 'SongBook Vol. 2', '03 - Cécile Corbel - Arrietty\u2019s Song.mp3');
|
||||
// Soundtrack: liegt unter dem ALBUM-Kuenstler, nicht unter dem Titel-Kuenstler
|
||||
touch('Arcane', 'Arcane League of Legends (Soundtrack)', '01 - Bea Miller - Playground.mp3');
|
||||
// Zwei verschiedene Kuenstler mit demselben Songtitel — duerfen sich NIE gegenseitig treffen
|
||||
touch('Cro', 'Raop', '03 - Cro - Easy.mp3');
|
||||
touch('Kraftklub', 'Mit K', '05 - Kraftklub - Easy.mp3');
|
||||
// Aehnlicher, aber anderer Kuenstlername
|
||||
touch('Cecil Taylor', 'Unit Structures', '01 - Cecil Taylor - Steps.mp3');
|
||||
});
|
||||
after(() => { try { fs.rmSync(ROOT, { recursive: true, force: true }); } catch {} });
|
||||
|
||||
@@ -48,3 +57,166 @@ test('remix stays distinct from the base track (no regression from the version-m
|
||||
assert.equal(findExistingTrack(ROOT, 'Some Artist', 'Therapy (Remix)'), null);
|
||||
assert.ok(findExistingTrack(ROOT, 'Some Artist', 'Therapy')); // base still found
|
||||
});
|
||||
|
||||
|
||||
// ── Akzente (Cécile Corbel) ────────────────────────────────────────────────────
|
||||
test('findet den Song auch ohne Akzent in der Anfrage', () => {
|
||||
assert.ok(findExistingTrack(ROOT, 'Cecile Corbel', "Arrietty's Song"),
|
||||
'ASCII-Schreibweise muss die Datei mit Akzent finden');
|
||||
});
|
||||
|
||||
test('findet ihn ebenso mit Akzent', () => {
|
||||
assert.ok(findExistingTrack(ROOT, 'Cécile Corbel', "Arrietty's Song"));
|
||||
});
|
||||
|
||||
test('kein Fehlalarm: aehnlicher Name ist nicht dieselbe Person', () => {
|
||||
// "Cecil Taylor" darf nicht auf "Cécile Corbel" passen — und umgekehrt.
|
||||
assert.equal(findExistingTrack(ROOT, 'Cécile Corbel', 'Steps'), null);
|
||||
assert.equal(findExistingTrack(ROOT, 'Cecil Taylor', "Arrietty's Song"), null);
|
||||
});
|
||||
|
||||
test('kein Fehlalarm: Akzent-Normalisierung kollabiert keine echten Titel', () => {
|
||||
assert.equal(findExistingTrack(ROOT, 'Cecile Corbel', 'Ein anderer Song'), null);
|
||||
});
|
||||
|
||||
// ── Gleicher Titel, verschiedene Kuenstler ─────────────────────────────────────
|
||||
test('kein Fehlalarm: gleicher Titel bei zwei Kuenstlern bleibt getrennt', () => {
|
||||
const cro = findExistingTrack(ROOT, 'Cro', 'Easy');
|
||||
const kk = findExistingTrack(ROOT, 'Kraftklub', 'Easy');
|
||||
assert.ok(cro && cro.includes('Cro'), 'Cro-Version muss im Cro-Ordner gefunden werden');
|
||||
assert.ok(kk && kk.includes('Kraftklub'), 'Kraftklub-Version im Kraftklub-Ordner');
|
||||
assert.notEqual(cro, kk, 'die beiden duerfen nicht dieselbe Datei sein');
|
||||
// Ein dritter Kuenstler mit demselben Titel findet nichts.
|
||||
assert.equal(findExistingTrack(ROOT, 'Irgendwer', 'Easy'), null);
|
||||
});
|
||||
|
||||
// ── Soundtrack: Album-Kuenstler vs. Titel-Kuenstler ────────────────────────────
|
||||
test('Soundtrack unter dem Album-Kuenstler wird ueber diesen gefunden', () => {
|
||||
assert.ok(findExistingTrack(ROOT, 'Arcane', 'Playground'));
|
||||
});
|
||||
|
||||
test('unter dem Titel-Kuenstler allein wird er NICHT gefunden (Grund fuer die Zweitpruefung)', () => {
|
||||
// Genau diese Luecke erzeugte die Dublette: geprueft wurde nur "Bea Miller".
|
||||
assert.equal(findExistingTrack(ROOT, 'Bea Miller', 'Playground'), null);
|
||||
});
|
||||
|
||||
test('die Zweitpruefung schliesst die Luecke, ohne Fremdes zu treffen', () => {
|
||||
const pruefe = (albumArtist, trackArtist, titel) =>
|
||||
findExistingTrack(ROOT, albumArtist, titel)
|
||||
|| (trackArtist !== albumArtist ? findExistingTrack(ROOT, trackArtist, titel) : null);
|
||||
// Soundtrack-Fall: wird jetzt erkannt.
|
||||
assert.ok(pruefe('Bea Miller', 'Bea Miller', 'Playground') === null,
|
||||
'ohne Album-Kuenstler weiterhin nichts — richtig, die Datei liegt unter Arcane');
|
||||
assert.ok(pruefe('Arcane', 'Bea Miller', 'Playground'), 'mit Album-Kuenstler gefunden');
|
||||
// Fremder Titel im selben Ordner darf trotz zweier Durchlaeufe nicht anschlagen.
|
||||
assert.equal(pruefe('Arcane', 'Bea Miller', 'Irgendein anderer Titel'), null);
|
||||
// Remix bleibt auch ueber zwei Durchlaeufe hinweg eigenstaendig.
|
||||
assert.equal(pruefe('Arcane', 'Bea Miller', 'Playground (Remix)'), null);
|
||||
});
|
||||
|
||||
// ─── Dateisuche: Titelstamm und Schraegstrich im Namen ───────────────────────
|
||||
// Regression zu "K/DA – VILLAIN": Navidromes Tag-Pfad wurde zu "K/DA/ALL OUT/…", auf der Platte
|
||||
// steht "K_DA/ALL OUT/…". Der Pfad fand nichts, und die Aehnlichkeitssuche lieferte
|
||||
// "Bad Omens - Like A Villain", weil deren Stamm auf "villain" ENDET.
|
||||
const { findFileByStem, albumDirFromTags } = require('../lib/metadata');
|
||||
|
||||
let SLASH_ROOT;
|
||||
before(() => {
|
||||
SLASH_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-slash-'));
|
||||
const put = (...segs) => {
|
||||
const file = segs.pop();
|
||||
const dir = path.join(SLASH_ROOT, ...segs);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), '');
|
||||
};
|
||||
put('K_DA', 'ALL OUT', '03 - K_DA - VILLAIN.mp3');
|
||||
put('K_DA', 'POP_STARS', '01 - K_DA - POP_STARS.mp3');
|
||||
put('Bad Omens', 'THE DEATH OF PEACE OF MIND', '06 - Bad Omens - Like A Villain.mp3');
|
||||
});
|
||||
after(() => { try { fs.rmSync(SLASH_ROOT, { recursive: true, force: true }); } catch {} });
|
||||
|
||||
test('Titelstamm trifft nicht auf blosse Endsilbe', () => {
|
||||
// "Like A Villain" darf NICHT als "VILLAIN" durchgehen
|
||||
const t = findFileByStem(path.join(SLASH_ROOT, 'Bad Omens'), 'villain');
|
||||
assert.equal(t, null);
|
||||
});
|
||||
|
||||
test('Titelstamm trifft "Kuenstler - Titel" weiterhin', () => {
|
||||
const t = findFileByStem(path.join(SLASH_ROOT, 'Bad Omens'), 'like a villain');
|
||||
assert.ok(t && t.endsWith('06 - Bad Omens - Like A Villain.mp3'));
|
||||
});
|
||||
|
||||
test('Ganze Bibliothek: VILLAIN findet K_DA, nicht Bad Omens', () => {
|
||||
const t = findFileByStem(SLASH_ROOT, 'villain');
|
||||
assert.ok(t && t.includes(path.join('K_DA', 'ALL OUT')), `unerwartet: ${t}`);
|
||||
});
|
||||
|
||||
test('Schraegstrich im Kuenstlernamen fuehrt zum richtigen Ordner', () => {
|
||||
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'ALL OUT'), path.join(SLASH_ROOT, 'K_DA', 'ALL OUT'));
|
||||
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'POP/STARS'), path.join(SLASH_ROOT, 'K_DA', 'POP_STARS'));
|
||||
});
|
||||
|
||||
test('Unbekanntes Album faellt auf den Kuenstlerordner zurueck', () => {
|
||||
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'Gibt Es Nicht'), path.join(SLASH_ROOT, 'K_DA'));
|
||||
});
|
||||
|
||||
test('Mehrere Kuenstler: Ordner haengt am ersten', () => {
|
||||
assert.equal(
|
||||
albumDirFromTags(SLASH_ROOT, 'K/DA, Madison Beer, Kim Petras', 'ALL OUT'),
|
||||
path.join(SLASH_ROOT, 'K_DA', 'ALL OUT'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Kein Treffer bleibt null', () => {
|
||||
assert.equal(albumDirFromTags(SLASH_ROOT, 'Niemand', 'Nichts'), null);
|
||||
});
|
||||
|
||||
// ─── Kuenstler im Dateinamen ────────────────────────────────────────────────
|
||||
// Frueher stand die vollstaendige Gaesteliste im Namen und wurde bei 100 Zeichen mitten im
|
||||
// letzten Namen abgeschnitten. Jetzt nur noch der erste Kuenstler.
|
||||
const { dateinamensKuenstler } = require('../lib/metadata');
|
||||
|
||||
test('Nur der erste Kuenstler landet im Dateinamen', () => {
|
||||
assert.equal(
|
||||
dateinamensKuenstler('K/DA;Madison Beer;i-dle;Lexie Liu;Jaira Burns;Seraphine;League of Legends'),
|
||||
'K/DA',
|
||||
);
|
||||
assert.equal(dateinamensKuenstler('Dove Cameron, Sofia Carson'), 'Dove Cameron');
|
||||
assert.equal(dateinamensKuenstler('Calvin Harris & Dua Lipa'), 'Calvin Harris');
|
||||
});
|
||||
|
||||
test('Bandnamen mit & bleiben ganz, wenn sie keine Liste sind', () => {
|
||||
// "&" trennt nur MIT umgebenden Leerzeichen — "Earth, Wind & Fire" waere ohnehin durch das
|
||||
// Komma getrennt, aber ein reiner Bandname ohne Liste darf nicht zerlegt werden.
|
||||
assert.equal(dateinamensKuenstler('AC/DC'), 'AC/DC');
|
||||
assert.equal(dateinamensKuenstler('Simon&Garfunkel'), 'Simon&Garfunkel');
|
||||
});
|
||||
|
||||
test('Leerer Kuenstler faellt auf den Album-Kuenstler zurueck', () => {
|
||||
assert.equal(dateinamensKuenstler('', 'K/DA;Madison Beer'), 'K/DA');
|
||||
assert.equal(dateinamensKuenstler(undefined, undefined), '');
|
||||
});
|
||||
|
||||
test('Der Dateiname bleibt kurz genug fuers Dateisystem', () => {
|
||||
const lang = 'K/DA;Madison Beer;(G)I-DLE;Lexie Liu;Jaira Burns;Seraphine;League of Legends;Bea Miller;Wolftyla;Duckwrth';
|
||||
const teil = dateinamensKuenstler(lang);
|
||||
assert.equal(teil, 'K/DA');
|
||||
assert.ok(teil.length < 100, 'wird nie von sanitize() abgeschnitten');
|
||||
});
|
||||
|
||||
test('Dublettenpruefung findet auch die alt benannten Dateien', () => {
|
||||
// Nach der Umstellung liegen beide Namensformen nebeneinander. Beide muessen als "schon da"
|
||||
// erkannt werden, sonst laedt der Downloader die alten Dateien ein zweites Mal.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-namen-'));
|
||||
const put = (album, file) => {
|
||||
const dir = path.join(root, 'K_DA', album);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), '');
|
||||
};
|
||||
put('MORE', '01 - K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine - MORE.mp3'); // alt
|
||||
put('ALL OUT', '03 - K_DA - VILLAIN.mp3'); // neu
|
||||
assert.ok(findExistingTrack(root, 'K/DA', 'MORE'), 'alte Namensform nicht erkannt');
|
||||
assert.ok(findExistingTrack(root, 'K/DA', 'VILLAIN'), 'neue Namensform nicht erkannt');
|
||||
assert.equal(findExistingTrack(root, 'K/DA', 'POP/STARS'), null);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { buildTitleKeywords } = require('../lib/ytdlp.js');
|
||||
|
||||
// The keywords become `title ~= (?i)<kw>` match-filters in yt-dlp, i.e. every keyword must match
|
||||
// the candidate title case-insensitively. This mirrors that check.
|
||||
const accepts = (title, candidate) =>
|
||||
buildTitleKeywords(title).every(kw => new RegExp(kw, 'i').test(candidate));
|
||||
|
||||
test('a number in the title is always required, however short', () => {
|
||||
const kws = buildTitleKeywords('700 Main Street');
|
||||
assert.ok(kws.some(k => k.includes('700')), `700 fehlt in ${JSON.stringify(kws)}`);
|
||||
});
|
||||
|
||||
test('rejects a same-shaped title with a different number', () => {
|
||||
// The real regression: "777 Main Street" (Release - Topic) was downloaded for DAT ADAM's
|
||||
// "700 Main Street", because only the two longest words (Street, Main) were required.
|
||||
assert.equal(accepts('700 Main Street', '777 Main Street'), false);
|
||||
assert.equal(accepts('700 Main Street', '700 Main Street'), true);
|
||||
});
|
||||
|
||||
test('accepts the abbreviated spelling uploaders use', () => {
|
||||
assert.equal(accepts('700 Main Street', '700 Main st (cr-Version)'), true);
|
||||
assert.equal(accepts('700 Main Street', 'DAT ADAM - 700 Main St [FULL SONG]'), true);
|
||||
});
|
||||
|
||||
test('the abbreviation does not match inside another word', () => {
|
||||
assert.equal(accepts('700 Main Street', '700 Main Stuff'), false);
|
||||
});
|
||||
|
||||
test('titles without numbers keep the previous two-longest-words behaviour', () => {
|
||||
assert.deepEqual(buildTitleKeywords('Bohemian Rhapsody'), ['Bohemian', 'Rhapsody']);
|
||||
assert.equal(accepts('Bohemian Rhapsody', 'Queen - Bohemian Rhapsody (Official Video)'), true);
|
||||
assert.equal(accepts('Bohemian Rhapsody', 'Queen - Another One Bites The Dust'), false);
|
||||
});
|
||||
|
||||
test('a remaster year is not required — YouTube titles never carry it', () => {
|
||||
// "… - 2020 Remaster" steht auf der Veröffentlichung, der Upload heißt schlicht
|
||||
// "Take Me Home, Country Roads". Vor dieser Ausnahme hätte die 2020 ihn abgelehnt.
|
||||
assert.equal(accepts('Take Me Home, Country Roads - 2020 Remaster',
|
||||
'John Denver - Take Me Home, Country Roads'), true);
|
||||
});
|
||||
|
||||
test('a year that IS the title stays required', () => {
|
||||
assert.equal(accepts('1999', 'Prince - 1999 (Official Music Video)'), true);
|
||||
assert.equal(accepts('1999', 'Prince - 1984 (Official Music Video)'), false);
|
||||
});
|
||||
|
||||
const { buildVariantFilter } = require('../lib/ytdlp.js');
|
||||
|
||||
// Der Variantenfilter ist eine yt-dlp-Ausdruckskette "title !~= (?i)\bx\b & …".
|
||||
// Diese Hilfsfunktion wertet sie so aus, wie yt-dlp es tut.
|
||||
const variantAccepts = (searchTitle, candidate) => {
|
||||
const f = buildVariantFilter(searchTitle);
|
||||
if (!f) return true;
|
||||
return f.split(' & ').every(part => {
|
||||
const m = /^title !~= \(\?i\)(.+)$/.exec(part);
|
||||
return !new RegExp(m[1], 'i').test(candidate);
|
||||
});
|
||||
};
|
||||
|
||||
test('rejects a live recording — is_live only covers running streams', () => {
|
||||
// Der echte Fall: auf dem Kuenstlerkanal, Dauer im Fenster, Titel passend.
|
||||
assert.equal(variantAccepts('UFO', 'Dat Adam - Ufo Video Days 2015 Live'), false);
|
||||
assert.equal(variantAccepts('UFO', 'DAT ADAM - 700 Main St | ROCK AM RING 2017 [LIVE]'), false);
|
||||
assert.equal(variantAccepts('UFO', 'UFO'), true);
|
||||
});
|
||||
|
||||
test('a deliberately live title keeps its live results', () => {
|
||||
assert.equal(variantAccepts('Live Forever', 'Oasis - Live Forever'), true);
|
||||
assert.equal(variantAccepts('Unplugged in New York (Live)', 'Nirvana - All Apologies (Live)'), true);
|
||||
});
|
||||
|
||||
test('"live" inside another word does not disable the rule', () => {
|
||||
// "deliver" enthaelt "live" als Teilstring — die Regel muss trotzdem greifen.
|
||||
assert.equal(variantAccepts('Deliver', 'Artist - Deliver (Live at Wembley)'), false);
|
||||
assert.equal(variantAccepts('Deliver', 'Artist - Deliver'), true);
|
||||
});
|
||||
|
||||
const { pickYtmVideoUrls } = require('../lib/ytdlp.js');
|
||||
|
||||
// Echte Ausgabe der YTM-Suche "DAT ADAM UFO": Songs, ein Album (MPREb_) und der
|
||||
// Kuenstlerkanal (UC…) auf Platz 9.
|
||||
const YTM_OUT = [
|
||||
'https://music.youtube.com/watch?v=ZG4XhPn3Yb4',
|
||||
'https://music.youtube.com/watch?v=2KVU6aikZF0',
|
||||
'https://music.youtube.com/browse/MPREb_IhxJPixC1QT',
|
||||
'https://music.youtube.com/watch?v=phLOTtJaqZk',
|
||||
'https://music.youtube.com/watch?v=p935nH6aj8Y',
|
||||
'https://music.youtube.com/watch?v=Pb2YcDBm5fA',
|
||||
'https://music.youtube.com/browse/MPREb_S7MMKVMduma',
|
||||
'https://music.youtube.com/watch?v=mNfsX2RbZ2M',
|
||||
'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA',
|
||||
'https://music.youtube.com/watch?v=O034JUlf1J4',
|
||||
].join('\n');
|
||||
|
||||
test('a channel hit no longer discards the whole result list', () => {
|
||||
const { urls, skipped } = pickYtmVideoUrls(YTM_OUT);
|
||||
assert.equal(urls.length, 7);
|
||||
assert.equal(skipped, 3); // 2 Alben + 1 Kanal
|
||||
assert.match(urls[0], /ZG4XhPn3Yb4/); // der offizielle Art Track
|
||||
assert.ok(!urls.some(u => u.includes('/browse/')));
|
||||
});
|
||||
|
||||
test('nothing but list entries yields no candidates', () => {
|
||||
const only = 'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA';
|
||||
assert.deepEqual(pickYtmVideoUrls(only).urls, []);
|
||||
assert.deepEqual(pickYtmVideoUrls('').urls, []);
|
||||
});
|
||||
|
||||
// ─── Japanische Titel bekommen wieder eine Titelpruefung ────────────────────
|
||||
// Regression zum 24.9.2026: "銀髪の少年" (HUNTER×HUNTER, Yoshihisa Hirano) lieferte ein anderes
|
||||
// Stueck desselben Komponisten. Ursache: fuer japanische Titel gab buildTitleKeywords eine LEERE
|
||||
// Liste zurueck, und die Kaskadenschritte 3/4 lassen die Kuenstlerpruefung fallen — uebrig blieb
|
||||
// allein das Dauerfenster, in das auf einem Soundtrack-Album fast jeder Titel passt.
|
||||
|
||||
test('japanischer Titel liefert jetzt ein Muster statt gar nichts', () => {
|
||||
const kws = buildTitleKeywords('銀髪の少年');
|
||||
assert.equal(kws.length, 1);
|
||||
assert.ok(new RegExp(kws[0], 'i').test('銀髪の少年'), 'Originaltitel muss passen');
|
||||
assert.ok(!new RegExp(kws[0], 'i').test('闘う者たち'), 'anderes Stueck darf NICHT passen');
|
||||
});
|
||||
|
||||
test('Romaji-Fassung wird als Alternative akzeptiert', () => {
|
||||
const kws = buildTitleKeywords('銀髪の少年', 'ginpatsu no shounen');
|
||||
const re = new RegExp(kws[0], 'i');
|
||||
assert.ok(re.test('銀髪の少年'), 'Original');
|
||||
assert.ok(re.test('Ginpatsu no Shounen'), 'Romaji mit Leerzeichen');
|
||||
assert.ok(re.test('Ginpatsu-no-Shounen'), 'Romaji mit Bindestrichen');
|
||||
assert.ok(re.test('Hunter x Hunter OST - Ginpatsu no Shounen'), 'als Teil eines laengeren Titels');
|
||||
assert.ok(!re.test('Requiem Aranea'), 'anderes Stueck desselben Albums');
|
||||
});
|
||||
|
||||
test('Romaji, die gar keine sind, werden nicht als Alternative genommen', () => {
|
||||
// romajiForStorage gibt den Text unveraendert zurueck, wenn der Tokenizer fehlt
|
||||
const kws = buildTitleKeywords('銀髪の少年', '銀髪の少年');
|
||||
assert.equal(kws.length, 1);
|
||||
assert.ok(!kws[0].includes('|'), 'keine doppelte Alternative');
|
||||
});
|
||||
|
||||
test('Sonderzeichen im japanischen Titel bleiben woertlich', () => {
|
||||
const kws = buildTitleKeywords('君の名は。(Movie ver.)');
|
||||
const re = new RegExp(kws[0], 'i');
|
||||
assert.ok(re.test('君の名は。(Movie ver.)'));
|
||||
assert.ok(!re.test('君の名はX Movie verY'), 'Punkt darf nicht als Platzhalter wirken');
|
||||
});
|
||||
|
||||
test('lateinische Titel bleiben unveraendert', () => {
|
||||
assert.deepEqual(buildTitleKeywords('Bohemian Rhapsody'), ['Bohemian', 'Rhapsody']);
|
||||
assert.deepEqual(buildTitleKeywords('Dearly Beloved'), ['Beloved', 'Dearly']);
|
||||
});
|
||||
|
||||
test('Umschrift toleriert die ueblichen Schreibvarianten', () => {
|
||||
const re = new RegExp(buildTitleKeywords('銀髪の少年', 'Ginpatsu No Shounen')[0], 'i');
|
||||
assert.ok(re.test('Ginpatsu no Shounen'), 'wie der Tokenizer schreibt');
|
||||
assert.ok(re.test('Ginpatsu no Shonen'), 'ohne langes o — die haeufigste Variante');
|
||||
assert.ok(re.test('Ginpatsu-no-Shonen'), 'mit Bindestrichen');
|
||||
assert.ok(re.test('GINPATSU NO SHŌNEN'), 'mit Makron');
|
||||
assert.ok(!re.test('Ginpatsu no Otona'), 'anderer Titel faellt weiterhin durch');
|
||||
});
|
||||
|
||||
test('Katakana-Laengungsstrich faellt aus der Umschrift', () => {
|
||||
// romajiForStorage laesst "ー" stehen ("TeーZe"); in einem lateinischen Titel steht es nie.
|
||||
const re = new RegExp(buildTitleKeywords('残酷な天使のテーゼ', 'Zankoku Na Tenshi No TeーZe')[0], 'i');
|
||||
assert.ok(re.test('Zankoku na Tenshi no Teze'), 'ohne Strich geschrieben');
|
||||
assert.ok(re.test('残酷な天使のテーゼ'), 'Originaltitel weiterhin');
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
# NovaSonic — DEV/Selbstbauen: baut lokal aus den Dockerfiles statt Images zu ziehen. Die schweren
|
||||
# Deps (ffmpeg, Deno, yt-dlp, node libs) stecken in den Images, also sind RESTARTS sofort da und ein
|
||||
# Rebuild nach Code-Änderungen ist schnell (Docker cacht die Dep-Layer — nur der COPY-Layer ändert sich).
|
||||
#
|
||||
# git pull
|
||||
# docker compose -f docker-compose.dev.yml up -d --build # geänderte Images neu bauen + starten
|
||||
# docker compose -f docker-compose.dev.yml up -d # nur starten, mit den gebauten Images
|
||||
#
|
||||
# Keine Registry nötig. Der Normalfall (fertige Images ziehen) ist docker-compose.yml.
|
||||
services:
|
||||
|
||||
# ── Frontend (nginx serving the built PWA) ───────────────────────────────────
|
||||
novasonic-frontend:
|
||||
build: .
|
||||
container_name: novasonic-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80" # route this port through your reverse proxy
|
||||
environment:
|
||||
- NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen
|
||||
- BACKEND_URL=${BACKEND_URL}
|
||||
- SCROBBLE_URL=${SCROBBLE_URL}
|
||||
|
||||
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
|
||||
novasonic-backend:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: novasonic-backend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ${MUSIC_PATH}:${MUSIC_PATH} # exact path from .env, no remapping
|
||||
- novasonic-data:/data # writable data dir for overrides, state, etc.
|
||||
ports:
|
||||
- "3700:3700"
|
||||
environment:
|
||||
- PORT=3700
|
||||
- MUSIC_PATH=${MUSIC_PATH}
|
||||
- DATA_PATH=/data
|
||||
- AUDIO_FMT=${AUDIO_FMT:-mp3}
|
||||
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
|
||||
- MAX_PARALLEL=${MAX_PARALLEL:-2}
|
||||
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
|
||||
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
|
||||
# Android APK download button — backend proxies the fixed Gitea release ("apk-latest").
|
||||
- GITEA_URL=${GITEA_URL:-}
|
||||
- GITEA_REPO=${GITEA_REPO:-}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:-}
|
||||
- GITEA_APK_TAG=${GITEA_APK_TAG:-apk-latest}
|
||||
|
||||
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ────────
|
||||
novasonic-scrobble:
|
||||
build:
|
||||
context: ./scrobble
|
||||
container_name: novasonic-scrobble
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- novasonic-scrobble-data:/data # own DB volume, isolated from downloads
|
||||
ports:
|
||||
- "3710:3710"
|
||||
environment:
|
||||
- PORT=3710
|
||||
- DATA_PATH=/data
|
||||
- NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification
|
||||
|
||||
volumes:
|
||||
novasonic-data:
|
||||
novasonic-scrobble-data:
|
||||
+24
-35
@@ -1,75 +1,64 @@
|
||||
# NovaSonic — Standard/Produktiv: zieht die fertigen Images aus der Registry, baut nichts.
|
||||
# Auf dem Server nur .env danebenlegen (siehe .env.example) und:
|
||||
#
|
||||
# docker compose pull
|
||||
# docker compose up -d
|
||||
#
|
||||
# Die Images kommen von der Dev-Maschine: `npm run push-images` (baut die drei Dockerfiles und
|
||||
# pusht nach ${REGISTRY}). Selbst bauen statt ziehen: docker-compose.dev.yml.
|
||||
services:
|
||||
|
||||
# ── Frontend (nginx) ──────────────────────────────────────────────────────────
|
||||
# ── Frontend (nginx serving the built PWA) ───────────────────────────────────
|
||||
novasonic-frontend:
|
||||
build: .
|
||||
image: ${REGISTRY}/novasonic-frontend:${TAG:-latest}
|
||||
container_name: novasonic-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80" # route this port through your reverse proxy
|
||||
environment:
|
||||
- NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen
|
||||
- BACKEND_URL=${BACKEND_URL} # pre-filled on the setup screen
|
||||
- SCROBBLE_URL=${SCROBBLE_URL} # play-log / stats service (setup screen)
|
||||
- BACKEND_URL=${BACKEND_URL}
|
||||
- SCROBBLE_URL=${SCROBBLE_URL}
|
||||
|
||||
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
|
||||
novasonic-backend:
|
||||
image: node:22-slim
|
||||
image: ${REGISTRY}/novasonic-backend:${TAG:-latest}
|
||||
container_name: novasonic-backend
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./backend:/app:ro # mount entire backend dir (server + lib/)
|
||||
- ${MUSIC_PATH}:${MUSIC_PATH} # exact path from .env, no remapping
|
||||
- novasonic-data:/data # writable data dir for overrides, state, etc.
|
||||
ports:
|
||||
- "3700:3700" # route this port through your reverse proxy
|
||||
- "3700:3700"
|
||||
environment:
|
||||
- PORT=3700
|
||||
- MUSIC_PATH=${MUSIC_PATH} # same path inside the container
|
||||
- MUSIC_PATH=${MUSIC_PATH}
|
||||
- DATA_PATH=/data
|
||||
- AUDIO_FMT=${AUDIO_FMT:-mp3}
|
||||
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
|
||||
- MAX_PARALLEL=${MAX_PARALLEL:-2}
|
||||
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
|
||||
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
|
||||
# Android APK download button — backend proxies a fixed Gitea release ("apk-latest"), same LAN.
|
||||
# Optional: leave unset to use the local /data/novasonic.apk fallback instead.
|
||||
- GITEA_URL=${GITEA_URL:-} # internal Gitea base, e.g. http://gitea:3000
|
||||
- GITEA_REPO=${GITEA_REPO:-} # owner/repo, e.g. Docker/NovaSonic
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:-} # read token (only for a private repo)
|
||||
# Android APK download button — backend proxies the fixed Gitea release ("apk-latest").
|
||||
- GITEA_URL=${GITEA_URL:-}
|
||||
- GITEA_REPO=${GITEA_REPO:-}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN:-}
|
||||
- GITEA_APK_TAG=${GITEA_APK_TAG:-apk-latest}
|
||||
command: >
|
||||
sh -c "
|
||||
apt-get update && apt-get install -y --no-install-recommends python3 python3-pip ffmpeg curl git unzip && rm -rf /var/lib/apt/lists/* &&
|
||||
pip install --break-system-packages -U --pre 'yt-dlp[default]' &&
|
||||
curl -fsSL https://deno.land/install.sh | sh &&
|
||||
pip install --break-system-packages spotapi 'git+https://github.com/TzurSoffer/spotify-downloader' &&
|
||||
mkdir -p /deps && cd /deps && npm install express kuromoji &&
|
||||
cd /app && PATH=/root/.deno/bin:$$PATH NODE_PATH=/deps/node_modules node novasonic-server.js
|
||||
"
|
||||
|
||||
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ────────
|
||||
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ───────
|
||||
novasonic-scrobble:
|
||||
image: node:22-slim
|
||||
image: ${REGISTRY}/novasonic-scrobble:${TAG:-latest}
|
||||
container_name: novasonic-scrobble
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./scrobble:/app:ro # server code (read-only)
|
||||
- novasonic-scrobble-data:/data # own DB volume, isolated from downloads
|
||||
ports:
|
||||
- "3710:3710" # route this port through your reverse proxy
|
||||
- "3710:3710"
|
||||
environment:
|
||||
- PORT=3710
|
||||
- DATA_PATH=/data
|
||||
- NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification
|
||||
command: >
|
||||
sh -c "
|
||||
mkdir -p /deps && cd /deps && npm install express &&
|
||||
cd /app && NODE_PATH=/deps/node_modules node --experimental-sqlite scrobble-server.js
|
||||
"
|
||||
|
||||
volumes:
|
||||
novasonic-data: # persistent storage for romaji overrides and app state
|
||||
novasonic-scrobble-data: # persistent play-log DB (novasonic-plays.db)
|
||||
novasonic-data:
|
||||
novasonic-scrobble-data:
|
||||
@@ -0,0 +1,114 @@
|
||||
# Teilen per Link
|
||||
|
||||
Ein geteilter Link sieht so aus:
|
||||
|
||||
```
|
||||
https://<deine-web-adresse>/?song=<navidrome-song-id>
|
||||
https://<deine-web-adresse>/?album=<navidrome-album-id>
|
||||
```
|
||||
|
||||
Er trägt **nur die Id**, keine Zugangsdaten. Der Empfänger sieht den Inhalt mit seinem eigenen
|
||||
Navidrome-Konto auf derselben Instanz — wer dort keinen Zugang hat, sieht nichts.
|
||||
|
||||
## Web
|
||||
|
||||
Funktioniert ohne weitere Einrichtung. Der Link wird beim Start ausgelesen (`?song=` / `?album=`),
|
||||
das Album geöffnet und der Song abgespielt; danach verschwindet der Parameter aus der Adresszeile,
|
||||
damit ein Neuladen nicht erneut startet. Als installierte PWA landet der Link dank
|
||||
`launch_handler: navigate-existing` im bereits offenen Fenster statt in einem neuen.
|
||||
|
||||
## Android
|
||||
|
||||
Zwei Dinge sind nötig, damit ein Link **direkt in der App** landet statt im Browser:
|
||||
|
||||
**1. Web-Adresse in der App hinterlegen** — Einstellungen → „NovaSonic Web-Adresse". Ohne sie
|
||||
erscheint der Teilen-Eintrag im Kontextmenü gar nicht erst, weil der Link sonst ins Leere zeigt.
|
||||
|
||||
**2. Den Host beim Bauen mitgeben** und die Signatur hinterlegen:
|
||||
|
||||
```bash
|
||||
./gradlew assembleRelease -PnovasonicHost=music.example.com
|
||||
```
|
||||
|
||||
Dann den SHA-256 des **Release**-Signaturschlüssels ermitteln …
|
||||
|
||||
```bash
|
||||
keytool -list -v -keystore <dein-keystore>.jks -alias <alias> | grep SHA256
|
||||
```
|
||||
|
||||
… und in `public/.well-known/assetlinks.json` anstelle von `PLATZHALTER_RELEASE_SHA256` eintragen.
|
||||
Die Datei muss unter `https://<deine-web-adresse>/.well-known/assetlinks.json` erreichbar sein —
|
||||
sie liegt im `public/`-Verzeichnis und wird vom Frontend-Image mit ausgeliefert.
|
||||
|
||||
## APK veröffentlichen
|
||||
|
||||
`npm run publish-apk` gibt den Host jetzt an Gradle weiter. Dafür in `scripts/.env.release`
|
||||
ergänzen (die Datei wird nie eingecheckt):
|
||||
|
||||
```
|
||||
NOVASONIC_HOST=music.deine-domain.de
|
||||
# optional während eines Domainwechsels:
|
||||
# NOVASONIC_HOST2=alt.deine-domain.de
|
||||
```
|
||||
|
||||
Fehlt der Eintrag, warnt das Skript und baut mit dem Platzhalter weiter — das APK ist dann
|
||||
installierbar, aber eingehende Links öffnen im Browser statt in der App.
|
||||
|
||||
## Domain wechseln
|
||||
|
||||
**Ausgehende Links** folgen der Einstellung „NovaSonic Web-Adresse" — Adresse ändern, fertig, kein
|
||||
neues APK nötig.
|
||||
|
||||
**Eingehende Links** stehen fest im Manifest und brauchen ein neues APK. Damit während des Umzugs
|
||||
beide Adressen funktionieren, lassen sich zwei Hosts angeben:
|
||||
|
||||
```bash
|
||||
./gradlew assembleRelease -PnovasonicHost=neu.example.com -PnovasonicHost2=alt.example.com
|
||||
```
|
||||
|
||||
Beide müssen eine gültige `assetlinks.json` ausliefern. Das ist keine Feinheit: **auf Android 11 und
|
||||
älter verifiziert das System nur, wenn ALLE angegebenen Hosts eine passende Datei liefern** — ein
|
||||
toter Host blockiert also auch den funktionierenden. Sobald die alte Domain abgeschaltet wird, das
|
||||
APK deshalb ohne `novasonicHost2` neu bauen.
|
||||
|
||||
### Ablauf eines Umzugs
|
||||
|
||||
1. **Neue Domain aufsetzen**, Web-App und `/.well-known/assetlinks.json` dort erreichbar machen.
|
||||
Die alte Domain bleibt vorerst unverändert online.
|
||||
2. **APK mit beiden Hosts bauen** (`-PnovasonicHost=neu -PnovasonicHost2=alt`) und installieren.
|
||||
Ab jetzt öffnen Links beider Adressen in der App.
|
||||
3. **In der App die „NovaSonic Web-Adresse" auf die neue Domain umstellen.** Neu geteilte Links
|
||||
zeigen ab sofort dorthin, alte funktionieren weiter.
|
||||
4. **Warten**, bis auf allen Geräten das neue APK läuft.
|
||||
5. **Alte Domain abschalten** — und danach das APK ohne `novasonicHost2` neu bauen.
|
||||
|
||||
Schritt 5 nicht vergessen: Android verifiziert die Domains bei jeder Installation neu, und auf
|
||||
Android 11 und älter genügt ein einziger nicht erreichbarer Host, damit die Verifikation komplett
|
||||
fehlschlägt. Bereits verifizierte Installationen laufen zwar weiter, aber die nächste Neuinstallation
|
||||
hätte gar keine funktionierenden App Links mehr.
|
||||
|
||||
Zwei weitere Fallen:
|
||||
|
||||
- **Signaturschlüssel muss derselbe bleiben.** Ein anders signiertes APK lässt sich nur nach
|
||||
Deinstallation installieren — und die nimmt die Offline-Downloads mit.
|
||||
- **Alte Links nach der Abschaltung** öffnen nur dann noch etwas, wenn die alte Domain auf die neue
|
||||
weiterleitet. Dann landen sie allerdings im Browser statt in der App, weil Android eine
|
||||
Weiterleitung nicht erneut an die App übergibt.
|
||||
|
||||
Wildcards (`*.example.com`) sind für App Links nicht dokumentiert und werden hier bewusst nicht
|
||||
verwendet — die Verifikation läuft pro Host.
|
||||
|
||||
Bereits verschickte Links auf die alte Domain öffnen nach deren Abschaltung gar nichts mehr; das
|
||||
lässt sich nur mit einer Weiterleitung von alt nach neu abfangen.
|
||||
|
||||
Prüfen lässt sich das Ergebnis mit:
|
||||
|
||||
```bash
|
||||
adb shell pm get-app-links com.novasonic.app
|
||||
```
|
||||
|
||||
Steht dort `verified`, öffnet Android die Links ohne Nachfrage.
|
||||
|
||||
**Ohne diesen Schritt** ist der Link trotzdem nicht verloren: er öffnet im Browser die Web-App.
|
||||
Wer die App bevorzugt, kann sie in den Systemeinstellungen unter *Standardmäßig öffnen* → *Links*
|
||||
manuell für die Domain zulassen.
|
||||
+90
-38
@@ -98,32 +98,33 @@
|
||||
<div class="mobile-overlay" id="mobile-overlay" onclick="closeSidebar()"></div>
|
||||
|
||||
<!-- MAIN -->
|
||||
<main class="main" id="main-content">
|
||||
|
||||
<!-- Mobile header -->
|
||||
<div id="mobile-header" style="position:sticky;top:0;background:var(--bg);z-index:10;border-bottom:1px solid var(--border)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:10px 12px">
|
||||
<div class="header-search-wrap">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<input id="header-search-input" type="text" placeholder="Suchen…" oninput="onHeaderSearch(this.value)" autocomplete="off">
|
||||
<button id="header-search-clear" onclick="closeHeaderSearch()" style="display:none;background:none;border:none;color:var(--text3);cursor:pointer;font-size:15px;padding:0;line-height:1;flex-shrink:0">✕</button>
|
||||
<!-- Kopfzeile liegt AUSSERHALB des Rollbereichs: sonst laeuft der Rollbalken bis ganz
|
||||
nach oben neben die Suchleiste, obwohl dort nichts rollt. -->
|
||||
<div class="main-wrap">
|
||||
<div id="mobile-header" style="position:sticky;top:0;background:var(--bg);z-index:10;border-bottom:1px solid var(--border)">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:10px 12px">
|
||||
<div class="header-search-wrap">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<input id="header-search-input" type="text" placeholder="Suchen…" oninput="onHeaderSearch(this.value)" autocomplete="off">
|
||||
<button id="header-search-clear" onclick="closeHeaderSearch()" style="display:none;background:none;border:none;color:var(--text3);cursor:pointer;font-size:15px;padding:0;line-height:1;flex-shrink:0">✕</button>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:2px;flex-shrink:0">
|
||||
<button class="hbtn notif-bell-btn" onclick="toggleNotifPanel()" title="Benachrichtigungen" style="padding:8px;color:var(--text2)">
|
||||
<!-- Honigtopf mit Brief statt Glocke --><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"><g transform="translate(-4.56 -4.54) scale(1.38)"><rect x="3.6" y="4.6" width="16.8" height="3" rx="1.1"/><path d="M5.5 7.6c-1.1 3.9-.7 8.2.7 10.4a2 2 0 0 0 1.7.9h8.2a2 2 0 0 0 1.7-.9c1.4-2.2 1.8-6.5.7-10.4"/><rect x="8.3" y="11.7" width="7.4" height="4.6" rx="0.7" stroke-width="1.25"/><path d="M8.6 12.1 12 14.4l3.4-2.3" stroke-width="1.25"/></g></svg>
|
||||
<span class="notif-badge" style="display:none">0</span>
|
||||
</button>
|
||||
<button class="hbtn" onclick="openSettings()" title="Einstellungen" style="padding:8px;color:var(--text2)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:2px;flex-shrink:0">
|
||||
<button class="hbtn notif-bell-btn" onclick="toggleNotifPanel()" title="Benachrichtigungen" style="padding:8px;color:var(--text2)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notif-badge" style="display:none">0</span>
|
||||
</button>
|
||||
<button class="hbtn" onclick="openSettings()" title="Einstellungen" style="padding:8px;color:var(--text2)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
<!-- Results panel: spans the full main-content width (anchored to the header, which already
|
||||
sits right of the sidebar) so it never covers the sidebar and leaves no dead scroll strip -->
|
||||
<div id="header-search-overlay" style="display:none">
|
||||
<div id="header-search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Results panel: spans the full main-content width (anchored to the header, which already
|
||||
sits right of the sidebar) so it never covers the sidebar and leaves no dead scroll strip -->
|
||||
<div id="header-search-overlay" style="display:none">
|
||||
<div id="header-search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
<main class="main" id="main-content">
|
||||
|
||||
<!-- HOME -->
|
||||
<div class="page active" id="page-home">
|
||||
@@ -144,6 +145,14 @@
|
||||
<!-- ARTISTS -->
|
||||
<div class="page" id="page-artists">
|
||||
<div class="page-header"><div class="page-title">Künstler</div></div>
|
||||
<div id="artists-filter" class="sort-chips">
|
||||
<button class="sort-chip active" data-filter="all" onclick="loadArtists('all')">Alle</button>
|
||||
<button class="sort-chip" data-filter="followed" onclick="loadArtists('followed')">❤ Gefolgt <span id="artists-followed-count"></span></button>
|
||||
</div>
|
||||
<div class="list-search-wrap">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="artists-search" class="list-search" type="text" placeholder="Künstler suchen…" oninput="filterArtists(this.value)">
|
||||
</div>
|
||||
<div id="artists-grid" class="grid grid-4"></div>
|
||||
</div>
|
||||
|
||||
@@ -228,6 +237,7 @@
|
||||
<div class="album-actions">
|
||||
<button class="btn-play-all" id="btn-play-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Abspielen</button>
|
||||
<button class="btn-outline" id="btn-dl-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Download</button>
|
||||
<button class="btn-outline" id="btn-star-album" title="Album markieren"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg><span>Merken</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +263,14 @@
|
||||
|
||||
<!-- STARRED -->
|
||||
<div class="page" id="page-starred">
|
||||
<div class="page-header"><div class="page-title">Favoriten ⭐</div></div>
|
||||
<div class="page-header">
|
||||
<div class="page-title">Favoriten ⭐</div>
|
||||
<!-- Einstieg in die Zeitleiste steht dort, wo auch die gefolgten Kuenstler stehen —
|
||||
gleiche Stelle wie in Android. Erscheint nur, wenn man ueberhaupt jemandem folgt. -->
|
||||
<button id="starred-news-btn" class="btn-outline" style="display:none;font-size:12px;padding:8px 14px" onclick="navigate('artist-news')">
|
||||
✨ Neu
|
||||
</button>
|
||||
</div>
|
||||
<div id="starred-sort-chips" class="sort-chips">
|
||||
<button class="sort-chip active" data-sort="newest" onclick="loadStarred('newest')">Neueste</button>
|
||||
<button class="sort-chip" data-sort="oldest" onclick="loadStarred('oldest')">Älteste</button>
|
||||
@@ -265,6 +282,9 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="starred-search" class="list-search" type="text" placeholder="In Favoriten suchen…" oninput="filterStarred(this.value)">
|
||||
</div>
|
||||
<div id="starred-artists-section"></div>
|
||||
<div id="starred-albums-section"></div>
|
||||
<div id="starred-songs-title" class="search-section-title" style="display:none;margin:18px 0 8px">Songs</div>
|
||||
<div id="starred-songs" class="song-list"></div>
|
||||
</div>
|
||||
|
||||
@@ -402,6 +422,26 @@
|
||||
</div>
|
||||
|
||||
<!-- NEW RELEASES -->
|
||||
<div class="page" id="page-artist-news">
|
||||
<button class="back-btn" id="artist-news-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Favoriten</button>
|
||||
<div class="page-header">
|
||||
<div class="page-title">Neu von <em>deinen Künstlern</em></div>
|
||||
</div>
|
||||
<!-- Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind
|
||||
dasselbe Thema. Nicht in EINE Liste gemischt — Veroeffentlichungen liegen hinter,
|
||||
Konzerte vor dem heutigen Tag, chronologisch gemischt waere das nicht zu lesen. -->
|
||||
<div id="artist-news-tabs" class="sort-chips" style="margin-bottom:6px">
|
||||
<button class="sort-chip active" data-tab="releases" onclick="showArtistNewsTab('releases')">Neu erschienen</button>
|
||||
<button class="sort-chip" data-tab="konzerte" onclick="showArtistNewsTab('konzerte')">Konzerte</button>
|
||||
</div>
|
||||
<div id="artist-news-sort" class="sort-chips">
|
||||
<button class="sort-chip active" data-days="90" onclick="setArtistNewsDays(90)">90 Tage</button>
|
||||
<button class="sort-chip" data-days="30" onclick="setArtistNewsDays(30)">30 Tage</button>
|
||||
<button class="sort-chip" data-days="365" onclick="setArtistNewsDays(365)">1 Jahr</button>
|
||||
</div>
|
||||
<div id="artist-news-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="page-new-releases">
|
||||
<div class="page-header">
|
||||
<div class="page-title">Neue <em>Releases</em></div>
|
||||
@@ -446,12 +486,18 @@
|
||||
</div>
|
||||
|
||||
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QUEUE PANEL -->
|
||||
<div class="queue-panel" id="queue-panel">
|
||||
<div class="queue-title">Warteschlange</div>
|
||||
<div class="queue-head">
|
||||
<div class="queue-title">Warteschlange</div>
|
||||
<button class="queue-close" onclick="toggleQueue()" title="Warteschlange schließen" aria-label="Schließen">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="queue-list" id="queue-list"></div>
|
||||
</div>
|
||||
|
||||
@@ -605,18 +651,6 @@
|
||||
<div id="s-cover-color-bg" class="toggle" onclick="this.classList.toggle('on')"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section" id="s-apk-section" style="display:none">
|
||||
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">🤖</span>
|
||||
Android App
|
||||
</h3>
|
||||
<div style="font-size:12px;color:var(--text2);margin-bottom:10px;line-height:1.6">Installiere die NovaSonic Android-App direkt aufs Handy. <span id="s-apk-meta" style="color:var(--text3)"></span></div>
|
||||
<a id="s-apk-btn" href="#" download style="display:inline-flex;align-items:center;gap:8px;padding:10px 16px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff;font-weight:700;font-size:13px;text-decoration:none;box-shadow:0 4px 12px var(--accent-glow)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
APK herunterladen
|
||||
</a>
|
||||
<div style="font-size:11px;color:var(--text3);margin-top:8px">Danach im Handy „Installation aus unbekannten Quellen" für den Browser erlauben.</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">📊</span>
|
||||
@@ -709,6 +743,18 @@
|
||||
<div class="field"><label>Benutzername</label><input id="s-user" type="text" placeholder="admin"></div>
|
||||
<div class="field"><label>Passwort</label><input id="s-pass" type="password" placeholder="(unverändert lassen)"></div>
|
||||
</div>
|
||||
<div class="settings-section" id="s-apk-section" style="display:none">
|
||||
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">🤖</span>
|
||||
Android App
|
||||
</h3>
|
||||
<div style="font-size:12px;color:var(--text2);margin-bottom:10px;line-height:1.6">Installiere die NovaSonic Android-App direkt aufs Handy. <span id="s-apk-meta" style="color:var(--text3)"></span></div>
|
||||
<a id="s-apk-btn" href="#" download style="display:inline-flex;align-items:center;gap:8px;padding:10px 16px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff;font-weight:700;font-size:13px;text-decoration:none;box-shadow:0 4px 12px var(--accent-glow)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
APK herunterladen
|
||||
</a>
|
||||
<div style="font-size:11px;color:var(--text3);margin-top:8px">Danach im Handy „Installation aus unbekannten Quellen" für den Browser erlauben.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sticky footer -->
|
||||
<div style="display:flex;flex-direction:column;gap:8px;padding:12px 24px 16px;border-top:1px solid var(--border);flex-shrink:0;background:var(--surface);border-radius:0 0 18px 18px">
|
||||
@@ -1132,6 +1178,12 @@
|
||||
<div id="np-lyrics-view">
|
||||
<div id="np-lyrics-content"></div>
|
||||
</div>
|
||||
<!-- Mitlaufen an/aus. Nur sichtbar, wenn es synchrone Zeilen gibt — bei reinem Text
|
||||
gibt es nichts mitzuführen. -->
|
||||
<button class="np-follow" id="np-follow-btn" onclick="toggleLyricsFolgen()" title="Automatisch mitlaufen">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="12 5 12 19"/><polyline points="6 13 12 19 18 13"/></svg>
|
||||
<span id="np-follow-text">Mitlaufen</span>
|
||||
</button>
|
||||
|
||||
<div id="np-queue-view">
|
||||
<div id="np-queue-list"></div>
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"icons": "node scripts/gen-icons.mjs",
|
||||
"publish-apk": "node scripts/publish-apk.mjs"
|
||||
"publish-apk": "node scripts/publish-apk.mjs",
|
||||
"push-images": "node scripts/build-push-images.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.novasonic.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"10:39:A0:0E:D3:93:D3:93:CB:50:5D:C5:0D:73:97:2B:F7:40:7C:F8:08:CC:DB:EE:67:D0:E3:CD:64:AB:01:CF"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -27,5 +27,9 @@
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"launch_handler": {
|
||||
"client_mode": "navigate-existing"
|
||||
},
|
||||
"handle_links": "preferred"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
// Build the three NovaSonic images and push them to your registry, so the production host just PULLS
|
||||
// (docker-compose.yml) instead of building (that's docker-compose.dev.yml). Run from your dev machine:
|
||||
//
|
||||
// npm run push-images # builds + pushes REGISTRY/*:TAG (and :latest)
|
||||
//
|
||||
// Config (env or a gitignored scripts/.env.release / repo .env):
|
||||
// REGISTRY e.g. gitea.honeyy.duckdns.org/docker (Gitea uses a LOWERCASE owner)
|
||||
// TAG image tag (default: latest)
|
||||
// You must `docker login <registry-host>` once first. Plain-HTTP internal registries also need the
|
||||
// daemon's insecure-registries list (see .env.example).
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
// Load REGISTRY/TAG from a local env file if present (repo .env, then scripts/.env.release).
|
||||
for (const f of [join(root, '.env'), join(root, 'scripts', '.env.release')]) {
|
||||
if (!existsSync(f)) continue;
|
||||
for (const line of readFileSync(f, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i);
|
||||
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
const REGISTRY = (process.env.REGISTRY || '').replace(/\/$/, '');
|
||||
const TAG = process.env.TAG || 'latest';
|
||||
if (!REGISTRY) { console.error('\n✖ REGISTRY muss gesetzt sein (env oder .env). Siehe .env.example.'); process.exit(1); }
|
||||
|
||||
// name → build context (relative to repo root). Frontend uses the root Dockerfile.
|
||||
const images = {
|
||||
'novasonic-frontend': '.',
|
||||
'novasonic-backend': 'backend',
|
||||
'novasonic-scrobble': 'scrobble',
|
||||
};
|
||||
|
||||
function run(cmd, args) {
|
||||
console.log(`\n$ ${cmd} ${args.join(' ')}`);
|
||||
const r = spawnSync(cmd, args, { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' });
|
||||
if (r.status !== 0) { console.error(`\n✖ Fehlgeschlagen: ${cmd} ${args.join(' ')}`); process.exit(1); }
|
||||
}
|
||||
|
||||
for (const [name, ctx] of Object.entries(images)) {
|
||||
const ref = `${REGISTRY}/${name}:${TAG}`;
|
||||
const latest = `${REGISTRY}/${name}:latest`;
|
||||
console.log(`\n=== ${name} → ${ref} ===`);
|
||||
run('docker', ['build', '-t', ref, '-t', latest, ctx]);
|
||||
run('docker', ['push', ref]);
|
||||
if (latest !== ref) run('docker', ['push', latest]);
|
||||
}
|
||||
|
||||
console.log(`\n✔ Alle Images gebaut + gepusht (${REGISTRY}/*:${TAG}). Auf dem Server:`);
|
||||
console.log(' docker compose pull && docker compose up -d');
|
||||
+23
-1
@@ -37,6 +37,17 @@ const GITEA_REPO = process.env.GITEA_REPO || '';
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
|
||||
const TAG = process.env.GITEA_APK_TAG || 'apk-latest';
|
||||
const TASK = process.env.APK_GRADLE_TASK || 'assembleDebug';
|
||||
// Host fuer die App-Links geteilter Songs (https://<host>/?song=…). Steht fest im Manifest, muss
|
||||
// also beim BAUEN mitgegeben werden — ohne ihn traegt das APK den Platzhalter aus build.gradle.kts
|
||||
// und eingehende Links landen im Browser statt in der App. NOVASONIC_HOST2 optional fuer einen
|
||||
// Domainwechsel (beide Adressen oeffnen dann in der App), siehe docs/share-links.md.
|
||||
const HOST = process.env.NOVASONIC_HOST || '';
|
||||
const HOST2 = process.env.NOVASONIC_HOST2 || '';
|
||||
// Vorgabe-Adressen, die ins APK gebacken werden, damit auf einem frischen Geraet nur noch Benutzer
|
||||
// und Passwort einzutippen sind. Zugangsdaten kommen hier bewusst NICHT vor.
|
||||
const ND_URL = process.env.NOVASONIC_NAVIDROME || '';
|
||||
const BACKEND_URL = process.env.NOVASONIC_BACKEND || '';
|
||||
const SCROBBLE_URL= process.env.NOVASONIC_SCROBBLE || '';
|
||||
|
||||
function die(msg) { console.error(`\n✖ ${msg}`); process.exit(1); }
|
||||
if (!GITEA_URL || !GITEA_REPO || !GITEA_TOKEN)
|
||||
@@ -68,7 +79,18 @@ const relBody = `versionCode=${versionCode}\nversionName=${version}`;
|
||||
const isWin = process.platform === 'win32';
|
||||
const gradlew = join(root, 'android', isWin ? 'gradlew.bat' : 'gradlew');
|
||||
console.log(`▶ Baue APK: ${TASK} …`);
|
||||
const build = spawnSync(gradlew, [`:app:${TASK}`], {
|
||||
if (!HOST) {
|
||||
console.warn('⚠ NOVASONIC_HOST ist nicht gesetzt — das APK bekommt den Platzhalter-Host aus');
|
||||
console.warn(' build.gradle.kts, geteilte Links oeffnen dann NICHT in der App.');
|
||||
console.warn(' In scripts/.env.release eintragen: NOVASONIC_HOST=music.deine-domain.de');
|
||||
}
|
||||
const gradleArgs = [`:app:${TASK}`];
|
||||
if (HOST) gradleArgs.push(`-PnovasonicHost=${HOST}`);
|
||||
if (HOST2) gradleArgs.push(`-PnovasonicHost2=${HOST2}`);
|
||||
if (ND_URL) gradleArgs.push(`-PnovasonicNavidrome=${ND_URL}`);
|
||||
if (BACKEND_URL) gradleArgs.push(`-PnovasonicBackend=${BACKEND_URL}`);
|
||||
if (SCROBBLE_URL) gradleArgs.push(`-PnovasonicScrobble=${SCROBBLE_URL}`);
|
||||
const build = spawnSync(gradlew, gradleArgs, {
|
||||
cwd: join(root, 'android'), stdio: 'inherit', shell: isWin,
|
||||
});
|
||||
if (build.status !== 0) die('Gradle-Build fehlgeschlagen.');
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# NovaSonic scrobble service — isolated timestamped play-log. Uses Node's built-in SQLite
|
||||
# (--experimental-sqlite), so the only dependency is express. Build context = ./scrobble.
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /deps
|
||||
RUN npm install express
|
||||
ENV NODE_PATH=/deps/node_modules
|
||||
|
||||
WORKDIR /app
|
||||
COPY . /app/
|
||||
|
||||
ENV PORT=3710 DATA_PATH=/data
|
||||
EXPOSE 3710
|
||||
|
||||
CMD ["node","--experimental-sqlite","scrobble-server.js"]
|
||||
Generated
+829
@@ -0,0 +1,829 @@
|
||||
{
|
||||
"name": "novasonic-scrobble",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "novasonic-scrobble",
|
||||
"dependencies": {
|
||||
"express": "^4.22.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
"name": "novasonic-scrobble",
|
||||
"private": true,
|
||||
"description": "Isolated timestamped play-log service for NovaSonic (stats + Wrapped).",
|
||||
"engines": { "node": ">=22" },
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node --experimental-sqlite scrobble-server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.0"
|
||||
"express": "^4.22.2"
|
||||
}
|
||||
}
|
||||
+108
-20
@@ -92,7 +92,7 @@ async function verifyUser(req) {
|
||||
if (cached && cached.exp > now) return cached.ok ? u : null;
|
||||
|
||||
try {
|
||||
const url = `${NAVIDROME_URL}/rest/ping.view?u=${encodeURIComponent(u)}&t=${encodeURIComponent(t)}&s=${encodeURIComponent(s)}&v=1.16.1&c=NovaSonic&f=json`;
|
||||
const url = `${NAVIDROME_URL}/rest/ping.view?u=${encodeURIComponent(u)}&t=${encodeURIComponent(t)}&s=${encodeURIComponent(s)}&v=1.16.1&c=NovaSonic-Scrobble&f=json`;
|
||||
const r = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
||||
const j = await r.json();
|
||||
const ok = j?.['subsonic-response']?.status === 'ok';
|
||||
@@ -172,6 +172,14 @@ app.get('/scrobble/history', async (req, res) => {
|
||||
// ?tz= seconds east of UTC for local-day bucketing (default 0)
|
||||
// ?bucket= day | month | year — granularity of the time series (default day)
|
||||
// ?limit= top-list size (default 20, max 100)
|
||||
// Gruppierungsschluessel fuer Texte: Klein-/Grossschreibung, Rand-Leerzeichen und mehrfache
|
||||
// Leerzeichen duerfen keine zwei Eintraege aus einem machen. Genau das passiert sonst, sobald
|
||||
// jemand Metadaten bearbeitet ("Dat Adam" -> "DAT ADAM" waeren zwei Kuenstler in der Statistik).
|
||||
// SQLite kennt kein Regex, deshalb wird die Leerzeichen-Faltung zweimal angewandt (deckt bis zu
|
||||
// vier aufeinanderfolgende Leerzeichen ab — mehr kommt in Tags praktisch nicht vor).
|
||||
const normKey = (col) =>
|
||||
`lower(trim(replace(replace(replace(COALESCE(${col},''), char(9), ' '), ' ', ' '), ' ', ' ')))`;
|
||||
|
||||
// Buckets are computed on the fly (GROUP BY strftime) — no rollup tables.
|
||||
const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' };
|
||||
|
||||
@@ -202,31 +210,62 @@ app.get('/scrobble/stats', async (req, res) => {
|
||||
// navigator so you can't step before logging began.
|
||||
const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null;
|
||||
|
||||
// Anzeigename aus dem JUENGSTEN Play, nicht per MAX(): sonst gewinnt lexikografisch die alte
|
||||
// Schreibweise und die Statistik zeigt nach einer Korrektur weiter den alten Namen.
|
||||
const topArtists = db.prepare(`
|
||||
SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
|
||||
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
|
||||
GROUP BY lower(artist) ORDER BY plays DESC, seconds DESC LIMIT :limit
|
||||
WITH neueste AS (
|
||||
SELECT ${normKey('artist')} AS k, artist, artist_id, MAX(played_at)
|
||||
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
|
||||
), zaehler AS (
|
||||
SELECT ${normKey('artist')} AS k, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
|
||||
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
|
||||
)
|
||||
SELECT n.artist AS name, n.artist_id AS artistId, z.plays AS plays, z.seconds AS seconds
|
||||
FROM neueste n JOIN zaehler z ON z.k = n.k
|
||||
ORDER BY z.plays DESC, z.seconds DESC LIMIT :limit
|
||||
`).all(winL);
|
||||
|
||||
const topAlbums = db.prepare(`
|
||||
SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist,
|
||||
MAX(cover_art) AS coverArt, COUNT(*) AS plays
|
||||
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> ''
|
||||
GROUP BY lower(album), lower(COALESCE(artist,'')) ORDER BY plays DESC LIMIT :limit
|
||||
WITH neueste AS (
|
||||
SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2,
|
||||
album, album_id, artist, cover_art, MAX(played_at)
|
||||
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
|
||||
), zaehler AS (
|
||||
SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
|
||||
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
|
||||
)
|
||||
SELECT n.album AS name, n.album_id AS albumId, n.artist AS artist,
|
||||
n.cover_art AS coverArt, z.plays AS plays
|
||||
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
|
||||
ORDER BY z.plays DESC LIMIT :limit
|
||||
`).all(winL);
|
||||
|
||||
// Nicht nach song_id gruppieren: wird eine Datei beim Metadaten-Bearbeiten umbenannt, vergibt
|
||||
// Navidrome eine NEUE id — der Titel erschiene dann zweimal mit geteilten Zaehlern. Schluessel ist
|
||||
// deshalb Titel+Kuenstler; Anzeigefelder (inkl. song_id fuer die Navigation) kommen aus dem
|
||||
// JUENGSTEN Play, also mit den frisch bearbeiteten Tags. Das `MAX(played_at)` mit ansonsten
|
||||
// blanken Spalten ist dokumentiertes SQLite-Verhalten: es liefert die Zeile des Maximums.
|
||||
const topTracks = db.prepare(`
|
||||
SELECT MAX(title) AS title, MAX(artist) AS artist, MAX(album) AS album,
|
||||
MAX(album_id) AS albumId, MAX(artist_id) AS artistId, MAX(cover_art) AS coverArt,
|
||||
song_id AS songId, COUNT(*) AS plays
|
||||
FROM plays WHERE ${WHERE}
|
||||
GROUP BY song_id ORDER BY plays DESC LIMIT :limit
|
||||
WITH neueste AS (
|
||||
SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2,
|
||||
title, artist, album, album_id, artist_id, cover_art, song_id, MAX(played_at)
|
||||
FROM plays WHERE ${WHERE}
|
||||
GROUP BY k1, k2
|
||||
), zaehler AS (
|
||||
SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
|
||||
FROM plays WHERE ${WHERE}
|
||||
GROUP BY k1, k2
|
||||
)
|
||||
SELECT n.title AS title, n.artist AS artist, n.album AS album, n.album_id AS albumId,
|
||||
n.artist_id AS artistId, n.cover_art AS coverArt, n.song_id AS songId, z.plays AS plays
|
||||
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
|
||||
ORDER BY z.plays DESC LIMIT :limit
|
||||
`).all(winL);
|
||||
|
||||
const topGenres = db.prepare(`
|
||||
SELECT MAX(genre) AS name, COUNT(*) AS plays
|
||||
FROM plays WHERE ${WHERE} AND genre IS NOT NULL AND genre <> ''
|
||||
GROUP BY lower(genre) ORDER BY plays DESC LIMIT :limit
|
||||
GROUP BY ${normKey('genre')} ORDER BY plays DESC LIMIT :limit
|
||||
`).all(winL);
|
||||
|
||||
const series = db.prepare(`
|
||||
@@ -263,6 +302,9 @@ app.get('/scrobble/users', async (req, res) => {
|
||||
res.json({ me, users: rows });
|
||||
});
|
||||
|
||||
// Gegenstueck zu normKey() in JS — fuer Abgleiche, die nicht in SQL stattfinden.
|
||||
const _norm = (v) => (v || '').toLowerCase().replace(/[\t\s]+/g, ' ').trim();
|
||||
|
||||
// GET /scrobble/compare?with=NAME&from= — Head-to-Head "Musik-Match" of the authed user (A) vs
|
||||
// another user (B). Taste similarity = cosine of the two artist-play vectors (robust, 0..100).
|
||||
// Also returns shared artists/tracks and each side's unique top artists.
|
||||
@@ -280,14 +322,14 @@ app.get('/scrobble/compare', async (req, res) => {
|
||||
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
|
||||
|
||||
const artistsOf = u => db.prepare(`
|
||||
SELECT lower(artist) AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
|
||||
SELECT ${normKey('artist')} AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
|
||||
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
|
||||
GROUP BY lower(artist)
|
||||
GROUP BY ${normKey('artist')}
|
||||
`).all({ user: u, from, to });
|
||||
const tracksOf = u => db.prepare(`
|
||||
SELECT song_id AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
|
||||
SELECT MAX(song_id) AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
|
||||
MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays
|
||||
FROM plays WHERE ${WHERE} GROUP BY song_id
|
||||
FROM plays WHERE ${WHERE} GROUP BY ${normKey('title')}, ${normKey('artist')}
|
||||
`).all({ user: u, from, to });
|
||||
const totalsOf = u => db.prepare(
|
||||
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`
|
||||
@@ -316,11 +358,15 @@ app.get('/scrobble/compare', async (req, res) => {
|
||||
.sort((x, y) => y.plays - x.plays).slice(0, 8)
|
||||
.map(r => ({ name: r.name, artistId: r.artistId, plays: r.plays }));
|
||||
|
||||
// Gemeinsame Titel ueber Titel+Kuenstler abgleichen, NICHT ueber song_id: haben beide denselben
|
||||
// Song aus verschiedenen Dateien gehoert — oder wurde eine Datei zwischendurch umbenannt, wobei
|
||||
// Navidrome eine neue id vergibt —, galt er sonst faelschlich als nicht gemeinsam.
|
||||
const tKey = (r) => `${_norm(r.title)}\u0000${_norm(r.artist)}`;
|
||||
const aTracks = tracksOf(userA);
|
||||
const bTrackMap = new Map(tracksOf(userB).map(r => [r.songId, r]));
|
||||
const bTrackMap = new Map(tracksOf(userB).map(r => [tKey(r), r]));
|
||||
const sharedTracks = [];
|
||||
for (const ta of aTracks) {
|
||||
const tb = bTrackMap.get(ta.songId);
|
||||
const tb = bTrackMap.get(tKey(ta));
|
||||
if (tb) sharedTracks.push({
|
||||
songId: ta.songId, title: ta.title, artist: ta.artist, artistId: ta.artistId,
|
||||
albumId: ta.albumId, coverArt: ta.coverArt, playsA: ta.plays, playsB: tb.plays,
|
||||
@@ -328,9 +374,51 @@ app.get('/scrobble/compare', async (req, res) => {
|
||||
}
|
||||
sharedTracks.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
|
||||
|
||||
// ── Anzeigetafel: Kategorien, die je einen Sieger haben ──────────────────────
|
||||
// Bewusst Kennzahlen, die etwas ueber die HOERWEISE sagen statt ueber schiere Menge — sonst
|
||||
// gewinnt immer, wer laenger dabei ist. Unentschieden ist erlaubt und zaehlt fuer niemanden.
|
||||
const nachtAnteil = u => {
|
||||
const r = db.prepare(`
|
||||
SELECT COUNT(*) AS n,
|
||||
SUM(CASE WHEN CAST(strftime('%H', played_at + :tz, 'unixepoch') AS INTEGER) < 5
|
||||
THEN 1 ELSE 0 END) AS nacht
|
||||
FROM plays WHERE ${WHERE}
|
||||
`).get({ user: u, from, to, tz: Math.trunc(Number(req.query.tz) || 0) });
|
||||
return r.n ? (r.nacht || 0) / r.n : 0;
|
||||
};
|
||||
const tA = totalsOf(userA), tB = totalsOf(userB);
|
||||
const exklusivAnteil = (mine, other) =>
|
||||
mine.length ? mine.filter(r => !other.has(r.k)).length / mine.length : 0;
|
||||
const tiefe = (t, artists) => (artists ? t.plays / artists : 0);
|
||||
|
||||
const kat = (schluessel, label, a, b, format, hoeherGewinnt = true) => {
|
||||
const gleich = Math.abs(a - b) < 1e-9;
|
||||
return {
|
||||
key: schluessel, label,
|
||||
a, b,
|
||||
textA: format(a), textB: format(b),
|
||||
sieger: gleich ? null : ((a > b) === hoeherGewinnt ? 'A' : 'B'),
|
||||
};
|
||||
};
|
||||
const std = v => `${(v / 3600).toFixed(1)} h`;
|
||||
const proz = v => `${Math.round(v * 100)} %`;
|
||||
const zahl = v => v.toLocaleString('de-DE');
|
||||
const komma = v => v.toFixed(1);
|
||||
|
||||
const kategorien = [
|
||||
kat('hoerzeit', 'Hörzeit', tA.seconds, tB.seconds, std),
|
||||
kat('vielfalt', 'Vielfalt', aArtists.length, bArtists.length, zahl),
|
||||
kat('tiefe', 'Tiefe', tiefe(tA, aArtists.length), tiefe(tB, bArtists.length), komma),
|
||||
kat('nachteule', 'Nachteule', nachtAnteil(userA), nachtAnteil(userB), proz),
|
||||
kat('entdecker', 'Entdeckerdrang', exklusivAnteil(aArtists, bMap), exklusivAnteil(bArtists, aMap), proz),
|
||||
];
|
||||
const punkteA = kategorien.filter(k => k.sieger === 'A').length;
|
||||
const punkteB = kategorien.filter(k => k.sieger === 'B').length;
|
||||
|
||||
res.json({
|
||||
userA, userB, range: { from, to },
|
||||
matchScore,
|
||||
battle: { kategorien, punkteA, punkteB },
|
||||
totalsA: { ...totalsOf(userA), artists: aArtists.length },
|
||||
totalsB: { ...totalsOf(userB), artists: bArtists.length },
|
||||
sharedArtists: sharedArtists.slice(0, 30),
|
||||
|
||||
+41
-4
@@ -20,7 +20,7 @@ import {
|
||||
navigate, _navGoBack, _navOnPopState, _navLoad, onHeaderSearch, closeHeaderSearch,
|
||||
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
|
||||
searchSimilarArtist, searchAndPreview, loadDeezerAlbum, closeEventsModal,
|
||||
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
||||
loadAlbums, loadArtists, filterArtists, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
||||
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
|
||||
} from './views/views.js';
|
||||
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from './views/abs.js';
|
||||
import {
|
||||
openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum,
|
||||
openNpMenu, closeNpMenu,
|
||||
openNpMenu, closeNpMenu, toggleLyricsFolgen,
|
||||
} from './player/nowplaying.js';
|
||||
import {
|
||||
openSettings, closeSettings, saveSettings, confirmDisconnect,
|
||||
@@ -111,6 +111,12 @@ async function startApp() {
|
||||
} else {
|
||||
navigate('home');
|
||||
}
|
||||
// Geteilter Link (?song=… / ?album=…): erst NACH der Anmeldung aufloesen — vorher gibt es keine
|
||||
// Zugangsdaten, mit denen sich der Inhalt laden liesse. Ueberschreibt bewusst die
|
||||
// wiederhergestellte Ansicht: wer einen Link oeffnet, will dorthin und nicht dahin, wo er zuletzt war.
|
||||
import('./ui/share.js')
|
||||
.then(m => m.handleShareParam(navigate, playQueue))
|
||||
.catch(() => {});
|
||||
initNotifBell();
|
||||
initPlayLog(); // flush any queued plays to the scrobble service (offline-safe)
|
||||
loadArtistOverrides().catch(() => {}); // pinned same-name artist → Deezer mappings (shared)
|
||||
@@ -120,6 +126,8 @@ async function startApp() {
|
||||
const sr = localStorage.getItem('ns_repeat');
|
||||
if(sr === 'all' || sr === 'one') S.repeat = sr;
|
||||
if(localStorage.getItem('ns_shuffle') === '1') S.shuffle = true;
|
||||
// Mitlaufen der Lyrics: Vorgabe AUS, nur ein ausdruecklich gesetztes '1' schaltet es ein.
|
||||
S.lyricsFolgen = localStorage.getItem('ns_lyrics_folgen') === '1';
|
||||
_applyShuffleRepeat();
|
||||
}
|
||||
|
||||
@@ -210,6 +218,12 @@ window.addEventListener('load', () => {
|
||||
// backgrounded (that routes through togglePlay → S._userPaused=true), which used to wrongly
|
||||
// auto-resume on foreground because _wasPlayingBeforeHide was still true from hide-time.
|
||||
if(!S.audio.paused) return;
|
||||
// Am ENDE der Warteschlange nicht fortsetzen. Ein beendetes <audio> ist zwar "paused", aber
|
||||
// play() spult dann auf Anfang zurueck und spielt denselben Titel NOCH EINMAL. Bei
|
||||
// ausgeschaltetem Repeat steht nach dem letzten Titel nichts mehr an, trotzdem lief er bei
|
||||
// jedem Zurueckholen der Seite wieder los — genau das sah wie "der letzte Titel wiederholt
|
||||
// sich mehrfach" aus, besonders bei Shuffle, wo der Letzte ein beliebiger ist.
|
||||
if(S.audio.ended) return;
|
||||
if(s && S.audio.src && _wasPlayingBeforeHide && !S._userPaused) S.audio.play().catch(() => { S._trackChanging = false; });
|
||||
}
|
||||
|
||||
@@ -310,6 +324,25 @@ window.addEventListener('load', () => {
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', (e) => _navOnPopState(e.state));
|
||||
|
||||
// Im installierten Fenster ist "display": "standalone" — es gibt keine Zurueck-/Vorwaerts-Knoepfe,
|
||||
// und die Maustasten werden dort nicht auf den Verlauf umgelenkt: Zurueck war im Programmfenster am
|
||||
// PC also ueberhaupt nicht erreichbar, im Browser-Tab dagegen schon. Deshalb uebernehmen wir X1/X2
|
||||
// selbst — aber NUR im Standalone-Fenster, im Tab macht das der Browser und wir wuerden doppelt
|
||||
// navigieren. (Alt+Links/Rechts bleibt dem Browser ueberlassen, das kann er auch dort selbst.)
|
||||
const _imAppFenster = () => {
|
||||
try { return window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; }
|
||||
catch { return false; }
|
||||
};
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (!_imAppFenster()) return;
|
||||
if (e.button === 3) { e.preventDefault(); _navGoBack(); }
|
||||
else if (e.button === 4) { e.preventDefault(); try { history.forward(); } catch {} }
|
||||
});
|
||||
// Ohne das zeigt Chrome im Fenster das Kontextmenue der Seitentaste.
|
||||
window.addEventListener('auxclick', (e) => {
|
||||
if (_imAppFenster() && (e.button === 3 || e.button === 4)) e.preventDefault();
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if(e.key !== 'Backspace') return;
|
||||
const t = document.activeElement?.tagName;
|
||||
@@ -325,13 +358,17 @@ Object.assign(window, {
|
||||
navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
|
||||
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
|
||||
loadDeezerAlbum, closeEventsModal,
|
||||
// Lazy: die Zeitleiste zieht nur beim Öffnen ihren Code nach.
|
||||
loadArtistNews: (tage) => import('./views/artistNews.js').then(m => m.loadArtistNews(tage)),
|
||||
showArtistNewsTab: (reiter) => import('./views/artistNews.js').then(m => m.showArtistNewsTab(reiter)),
|
||||
setArtistNewsDays: (tage) => import('./views/artistNews.js').then(m => m.setArtistNewsDays(tage)),
|
||||
// Player
|
||||
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
|
||||
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
|
||||
toggleStar, dlSongLocal, toggleRadioMode, stopPreview, npToggleStar,
|
||||
// Now Playing
|
||||
openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum,
|
||||
openNpMenu, closeNpMenu,
|
||||
openNpMenu, closeNpMenu, toggleLyricsFolgen,
|
||||
npSeek: () => {},
|
||||
// UI / Modals
|
||||
openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend,
|
||||
@@ -340,7 +377,7 @@ Object.assign(window, {
|
||||
openCompare, closeCompare,
|
||||
closeArtistPin, clearArtistPin,
|
||||
// Views
|
||||
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
||||
loadAlbums, loadArtists, filterArtists, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
||||
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
|
||||
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
|
||||
onImporterInput, resolveImporterLink, onGlobalInput, tryImporterResolve,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { coverUrl } from '../api.js';
|
||||
import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar, _syncOverlayState, isPlaying, seekToMs, updatePlayerUI } from './player.js';
|
||||
import { navigate } from '../views/views.js';
|
||||
import { openPlModal, showSongInfo } from '../ui/ui.js';
|
||||
import { macheQueueBearbeitbar, QUEUE_WERKZEUGE } from './queueEdit.js';
|
||||
|
||||
// ─── NOW PLAYING OVERLAY ───
|
||||
export function openNowPlaying() {
|
||||
@@ -122,9 +123,32 @@ function _renderSyncedLines(content, lines) {
|
||||
const bot = document.createElement('div');
|
||||
bot.style.cssText = 'height:40vh;flex-shrink:0';
|
||||
content.appendChild(bot);
|
||||
_zeigeFolgenSchalter(true);
|
||||
updateLyricsHighlight(S.audio.currentTime * 1000);
|
||||
}
|
||||
|
||||
// ─── MITLAUFEN ───
|
||||
// Der Schalter erscheint nur bei synchronen Zeilen: ohne Zeitstempel gibt es nichts mitzufuehren.
|
||||
function _zeigeFolgenSchalter(hatSync) {
|
||||
const btn = document.getElementById('np-follow-btn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('hat-sync', !!hatSync);
|
||||
btn.classList.toggle('active', !!S.lyricsFolgen);
|
||||
const txt = document.getElementById('np-follow-text');
|
||||
if (txt) txt.textContent = S.lyricsFolgen ? 'Folgt' : 'Mitlaufen';
|
||||
btn.title = S.lyricsFolgen
|
||||
? 'Mitlaufen aus — Text von Hand scrollen'
|
||||
: 'Mitlaufen an — Text springt zur laufenden Zeile';
|
||||
}
|
||||
|
||||
export function toggleLyricsFolgen() {
|
||||
S.lyricsFolgen = !S.lyricsFolgen;
|
||||
localStorage.setItem('ns_lyrics_folgen', S.lyricsFolgen ? '1' : '0');
|
||||
_zeigeFolgenSchalter(_lyricsLines.length > 0);
|
||||
// Beim Einschalten sofort zur laufenden Zeile — sonst wartet man bis zur naechsten.
|
||||
if (S.lyricsFolgen) updateLyricsHighlight(S.audio.currentTime * 1000);
|
||||
}
|
||||
|
||||
function _parseLrc(text) {
|
||||
let offsetMs = 0;
|
||||
const lines = [];
|
||||
@@ -142,7 +166,7 @@ async function _loadNpLyrics(songId) {
|
||||
const content = document.getElementById('np-lyrics-content');
|
||||
if(!content) return;
|
||||
if(_lyricsSongId === songId && (_lyricsLines.length || content.querySelector('.lyrics-plain,.lyrics-none'))) {
|
||||
if(_lyricsLines.length) updateLyricsHighlight(S.audio.currentTime * 1000);
|
||||
if(_lyricsLines.length) { _zeigeFolgenSchalter(true); updateLyricsHighlight(S.audio.currentTime * 1000); }
|
||||
return;
|
||||
}
|
||||
_lyricsSongId = songId;
|
||||
@@ -252,6 +276,7 @@ function _showLyricsResult(content, result) {
|
||||
const parsed = _parseLrc(result.syncedLyrics);
|
||||
if(parsed.length) { _renderSyncedLines(content, parsed); return; }
|
||||
}
|
||||
_zeigeFolgenSchalter(false);
|
||||
if(result?.plainLyrics) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'lyrics-plain';
|
||||
@@ -278,7 +303,8 @@ export function updateLyricsHighlight(currentMs) {
|
||||
line.el.classList.toggle('active', i === activeIdx);
|
||||
line.el.classList.toggle('past', i < activeIdx);
|
||||
});
|
||||
if(activeIdx >= 0) {
|
||||
// Hervorheben passiert immer, Scrollen nur wenn Mitlaufen eingeschaltet ist.
|
||||
if(activeIdx >= 0 && S.lyricsFolgen) {
|
||||
const activeEl = _lyricsLines[activeIdx].el;
|
||||
const container = document.getElementById('np-lyrics-view');
|
||||
if(container) {
|
||||
@@ -293,6 +319,7 @@ export function onNpSongChange(songId) {
|
||||
_lyricsLines = [];
|
||||
const content = document.getElementById('np-lyrics-content');
|
||||
if(content) content.innerHTML = '';
|
||||
_zeigeFolgenSchalter(false);
|
||||
// Warm the cache shortly AFTER the track starts — deferred so the lyrics requests
|
||||
// don't compete with the audio stream and delay playback start.
|
||||
clearTimeout(_prefetchTimer);
|
||||
@@ -313,11 +340,13 @@ export function renderNpQueue() {
|
||||
S.queue.forEach((s, i) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'np-queue-item' + (i===S.qIdx ? ' nq-active' : '');
|
||||
item.dataset.qidx = i;
|
||||
const cv = coverUrl(s.coverArt, 128);
|
||||
item.innerHTML = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>`;
|
||||
item.innerHTML = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>` + QUEUE_WERKZEUGE;
|
||||
item.onclick = () => { S.qIdx=i; playCurrent(); renderNpQueue(); };
|
||||
el.appendChild(item);
|
||||
});
|
||||
macheQueueBearbeitbar('np-queue-list');
|
||||
el.querySelector('.nq-active')?.scrollIntoView({block:'nearest'});
|
||||
}
|
||||
|
||||
|
||||
+64
-15
@@ -3,8 +3,10 @@ import { esc, formatDur, toast } from '../utils.js';
|
||||
import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from '../api.js';
|
||||
// views.js/ui.js circular imports — safe (only used inside function bodies)
|
||||
import { loadRecommendations } from '../views/views.js';
|
||||
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js';
|
||||
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying, renderNpQueue } from './nowplaying.js';
|
||||
import { logPlay } from './scrobbleLog.js';
|
||||
import { macheQueueBearbeitbar, QUEUE_WERKZEUGE } from './queueEdit.js';
|
||||
import { queueNachEntfernen, queueNachVerschieben, mischeListe } from './queueMath.js';
|
||||
|
||||
// ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
|
||||
// True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl).
|
||||
@@ -364,7 +366,7 @@ export function playQueue(songs, startIdx, opts = {}) {
|
||||
// opts.noShuffle = true skips shuffle (e.g. clicking a specific song on the artist page).
|
||||
if (S.shuffle && songs.length > 1 && !opts.noShuffle) {
|
||||
S._origQueue = songs.map(s => ({...s}));
|
||||
S.queue = [...songs].sort(() => Math.random() - 0.5).map(s => ({...s}));
|
||||
S.queue = mischeListe(songs).map(s => ({...s}));
|
||||
S.qIdx = 0;
|
||||
} else {
|
||||
S.queue = songs.map(s => ({...s}));
|
||||
@@ -791,7 +793,7 @@ export function toggleShuffle() {
|
||||
if (S.shuffle) {
|
||||
const cur = S.queue[S.qIdx];
|
||||
S._origQueue = [...S.queue];
|
||||
const rest = S.queue.filter((_, i) => i !== S.qIdx).sort(() => Math.random() - 0.5);
|
||||
const rest = mischeListe(S.queue.filter((_, i) => i !== S.qIdx));
|
||||
S.queue = [cur, ...rest];
|
||||
S.qIdx = 0;
|
||||
} else if (S._origQueue) {
|
||||
@@ -869,8 +871,12 @@ export async function fillRadioQueue() {
|
||||
if (gen !== _queueGen) return;
|
||||
const rr = await api('getRandomSongs', '&size=20');
|
||||
const randomFresh = (rr.randomSongs?.song || []).filter(s => !recentIds.has(s.id));
|
||||
// If random is also exhausted, allow repeats to keep music playing
|
||||
newSongs = randomFresh.length > 0 ? randomFresh : newSongs.slice(0, 5);
|
||||
// Ist auch der Zufall erschoepft, sind Wiederholungen erlaubt, damit Musik laeuft — aber
|
||||
// NIE der gerade laufende Titel: der landete sonst direkt hinter sich selbst und spielte
|
||||
// zweimal in Folge.
|
||||
newSongs = randomFresh.length > 0
|
||||
? randomFresh
|
||||
: newSongs.filter(s => s.id !== seed?.id).slice(0, 5);
|
||||
} else {
|
||||
newSongs = deduped;
|
||||
}
|
||||
@@ -1085,16 +1091,19 @@ export function updateMediaSession(s) {
|
||||
{ src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' },
|
||||
{ src: _cachedCoverUrl(s.coverArt, 1024), sizes:'1024x1024', type:'image/jpeg' },
|
||||
] : [];
|
||||
// Mutate the existing metadata in place rather than assigning a new MediaMetadata —
|
||||
// replacing the object makes Windows tear down & rebuild the media tile (visible
|
||||
// flicker when skipping). Spotify-style: keep one object, just update its fields.
|
||||
if (ms.metadata) {
|
||||
ms.metadata.title = s.title;
|
||||
ms.metadata.artist = s.artist || '';
|
||||
ms.metadata.album = s.album || '';
|
||||
ms.metadata.artwork = artwork;
|
||||
} else {
|
||||
// Feldweise mutieren statt ein neues MediaMetadata zuzuweisen vermeidet unter Windows das
|
||||
// Neuaufbauen der Medienkachel (sichtbares Flackern beim Skippen). Auf Linux kam davon bei MPRIS
|
||||
// aber nichts an: Desktop-Widgets zeigten dauerhaft den ZUERST gespielten Titel, weil nur die
|
||||
// erste Zuweisung gemeldet wurde. Deshalb jetzt zweigeteilt — bei echtem Titelwechsel ein neues
|
||||
// Objekt (garantiert gemeldet), sonst weiter in-place (z. B. wenn nur das Cover nachgeladen wird).
|
||||
const wechsel = !ms.metadata
|
||||
|| ms.metadata.title !== s.title
|
||||
|| ms.metadata.artist !== (s.artist || '')
|
||||
|| ms.metadata.album !== (s.album || '');
|
||||
if (wechsel) {
|
||||
ms.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
|
||||
} else {
|
||||
ms.metadata.artwork = artwork;
|
||||
}
|
||||
// Action handlers are stable — set them once, not on every track change.
|
||||
if (_msHandlersSet) return;
|
||||
@@ -1184,14 +1193,53 @@ export function renderQueue() {
|
||||
S.queue.forEach((s, i) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'queue-item' + (i === S.qIdx ? ' active' : '');
|
||||
item.dataset.qidx = i;
|
||||
const cv = coverUrl(s.coverArt, 128);
|
||||
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(dispTitle(s))}</div><div class="qi-artist">${esc(dispArtist(s))}</div></div>`;
|
||||
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(dispTitle(s))}</div><div class="qi-artist">${esc(dispArtist(s))}</div></div>` + QUEUE_WERKZEUGE;
|
||||
item.onclick = () => { S.qIdx = i; playCurrent(); };
|
||||
el.appendChild(item);
|
||||
});
|
||||
macheQueueBearbeitbar('queue-list');
|
||||
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// ─── WARTESCHLANGE BEARBEITEN ─────────────────────────────────────────────
|
||||
// S.queue ist eine einfache Liste, S.qIdx zeigt auf den laufenden Titel. Beim Aendern muss der
|
||||
// Zeiger mitwandern — sonst spielt nach dem Entfernen eines Titels weiter oben ploetzlich der
|
||||
// falsche. Gemerkt wird deshalb die ID des laufenden Titels, nicht seine Position.
|
||||
|
||||
/** Entfernt den Titel an Position i. Traf es den laufenden, laeuft der naechste an. */
|
||||
export function queueEntfernen(i) {
|
||||
if (i < 0 || i >= S.queue.length) return;
|
||||
const { qIdx, warAktuell } = queueNachEntfernen(S.queue.length, S.qIdx, i);
|
||||
S.queue.splice(i, 1);
|
||||
S.qIdx = qIdx;
|
||||
_renderedQueueKey = '';
|
||||
|
||||
if (!S.queue.length) {
|
||||
S.qIdx = -1;
|
||||
try { S._audioEls?.forEach(a => { a.pause(); }); } catch {}
|
||||
setPlayIcon(false);
|
||||
renderQueue(); renderNpQueue();
|
||||
toast('Warteschlange geleert');
|
||||
return;
|
||||
}
|
||||
if (warAktuell) playCurrent();
|
||||
renderQueue(); renderNpQueue();
|
||||
}
|
||||
|
||||
/** Verschiebt den Titel von Position `von` auf Position `nach`. */
|
||||
export function queueVerschieben(von, nach) {
|
||||
const n = S.queue.length;
|
||||
if (von === nach || von < 0 || von >= n || nach < 0 || nach >= n) return;
|
||||
const { qIdx } = queueNachVerschieben(S.queue.map(x => x.id), S.qIdx, von, nach);
|
||||
const [titel] = S.queue.splice(von, 1);
|
||||
S.queue.splice(nach, 0, titel);
|
||||
S.qIdx = qIdx;
|
||||
_renderedQueueKey = '';
|
||||
renderQueue(); renderNpQueue();
|
||||
}
|
||||
|
||||
export function toggleQueue() {
|
||||
S.queueOpen = !S.queueOpen;
|
||||
document.getElementById('queue-panel').classList.toggle('open', S.queueOpen);
|
||||
@@ -1385,5 +1433,6 @@ export function _restoreLastPlayed() {
|
||||
// Web path: prime the audio element so play button works immediately.
|
||||
S.audio.src = streamUrl(s.id);
|
||||
S.audio.load();
|
||||
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { queueEntfernen, queueVerschieben } from './player.js';
|
||||
|
||||
// ─── WARTESCHLANGE BEARBEITEN (Web) ─────────────────────────────────────────
|
||||
// Ziehen und Entfernen fuer beide Queue-Listen: die Seitenleiste und die im Now-Playing.
|
||||
//
|
||||
// Bewusst ueber Zeiger-Ereignisse statt HTML5-Drag-and-Drop: dessen `dragstart` gibt es auf
|
||||
// Touch-Geraeten gar nicht, die Warteschlange waere am Handy also unbearbeitbar geblieben.
|
||||
// `setPointerCapture` liefert dafuer Maus und Finger ueber denselben Weg.
|
||||
|
||||
const ZIEH_SCHWELLE = 4; // px, bevor aus einem Tippen ein Ziehen wird
|
||||
|
||||
export function macheQueueBearbeitbar(listeId) {
|
||||
const liste = document.getElementById(listeId);
|
||||
if (!liste || liste.dataset.bearbeitbar) return;
|
||||
liste.dataset.bearbeitbar = '1';
|
||||
|
||||
liste.addEventListener('click', (e) => {
|
||||
const weg = e.target.closest('.q-del');
|
||||
if (!weg) return;
|
||||
e.stopPropagation();
|
||||
const zeile = weg.closest('[data-qidx]');
|
||||
if (zeile) queueEntfernen(Number(zeile.dataset.qidx));
|
||||
});
|
||||
|
||||
liste.addEventListener('pointerdown', (e) => {
|
||||
const griff = e.target.closest('.q-grip');
|
||||
if (!griff || e.button > 0) return;
|
||||
const zeile = griff.closest('[data-qidx]');
|
||||
if (!zeile) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ziehen(liste, zeile, e);
|
||||
});
|
||||
}
|
||||
|
||||
function ziehen(liste, zeile, start) {
|
||||
const zeilen = [...liste.querySelectorAll('[data-qidx]')];
|
||||
const vonIdx = Number(zeile.dataset.qidx);
|
||||
const hoehe = zeile.offsetHeight || 1;
|
||||
const startY = start.clientY;
|
||||
let zielIdx = vonIdx;
|
||||
let aktiv = false;
|
||||
|
||||
const bewegen = (e) => {
|
||||
const dy = e.clientY - startY;
|
||||
if (!aktiv) {
|
||||
if (Math.abs(dy) < ZIEH_SCHWELLE) return;
|
||||
aktiv = true;
|
||||
zeile.classList.add('q-dragging');
|
||||
liste.classList.add('q-dragging-aktiv');
|
||||
}
|
||||
zeile.style.transform = `translateY(${dy}px)`;
|
||||
zielIdx = Math.max(0, Math.min(zeilen.length - 1, vonIdx + Math.round(dy / hoehe)));
|
||||
// Die Zeilen zwischen Start und Ziel weichen um eine Zeilenhoehe aus, damit die Luecke
|
||||
// dorthin wandert, wo losgelassen wird.
|
||||
zeilen.forEach((z, i) => {
|
||||
if (z === zeile) return;
|
||||
let v = 0;
|
||||
if (vonIdx < zielIdx && i > vonIdx && i <= zielIdx) v = -hoehe;
|
||||
else if (vonIdx > zielIdx && i >= zielIdx && i < vonIdx) v = hoehe;
|
||||
z.style.transform = v ? `translateY(${v}px)` : '';
|
||||
});
|
||||
};
|
||||
|
||||
const beenden = () => {
|
||||
liste.releasePointerCapture?.(start.pointerId);
|
||||
window.removeEventListener('pointermove', bewegen);
|
||||
window.removeEventListener('pointerup', beenden);
|
||||
window.removeEventListener('pointercancel', beenden);
|
||||
zeilen.forEach(z => { z.style.transform = ''; z.classList.remove('q-dragging'); });
|
||||
liste.classList.remove('q-dragging-aktiv');
|
||||
if (aktiv && zielIdx !== vonIdx) queueVerschieben(vonIdx, zielIdx);
|
||||
};
|
||||
|
||||
try { liste.setPointerCapture(start.pointerId); } catch {}
|
||||
window.addEventListener('pointermove', bewegen);
|
||||
window.addEventListener('pointerup', beenden);
|
||||
window.addEventListener('pointercancel', beenden);
|
||||
}
|
||||
|
||||
/** Griff und Entfernen-Knopf als HTML — in beiden Listen identisch. */
|
||||
export const QUEUE_WERKZEUGE =
|
||||
'<button class="q-del" title="Aus der Warteschlange entfernen" aria-label="Entfernen">' +
|
||||
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
|
||||
'<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' +
|
||||
'</button>' +
|
||||
'<span class="q-grip" title="Zum Verschieben ziehen" aria-label="Verschieben">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
|
||||
'<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/></svg>' +
|
||||
'</span>';
|
||||
@@ -0,0 +1,58 @@
|
||||
// Reine Indexrechnung der Warteschlange — bewusst OHNE Importe, damit sie sich ohne DOM und
|
||||
// ohne Wiedergabe pruefen laesst. Hier steckt der Teil, bei dem ein Fehler nicht auffaellt,
|
||||
// sondern spaeter den falschen Titel spielt.
|
||||
|
||||
|
||||
/**
|
||||
* Entfernt Position `i`. Liefert den neuen Zeiger und ob der LAUFENDE Titel getroffen wurde.
|
||||
* Der Zeiger wandert mit: faellt ein Titel VOR dem laufenden weg, rutscht dieser eine Stelle vor.
|
||||
*/
|
||||
export function queueNachEntfernen(laenge, qIdx, i) {
|
||||
if (i < 0 || i >= laenge) return { qIdx, warAktuell: false, leer: laenge === 0 };
|
||||
const warAktuell = i === qIdx;
|
||||
const neueLaenge = laenge - 1;
|
||||
if (neueLaenge === 0) return { qIdx: -1, warAktuell, leer: true };
|
||||
let neu = qIdx;
|
||||
if (!warAktuell && i < qIdx) neu--;
|
||||
// Traf es den laufenden Titel, zeigt der Index jetzt auf den nachrueckenden — am Ende der
|
||||
// Liste gibt es keinen, dann auf den davor.
|
||||
if (warAktuell) neu = Math.min(neu, neueLaenge - 1);
|
||||
return { qIdx: neu, warAktuell, leer: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt `von` nach `nach` und liefert die neue Reihenfolge samt nachgefuehrtem Zeiger.
|
||||
* Der Zeiger wird ueber die POSITION des laufenden Titels neu bestimmt, nicht gerechnet — das
|
||||
* stimmt auch, wenn er selbst gezogen wurde oder ein anderer ueber ihn hinweggezogen ist.
|
||||
*/
|
||||
export function queueNachVerschieben(ids, qIdx, von, nach) {
|
||||
const n = ids.length;
|
||||
if (von === nach || von < 0 || von >= n || nach < 0 || nach >= n) return { ids, qIdx };
|
||||
const laufende = ids[qIdx];
|
||||
const neu = ids.slice();
|
||||
const [x] = neu.splice(von, 1);
|
||||
neu.splice(nach, 0, x);
|
||||
const gefunden = neu.indexOf(laufende);
|
||||
return { ids: neu, qIdx: gefunden >= 0 ? gefunden : qIdx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Echte Mischung (Fisher-Yates). Liefert eine NEUE Liste, die Eingabe bleibt unberuehrt.
|
||||
*
|
||||
* Warum nicht `liste.sort(() => Math.random() - 0.5)`: ein Vergleicher, der bei derselben Paarung
|
||||
* mal so und mal anders antwortet, ist fuer eine Sortierung ungueltig, und das Ergebnis ist nicht
|
||||
* gleichverteilt. Gemessen (V8, 3000 Durchlaeufe je Laenge) bleibt der ERSTE Titel viel zu oft
|
||||
* vorn: bei 10 Titeln in 18,8 % der Faelle statt 10 %, bei 100 in 3,1 % statt 1 %, bei 2000 in
|
||||
* 0,23 % statt 0,05 % — also durchweg das Drei- bis Fuenffache. Die Grobordnung sieht dabei
|
||||
* gemischt aus, weshalb es lange nicht auffaellt; systematisch bevorzugt werden trotzdem immer
|
||||
* dieselben Titel. Fisher-Yates zieht fuer jede Position gleichverteilt aus dem Rest und traf in
|
||||
* derselben Messung 9,7 % / 0,87 % / 0,03 %.
|
||||
*/
|
||||
export function mischeListe(liste, rnd = Math.random) {
|
||||
const a = liste.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rnd() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export const S = {
|
||||
npOpen: false,
|
||||
npQueueMode: false,
|
||||
npLyricsMode: false,
|
||||
// Lyrics: automatisch mitlaufen oder von Hand scrollen. Standard AUS — wer liest, will nicht,
|
||||
// dass der Text unter dem Finger wegspringt; einschalten geht im Lyrics-Tab mit einem Druck.
|
||||
lyricsFolgen: false,
|
||||
|
||||
// Navigation history
|
||||
_navStack: [],
|
||||
|
||||
+57
-1
@@ -124,8 +124,64 @@ export const THEMES = [
|
||||
|
||||
'--danger': '#e05555',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: 'violet',
|
||||
name: 'Violet',
|
||||
accent: '#a78bfa',
|
||||
bg: '#0d0916',
|
||||
vars: {
|
||||
'--bg': '#0d0916', // Tiefes Violettschwarz
|
||||
'--bg2': '#140e20',
|
||||
'--bg3': '#1a1229',
|
||||
'--surface': '#221835',
|
||||
'--surface2': '#2d2145',
|
||||
'--border': 'rgba(255,255,255,0.07)',
|
||||
'--border2': 'rgba(255,255,255,0.13)',
|
||||
|
||||
'--accent': '#a78bfa', // Helles Lila — auf dunklem Grund gut lesbar
|
||||
'--accent2': '#c4b5fd',
|
||||
'--accent-dark':'#7c5cf0',
|
||||
'--accent-dim': 'rgba(167,139,250,0.15)',
|
||||
'--accent-glow':'rgba(167,139,250,0.32)',
|
||||
|
||||
'--text': '#f2edff',
|
||||
'--text2': '#9b8bbd',
|
||||
'--text3': '#655780',
|
||||
|
||||
'--danger': '#e05555',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'crimson',
|
||||
name: 'Crimson',
|
||||
accent: '#e05263',
|
||||
bg: '#120708',
|
||||
vars: {
|
||||
'--bg': '#120708', // Dunkles Weinrot-Schwarz
|
||||
'--bg2': '#1a0b0d',
|
||||
'--bg3': '#210e11',
|
||||
'--surface': '#2b1316',
|
||||
'--surface2': '#3a1a1e',
|
||||
'--border': 'rgba(255,255,255,0.07)',
|
||||
'--border2': 'rgba(255,255,255,0.13)',
|
||||
|
||||
// Bewusst kein reines Rot: das laege zu nah an --danger, und Loeschknoepfe saehen
|
||||
// dann aus wie gewoehnliche Akzente.
|
||||
'--accent': '#e05263',
|
||||
'--accent2': '#f2788a',
|
||||
'--accent-dark':'#b03445',
|
||||
'--accent-dim': 'rgba(224,82,99,0.16)',
|
||||
'--accent-glow':'rgba(224,82,99,0.32)',
|
||||
|
||||
'--text': '#ffeef0',
|
||||
'--text2': '#bf8d94',
|
||||
'--text3': '#7d545c',
|
||||
|
||||
'--danger': '#ff7a6b', // heller als der Akzent, damit Warnungen abstechen
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
|
||||
+72
-34
@@ -35,7 +35,7 @@ function _buildOverlay() {
|
||||
_overlay.id = 'compare-overlay';
|
||||
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeCompare(); });
|
||||
_overlay.innerHTML = `
|
||||
<div class="modal-card" style="max-width:720px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
|
||||
<div class="modal-card" style="max-width:940px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
|
||||
<div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
|
||||
<span style="font-size:18px">🎧</span>
|
||||
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Musik-Match</div>
|
||||
@@ -126,35 +126,41 @@ async function _loadCompare() {
|
||||
}
|
||||
|
||||
function _renderCompare(body, d) {
|
||||
const score = Math.max(0, Math.min(100, d.matchScore || 0));
|
||||
const verdict =
|
||||
score >= 80 ? 'Seelenverwandte 🎯' :
|
||||
score >= 60 ? 'Starkes Match 🔥' :
|
||||
score >= 40 ? 'Solide Überschneidung 👍' :
|
||||
score >= 20 ? 'Ein paar Gemeinsamkeiten 🙂' :
|
||||
'Ziemlich unterschiedlich 🌗';
|
||||
const b = d.battle;
|
||||
const urteil = !b ? '' :
|
||||
b.punkteA === b.punkteB ? 'Kopf-an-Kopf — kein Sieger.' :
|
||||
b.punkteA > b.punkteB ? `${d.userA} gewinnt ${b.punkteA}:${b.punkteB}.` :
|
||||
`${d.userB} gewinnt ${b.punkteB}:${b.punkteA}.`;
|
||||
|
||||
body.innerHTML = [
|
||||
_scoreRing(d, score, verdict),
|
||||
_totals(d),
|
||||
_sharedArtists(d.sharedArtists),
|
||||
_sharedTracks(d.sharedTracks),
|
||||
_uniqueCols(d),
|
||||
].join('');
|
||||
body.innerHTML = `<div class="cmp-body">
|
||||
<div class="cmp-wide">${_scoreboard(d, b, urteil)}</div>
|
||||
<div class="cmp-wide">${_totals(d)}</div>
|
||||
<div>${_streitpunkte(d)}</div>
|
||||
<div>${_sharedArtists(d.sharedArtists)}</div>
|
||||
<div>${_sharedTracks(d.sharedTracks)}</div>
|
||||
<div class="cmp-wide">${_uniqueCols(d)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _scoreRing(d, score, verdict) {
|
||||
const deg = Math.round(score * 3.6);
|
||||
return `<div style="display:flex;flex-direction:column;align-items:center;gap:10px;margin:6px 0 22px">
|
||||
<div style="width:132px;height:132px;border-radius:50%;background:conic-gradient(var(--accent) ${deg}deg, var(--surface2) ${deg}deg);display:flex;align-items:center;justify-content:center">
|
||||
<div style="width:104px;height:104px;border-radius:50%;background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center">
|
||||
<div style="font-size:32px;font-weight:800;letter-spacing:-1px;line-height:1">${score}<span style="font-size:16px">%</span></div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:2px">Match</div>
|
||||
</div>
|
||||
// Anzeigetafel: pro Kategorie ein Sieger, unten der Gesamtstand. Ersetzt den frueheren
|
||||
// Prozentring — nicht "wie aehnlich seid ihr", sondern "wer gewinnt worin".
|
||||
function _scoreboard(d, b, urteil) {
|
||||
if (!b?.kategorien?.length) return '';
|
||||
const zeilen = b.kategorien.map(k => {
|
||||
const gewinnA = k.sieger === 'A', gewinnB = k.sieger === 'B';
|
||||
return `<div class="cmp-cat ${gewinnA ? 'win-a' : gewinnB ? 'win-b' : ''}">
|
||||
<div class="cmp-val a ${gewinnA ? 'win' : gewinnB ? 'lose' : ''}">${esc(k.textA)}</div>
|
||||
<div class="cmp-cat-label">${esc(k.label)}</div>
|
||||
<div class="cmp-val b ${gewinnB ? 'win' : gewinnA ? 'lose' : ''}">${esc(k.textB)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div class="cmp-head">
|
||||
<div class="cmp-name">${esc(d.userA)}</div>
|
||||
<div class="cmp-score">${b.punkteA} : ${b.punkteB}</div>
|
||||
<div class="cmp-name">${esc(d.userB)}</div>
|
||||
</div>
|
||||
<div style="font-size:15px;font-weight:700">${esc(verdict)}</div>
|
||||
<div style="font-size:13px;color:var(--text2)"><b>${esc(d.userA)}</b> ⟷ <b>${esc(d.userB)}</b></div>
|
||||
</div>`;
|
||||
<div class="cmp-verdict">${esc(urteil)}</div>
|
||||
${zeilen}`;
|
||||
}
|
||||
|
||||
function _totals(d) {
|
||||
@@ -172,20 +178,52 @@ function _h(t) {
|
||||
|
||||
// Shared artist row: a centered name with each side's play count on its flank + a
|
||||
// mirrored bar so you can see who plays them more.
|
||||
// Streitpunkte: gemeinsame Künstler, bei denen die Zählerstände am weitesten auseinanderliegen.
|
||||
// Umgekehrt zu "Gemeinsame Künstler", das nach Summe sortiert und damit zeigt, worin ihr euch einig
|
||||
// seid. Sortiert wird nach relativem Ungleichgewicht, sonst stünden nur die meistgehörten oben.
|
||||
function _streitpunkte(d) {
|
||||
const items = (d.sharedArtists || [])
|
||||
.map(a => {
|
||||
const summe = a.playsA + a.playsB;
|
||||
return { ...a, unwucht: summe ? Math.abs(a.playsA - a.playsB) / summe : 0, summe };
|
||||
})
|
||||
// Ein einzelner Play gegen null ist kein Streit, sondern Zufall — Mindestmasse verlangen.
|
||||
.filter(a => a.summe >= 4 && a.unwucht >= 0.5)
|
||||
.sort((x, y) => (y.unwucht - x.unwucht) || (y.summe - x.summe))
|
||||
.slice(0, 8);
|
||||
if (!items.length) return `${_h('Streitpunkte')}${_info('Ihr hört gemeinsame Künstler erstaunlich ausgewogen.')}`;
|
||||
const rows = items.map(a => {
|
||||
const anteilA = Math.round((a.playsA / a.summe) * 100);
|
||||
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
|
||||
return `<div ${nav} style="padding:7px 0">
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;font-size:13px;margin-bottom:4px">
|
||||
<span class="cmp-num">${a.playsA}</span>
|
||||
<span style="font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.name)}</span>
|
||||
<span class="cmp-num">${a.playsB}</span>
|
||||
</div>
|
||||
<div style="display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--surface2)">
|
||||
<div style="width:${anteilA}%;background:var(--accent)"></div>
|
||||
<div style="width:${100 - anteilA}%;background:var(--accent);opacity:.35"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `${_h('Streitpunkte')}<div>${rows}</div>`;
|
||||
}
|
||||
|
||||
function _sharedArtists(items) {
|
||||
if (!items?.length) return `${_h('Gemeinsame Künstler')}${_info('Keine gemeinsamen Künstler in diesem Zeitraum.')}`;
|
||||
const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1);
|
||||
const rows = items.slice(0, 15).map(a => {
|
||||
const rows = items.slice(0, 12).map(a => {
|
||||
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
|
||||
return `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:7px 0">
|
||||
<div style="width:34px;text-align:right;font-size:12px;color:var(--text2);font-weight:700">${a.playsA}</div>
|
||||
<div style="flex:1;display:flex;justify-content:flex-end"><div style="height:6px;background:var(--accent);border-radius:3px;width:${Math.round((a.playsA / max) * 100)}%"></div></div>
|
||||
<div style="flex:1.6;text-align:center;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">${esc(a.name)}</div>
|
||||
<div style="flex:1;display:flex;justify-content:flex-start"><div style="height:6px;background:var(--accent-dark, var(--accent));opacity:.7;border-radius:3px;width:${Math.round((a.playsB / max) * 100)}%"></div></div>
|
||||
<div style="width:34px;text-align:left;font-size:12px;color:var(--text2);font-weight:700">${a.playsB}</div>
|
||||
return `<div ${nav} class="cmp-row">
|
||||
<div class="cmp-num" style="text-align:right">${a.playsA}</div>
|
||||
<div style="display:flex;justify-content:flex-end"><div class="cmp-bar" style="width:${Math.round((a.playsA / max) * 100)}%"></div></div>
|
||||
<div class="cmp-row-name">${esc(a.name)}</div>
|
||||
<div><div class="cmp-bar" style="width:${Math.round((a.playsB / max) * 100)}%;opacity:.65"></div></div>
|
||||
<div class="cmp-num">${a.playsB}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `${_h('Gemeinsame Künstler')}<div style="margin-bottom:22px">${rows}</div>`;
|
||||
return `${_h('Gemeinsame Künstler')}<div>${rows}</div>`;
|
||||
}
|
||||
|
||||
function _sharedTracks(items) {
|
||||
|
||||
+80
-2
@@ -38,6 +38,9 @@ function _renderInfoModal(s) {
|
||||
['Bitrate', s.bitRate ? s.bitRate+' kbps' : null],
|
||||
['Format', s.suffix?.toUpperCase()],
|
||||
['Dateigröße', s.size ? Math.round(s.size/1024/1024*10)/10+' MB' : null],
|
||||
// Kommt so aus der Subsonic-API. Ohne "Report Real Path" am Player ist das NICHT der echte
|
||||
// Dateiname, sondern von Navidrome aus den Tags gebaut (fakePath(): AlbumArtist/Album/
|
||||
// DD-TT - Titel.ext) — siehe CHANGELOG 2.4, Known Bugs.
|
||||
['Pfad', s.path],
|
||||
['Plays', s.playCount],
|
||||
].filter(([,v]) => v!=null && v!=='' && v!==undefined);
|
||||
@@ -281,16 +284,91 @@ export async function saveMetadata() {
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
|
||||
status.textContent = '✅ Gespeichert! Navidrome scannt…';
|
||||
toast('✅ Metadaten gespeichert');
|
||||
invalidateRomajiIndex();
|
||||
invalidateApiCache('getArtists');
|
||||
// Warnungen des Backends (Dublette am Zielnamen, nicht verschoben, …) sichtbar machen und das
|
||||
// Fenster offen lassen — sonst sieht es nach "gespeichert" aus, obwohl die Datei liegen blieb.
|
||||
if (d.warnings?.length) {
|
||||
status.innerHTML = '⚠ Gespeichert, aber: ' + d.warnings.map(esc).join('<br>⚠ ') +
|
||||
'<br><span style="color:var(--text3)">Datei: ' + esc(d.file || '?') + '</span>';
|
||||
// Liegt derselbe Song doppelt, kann das Aufraeumen hier passieren statt auf der Konsole.
|
||||
if (d.duplicate) _zeigeDublette(status, d.duplicate);
|
||||
toast('⚠ Gespeichert — mit Hinweisen, siehe Fenster');
|
||||
return;
|
||||
}
|
||||
status.textContent = '✅ Gespeichert! Navidrome scannt…';
|
||||
toast('✅ Metadaten gespeichert');
|
||||
setTimeout(() => { _leaveEditMode(); closeInfoModal(); }, 1500);
|
||||
} catch (e) {
|
||||
status.textContent = '❌ Fehler: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DUBLETTE AUFLOESEN ───
|
||||
// Das Verschieben wurde abgelehnt, weil am Ziel schon eine Datei liegt — derselbe Song liegt also
|
||||
// zweimal auf der Platte. Statt den Nutzer zum Dateimanager zu schicken, werden beide Kopien
|
||||
// gegenuebergestellt und die ueberzaehlige zum Loeschen angeboten.
|
||||
const _mb = (n) => (n == null ? '?' : (n / 1048576).toFixed(1) + ' MB');
|
||||
const _sek = (n) => (n == null ? '?' : formatDur(n));
|
||||
|
||||
function _zeigeDublette(status, dub) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'dublette-box';
|
||||
const zeile = (titel, f) =>
|
||||
`<div class="dublette-datei"><b>${esc(titel)}</b>` +
|
||||
`<div class="dublette-pfad">${esc(f.pfad || '?')}</div>` +
|
||||
`<div class="dublette-meta">${esc(f.artist || '?')} — ${esc(f.titel || '?')} · ` +
|
||||
`${_sek(f.dauer)} · ${_mb(f.groesse)}</div></div>`;
|
||||
box.innerHTML =
|
||||
zeile('Bleibt liegen (richtiger Ordner)', dub.behalten) +
|
||||
zeile('Überzählig (gerade bearbeitet)', dub.ueberzaehlig) +
|
||||
(dub.gleich
|
||||
? '<div class="dublette-urteil ok">Gleiche Länge und gleicher Titel — zwei Kopien desselben Songs.</div>'
|
||||
: '<div class="dublette-urteil warn">Länge oder Titel weichen ab. Das Backend lehnt das Löschen ab, ' +
|
||||
'solange das so ist — bitte erst selbst nachsehen.</div>');
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-danger';
|
||||
btn.textContent = '🗑 Überzählige Kopie löschen';
|
||||
btn.onclick = () => _loescheDublette(btn, status, dub);
|
||||
box.appendChild(btn);
|
||||
status.appendChild(box);
|
||||
}
|
||||
|
||||
async function _loescheDublette(btn, status, dub) {
|
||||
const ok = await showConfirm(
|
||||
'Dublette löschen',
|
||||
`"${dub.ueberzaehlig.pfad}" wird unwiderruflich von der Festplatte gelöscht. ` +
|
||||
`Die Kopie im richtigen Ordner bleibt bestehen.`,
|
||||
'Löschen'
|
||||
);
|
||||
if (!ok) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '🗑 Löschen…';
|
||||
try {
|
||||
const r = await fetch(`${S.cfg.backendUrl}/api/resolve-duplicate`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass,
|
||||
behalten: dub.behalten.pfad, ueberzaehlig: dub.ueberzaehlig.pfad,
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
|
||||
invalidateApiCache('getArtists');
|
||||
invalidateRomajiIndex();
|
||||
btn.remove();
|
||||
status.insertAdjacentHTML('beforeend',
|
||||
'<div class="dublette-urteil ok">✅ Gelöscht. Navidrome scannt — danach steht der Song nur noch einmal da.</div>');
|
||||
toast('✅ Dublette gelöscht');
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🗑 Überzählige Kopie löschen';
|
||||
toast('❌ ' + e.message);
|
||||
status.insertAdjacentHTML('beforeend',
|
||||
'<div class="dublette-urteil warn">' + esc(e.message) + '</div>');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSong() {
|
||||
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
|
||||
const s = S.infoSong;
|
||||
|
||||
@@ -31,6 +31,7 @@ export function showCtxMenu(e, song, list, idx) {
|
||||
}},
|
||||
{ sep: true },
|
||||
{ icon: '💿', label: 'Zum Album', fn: () => song.albumId && navigate('album-detail', { id: song.albumId, name: song.album||'', artist: song.artist||'', artistId: song.artistId||'', coverArt: song.coverArt||'' }) },
|
||||
{ icon: '🔗', label: 'Teilen', fn: () => import('./share.js').then(m => m.shareSong(song)) },
|
||||
{ icon: 'ℹ', label: 'Song-Info', fn: () => import('./metadata.js').then(m => m.showSongInfo(song)) },
|
||||
{ icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { S } from '../state.js';
|
||||
import { toast } from '../utils.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
// ─── TEILEN PER LINK ───
|
||||
// Ein Link traegt NUR eine Navidrome-Id, keine Zugangsdaten: der Empfaenger sieht den Inhalt mit
|
||||
// seinen eigenen Anmeldedaten auf derselben Instanz. Als Query-Parameter (?song=…) statt als Pfad,
|
||||
// damit der Webserver keine SPA-Umschreibung braucht — die App liest ihn beim Start selbst aus.
|
||||
export function buildShareLink(type, id) {
|
||||
const base = `${location.origin}${location.pathname.replace(/index\.html$/, '')}`;
|
||||
return `${base}?${type}=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Teilt einen Link ueber das System (Handy: Teilen-Dialog) und faellt sonst auf die Zwischenablage
|
||||
* zurueck. navigator.share gibt es nur in sicheren Kontexten und nicht auf jedem Desktop-Browser.
|
||||
*/
|
||||
export async function shareLink(type, id, title, subtitle) {
|
||||
const url = buildShareLink(type, id);
|
||||
const text = subtitle ? `${title} — ${subtitle}` : title;
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title, text, url });
|
||||
return;
|
||||
} catch (e) {
|
||||
// AbortError = der Nutzer hat den Dialog geschlossen; das ist kein Fehler.
|
||||
if (e?.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast('🔗 Link kopiert');
|
||||
} catch {
|
||||
// Zwischenablage kann blockiert sein (kein HTTPS, kein Nutzergesten-Kontext) — dann zeigen wir
|
||||
// den Link, damit er wenigstens von Hand kopiert werden kann.
|
||||
toast('🔗 ' + url);
|
||||
}
|
||||
}
|
||||
|
||||
export const shareSong = (s) => shareLink('song', s.id, s.title || 'Song', s.artist || '');
|
||||
export const shareAlbum = (a) => shareLink('album', a.id, a.name || 'Album', a.artist || '');
|
||||
|
||||
/**
|
||||
* Loest einen beim Start uebergebenen Link auf: Song abspielen bzw. Album oeffnen. Wird NACH der
|
||||
* Anmeldung aufgerufen — ohne gueltige Zugangsdaten gibt es nichts abzuspielen. Der Parameter wird
|
||||
* anschliessend aus der Adresszeile entfernt, damit ein Neuladen nicht erneut losspielt.
|
||||
*/
|
||||
export async function handleShareParam(navigate, playQueue) {
|
||||
const p = new URLSearchParams(location.search);
|
||||
const songId = p.get('song');
|
||||
const albumId = p.get('album');
|
||||
if (!songId && !albumId) return false;
|
||||
|
||||
const sauber = () => {
|
||||
try { history.replaceState(history.state, '', location.pathname); } catch {}
|
||||
};
|
||||
|
||||
try {
|
||||
if (songId) {
|
||||
const r = await api('getSong', `&id=${encodeURIComponent(songId)}`);
|
||||
const song = r.song;
|
||||
if (!song) { toast('Song nicht gefunden'); sauber(); return false; }
|
||||
if (song.albumId) {
|
||||
navigate('album-detail', {
|
||||
id: song.albumId, name: song.album || '', artist: song.artist || '',
|
||||
artistId: song.artistId || '', coverArt: song.coverArt || '',
|
||||
});
|
||||
}
|
||||
playQueue([song], 0);
|
||||
toast(`▶ ${song.title}`);
|
||||
} else {
|
||||
const r = await api('getAlbum', `&id=${encodeURIComponent(albumId)}`);
|
||||
const al = r.album;
|
||||
if (!al) { toast('Album nicht gefunden'); sauber(); return false; }
|
||||
navigate('album-detail', {
|
||||
id: al.id, name: al.name || '', artist: al.artist || '',
|
||||
artistId: al.artistId || '', coverArt: al.coverArt || '',
|
||||
});
|
||||
}
|
||||
sauber();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast('Link konnte nicht geöffnet werden: ' + e.message);
|
||||
sauber();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,17 @@ export function formatDur(s) {
|
||||
return `${m}:${sec.toString().padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
// Gesamtdauer einer Playlist oder eines Albums. Ab einer Stunde werden Stunden ausgewiesen —
|
||||
// "134 Min." muss man sonst selbst umrechnen, und bei einer Playlist mit 2000 Titeln steht dort
|
||||
// sonst eine vierstellige Minutenzahl.
|
||||
export function formatGesamtdauer(sekunden) {
|
||||
const min = Math.round((sekunden || 0) / 60);
|
||||
if (min < 60) return `${min} Min.`;
|
||||
const std = Math.floor(min / 60);
|
||||
const rest = min % 60;
|
||||
return rest ? `${std} Std. ${rest} Min.` : `${std} Std.`;
|
||||
}
|
||||
|
||||
// Fan/count formatting in German: full grouped number up to a million (559534 → "559.542"),
|
||||
// then compact millions (1200000 → "1,2 Mio.").
|
||||
export function fmtCount(n) {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { S } from '../state.js';
|
||||
import { esc } from '../utils.js';
|
||||
import { api } from '../api.js';
|
||||
import { openDlModal } from '../ui/ui.js';
|
||||
// Zirkulär — sicher, weil nur in Ereignisbehandlern aufgerufen
|
||||
import { navigate, _navGoBack } from './navigation.js';
|
||||
|
||||
// ─── NEU VON DEINEN KÜNSTLERN ───────────────────────────────────────────────
|
||||
// Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. "Folgen" ist
|
||||
// Navidromes Stern, die Liste kommt also aus getStarred2 — kein eigener Speicher.
|
||||
// Die Deezer-Auflösung macht das Backend, weil dort auch die Künstler-Pins liegen.
|
||||
|
||||
let _tage = 90;
|
||||
let _reiter = 'releases';
|
||||
let _laeuft = false;
|
||||
|
||||
/** Reiter wechseln: "releases" oder "konzerte". */
|
||||
export function showArtistNewsTab(reiter) {
|
||||
_reiter = reiter === 'konzerte' ? 'konzerte' : 'releases';
|
||||
document.querySelectorAll('#artist-news-tabs .sort-chip').forEach(c =>
|
||||
c.classList.toggle('active', c.dataset.tab === _reiter));
|
||||
const titel = document.querySelector('#page-artist-news .page-title');
|
||||
if (titel) {
|
||||
titel.innerHTML = _reiter === 'konzerte'
|
||||
? 'Konzerte <em>deiner Künstler</em>'
|
||||
: 'Neu von <em>deinen Künstlern</em>';
|
||||
}
|
||||
// Das Zeitfenster gilt nur fuer die Veroeffentlichungen. Konzerte sind ohnehin Zukunft, und
|
||||
// dort will man alles Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
|
||||
const tageReihe = document.getElementById('artist-news-sort');
|
||||
if (tageReihe) tageReihe.style.display = _reiter === 'konzerte' ? 'none' : '';
|
||||
if (_reiter === 'konzerte') loadKonzerte();
|
||||
else loadArtistNews();
|
||||
}
|
||||
|
||||
/** Zeitfenster setzen — gibt es nur bei den Veroeffentlichungen. */
|
||||
export function setArtistNewsDays(tage) {
|
||||
_tage = tage;
|
||||
loadArtistNews();
|
||||
}
|
||||
|
||||
function _markiereTage(tage) {
|
||||
document.querySelectorAll('#artist-news-sort .sort-chip').forEach(c =>
|
||||
c.classList.toggle('active', Number(c.dataset.days) === tage));
|
||||
}
|
||||
|
||||
export async function loadArtistNews(tage = _tage) {
|
||||
_tage = tage;
|
||||
_reiter = 'releases';
|
||||
const tageReihe = document.getElementById('artist-news-sort');
|
||||
if (tageReihe) tageReihe.style.display = '';
|
||||
// Die Seite haengt an den Favoriten (Knopf "✨ Neu") und nicht mehr an der Seitenleiste,
|
||||
// deshalb braucht sie einen Weg zurueck wie jede andere Unterseite.
|
||||
const zurueck = document.getElementById('artist-news-back-btn');
|
||||
if (zurueck) zurueck.onclick = () => _navGoBack();
|
||||
_markiereTage(tage);
|
||||
|
||||
const el = document.getElementById('artist-news-list');
|
||||
if (!el || _laeuft) return;
|
||||
|
||||
if (!S.cfg?.backendUrl) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="icon">🔌</div><p>Kein Backend konfiguriert</p>'
|
||||
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Neue Veröffentlichungen kommen über das Backend von Deezer.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
_laeuft = true;
|
||||
el.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const r = await api('getStarred2');
|
||||
const kuenstler = (r.starred2?.artist || []).map(a => ({ id: a.id, name: a.name }));
|
||||
if (!kuenstler.length) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p>'
|
||||
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${S.cfg.backendUrl}/api/artist-releases`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ artists: kuenstler, days: tage }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { releases = [], ohneZuordnung = [] } = await res.json();
|
||||
|
||||
// Was hat man schon? Verglichen wird über Künstler + Albumname — Deezers Album-Id hat mit
|
||||
// Navidromes nichts zu tun.
|
||||
const vorhanden = new Set();
|
||||
(r.starred2?.album || []).forEach(a => vorhanden.add(_schluessel(a.artist, a.name)));
|
||||
await _ergaenzeVorhandene(kuenstler, vorhanden);
|
||||
|
||||
el.innerHTML = '';
|
||||
if (ohneZuordnung.length) el.appendChild(_hinweisOhneZuordnung(ohneZuordnung));
|
||||
if (!releases.length) {
|
||||
const leer = document.createElement('div');
|
||||
leer.className = 'empty-state';
|
||||
leer.innerHTML = `<div class="icon">🌙</div><p>Nichts Neues in den letzten ${tage} Tagen</p>`;
|
||||
el.appendChild(leer);
|
||||
return;
|
||||
}
|
||||
_renderZeitleiste(el, releases, vorhanden);
|
||||
} catch (e) {
|
||||
el.innerHTML = `<p style="color:var(--danger)">${esc(e.message)}</p>`;
|
||||
} finally {
|
||||
_laeuft = false;
|
||||
}
|
||||
}
|
||||
|
||||
const _norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
const _schluessel = (kuenstler, album) => `${_norm(kuenstler)}~~${_norm(album)}`;
|
||||
|
||||
/** Alben der gefolgten Künstler aus der eigenen Bibliothek nachtragen (parallel, begrenzt). */
|
||||
async function _ergaenzeVorhandene(kuenstler, vorhanden) {
|
||||
const haeppchen = 6;
|
||||
for (let i = 0; i < kuenstler.length; i += haeppchen) {
|
||||
await Promise.all(kuenstler.slice(i, i + haeppchen).map(async k => {
|
||||
try {
|
||||
const a = await api('getArtist', `&id=${encodeURIComponent(k.id)}`);
|
||||
(a.artist?.album || []).forEach(al => vorhanden.add(_schluessel(k.name, al.name)));
|
||||
} catch {}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function _hinweisOhneZuordnung(namen) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'artist-news-hint';
|
||||
d.innerHTML = `⚠ Für ${namen.length} Künstler wurde kein Deezer-Profil gefunden: `
|
||||
+ `<strong>${namen.slice(0, 4).map(esc).join(', ')}</strong>${namen.length > 4 ? ' …' : ''}. `
|
||||
+ `Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.`;
|
||||
return d;
|
||||
}
|
||||
|
||||
function _renderZeitleiste(el, releases, vorhanden) {
|
||||
let letzterMonat = '';
|
||||
releases.forEach(rel => {
|
||||
const monat = new Date(rel.releaseDate)
|
||||
.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
if (monat !== letzterMonat) {
|
||||
letzterMonat = monat;
|
||||
const h = document.createElement('div');
|
||||
h.className = 'search-section-title';
|
||||
h.style.cssText = 'margin:18px 0 8px';
|
||||
h.textContent = monat;
|
||||
el.appendChild(h);
|
||||
}
|
||||
el.appendChild(_zeile(rel, vorhanden.has(_schluessel(rel.artistName, rel.title))));
|
||||
});
|
||||
}
|
||||
|
||||
const _TYP = { album: 'Album', single: 'Single', ep: 'EP', compile: 'Compilation' };
|
||||
|
||||
function _zeile(rel, schonDa) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'artist-news-row';
|
||||
const datum = new Date(rel.releaseDate).toLocaleDateString('de-DE',
|
||||
{ day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const typ = _TYP[rel.recordType] || rel.recordType;
|
||||
row.innerHTML = `
|
||||
<div class="artist-news-art">${rel.cover ? `<img src="${esc(rel.cover)}" loading="lazy" alt="">` : '<div class="no-art">💿</div>'}</div>
|
||||
<div class="artist-news-info">
|
||||
<div class="artist-news-title">${esc(rel.title)}</div>
|
||||
<div class="artist-news-sub"><span class="artist-news-artist">${esc(rel.artistName)}</span> · ${typ}${rel.trackCount ? ` · ${rel.trackCount} Titel` : ''}</div>
|
||||
<div class="artist-news-date">${datum}</div>
|
||||
</div>`;
|
||||
row.querySelector('.artist-news-artist').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
navigate('artist-detail', { id: rel.artistId, name: rel.artistName });
|
||||
};
|
||||
|
||||
const aktion = document.createElement('div');
|
||||
aktion.className = 'artist-news-action';
|
||||
if (schonDa) {
|
||||
aktion.innerHTML = '<span class="artist-news-have" title="In deiner Bibliothek">✓ Hast du</span>';
|
||||
} else {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-outline blue';
|
||||
btn.style.cssText = 'font-size:12px;padding:7px 12px';
|
||||
btn.textContent = '⬇ Laden';
|
||||
btn.onclick = () => openDlModal({
|
||||
artistName: rel.artistName,
|
||||
trackName: rel.title,
|
||||
collectionName: rel.title,
|
||||
artworkUrl100: rel.cover || '',
|
||||
isAlbum: true,
|
||||
collectionId: rel.albumId,
|
||||
deezerData: {
|
||||
collectionId: rel.albumId, collectionName: rel.title,
|
||||
artistName: rel.artistName, artworkUrl100: rel.cover || '',
|
||||
releaseDate: rel.releaseDate, trackCount: rel.trackCount,
|
||||
},
|
||||
});
|
||||
aktion.appendChild(btn);
|
||||
}
|
||||
row.appendChild(aktion);
|
||||
return row;
|
||||
}
|
||||
|
||||
// ─── KONZERTE DEINER KÜNSTLER ───────────────────────────────────────────────
|
||||
// Gegenstück zur Release-Zeitleiste. Die Liste läuft NACH VORN: das nächste Konzert zuerst.
|
||||
// Die Termine kommen über das Backend von Ticketmaster, gecacht für alle Geräte gemeinsam.
|
||||
|
||||
export async function loadKonzerte() {
|
||||
_reiter = 'konzerte';
|
||||
const zurueck = document.getElementById('artist-news-back-btn');
|
||||
if (zurueck) zurueck.onclick = () => _navGoBack();
|
||||
const tageReihe = document.getElementById('artist-news-sort');
|
||||
if (tageReihe) tageReihe.style.display = 'none';
|
||||
|
||||
const el = document.getElementById('artist-news-list');
|
||||
if (!el || _laeuft) return;
|
||||
|
||||
if (!S.cfg?.backendUrl) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="icon">🔌</div><p>Kein Backend konfiguriert</p>'
|
||||
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Konzerttermine kommen über das Backend von Ticketmaster.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
_laeuft = true;
|
||||
el.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const r = await api('getStarred2');
|
||||
const kuenstler = (r.starred2?.artist || []).map(a => ({ id: a.id, name: a.name }));
|
||||
if (!kuenstler.length) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p>'
|
||||
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${S.cfg.backendUrl}/api/artist-concerts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// Ohne `days`: das Backend liefert alles Angekuendigte.
|
||||
body: JSON.stringify({ artists: kuenstler }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { konzerte = [], keinSchluessel } = await res.json();
|
||||
|
||||
el.innerHTML = '';
|
||||
if (keinSchluessel) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="icon">🎟</div><p>Kein Ticketmaster-Schlüssel hinterlegt</p>'
|
||||
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Im Backend <code>TICKETMASTER_API_KEY</code> setzen.</p></div>';
|
||||
return;
|
||||
}
|
||||
if (!konzerte.length) {
|
||||
const leer = document.createElement('div');
|
||||
leer.className = 'empty-state';
|
||||
leer.innerHTML = '<div class="icon">🎪</div><p>Keine anstehenden Termine</p>'
|
||||
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Gesucht wird weltweit über den Künstlernamen.</p>';
|
||||
el.appendChild(leer);
|
||||
return;
|
||||
}
|
||||
_renderKonzerte(el, konzerte);
|
||||
} catch (e) {
|
||||
el.innerHTML = `<p style="color:var(--danger)">${esc(e.message)}</p>`;
|
||||
} finally {
|
||||
_laeuft = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderKonzerte(el, konzerte) {
|
||||
let letzterMonat = '';
|
||||
konzerte.forEach(k => {
|
||||
const d = new Date(k.datum);
|
||||
const monat = d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
if (monat !== letzterMonat) {
|
||||
letzterMonat = monat;
|
||||
const h = document.createElement('div');
|
||||
h.className = 'search-section-title';
|
||||
h.style.margin = '18px 0 8px';
|
||||
h.textContent = monat;
|
||||
el.appendChild(h);
|
||||
}
|
||||
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'artist-news-row konzert-row' + (k.abgesagt ? ' konzert-abgesagt' : '');
|
||||
const ort = [k.ort, k.stadt, k.land].filter(Boolean).join(' · ');
|
||||
const uhr = d.getHours() || d.getMinutes()
|
||||
? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
zeile.innerHTML =
|
||||
`<div class="konzert-datum"><span class="kd-tag">${d.getDate()}</span>`
|
||||
+ `<span class="kd-monat">${d.toLocaleDateString('de-DE', { month: 'short' })}</span></div>`
|
||||
+ `<div class="artist-news-info">`
|
||||
+ `<div class="artist-news-title">${esc(k.titel || k.artistName)}</div>`
|
||||
+ `<div class="artist-news-sub"><span class="artist-news-artist">${esc(k.artistName)}</span>`
|
||||
+ `${ort ? ' · ' + esc(ort) : ''}${uhr ? ' · ' + uhr + ' Uhr' : ''}</div></div>`;
|
||||
|
||||
// Künstlername führt zur Künstlerseite, wie in der Release-Liste.
|
||||
zeile.querySelector('.artist-news-artist').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
navigate('artist-detail', { id: k.artistId, name: k.artistName });
|
||||
};
|
||||
|
||||
const rechts = document.createElement('div');
|
||||
if (k.abgesagt) {
|
||||
rechts.className = 'artist-news-have';
|
||||
rechts.textContent = 'abgesagt';
|
||||
} else if (k.url) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'btn-outline konzert-ticket';
|
||||
a.href = k.url; a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||
a.textContent = 'Tickets';
|
||||
rechts.appendChild(a);
|
||||
}
|
||||
zeile.appendChild(rechts);
|
||||
el.appendChild(zeile);
|
||||
});
|
||||
}
|
||||
+95
-7
@@ -1,6 +1,6 @@
|
||||
import { S } from '../state.js';
|
||||
import { esc, toast, fmtCount } from '../utils.js';
|
||||
import { api, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
|
||||
import { esc, toast, fmtCount, formatGesamtdauer } from '../utils.js';
|
||||
import { api, invalidateApiCache, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
|
||||
import { openArtistPin } from '../ui/artistPin.js';
|
||||
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
|
||||
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js';
|
||||
@@ -105,6 +105,15 @@ export async function loadArtistDetail(artist) {
|
||||
pinBtn.style.cssText = `position:absolute;top:10px;right:10px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:${_pin ? 'var(--accent)' : 'rgba(255,255,255,.72)'};font-size:14px;line-height:1;cursor:pointer;backdrop-filter:blur(4px)`;
|
||||
pinBtn.onclick = () => openArtistPin(artist.id, artist.name);
|
||||
hero.appendChild(pinBtn);
|
||||
|
||||
// Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste: die Leiste ist "was ich jetzt
|
||||
// tue" (abspielen, mischen), Folgen ist ein Zustand. Deshalb als Herz ins Banner, neben das
|
||||
// Zahnrad. Verdrahtet wird es weiter unten, sobald `starred` aus getArtist da ist.
|
||||
hero.querySelector('.artist-follow-btn')?.remove();
|
||||
const folgeBtn = document.createElement('button');
|
||||
folgeBtn.className = 'artist-follow-btn';
|
||||
folgeBtn.style.cssText = 'position:absolute;top:10px;right:46px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:rgba(255,255,255,.72);cursor:pointer;backdrop-filter:blur(4px)';
|
||||
hero.appendChild(folgeBtn);
|
||||
}
|
||||
|
||||
// Fire all requests immediately so they run in parallel.
|
||||
@@ -112,7 +121,11 @@ export async function loadArtistDetail(artist) {
|
||||
: hasND ? getArtistImage(artist.id) : Promise.resolve(null);
|
||||
const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null);
|
||||
const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null);
|
||||
const topP = hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
|
||||
// id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
|
||||
// Namen zurueck, wenn es sie nicht kennt (aeltere Versionen ignorieren id einfach). Der Namensweg
|
||||
// sucht mit LIKE und Max:1 — bei zwei gleichnamigen Kuenstlern in der Bibliothek gewinnt also der
|
||||
// erste Treffer, und das war nicht zwingend der gemeinte.
|
||||
const topP = hasND ? api('getTopSongs', `&id=${encodeURIComponent(artist.id)}&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
|
||||
const iArtistP = knownDeezer ? Promise.resolve([{ artistId: knownDeezer }]) : deezerSearch(artist.name, 'musicArtist', 5);
|
||||
const eventsP = fetchEvents(artist.name);
|
||||
|
||||
@@ -122,6 +135,24 @@ export async function loadArtistDetail(artist) {
|
||||
const [albumRes] = await Promise.allSettled([albumP]);
|
||||
|
||||
const _ndArtist = albumRes.status === 'fulfilled' ? albumRes.value?.artist : null;
|
||||
if (hasND && artist.id) {
|
||||
const folgeBtn = hero?.querySelector('.artist-follow-btn');
|
||||
_verdrahteMerken(
|
||||
folgeBtn, artist.id, _ndArtist?.starred, '', '',
|
||||
`Du folgst ${artist.name}`, `${artist.name} folgen`,
|
||||
);
|
||||
// Ohne Pinnung laeuft die Deezer-Zuordnung ueber den Namen — bei gleichnamigen Kuenstlern
|
||||
// kaemen dauerhaft die Neuheiten des Falschen.
|
||||
if (folgeBtn && !_pin && !_ndArtist?.starred) {
|
||||
folgeBtn.addEventListener('click', () => {
|
||||
setTimeout(() => {
|
||||
if (folgeBtn.classList.contains('is-starred')) {
|
||||
toast('Tipp: Über ⚙ daneben den richtigen Deezer-Künstler festpinnen');
|
||||
}
|
||||
}, 900);
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
if (_ndArtist) cacheArtistSortNames([_ndArtist]);
|
||||
const _firstAlbums = _ndArtist?.album || [];
|
||||
const _bgCover = _firstAlbums[0] ? coverUrl(_firstAlbums[0].coverArt, 512) : null;
|
||||
@@ -331,6 +362,7 @@ export async function loadArtistDetail(artist) {
|
||||
evBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Konzerte${events.length ? ` <span style="font-size:11px;opacity:.7">(${events.length})</span>` : ''}`;
|
||||
evBtn.onclick = () => openEventsModal(events, artist.name);
|
||||
actionBar.appendChild(evBtn);
|
||||
|
||||
}
|
||||
|
||||
if(albEl) _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist);
|
||||
@@ -479,10 +511,57 @@ export async function loadArtistAllSongs({ albums, artist }) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── MARKIEREN (Künstler + Album) ───
|
||||
// Navidrome wirft beim Markieren `id`, `albumId` und `artistId` in eine Liste und behandelt sie
|
||||
// gleich (media_annotation.go) — derselbe Aufruf taugt also für Songs, Alben und Künstler. Der
|
||||
// Zustand kommt aus `starred` in getArtist/getAlbum.
|
||||
const _HERZ = (voll) => `<svg width="13" height="13" viewBox="0 0 24 24" fill="${voll ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`;
|
||||
|
||||
/**
|
||||
* Verdrahtet einen Knopf als Markieren-Umschalter. Optimistisch: der Knopf springt sofort um und
|
||||
* kehrt zurück, wenn der Aufruf scheitert — sonst fühlt sich das Markieren träge an.
|
||||
*/
|
||||
function _verdrahteMerken(btn, id, markiert, textAn, textAus, meldungAn, meldungAus) {
|
||||
if (!btn || !id) return;
|
||||
let an = !!markiert;
|
||||
const malen = () => {
|
||||
const txt = an ? textAn : textAus;
|
||||
btn.innerHTML = _HERZ(an) + (txt ? `<span>${txt}</span>` : '');
|
||||
btn.classList.toggle('is-starred', an);
|
||||
if (!txt) btn.title = an ? meldungAn : meldungAus;
|
||||
};
|
||||
malen();
|
||||
btn.onclick = async () => {
|
||||
const vorher = an;
|
||||
an = !an; malen();
|
||||
try {
|
||||
await api(vorher ? 'unstar' : 'star', `&id=${encodeURIComponent(id)}`);
|
||||
invalidateApiCache('getStarred2');
|
||||
// Der "Gefolgt"-Filter auf der Künstlerseite hält eine eigene kurze Liste — die muss mit weg,
|
||||
// sonst zeigt er nach dem Entfolgen noch eine halbe Minute den alten Stand.
|
||||
import('./home.js').then(m => m.invalidateGefolgt?.()).catch(() => {});
|
||||
toast(an ? meldungAn : meldungAus);
|
||||
} catch (e) {
|
||||
an = vorher; malen();
|
||||
toast('Fehler: ' + e.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDeezerAlbumDetail(album) {
|
||||
document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack();
|
||||
document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—';
|
||||
document.getElementById('deezer-album-detail-artist').textContent = album.artistName || '';
|
||||
// Deezer liefert die Künstler-ID in jedem Albumtreffer gleich mit — ohne sie ist der Name nur
|
||||
// toter Text (z. B. bei Alben aus /artist/{id}/albums, wo Deezer das artist-Objekt weglässt).
|
||||
const dzArtistEl = document.getElementById('deezer-album-detail-artist');
|
||||
dzArtistEl.textContent = album.artistName || '';
|
||||
dzArtistEl.onclick = null;
|
||||
dzArtistEl.classList.toggle('is-link', !!(album.artistId && album.artistName));
|
||||
if (album.artistId && album.artistName) {
|
||||
dzArtistEl.onclick = () => navigate('artist-detail', {
|
||||
id: null, name: album.artistName, deezerArtistId: album.artistId, deezerFanCount: null,
|
||||
});
|
||||
}
|
||||
const cw = document.getElementById('deezer-album-cover-wrap');
|
||||
const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600');
|
||||
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
|
||||
@@ -572,8 +651,13 @@ export async function loadPartialAlbumDetail({ nd, deezer }) {
|
||||
// ─── ALBUM DETAIL ───
|
||||
export async function loadAlbumDetail(album) {
|
||||
if (S.cfg.showRomaji) await ensureArtistSortMap().catch(() => {});
|
||||
// Zurück heißt zurück: einen Eintrag im Verlauf zurückgehen, wie auf jeder anderen Detailseite.
|
||||
// Vorher navigierte dieser Knopf VORWÄRTS auf die Albenliste (bzw. den Artist) — das landete nicht
|
||||
// dort, wo man das Album angeklickt hatte, und schob zusätzlich einen neuen History-Eintrag auf den
|
||||
// Stapel, sodass der Browser-Zurück-Knopf sich durch diesen Müll arbeiten musste statt nach Home.
|
||||
// Nur wenn es keinen Verlauf gibt (Direkteinstieg auf ein Album), bleibt die Albenliste das Ziel.
|
||||
document.getElementById('album-back-btn').onclick = () =>
|
||||
album._fromArtist ? navigate('artist-detail', album._fromArtist) : navigate('albums');
|
||||
S._navCursor > 0 ? _navGoBack() : navigate('albums');
|
||||
document.getElementById('album-detail-name').textContent = dispAlbumName(album);
|
||||
const artistEl = document.getElementById('album-detail-artist');
|
||||
artistEl.innerHTML = parseArtists(album)
|
||||
@@ -587,8 +671,12 @@ export async function loadAlbumDetail(album) {
|
||||
try {
|
||||
const r = await api('getAlbum', `&id=${album.id}`);
|
||||
const songs = r.album?.song || [];
|
||||
const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60);
|
||||
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`;
|
||||
const gesamt = songs.reduce((s,t) => s+(t.duration||0), 0);
|
||||
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${formatGesamtdauer(gesamt)}`;
|
||||
_verdrahteMerken(
|
||||
document.getElementById('btn-star-album'), album.id, r.album?.starred,
|
||||
'Gemerkt', 'Merken', '❤ Album gemerkt', 'Aus den Favoriten entfernt',
|
||||
);
|
||||
// r.album has sortName (album sort tag) — use it; nav album may not have sortName
|
||||
document.getElementById('album-detail-name').textContent = dispAlbumName(r.album);
|
||||
// Album artist comes from the album's own album-artist tags (r.album.artists / .artist), NOT the
|
||||
|
||||
+56
-16
@@ -3,6 +3,7 @@ import { esc } from '../utils.js';
|
||||
import { api, apiCached, apiIsCached, invalidateApiCache, coverUrl, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames } from '../api.js';
|
||||
import { makeCard, makeAlbumCard, makeSongItem } from '../ui/components.js';
|
||||
import { playQueue } from '../player/player.js';
|
||||
import { mischeListe } from '../player/queueMath.js';
|
||||
// Circular — safe (called inside event handlers)
|
||||
import { navigate, _navGoBack } from './navigation.js';
|
||||
|
||||
@@ -161,7 +162,7 @@ async function playGenre(genre) {
|
||||
const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=200`);
|
||||
const songs = r.songsByGenre?.song || [];
|
||||
if (!songs.length) return;
|
||||
playQueue([...songs].sort(() => Math.random() - 0.5), 0);
|
||||
playQueue(mischeListe(songs), 0);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -187,7 +188,7 @@ export async function loadGenreDetail(g) {
|
||||
|
||||
document.getElementById('genre-detail-stats').textContent = `${songs.length} Titel`;
|
||||
document.getElementById('btn-play-genre').onclick = () => {
|
||||
playQueue([...songs].sort(() => Math.random() - 0.5), 0);
|
||||
playQueue(mischeListe(songs), 0);
|
||||
};
|
||||
|
||||
list.innerHTML = '';
|
||||
@@ -210,7 +211,12 @@ export async function loadAlbumRow(id, method, extra, force = false) {
|
||||
}
|
||||
|
||||
// ─── ARTISTS ───
|
||||
export async function loadArtists() {
|
||||
let _artistsFilter = 'all';
|
||||
|
||||
export async function loadArtists(filter = _artistsFilter) {
|
||||
_artistsFilter = filter;
|
||||
document.querySelectorAll('#artists-filter .sort-chip').forEach(c =>
|
||||
c.classList.toggle('active', c.dataset.filter === filter));
|
||||
const g = document.getElementById('artists-grid');
|
||||
if (!apiIsCached('getArtists')) g.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
@@ -218,27 +224,61 @@ export async function loadArtists() {
|
||||
const all = (r.artists?.index || []).flatMap(i => i.artist || []);
|
||||
cacheArtistSortNames(all);
|
||||
if (S.cfg.showRomaji) await fetchRomajiIndex().catch(() => {});
|
||||
|
||||
// "Gefolgt" = Navidromes Stern. getArtists liefert kein `starred` mit, die Liste kommt also
|
||||
// aus getStarred2 — zwischengespeichert, damit das Umschalten nicht jedes Mal nachfragt.
|
||||
const gefolgt = await _gefolgteIds();
|
||||
const zaehler = document.getElementById('artists-followed-count');
|
||||
if (zaehler) zaehler.textContent = gefolgt.size ? `(${gefolgt.size})` : '';
|
||||
|
||||
const liste = filter === 'followed' ? all.filter(a => gefolgt.has(a.id)) : all;
|
||||
g.innerHTML = '';
|
||||
all.forEach(a => {
|
||||
const c = makeCard(dispArtistById(a.id, a.name), a.albumCount+' Alben', null, '🎤');
|
||||
const suche = document.getElementById('artists-search');
|
||||
if (suche) suche.value = '';
|
||||
if (!liste.length) {
|
||||
g.innerHTML = filter === 'followed'
|
||||
? '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p><p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>'
|
||||
: '<div class="empty-state"><div class="icon">🎤</div><p>Keine Künstler</p></div>';
|
||||
return;
|
||||
}
|
||||
// Das Bild kommt direkt aus `coverArt`, das getArtists ohnehin mitliefert (Navidrome loest
|
||||
// Kuenstlerbilder selbst auf: artist.*, album/artist.*, extern — und cacht sie). Frueher lief
|
||||
// hier PRO KUENSTLER ein eigener getArtistInfo2-Aufruf nur um die Bild-Adresse zu erfahren:
|
||||
// bei 200 Kuenstlern 200 API-Aufrufe auf einmal, die der Browser zu sechst abarbeitet, und
|
||||
// jeder davon konnte Navidrome zu einer externen Abfrage zwingen. Ist coverArt leer, hat der
|
||||
// Kuenstler schlicht kein Bild — dann sparen wir uns den Versuch ganz.
|
||||
liste.forEach(a => {
|
||||
const bild = a.coverArt ? coverUrl(a.coverArt, 300) : null;
|
||||
const c = makeCard(dispArtistById(a.id, a.name), a.albumCount+' Alben', bild, '🎤');
|
||||
c.classList.add('artist-card');
|
||||
c.onclick = () => navigate('artist-detail', a);
|
||||
g.appendChild(c);
|
||||
getArtistImage(a.id).then(imgUrl => {
|
||||
if(!imgUrl) return;
|
||||
const artEl = c.querySelector('.card-art');
|
||||
if(!artEl) return;
|
||||
const overlay = artEl.querySelector('.play-overlay');
|
||||
const img = document.createElement('img');
|
||||
img.src = imgUrl; img.loading = 'lazy'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;transition:transform .4s';
|
||||
artEl.innerHTML = ''; artEl.appendChild(img); if(overlay) artEl.appendChild(overlay);
|
||||
c.addEventListener('mouseenter', () => img.style.transform = 'scale(1.05)');
|
||||
c.addEventListener('mouseleave', () => img.style.transform = 'scale(1)');
|
||||
});
|
||||
});
|
||||
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
/** IDs der markierten Künstler; kurz gecacht, damit das Umschalten der Chips flott bleibt. */
|
||||
let _gefolgtCache = { ts: 0, ids: new Set() };
|
||||
|
||||
/** Nach einem Herz-Klick verwerfen — sonst zeigte der Filter bis zu 30 s den alten Stand. */
|
||||
export function invalidateGefolgt() { _gefolgtCache = { ts: 0, ids: _gefolgtCache.ids }; }
|
||||
async function _gefolgteIds() {
|
||||
if (Date.now() - _gefolgtCache.ts < 30_000) return _gefolgtCache.ids;
|
||||
try {
|
||||
const r = await api('getStarred2');
|
||||
_gefolgtCache = { ts: Date.now(), ids: new Set((r.starred2?.artist || []).map(a => a.id)) };
|
||||
} catch { /* Filter bleibt beim letzten bekannten Stand */ }
|
||||
return _gefolgtCache.ids;
|
||||
}
|
||||
|
||||
export function filterArtists(val) {
|
||||
const q = val.trim().toLowerCase();
|
||||
document.querySelectorAll('#artists-grid .card').forEach(c => {
|
||||
const name = (c.querySelector('.card-title')?.textContent || '').toLowerCase();
|
||||
c.style.display = !q || name.includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// ─── ALBUMS ───
|
||||
let _albumSort = 'alphabeticalByName';
|
||||
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user