Compare commits
228
Commits
98d7c84269
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cbeca268c | ||
|
|
9afd73ae7f | ||
|
|
7cdcc352d6 | ||
|
|
624832959d | ||
|
|
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 | ||
|
|
6da40d9e63 | ||
|
|
2fd4d978d0 | ||
|
|
845f125d76 | ||
|
|
3ceba8a365 | ||
|
|
3000dc1622 | ||
|
|
33d24c6ed4 | ||
|
|
908b50bcd9 | ||
|
|
7996971ba0 | ||
|
|
026b392657 | ||
|
|
ed641e999c | ||
|
|
67642f8ab8 | ||
|
|
b7c96451d8 | ||
|
|
d11de8a69e | ||
|
|
b584159568 | ||
|
|
f56d47800c | ||
|
|
2208ce77b6 | ||
|
|
f27eaf9c82 | ||
|
|
7b4599c50a | ||
|
|
934253a036 | ||
|
|
0a6c81b4a1 | ||
|
|
593c9fdb7b | ||
|
|
cb7a3d77be | ||
|
|
635ba8d353 | ||
|
|
31d4a72e62 | ||
|
|
4daf87b550 | ||
|
|
8fc6a3aaef | ||
|
|
ef019b9b9d | ||
|
|
0990dfadb5 | ||
|
|
e82dec7e01 | ||
|
|
6f455b565f | ||
|
|
7f5d25a03b | ||
|
|
87ee8cd097 | ||
|
|
803331e807 | ||
|
|
26de85a140 | ||
|
|
1ee85482f3 | ||
|
|
a5e5d414b4 | ||
|
|
e34a934460 | ||
|
|
19f82ad076 | ||
|
|
c933847201 | ||
|
|
cbedbba6fc | ||
|
|
ba2306656e | ||
|
|
5f251d555f | ||
|
|
02960cf886 | ||
|
|
b788fedec3 | ||
|
|
75dceba97b | ||
|
|
e61d57425e | ||
|
|
6d084a7b7a | ||
|
|
16f76a4236 | ||
|
|
d195518dfb | ||
|
|
711a084686 | ||
|
|
0c7132a7f2 | ||
|
|
249b693936 | ||
|
|
d35fb01306 | ||
|
|
1d98b273e7 | ||
|
|
3129333f9a | ||
|
|
86eb893f40 | ||
|
|
14c0dea7e4 | ||
|
|
561014c83f | ||
|
|
906b3b48cb | ||
|
|
fff6f9e4da | ||
|
|
cc1ec1653f | ||
|
|
26880016e9 | ||
|
|
e782b75bd9 | ||
|
|
eb55184118 | ||
|
|
71190488ca | ||
|
|
e69fbaf75e | ||
|
|
17bb527e1d | ||
|
|
1e206e8df3 | ||
|
|
bc87a04da5 | ||
|
|
96861bea06 | ||
|
|
e4f030c98d | ||
|
|
b3cf01dd3b | ||
|
|
fbee695aad | ||
|
|
cc4d3b72d2 | ||
|
|
e059c358b3 | ||
|
|
569af968c5 | ||
|
|
640e475c51 | ||
|
|
5528947a93 | ||
|
|
9167bfaf0e | ||
|
|
c92ac3d719 | ||
|
|
065d2f7319 | ||
|
|
1e69831dd8 | ||
|
|
a65613b5a1 | ||
|
|
ad433c4dbc | ||
|
|
68a9cdd7d1 | ||
|
|
ccf704754a | ||
|
|
9c8b4775ea | ||
|
|
d98978a6c7 | ||
|
|
966b318d9e | ||
|
|
72b612f5ff | ||
|
|
8cd3e9ac23 | ||
|
|
a3c8b1afd1 | ||
|
|
04a29b684d | ||
|
|
f097dddc64 | ||
|
|
1e242796fe | ||
|
|
14fd5119f2 | ||
|
|
f21174bd2c | ||
|
|
b1a32f5130 | ||
|
|
179e0eb902 | ||
|
|
e78ce43019 | ||
|
|
48a13fbccb | ||
|
|
4d44a476a5 | ||
|
|
bdf2867bdd | ||
|
|
e8b82c177f | ||
|
|
ea98d81146 | ||
|
|
79100c5321 | ||
|
|
3f57e2ae26 | ||
|
|
315b8aaacc | ||
|
|
c5649ac22f | ||
|
|
a80ac3deb0 | ||
|
|
4503bbe54e | ||
|
|
9270d31608 | ||
|
|
e50b4dc93c | ||
|
|
b07212fdce | ||
|
|
419f80568c | ||
|
|
03046cd631 | ||
|
|
03bc736725 | ||
|
|
abb4047966 | ||
|
|
17a28b3011 | ||
|
|
1775ec5b9b | ||
|
|
d50e22b62d | ||
|
|
ef25ac3100 | ||
|
|
ec4d5d0572 | ||
|
|
8741c2fb88 | ||
|
|
445a7bf583 | ||
|
|
63a7260c94 | ||
|
|
ddb9418603 | ||
|
|
7f7d7740e4 | ||
|
|
17a38e9c64 | ||
|
|
7cba39dfaf | ||
|
|
46e252b494 | ||
|
|
be73f717ac | ||
|
|
e1ec1f3660 | ||
|
|
6131eea9ec | ||
|
|
f252586398 | ||
|
|
bed007258a | ||
|
|
420c84d191 |
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
-14
@@ -1,19 +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
|
# ── URLs pre-filled on the frontend setup screen ─────────────────────────────
|
||||||
NAVIDROME_URL=https://music.yourdomain.com
|
NAVIDROME_URL=https://music.example.com
|
||||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
BACKEND_URL=https://backend.example.com
|
||||||
|
SCROBBLE_URL=https://scrobble.example.com
|
||||||
|
|
||||||
# Absolute path to your Navidrome music library on the host
|
# ── Library / downloads (backend) ────────────────────────────────────────────
|
||||||
MUSIC_PATH=/srv/navidrome/music
|
# Absolute music path — mounted 1:1 into the backend (no remapping), same value inside the container.
|
||||||
|
MUSIC_PATH=/opt/navidrome/music
|
||||||
AUDIO_FMT=mp3 # mp3 | flac | opus | best (used by yt-dlp)
|
AUDIO_FMT=mp3
|
||||||
AUDIO_QUALITY=0 # 0 = best quality (used by yt-dlp)
|
AUDIO_QUALITY=0
|
||||||
MAX_PARALLEL=2
|
MAX_PARALLEL=2
|
||||||
|
|
||||||
DOWNLOAD_BACKEND=ytdlp
|
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=
|
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
|
||||||
@@ -2,3 +2,6 @@
|
|||||||
*.log
|
*.log
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
scripts/.env.release
|
||||||
|
.env
|
||||||
|
.idea/
|
||||||
+1227
-23
File diff suppressed because it is too large.
Load diff
@@ -20,10 +20,16 @@ plus a **Node.js/Express backend** that downloads tracks via yt-dlp and proxies
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `src/` — web PWA (vanilla ES modules). State in `src/state.js` (the `S` object);
|
- `src/` — web PWA (vanilla ES modules). State in `src/state.js` (the `S` object);
|
||||||
Subsonic/iTunes API in `src/api.js`; views in `src/views/`, UI in `src/ui/`.
|
Subsonic/Deezer API in `src/api.js`; views in `src/views/`, UI in `src/ui/`.
|
||||||
|
- **Client-side router** (`src/views/navigation.js`, History API): `navigate()` pushStates `{nsIdx}`
|
||||||
|
only on genuine new navigation; `popstate` applies the target entry's `nsIdx` for **both** back and
|
||||||
|
forward. Never `pushState` inside the `popstate`/back path — it wipes the browser's forward stack
|
||||||
|
(that was the "forward doesn't work" bug). In-app back buttons call `history.back()` (one path).
|
||||||
- `backend/novasonic-server.js` — Express API (downloads, metadata edit/delete, events/ABS proxy).
|
- `backend/novasonic-server.js` — Express API (downloads, metadata edit/delete, events/ABS proxy).
|
||||||
- `backend/lib/` — `ytdlp.js` (download + cascade + ffmpeg tagging), `downloader.js`,
|
- `backend/lib/` — `ytdlp.js` (download + cascade + ffmpeg tagging), `downloader.js`,
|
||||||
`metadata.js` (iTunes lookup, file resolution helpers), `config.js`, `jobs.js`, `romaji.js`.
|
`deezer.js` (Deezer metadata: track/album/artist lookups + matching),
|
||||||
|
`metadata.js` (artwork, file resolution helpers, duplicate detection), `config.js`, `jobs.js`, `romaji.js`.
|
||||||
|
- `scrobble/scrobble-server.js` — **separate** listening-stats service (own container + DB), see below.
|
||||||
- `android/` — native Kotlin app (separate; backend-dependent web features like the
|
- `android/` — native Kotlin app (separate; backend-dependent web features like the
|
||||||
metadata editor are **not** in the Android app).
|
metadata editor are **not** in the Android app).
|
||||||
|
|
||||||
@@ -35,13 +41,39 @@ npm run build # production build → dist/
|
|||||||
npm run preview # preview the production build
|
npm run preview # preview the production build
|
||||||
```
|
```
|
||||||
|
|
||||||
Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives at
|
Der Stack läuft in Docker. **`docker-compose.yml` ist der Normalfall und zieht fertige Images**
|
||||||
`/opt/navidrome/music`. Restart after backend changes: `docker compose restart novasonic-backend`
|
(`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}`) — der Server baut nichts:
|
||||||
(this also re-pulls the latest yt-dlp).
|
`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)
|
## Download pipeline (hard-won knowledge)
|
||||||
|
|
||||||
- Downloads are **yt-dlp only** (the old squid.wtf/Qobuz backend was removed).
|
- Downloads are **yt-dlp only** (the old squid.wtf/Qobuz backend was removed).
|
||||||
|
- **Metadata source = Deezer, not iTunes** (as of 1.3). Apple's iTunes `/search` returns **403 from
|
||||||
|
server/datacenter IPs**, which broke search + downloads + charts + editor autofill in production.
|
||||||
|
All metadata now comes from Deezer (`backend/lib/deezer.js` server-side; `_deezerBase()` in
|
||||||
|
`src/api.js`; Android `ItunesClient` is Deezer-backed). Free, no key, more requests, returns
|
||||||
|
explicit versions + real artist images + durations. Deezer generally gives one consistent name per
|
||||||
|
artist, so the old JP/US-store inconsistency (Western artists as katakana "DJスネイク" on some tracks,
|
||||||
|
kanji on others) is gone. Titles come from the source, so a Japanese title the user picked stays
|
||||||
|
Japanese; only Deezer-derived fields (album\_artist, genre, year, artwork) follow Deezer. The
|
||||||
|
Deezer results are mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged.
|
||||||
|
`lookupTrackMeta`/`fetchAlbumTracks`/`searchAlbumTracks`/`fetchArtistAlbums` replace the old
|
||||||
|
`lookupItunesTrackMeta`/`fetchItunesTracks`/`searchItunesAlbumTracks`. Editor autofill endpoint:
|
||||||
|
`/api/meta/lookup` (was `/api/itunes/lookup`). Matching logic (`pickTrackMatch`) is unit-tested in
|
||||||
|
`backend/test/deezer.test.js` (`npm test`).
|
||||||
- **File naming:** `{NN - }Artist - Title.ext` with the release suffix stripped from the
|
- **File naming:** `{NN - }Artist - Title.ext` with the release suffix stripped from the
|
||||||
title (no `- Single`/`- EP`). The **album folder keeps the full album name**. Original names
|
title (no `- Single`/`- EP`). The **album folder keeps the full album name**. Original names
|
||||||
are used for folder, filename, and all main tags (artist/title/album/album\_artist) — even
|
are used for folder, filename, and all main tags (artist/title/album/album\_artist) — even
|
||||||
@@ -49,16 +81,79 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
|
|||||||
- **Search cascade** (`ytdlp.js`): YTM search first (`web_music` player client, Songs filter
|
- **Search cascade** (`ytdlp.js`): YTM search first (`web_music` player client, Songs filter
|
||||||
param), then regular YouTube; each candidate filtered by duration (±10%, min 10s),
|
param), then regular YouTube; each candidate filtered by duration (±10%, min 10s),
|
||||||
title keyword, artist channel, and variant exclusions (remix/cover/sped/…).
|
title keyword, artist channel, and variant exclusions (remix/cover/sped/…).
|
||||||
|
- **Official audio via `artist` metadata (not just channel).** The strict steps accept a candidate
|
||||||
|
if the channel name matches the artist **OR** yt-dlp's music `artist` field does. YouTube "Art
|
||||||
|
Tracks" (label-provided official audio) carry `artist=<Artist>` even when the channel is
|
||||||
|
`BANGTANTV`/`HYBE LABELS`/… (no artist name). Confirmed on "BTS Pied Piper": only the official
|
||||||
|
upload had `artist=BTS`; every fan/live/lyric reupload had `artist=NA`. `channel_is_verified` was
|
||||||
|
rejected as a signal — lyric videos and fan MVs also run on verified channels. Implemented as two
|
||||||
|
`--match-filter` args (yt-dlp OR's them) in `mf()`.
|
||||||
- **"Mittelweg" rule** (with AND without duration): when nothing matches in the filtered steps,
|
- **"Mittelweg" rule** (with AND without duration): when nothing matches in the filtered steps,
|
||||||
relax only the duration constraint while **still requiring the artist channel**. If nothing
|
relax only the duration constraint while **still requiring the artist channel**. If nothing
|
||||||
matches, download **nothing** and fail cleanly with "Nicht eindeutig gefunden — bitte
|
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
|
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.
|
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×
|
- **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2×
|
||||||
expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through.
|
expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through.
|
||||||
|
- **Leading aesthetic characters:** artists often style their name with leading punctuation
|
||||||
|
(`.diedlonely`, `†…`). `deezer.js` strips leading non-alphanumerics (`stripLeadSym`) before the
|
||||||
|
Deezer lookup and in `firstWord`. Without it the lookup misses, falls through to a title-only
|
||||||
|
match, and returns an unrelated same-titled track — whose wrong duration then *also* misleads the
|
||||||
|
YouTube duration filter (observed: a 96 s track resolved to a 176 s slowed version). The same trim
|
||||||
|
runs client-side in Android's `ExploreViewModel.search()`.
|
||||||
|
- **Mixed-artist albums search per track.** `downloadAlbumTracks` passes `rawArtist = track.artistName`
|
||||||
|
so the YTM/YouTube search query and the channel filter use each track's *own* performer, not the
|
||||||
|
album artist. `ytdlp.js` derives both from `rawArtist || mainArtist`, and `mainArtist = albumArtist`,
|
||||||
|
so without this every track on a soundtrack searched the album-artist blob + its channel filter and
|
||||||
|
matched nothing (e.g. "Strategy" = TWICE, "Love, Maybe" = MeloMance failed). The `album_artist` tag
|
||||||
|
still uses `albumArtist`, so album grouping is unaffected.
|
||||||
|
- **Album/artist download `album_artist`** comes from the Deezer tracks' `collectionArtistName`
|
||||||
|
(= the album-level `album.artist`, identical across the album), derived in `novasonic-server.js` —
|
||||||
|
**not** the request's `artist`, which can be the lead single's performer (a KPop soundtrack got
|
||||||
|
tagged `album_artist=TWICE` from track 1 "TAKEDOWN"). Falls back to the request artist only when
|
||||||
|
Deezer omits it.
|
||||||
|
- **Always write an `album` tag.** `ytdlp.js` defaults it to `Singles` when no album is known.
|
||||||
|
Previously no tag was written at all, so Navidrome showed its own "Singles" fallback while the
|
||||||
|
metadata editor showed "[Unknown Album]" — the two never matched and file resolution failed.
|
||||||
- **Duplicate detection** (`metadata.js findExistingTrack`): searches case-insensitively across
|
- **Duplicate detection** (`metadata.js findExistingTrack`): searches case-insensitively across
|
||||||
all album folders under the matching artist folder; compares normalized artist+title ignoring
|
all album folders under the matching artist folder; compares normalized artist+title ignoring
|
||||||
track prefix and release/variant suffix. Handles DJ OKAWARI vs DJ Okawari and "- Single" drift.
|
track prefix and release/variant suffix. Handles DJ OKAWARI vs DJ Okawari and "- Single" drift.
|
||||||
|
- **Single-track metadata resolver = `backend/lib/trackmeta.js`** (Deezer → MusicBrainz → clean
|
||||||
|
fallback). Deezer's `pickTrackMatch` requires an **artist correspondence** (equal, or one name
|
||||||
|
contained in the other after stripping non-alphanumerics) — the old blind *title-only* fallback
|
||||||
|
grabbed a same-titled song by a different artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and
|
||||||
|
applied its wrong album/cover/year. On no match it returns `{}`; then `musicbrainz.js` (rate-limited
|
||||||
|
~1/s, `User-Agent` required, Cover Art Archive for covers, **same** artist discipline) is tried; if
|
||||||
|
that also misses, the download's built-in clean fallback runs (**album_artist = song artist, album =
|
||||||
|
"Singles", cover = source thumbnail, year/genre left untouched**). Only the three *single-track*
|
||||||
|
call sites use `trackmeta`; the album batch stays Deezer-only (avoids MB's rate limit). The clean
|
||||||
|
fallback always existed but was unreachable while the match was too greedy. Matching is unit-tested
|
||||||
|
(`deezer.test.js`, `musicbrainz.test.js`).
|
||||||
- **Known limitation:** yt-dlp's YTM/YouTube *search* can't surface very obscure Art Tracks by
|
- **Known limitation:** yt-dlp's YTM/YouTube *search* can't surface very obscure Art Tracks by
|
||||||
ambiguously-named artists (e.g. "MINA") — the browser finds them, yt-dlp's API extraction
|
ambiguously-named artists (e.g. "MINA") — the browser finds them, yt-dlp's API extraction
|
||||||
doesn't (confirmed across params/clients/deno). For those, the **direct link in the importer**
|
doesn't (confirmed across params/clients/deno). For those, the **direct link in the importer**
|
||||||
@@ -67,9 +162,47 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
|
|||||||
or a stale stem. `metadata.js` rebases the path and falls back to recursive/stem matching
|
or a stale stem. `metadata.js` rebases the path and falls back to recursive/stem matching
|
||||||
(tolerant of release-suffix mismatch). A ghost Navidrome entry needs a full library rescan.
|
(tolerant of release-suffix mismatch). A ghost Navidrome entry needs a full library rescan.
|
||||||
- **Album splitting:** prevented by consistent `album_artist` tag. All tracks in a download
|
- **Album splitting:** prevented by consistent `album_artist` tag. All tracks in a download
|
||||||
share the same `mainArtist` value (= iTunes `collectionArtistName` when available, else
|
share the same `mainArtist` value (= Deezer `collectionArtistName` when available, else
|
||||||
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
|
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
|
||||||
|
|
||||||
|
## Listening stats / scrobble system (v2.0)
|
||||||
|
|
||||||
|
- **Why separate:** Navidrome stores only a play *count* + *last* play date (no timestamps), so
|
||||||
|
per-day/month/year stats and a "Wrapped" are impossible from it. NovaSonic *is* the player, so it
|
||||||
|
records every counted play into its **own** timestamped, multi-user log.
|
||||||
|
- **Own container `novasonic-scrobble`** (`scrobble/scrobble-server.js`) — deliberately isolated from
|
||||||
|
the download backend: the backend is heavy (yt-dlp/ffmpeg) and restarts often; an archive needs
|
||||||
|
uptime + data integrity. Runs on `node:22-slim` with the **built-in `node:sqlite`** (run with
|
||||||
|
`--experimental-sqlite`; no native build). DB `/data/novasonic-plays.db` in its own volume.
|
||||||
|
- **Routes:** `POST /scrobble/plays` (batch, **idempotent by uuid**), `GET /scrobble/history`,
|
||||||
|
`GET /scrobble/stats` (totals, top artists/albums/tracks, day/month/year series, 24h clock),
|
||||||
|
`DELETE /scrobble/plays?user=` (test cleanup), `GET /scrobble/health`.
|
||||||
|
- **Multi-user auth:** every request carries the same Subsonic token creds (`u`, `t`, `s`) the app
|
||||||
|
already uses; the service verifies them via Navidrome **`ping.view`** (cached 5 min) → the stored
|
||||||
|
`user` is real, not spoofable. `node:sqlite` **throws on extra named params**, so each query gets
|
||||||
|
exactly the params its SQL references. `from` defaults to all-time (do **not** route `0` through
|
||||||
|
`_toEpochSec`, which maps `0`→now).
|
||||||
|
- **Denormalized on purpose:** title/artist/album are stored per play so the archive survives even if
|
||||||
|
the song is later deleted from Navidrome. Deleting a song does **not** touch the play-log.
|
||||||
|
- **Capture point (Web + Android):** `logPlay()` fires at the **existing** counted-scrobble moment
|
||||||
|
(respects the user's `scrobbleMode`, consistent with Last.fm) — **not** a separate 50%/4-min rule.
|
||||||
|
It runs **in addition to** the Navidrome/Last.fm scrobble, never replacing it. Web:
|
||||||
|
`src/player/scrobbleLog.js` (localStorage queue) + `subsonicAuthParams()` in `api.js`. Android:
|
||||||
|
`data/PlayLogClient.kt` (JSON-file queue, **one reused Ktor client**) + `repo.logPlay/flushPlayLog`
|
||||||
|
+ hook in `PlayerController.scheduleScrobble`. Both queue offline and flush as an **idempotent**
|
||||||
|
batch (server dedups by uuid) on start / when connectivity returns.
|
||||||
|
- **Stats view:** Web `src/ui/stats.js` (overlay from Settings), Android `ui/screen/StatsScreen.kt` +
|
||||||
|
`StatsViewModel.kt` (from a Settings button). **Cover art is resolved client-side** from Navidrome
|
||||||
|
(`coverUrl`/`coverArtUrl` via `artistId`/`albumId`/`songId`) — the scrobble service stays
|
||||||
|
cover-agnostic; the stats endpoint just returns the IDs.
|
||||||
|
- **Config:** `SCROBBLE_URL` env → `window.NS_SCROBBLE_URL` (entrypoint) → `S.cfg.scrobbleUrl` (web) /
|
||||||
|
`scrobbleUrl` pref (Android), mirroring `backendUrl`. Port **3710**.
|
||||||
|
- **Deploy gotcha:** the scrobble subdomain **must be excluded from any SSO / forward-auth gateway**
|
||||||
|
(like the download backend) — it self-authenticates via `ping.view`, and a gateway in front
|
||||||
|
302-redirects the browser's API calls to the login portal (breaks `fetch` → "nicht erreichbar").
|
||||||
|
- **Planned:** `/scrobble/wrapped` (yearly story) + optional ListenBrainz relay. See
|
||||||
|
`docs/scrobble-plan.md`.
|
||||||
|
|
||||||
## Android offline system
|
## Android offline system
|
||||||
|
|
||||||
- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
|
- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
|
||||||
@@ -79,6 +212,39 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
|
|||||||
- **Playback:** `MusicRepository.streamUrl()` checks `offlineRepo.localAudioPath(id)` first; if the file exists it returns a `file://` URI. This applies to every play path — album, playlist, Offline tab — no special offline mode needed.
|
- **Playback:** `MusicRepository.streamUrl()` checks `offlineRepo.localAudioPath(id)` first; if the file exists it returns a `file://` URI. This applies to every play path — album, playlist, Offline tab — no special offline mode needed.
|
||||||
- **Offline tab:** Library → tab 2 (OFFLINE). Driven by `OfflineViewModel`. Play button on a completed group calls `offlineVm.songsForGroup()` (JOIN query → `DownloadedTrackEntity.toSong()`) then `player.playSongs()`.
|
- **Offline tab:** Library → tab 2 (OFFLINE). Driven by `OfflineViewModel`. Play button on a completed group calls `offlineVm.songsForGroup()` (JOIN query → `DownloadedTrackEntity.toSong()`) then `player.playSongs()`.
|
||||||
|
|
||||||
|
## Android navigation, state & insets (hard-won)
|
||||||
|
|
||||||
|
- **A `BackHandler` inside a NavHost destination loses back priority to the NavHost.** The Erkunden
|
||||||
|
(Apple) artist/album are in-screen *overlays* on the `erkunden` destination, not routes — their
|
||||||
|
`BackHandler` never fired, so one back press popped the whole tab to Home. Fix: the overlay reports
|
||||||
|
its close action upward via `onOverlayBackChange`, and `AppNavigation` registers a **root-level**
|
||||||
|
`OnBackPressedCallback` for it (the same mechanism the NowPlaying overlay always used successfully).
|
||||||
|
Real nav routes (`album/{id}`, `artist/{id}`) are unaffected — `popBackStack()` is correct there.
|
||||||
|
- **Re-assert root-level back callbacks on `ON_RESUME`, not just on navigation.** Returning from the
|
||||||
|
background re-registers the NavController's own `OnBackPressedCallback` on **top** of the dispatcher,
|
||||||
|
which steals back-priority from the NowPlaying/ABS/Erkunden overlays — back then pops the NavHost
|
||||||
|
(invisibly, to Home) before dismissing the visible overlay. Fix in `AppNavigation`: a `resumeTick`
|
||||||
|
(`LifecycleEventEffect(ON_RESUME)`) plus `currentBackEntry` are keys on every overlay's
|
||||||
|
`DisposableEffect`, so each re-adds (bumps itself above the NavController) after a resume or a nav.
|
||||||
|
- **Overlay state belongs in the ViewModel, not `remember`.** Anything an overlay shows (e.g. the
|
||||||
|
artist's "all albums" section) is destroyed when the user drills into an album, because the overlay
|
||||||
|
leaves composition. `rememberSaveable` does **not** save you — it only survives for real nav
|
||||||
|
destinations. Hoist it into the ViewModel (see `ExploreViewModel.artistAllAlbums`).
|
||||||
|
- **`bottomBar` insets:** the tab `NavigationBar` absorbs the system-navigation inset. Detail screens
|
||||||
|
have no tab bar, so the MiniPlayer needs `navigationBarsPadding()` itself — otherwise it sits behind
|
||||||
|
the phone's gesture/navigation bar.
|
||||||
|
- **`ModalBottomSheet`:** use `rememberModalBottomSheetState(skipPartiallyExpanded = true)` *and*
|
||||||
|
`navigationBarsPadding()` on the content, or the bottom-most action (e.g. "Herunterladen") opens
|
||||||
|
hidden behind the navigation bar and needs an extra drag-up.
|
||||||
|
- **Reuse the Ktor client.** `MusicRepository.coverArtUrl()/streamUrl()` must go through
|
||||||
|
`ClientHolder.get(creds)`. Constructing a `SubsonicClient` per call spins up (and never closes) an
|
||||||
|
`HttpClient` for *every cover art request* — this was the cause of visible scroll/tab-switch lag.
|
||||||
|
- **Star/favorite is optimistic and global** via `data/StarState.kt`. Toggle through it so every icon
|
||||||
|
(song rows, NowPlaying, context sheet) flips instantly and rolls back if the API call fails.
|
||||||
|
- **Deezer artists carry a real photo** (`picture_*`), so artist search cards show it directly — no
|
||||||
|
album-cover borrowing needed. (The old iTunes artist entities had no `artworkUrl100`, which is why
|
||||||
|
the `entity=album&limit=1` album-cover fallback exists; it now only runs if a picture is missing.)
|
||||||
|
|
||||||
## UI / references
|
## UI / references
|
||||||
|
|
||||||
When referencing files in chat, use clickable markdown links, not backticks:
|
When referencing files in chat, use clickable markdown links, not backticks:
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
|
|||||||
- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
|
- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
|
||||||
- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
|
- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
|
||||||
- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
|
- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
|
||||||
- **Artist pages** — Hero card, Last.fm bio / similar / top songs, Ticketmaster events, and iTunes cross-reference showing owned vs. missing tracks.
|
- **Artist pages** — Hero card, bio / similar artists / top songs, Ticketmaster events, and a Deezer cross-reference showing owned vs. missing tracks. Same-name artists can be **pinned** (Deezer search or a pasted Deezer link/id) to fix the wrong-discography and wrong-photo problem; a pinned artist's similar list comes from Deezer.
|
||||||
- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
|
- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
|
||||||
- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with iTunes metadata enrichment and duplicate detection.
|
- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with Deezer metadata enrichment and duplicate detection.
|
||||||
- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save.
|
- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save.
|
||||||
- **Offline** (Android) — Download albums/playlists to private storage; an Offline tab to play and manage them; downloaded tracks play from disk on every screen.
|
- **Offline** (Android) — Download albums, playlists, or single songs to private storage; an Offline tab to play and manage them (works without a server connection); downloaded tracks play from disk on every screen.
|
||||||
|
- **Listening stats** — A separate, self-hosted scrobble service records **timestamped plays** (multi-user; Navidrome only keeps a play *count*). A statistics view (Web + Android, from Settings) shows totals, top artists/tracks/albums with cover art, a day/month/year play series, and a 24h listening clock, plus a **Musik-Match** that compares two users' taste. Runs *in addition to* Navidrome/Last.fm scrobbling.
|
||||||
- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync.
|
- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync.
|
||||||
|
|
||||||
Web is an installable PWA (themes, German UI); Android is native Jetpack Compose + Media3/ExoPlayer with a mini player, media/ABS notifications, and Android Auto. See [CHANGELOG.md](CHANGELOG.md) for the full web/Android parity matrix.
|
Web is an installable PWA (themes, German UI); Android is native Jetpack Compose + Media3/ExoPlayer with a mini player, media/ABS notifications, and Android Auto. See [CHANGELOG.md](CHANGELOG.md) for the full web/Android parity matrix.
|
||||||
@@ -28,7 +29,8 @@ NovaSonic/
|
|||||||
├── src/
|
├── src/
|
||||||
│ ├── player/
|
│ ├── player/
|
||||||
│ │ ├── player.js # Playback, queue, transport, media session, preload
|
│ │ ├── player.js # Playback, queue, transport, media session, preload
|
||||||
│ │ └── nowplaying.js # Now Playing overlay + synced lyrics
|
│ │ ├── nowplaying.js # Now Playing overlay + synced lyrics
|
||||||
|
│ │ └── scrobbleLog.js # Play-log capture + offline queue → scrobble service
|
||||||
│ ├── views/
|
│ ├── views/
|
||||||
│ │ ├── detail.js # Artist / album detail pages
|
│ │ ├── detail.js # Artist / album detail pages
|
||||||
│ │ ├── views.js # Navigation, page loaders, search
|
│ │ ├── views.js # Navigation, page loaders, search
|
||||||
@@ -48,24 +50,28 @@ NovaSonic/
|
|||||||
│ │ ├── metadata.js # Metadata editor
|
│ │ ├── metadata.js # Metadata editor
|
||||||
│ │ ├── modals.js # Song info, events modals
|
│ │ ├── modals.js # Song info, events modals
|
||||||
│ │ ├── notify.js # Toast notifications
|
│ │ ├── notify.js # Toast notifications
|
||||||
|
│ │ ├── stats.js # Listening statistics view (overlay)
|
||||||
│ │ └── settings.js # Settings page
|
│ │ └── settings.js # Settings page
|
||||||
│ ├── state.js # Shared mutable state (S object)
|
│ ├── state.js # Shared mutable state (S object)
|
||||||
│ ├── utils.js # Helpers (md5, esc, formatDur, toast)
|
│ ├── utils.js # Helpers (md5, esc, formatDur, toast)
|
||||||
│ ├── api.js # Subsonic API, iTunes API, cover art
|
│ ├── api.js # Subsonic API, Deezer API, cover art
|
||||||
│ └── main.js # App init, audio wiring, window globals
|
│ └── main.js # App init, audio wiring, window globals
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── novasonic-server.js # Express API: downloads, metadata, events proxy, ABS proxy
|
│ ├── novasonic-server.js # Express API: downloads, metadata, events proxy, ABS proxy
|
||||||
│ └── lib/
|
│ └── lib/
|
||||||
│ ├── config.js # Env var → config object
|
│ ├── config.js # Env var → config object
|
||||||
│ ├── jobs.js # In-memory download job store
|
│ ├── jobs.js # In-memory download job store
|
||||||
│ ├── metadata.js # iTunes lookup, artwork, filename helpers
|
│ ├── deezer.js # Deezer metadata: track/album/artist lookups
|
||||||
|
│ ├── metadata.js # Artwork, filename helpers, duplicate detection
|
||||||
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
|
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
|
||||||
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
|
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
|
||||||
│ └── romaji.js # Japanese → romaji search index
|
│ └── romaji.js # Japanese → romaji search index
|
||||||
|
├── scrobble/ # Isolated listening-stats service (own container + DB)
|
||||||
|
│ └── scrobble-server.js # node:sqlite play-log: ingest / history / stats / health
|
||||||
├── android/ # Native Android app (Jetpack Compose + Media3)
|
├── android/ # Native Android app (Jetpack Compose + Media3)
|
||||||
│ └── app/src/main/kotlin/com/novasonic/app/
|
│ └── app/src/main/kotlin/com/novasonic/app/
|
||||||
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient
|
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient (Deezer-backed)
|
||||||
│ ├── data/ # PrefsStore (DataStore), MusicRepository
|
│ ├── data/ # PrefsStore (DataStore), MusicRepository, StarState
|
||||||
│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads)
|
│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads)
|
||||||
│ ├── player/ # PlayerController, MusicService (Media3)
|
│ ├── player/ # PlayerController, MusicService (Media3)
|
||||||
│ ├── ui/
|
│ ├── ui/
|
||||||
@@ -81,9 +87,10 @@ NovaSonic/
|
|||||||
├── styles.css
|
├── styles.css
|
||||||
├── vite.config.js
|
├── vite.config.js
|
||||||
├── Dockerfile
|
├── 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
|
├── nginx.conf
|
||||||
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL at runtime
|
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL / SCROBBLE_URL at runtime
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -103,6 +110,7 @@ Edit `.env`:
|
|||||||
```env
|
```env
|
||||||
NAVIDROME_URL=https://music.yourdomain.com
|
NAVIDROME_URL=https://music.yourdomain.com
|
||||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
BACKEND_URL=https://novasonic-api.yourdomain.com
|
||||||
|
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # listening-stats service
|
||||||
|
|
||||||
MUSIC_PATH=/srv/navidrome/music
|
MUSIC_PATH=/srv/navidrome/music
|
||||||
|
|
||||||
@@ -116,10 +124,26 @@ TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
|
|||||||
### 2. Start
|
### 2. Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker compose pull
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend on port 8080, backend on port 3700. Route both through your reverse proxy.
|
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
|
### 3. First launch
|
||||||
|
|
||||||
@@ -127,6 +151,7 @@ Frontend on port 8080, backend on port 3700. Route both through your reverse pro
|
|||||||
2. Optionally add a Last.fm API key in Settings for bios and recommendations
|
2. Optionally add a Last.fm API key in Settings for bios and recommendations
|
||||||
3. Optionally connect Audiobookshelf in Settings
|
3. Optionally connect Audiobookshelf in Settings
|
||||||
4. Optionally add the NovaSonic backend URL in Settings to enable downloads
|
4. Optionally add the NovaSonic backend URL in Settings to enable downloads
|
||||||
|
5. Optionally add the NovaSonic scrobble URL in Settings to record listening stats
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -138,6 +163,7 @@ Frontend on port 8080, backend on port 3700. Route both through your reverse pro
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `NAVIDROME_URL` | Pre-fills the Navidrome server URL on the login screen |
|
| `NAVIDROME_URL` | Pre-fills the Navidrome server URL on the login screen |
|
||||||
| `BACKEND_URL` | Pre-fills the download backend URL on the login screen |
|
| `BACKEND_URL` | Pre-fills the download backend URL on the login screen |
|
||||||
|
| `SCROBBLE_URL` | Pre-fills the listening-stats (scrobble) service URL on the login screen |
|
||||||
|
|
||||||
### Backend container
|
### Backend container
|
||||||
|
|
||||||
@@ -150,6 +176,14 @@ Frontend on port 8080, backend on port 3700. Route both through your reverse pro
|
|||||||
| `MAX_PARALLEL` | `2` | Max concurrent download jobs |
|
| `MAX_PARALLEL` | `2` | Max concurrent download jobs |
|
||||||
| `TICKETMASTER_API_KEY` | — | Free key from [developer.ticketmaster.com](https://developer.ticketmaster.com) |
|
| `TICKETMASTER_API_KEY` | — | Free key from [developer.ticketmaster.com](https://developer.ticketmaster.com) |
|
||||||
|
|
||||||
|
### Scrobble container
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `3710` | Port the scrobble service listens on |
|
||||||
|
| `DATA_PATH` | `/data` | Where the play-log SQLite DB is stored (own volume) |
|
||||||
|
| `NAVIDROME_URL` | — | Used to verify user credentials via `ping.view` (multi-user auth) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -14,8 +14,35 @@ android {
|
|||||||
applicationId = "com.novasonic.app"
|
applicationId = "com.novasonic.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 30
|
||||||
versionName = "1.0"
|
versionName = "1.27"
|
||||||
|
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
|
||||||
|
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
|
||||||
|
// Platzhalter stehen und der App-Link greift schlicht nicht.
|
||||||
|
val host1 = (project.findProperty("novasonicHost") as String?) ?: "music.example.com"
|
||||||
|
// Zweiter Host fuer einen Domainwechsel: solange beide Adressen erreichbar sind, oeffnen
|
||||||
|
// Links von der alten UND der neuen in der App. Ohne Angabe wird host1 wiederholt — ein
|
||||||
|
// doppelter Eintrag ist harmlos, ein Platzhalter-Host waere es NICHT: auf Android 11 und
|
||||||
|
// aelter verifiziert das System nur, wenn ALLE angegebenen Hosts eine gueltige
|
||||||
|
// assetlinks.json ausliefern, ein toter Host wuerde also alle blockieren.
|
||||||
|
val host2 = (project.findProperty("novasonicHost2") as String?) ?: host1
|
||||||
|
manifestPlaceholders["shareHost"] = host1
|
||||||
|
manifestPlaceholders["shareHost2"] = host2
|
||||||
|
// Derselbe Host auch fuer AUSGEHENDE Links. Er steht ohnehin schon fest im Manifest, eine
|
||||||
|
// zusaetzliche Einstellung in der App waere also nur eine weitere Adresse zum Pflegen —
|
||||||
|
// und eine, die vom Manifest abweichen koennte, ohne dass es jemand merkt.
|
||||||
|
buildConfigField("String", "SHARE_HOST", "\"$host1\"")
|
||||||
|
|
||||||
|
// Vorgabe-Adressen fuer Backend und Scrobble-Dienst. Sie stehen ohnehin in der
|
||||||
|
// docker-compose.yml — sie zusaetzlich auf jedem Geraet einzutippen ist Arbeit ohne Nutzen.
|
||||||
|
// Leer gelassen verhaelt sich die App wie bisher (Felder sichtbar, nichts vorbelegt).
|
||||||
|
// Zugangsdaten bleiben bewusst aussen vor: die gehoeren pro Person, nicht ins APK.
|
||||||
|
val backendUrl = (project.findProperty("novasonicBackend") as String?) ?: ""
|
||||||
|
val scrobbleUrl = (project.findProperty("novasonicScrobble") as String?) ?: ""
|
||||||
|
val navidromeUrl= (project.findProperty("novasonicNavidrome") as String?) ?: ""
|
||||||
|
buildConfigField("String", "DEFAULT_BACKEND_URL", "\"$backendUrl\"")
|
||||||
|
buildConfigField("String", "DEFAULT_SCROBBLE_URL", "\"$scrobbleUrl\"")
|
||||||
|
buildConfigField("String", "DEFAULT_ND_URL", "\"$navidromeUrl\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
|
|||||||
Vendored
+6
@@ -6,3 +6,9 @@
|
|||||||
# Keep Media3 session / exoplayer classes used by reflection / service binding.
|
# Keep Media3 session / exoplayer classes used by reflection / service binding.
|
||||||
-keep class androidx.media3.** { *; }
|
-keep class androidx.media3.** { *; }
|
||||||
-keep class com.google.android.exoplayer2.** { *; }
|
-keep class com.google.android.exoplayer2.** { *; }
|
||||||
|
|
||||||
|
# Ktors Logging referenziert ueber slf4j den Binder aus slf4j 1.x. Auf Android existiert der nicht
|
||||||
|
# (slf4j 2.x sucht Implementierungen per ServiceLoader, und wir binden ohnehin keine), deshalb bricht
|
||||||
|
# R8 den Release-Build mit "Missing class org.slf4j.impl.StaticLoggerBinder" ab. Die Referenz ist zur
|
||||||
|
# Laufzeit unerreichbar — slf4j faengt das Fehlen selbst ab und schaltet auf einen No-op-Logger.
|
||||||
|
-dontwarn org.slf4j.**
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<!-- In-app updater: install a downloaded APK (user still confirms the install) -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".NovaSonicApp"
|
android:name=".NovaSonicApp"
|
||||||
@@ -31,6 +33,19 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Geteilte Links (https://<host>/?song=…) direkt in der App oeffnen.
|
||||||
|
autoVerify greift erst, wenn unter https://<host>/.well-known/assetlinks.json der
|
||||||
|
SHA-256 des RELEASE-Signaturschluessels hinterlegt ist. Ohne das bleibt der Filter
|
||||||
|
wirkungslos, bis der Nutzer die App in den Systemeinstellungen unter
|
||||||
|
"Standardmaessig oeffnen" fuer diese Domain zulaesst. -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="https" android:host="${shareHost}"/>
|
||||||
|
<data android:scheme="https" android:host="${shareHost2}"/>
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
@@ -44,6 +59,17 @@
|
|||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- FileProvider — hands the downloaded update APK to the system installer -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"/>
|
||||||
|
</provider>
|
||||||
|
|
||||||
<!-- Android Auto -->
|
<!-- Android Auto -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.car.application"
|
android:name="com.google.android.gms.car.application"
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import com.novasonic.app.data.MusicRepository
|
|||||||
import com.novasonic.app.offline.OfflineRepository
|
import com.novasonic.app.offline.OfflineRepository
|
||||||
import com.novasonic.app.player.AbsPlayerController
|
import com.novasonic.app.player.AbsPlayerController
|
||||||
import com.novasonic.app.player.PlayerController
|
import com.novasonic.app.player.PlayerController
|
||||||
|
import com.novasonic.app.player.PreviewController
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class NovaSonicApp : Application() {
|
class NovaSonicApp : Application() {
|
||||||
|
|
||||||
@@ -16,14 +21,48 @@ class NovaSonicApp : Application() {
|
|||||||
private set
|
private set
|
||||||
lateinit var offlineRepository: OfflineRepository
|
lateinit var offlineRepository: OfflineRepository
|
||||||
private set
|
private set
|
||||||
|
// 30s preview player — app-level so its control bar survives navigation (see AppNavigation).
|
||||||
|
val previewController: PreviewController = PreviewController()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raeumt den Cache nach einem App-Update einmalig auf.
|
||||||
|
*
|
||||||
|
* Im cacheDir liegen Coils Bildspeicher (Cover) und heruntergeladene APK-Updates — beides
|
||||||
|
* jederzeit neu beschaffbar. Nach einem Update konnten dort Reste liegen, die zu alten Anzeigen
|
||||||
|
* fuehrten; die einzige Abhilfe war bisher "Cache leeren" von Hand in den Systemeinstellungen,
|
||||||
|
* was man einem normalen Nutzer nicht erklaeren moechte. Das passiert jetzt automatisch und
|
||||||
|
* ausschliesslich beim Versionswechsel, nicht im laufenden Betrieb — sonst waeren die Cover bei
|
||||||
|
* jedem Start neu zu laden.
|
||||||
|
*
|
||||||
|
* Offline-Downloads sind NICHT betroffen: die liegen unter filesDir/offline, nicht im cacheDir.
|
||||||
|
*/
|
||||||
|
private fun cacheNachUpdateLeeren() {
|
||||||
|
val prefs = getSharedPreferences("app_state", MODE_PRIVATE)
|
||||||
|
val zuletzt = prefs.getInt("last_version_code", -1)
|
||||||
|
val jetzt = BuildConfig.VERSION_CODE
|
||||||
|
if (zuletzt == jetzt) return
|
||||||
|
if (zuletzt != -1) {
|
||||||
|
runCatching { cacheDir.deleteRecursively() }
|
||||||
|
.onSuccess { android.util.Log.i("NsCache", "Cache nach Update $zuletzt -> $jetzt geleert") }
|
||||||
|
.onFailure { android.util.Log.w("NsCache", "Cache-Leeren fehlgeschlagen", it) }
|
||||||
|
}
|
||||||
|
prefs.edit().putInt("last_version_code", jetzt).apply()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
cacheNachUpdateLeeren()
|
||||||
offlineRepository = OfflineRepository(applicationContext) // must be first
|
offlineRepository = OfflineRepository(applicationContext) // must be first
|
||||||
repository = MusicRepository(applicationContext)
|
repository = MusicRepository(applicationContext)
|
||||||
playerController = PlayerController(applicationContext, repository)
|
playerController = PlayerController(applicationContext, repository)
|
||||||
playerController.connect()
|
playerController.connect()
|
||||||
absPlayerController = AbsPlayerController(applicationContext)
|
absPlayerController = AbsPlayerController(applicationContext)
|
||||||
|
// Angefangene Downloads weiterlaufen lassen (grosse Playlists brauchen viele Laeufe).
|
||||||
|
offlineRepository.setzeUnvollstaendigeFort()
|
||||||
|
// Flush any plays queued while offline to the scrobble service.
|
||||||
|
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
|
||||||
|
runCatching { repository.flushPlayLog() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.novasonic.app.api
|
||||||
|
|
||||||
|
import com.novasonic.app.api.model.ArtistOverride
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.engine.android.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads/writes the shared artist-pin overrides on the download backend
|
||||||
|
* (GET/POST /api/artist-overrides, DELETE /api/artist-overrides/:id).
|
||||||
|
* The pins fix same-name artist collisions; see [ArtistOverride].
|
||||||
|
*/
|
||||||
|
class ArtistOverrideClient(private val backendUrl: String) {
|
||||||
|
|
||||||
|
private val http = HttpClient(Android) {
|
||||||
|
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; coerceInputValues = true }) }
|
||||||
|
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base() = backendUrl.trimEnd('/')
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SetReq(
|
||||||
|
val ndArtistId: String,
|
||||||
|
val deezerArtistId: String,
|
||||||
|
val name: String,
|
||||||
|
val image: String,
|
||||||
|
val fanCount: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Full map { ndArtistId -> override }. */
|
||||||
|
suspend fun loadAll(): Map<String, ArtistOverride> =
|
||||||
|
http.get("${base()}/api/artist-overrides").body()
|
||||||
|
|
||||||
|
/** Upsert a pin. Returns true on 2xx. */
|
||||||
|
suspend fun set(ndArtistId: String, ov: ArtistOverride): Boolean {
|
||||||
|
val resp: HttpResponse = http.post("${base()}/api/artist-overrides") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(SetReq(ndArtistId, ov.deezerArtistId, ov.name, ov.image, ov.fanCount))
|
||||||
|
}
|
||||||
|
return resp.status.isSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a pin. Returns true on 2xx. */
|
||||||
|
suspend fun remove(ndArtistId: String): Boolean {
|
||||||
|
val resp: HttpResponse = http.delete("${base()}/api/artist-overrides/${ndArtistId.encodeURLPath()}")
|
||||||
|
return resp.status.isSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() = http.close()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.novasonic.app.api.model.ConcertEvent
|
|||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.engine.android.*
|
import io.ktor.client.engine.android.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
@@ -12,6 +13,10 @@ import kotlinx.serialization.json.Json
|
|||||||
object EventsClient {
|
object EventsClient {
|
||||||
|
|
||||||
private val http = HttpClient(Android) {
|
private val http = HttpClient(Android) {
|
||||||
|
// Surface non-2xx as a real exception (e.g. an SSO redirect / HTML login page) instead of
|
||||||
|
// letting .body() fail obscurely — the ViewModel turns it into a visible "load failed".
|
||||||
|
expectSuccess = true
|
||||||
|
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 10_000 }
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import io.ktor.client.plugins.*
|
|||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
import io.ktor.http.isSuccess
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
@@ -54,5 +55,18 @@ class ImporterClient(private val backendUrl: String) {
|
|||||||
suspend fun status(jobId: String): DownloadStatus =
|
suspend fun status(jobId: String): DownloadStatus =
|
||||||
http.get("${base()}/api/status/$jobId").body()
|
http.get("${base()}/api/status/$jobId").body()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loescht einen Song endgueltig von der Platte — derselbe Endpunkt, den der Web-Client nutzt.
|
||||||
|
* Das Backend braucht die Navidrome-Zugangsdaten, weil es den Dateipfad ueber getSong aufloest.
|
||||||
|
* Wirft bei HTTP != 2xx (Ktor `expectSuccess` ist hier aus, deshalb der explizite Check).
|
||||||
|
*/
|
||||||
|
suspend fun deleteSong(ndUrl: String, user: String, pass: String, songId: String) {
|
||||||
|
val res = http.post("${base()}/api/delete-song") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(DeleteSongRequest(ndUrl.trimEnd('/'), user, pass, songId))
|
||||||
|
}
|
||||||
|
if (!res.status.isSuccess()) error("HTTP ${res.status.value}")
|
||||||
|
}
|
||||||
|
|
||||||
fun close() = http.close()
|
fun close() = http.close()
|
||||||
}
|
}
|
||||||
@@ -8,64 +8,107 @@ import io.ktor.client.plugins.contentnegotiation.*
|
|||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata/search client. Backed by **Deezer** (api.deezer.com) — Apple's iTunes /search
|
||||||
|
* returns 403 from datacenter IPs, and Deezer keeps the phone + web PWA on the *same* source
|
||||||
|
* (identical naming + explicit versions). Deezer responses are mapped to the legacy ItunesItem
|
||||||
|
* shape (see DeezerModels.kt) so the screens/view models did not change. The public method names
|
||||||
|
* are kept for the same reason.
|
||||||
|
*/
|
||||||
object ItunesClient {
|
object ItunesClient {
|
||||||
|
|
||||||
|
private const val BASE = "https://api.deezer.com"
|
||||||
|
|
||||||
private val http = HttpClient(Android) {
|
private val http = HttpClient(Android) {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
// iTunes returns text/javascript instead of application/json — accept any content type
|
|
||||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any)
|
json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined search. Deezer splits by type, so `entity` "album" hits /search/album; anything else
|
||||||
|
* runs tracks + albums + artists in parallel and merges them (the view model filters by wrapperType).
|
||||||
|
*/
|
||||||
suspend fun search(
|
suspend fun search(
|
||||||
query: String,
|
query: String,
|
||||||
entity: String = "musicTrack,album,musicArtist",
|
entity: String = "musicTrack,album,musicArtist",
|
||||||
limit: Int = 30,
|
limit: Int = 30,
|
||||||
country: String = "us",
|
country: String = "us",
|
||||||
): ItunesSearchResponse = http.get("https://itunes.apple.com/search") {
|
): ItunesSearchResponse {
|
||||||
parameter("term", query)
|
if (entity == "album") {
|
||||||
parameter("media", "music")
|
val albums: DzList<DzAlbum> = http.get("$BASE/search/album") {
|
||||||
parameter("entity", entity)
|
parameter("q", query); parameter("limit", limit)
|
||||||
parameter("limit", limit)
|
}.body()
|
||||||
parameter("country", country)
|
return ItunesSearchResponse(results = albums.data.map { it.toItunesItem() })
|
||||||
}.body()
|
}
|
||||||
|
return coroutineScope {
|
||||||
|
val tracksD = async { runCatching { http.get("$BASE/search") { parameter("q", query); parameter("limit", limit) }.body<DzList<DzTrack>>().data }.getOrDefault(emptyList()) }
|
||||||
|
val albumsD = async { runCatching { http.get("$BASE/search/album") { parameter("q", query); parameter("limit", 12) }.body<DzList<DzAlbum>>().data }.getOrDefault(emptyList()) }
|
||||||
|
val artistsD = async { runCatching { http.get("$BASE/search/artist") { parameter("q", query); parameter("limit", 8) }.body<DzList<DzArtist>>().data }.getOrDefault(emptyList()) }
|
||||||
|
// Deezer doesn't rank artists by popularity — sort by nb_fan so a name collision
|
||||||
|
// (5× "Joji") resolves to the artist the user means (real Joji has 559k fans, not 62).
|
||||||
|
val results = artistsD.await().sortedByDescending { it.nb_fan ?: 0 }.map { it.toItunesItem() } +
|
||||||
|
albumsD.await().map { it.toItunesItem() } +
|
||||||
|
tracksD.await().map { it.toItunesItem() }
|
||||||
|
ItunesSearchResponse(results = results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All tracks of a Deezer album, in the ItunesItem song shape. */
|
||||||
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
|
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
|
||||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
val album: DzAlbum = http.get("$BASE/album/$collectionId").body()
|
||||||
parameter("id", collectionId)
|
return album.tracks?.data.orEmpty().mapIndexed { i, t -> t.toAlbumTrackItem(album, i) }
|
||||||
parameter("entity", "song")
|
|
||||||
}.body()
|
|
||||||
// First result is the album itself; rest are tracks
|
|
||||||
return resp.results.filter { it.kind == "song" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All albums for an iTunes artist */
|
/** A Deezer artist's full discography — albums, EPs AND singles, matching what Deezer's own
|
||||||
|
* artist page shows. Ordered albums → EPs → singles (Deezer returns newest-first within each). */
|
||||||
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
|
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
|
||||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
val albums: DzList<DzAlbum> = http.get("$BASE/artist/$artistId/albums") {
|
||||||
parameter("id", artistId)
|
parameter("limit", 200)
|
||||||
parameter("entity", "album")
|
|
||||||
parameter("limit", 200)
|
|
||||||
}.body()
|
}.body()
|
||||||
return resp.results.filter { it.wrapperType == "collection" }
|
val rank = { rt: String? -> when (rt) { "album" -> 0; "ep" -> 1; "single" -> 2; else -> 3 } }
|
||||||
|
return albums.data.sortedBy { rank(it.record_type) }.map { it.toItunesItem() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Worldwide top tracks for an iTunes artist */
|
/**
|
||||||
|
* A single Deezer artist by id (name / picture / fan count), as an ItunesItem. The reliable
|
||||||
|
* route when /search/artist can't surface a low-profile same-name artist — used by the
|
||||||
|
* artist-pin "paste Deezer link/id" flow. Null on a bad id / error.
|
||||||
|
*/
|
||||||
|
suspend fun lookupArtist(artistId: Long): ItunesItem? {
|
||||||
|
val a = runCatching { http.get("$BASE/artist/$artistId").body<DzArtist>() }.getOrNull()
|
||||||
|
return if (a?.id != null) a.toItunesItem() else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top tracks for a Deezer artist. */
|
||||||
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
|
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
|
||||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
val tracks: DzList<DzTrack> = http.get("$BASE/artist/$artistId/top") {
|
||||||
parameter("id", artistId)
|
parameter("limit", limit)
|
||||||
parameter("entity", "song") // "musicTrack" only works on /search — lookup needs "song"
|
|
||||||
parameter("limit", limit + 1)
|
|
||||||
parameter("explicit", "Yes")
|
|
||||||
}.body()
|
}.body()
|
||||||
return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit)
|
return tracks.data.map { it.toItunesItem() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top albums RSS feed for a given country */
|
/**
|
||||||
|
* Related Deezer artists (real pictures + fan counts). Used for the "Similar Artists" row when
|
||||||
|
* a pin is active — Last.fm's similar list is keyed to the (possibly merged/wrong) Navidrome
|
||||||
|
* artist, so it goes "weird"; Deezer's related are correct for the pinned artist.
|
||||||
|
*/
|
||||||
|
suspend fun lookupRelatedArtists(artistId: Long, limit: Int = 12): List<ItunesItem> {
|
||||||
|
val artists: DzList<DzArtist> = http.get("$BASE/artist/$artistId/related") {
|
||||||
|
parameter("limit", limit)
|
||||||
|
}.body()
|
||||||
|
return artists.data.map { it.toItunesItem() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Neu & Beliebt" — Deezer's global album chart, mapped to the RSS-entry shape. */
|
||||||
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
|
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
|
||||||
val resp: ItunesRssResponse =
|
val albums: DzList<DzAlbum> = http.get("$BASE/chart/0/albums") {
|
||||||
http.get("https://itunes.apple.com/$country/rss/topalbums/limit=$limit/json").body()
|
parameter("limit", limit)
|
||||||
return resp.feed?.entry ?: emptyList()
|
}.body()
|
||||||
|
return albums.data.map { it.toRssEntry() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,8 @@ import io.ktor.http.*
|
|||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
@@ -55,9 +57,13 @@ object LrcLibClient {
|
|||||||
connectTimeoutMillis = 5_000
|
connectTimeoutMillis = 5_000
|
||||||
socketTimeoutMillis = 8_000
|
socketTimeoutMillis = 8_000
|
||||||
}
|
}
|
||||||
|
// lrclib asks clients to identify themselves; a proper UA also avoids being throttled.
|
||||||
|
install(UserAgent) { agent = "NovaSonic/1.0 (+https://gitea.honeyy.duckdns.org/Docker/NovaSonic)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private val cache = HashMap<String, LrcLibResponse?>()
|
private val cache = HashMap<String, LrcLibResponse?>()
|
||||||
|
// Per-song lock so a prefetch + an on-open load don't both fire the whole cascade (→ throttling).
|
||||||
|
private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
|
||||||
|
|
||||||
suspend fun getLyrics(
|
suspend fun getLyrics(
|
||||||
artist: String,
|
artist: String,
|
||||||
@@ -67,6 +73,18 @@ object LrcLibClient {
|
|||||||
): LrcLibResponse? {
|
): LrcLibResponse? {
|
||||||
val cacheKey = "$artist|$title"
|
val cacheKey = "$artist|$title"
|
||||||
if (cache.containsKey(cacheKey)) return cache[cacheKey]
|
if (cache.containsKey(cacheKey)) return cache[cacheKey]
|
||||||
|
val mutex = locks.getOrPut(cacheKey) { Mutex() }
|
||||||
|
return mutex.withLock { getLyricsInner(artist, title, album, durationSecs, cacheKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLyricsInner(
|
||||||
|
artist: String,
|
||||||
|
title: String,
|
||||||
|
album: String?,
|
||||||
|
durationSecs: Int?,
|
||||||
|
cacheKey: String,
|
||||||
|
): LrcLibResponse? {
|
||||||
|
if (cache.containsKey(cacheKey)) return cache[cacheKey] // populated while we waited on the lock
|
||||||
|
|
||||||
// 1a. lrclib exact match with duration
|
// 1a. lrclib exact match with duration
|
||||||
val exact = runCatching {
|
val exact = runCatching {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.novasonic.app.api
|
||||||
|
|
||||||
|
import com.novasonic.app.api.model.ArtistRef
|
||||||
|
import com.novasonic.app.api.model.ArtistReleasesRequest
|
||||||
|
import com.novasonic.app.api.model.ArtistKonzerteRequest
|
||||||
|
import com.novasonic.app.api.model.ArtistKonzerteResponse
|
||||||
|
import com.novasonic.app.api.model.ArtistReleasesResponse
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.engine.android.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neue Veröffentlichungen der gefolgten Künstler. Die Deezer-Auflösung macht das Backend — dort
|
||||||
|
* liegen auch die Künstler-Pins, und der Cache gilt dann für alle Geräte gemeinsam statt pro App.
|
||||||
|
*/
|
||||||
|
object ReleasesClient {
|
||||||
|
|
||||||
|
private val http = HttpClient(Android) {
|
||||||
|
expectSuccess = true
|
||||||
|
// Der erste Aufruf kann dauern: das Backend holt pro Künstler die Deezer-Diskografie.
|
||||||
|
// Danach liegt es dort im Cache.
|
||||||
|
install(HttpTimeout) { requestTimeoutMillis = 60_000; connectTimeoutMillis = 10_000 }
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetch(backendUrl: String, artists: List<ArtistRef>, days: Int): ArtistReleasesResponse {
|
||||||
|
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistReleasesResponse()
|
||||||
|
return http.post("${backendUrl.trimEnd('/')}/api/artist-releases") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(ArtistReleasesRequest(artists, days))
|
||||||
|
}.body()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anstehende Konzerte derselben Kuenstler — gleicher Weg, anderer Endpunkt. */
|
||||||
|
suspend fun konzerte(backendUrl: String, artists: List<ArtistRef>): ArtistKonzerteResponse {
|
||||||
|
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistKonzerteResponse()
|
||||||
|
// Ohne `days`: das Backend liefert alles Angekuendigte.
|
||||||
|
return http.post("${backendUrl.trimEnd('/')}/api/artist-concerts") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(ArtistKonzerteRequest(artists))
|
||||||
|
}.body()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,9 +149,16 @@ class SubsonicClient(
|
|||||||
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
|
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
|
||||||
?: com.novasonic.app.api.model.ArtistInfo()
|
?: com.novasonic.app.api.model.ArtistInfo()
|
||||||
|
|
||||||
suspend fun getTopSongs(artist: String, count: Int = 10): List<com.novasonic.app.api.model.Song> =
|
/**
|
||||||
get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song
|
* id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
|
||||||
?: emptyList()
|
* Namen zurueck, wenn es sie nicht kennt; aeltere Versionen ignorieren id. Der Namensweg sucht
|
||||||
|
* mit LIKE und Max:1 — bei gleichnamigen Kuenstlern gewann bisher der erste Treffer.
|
||||||
|
*/
|
||||||
|
suspend fun getTopSongs(artist: String, count: Int = 10, artistId: String? = null): List<com.novasonic.app.api.model.Song> =
|
||||||
|
get("getTopSongs.view", buildMap {
|
||||||
|
put("artist", artist); put("count", count)
|
||||||
|
artistId?.takeIf { it.isNotBlank() }?.let { put("id", it) }
|
||||||
|
}).topSongs?.song ?: emptyList()
|
||||||
|
|
||||||
suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
|
suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
|
||||||
get("getGenres.view").genres?.genre ?: emptyList()
|
get("getGenres.view").genres?.genre ?: emptyList()
|
||||||
@@ -163,6 +170,9 @@ class SubsonicClient(
|
|||||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
|
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
|
||||||
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
|
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
|
||||||
|
|
||||||
|
/** Stoesst Navidromes Bibliotheks-Scan an — nach einem Import, damit frische Dateien auffindbar sind. */
|
||||||
|
suspend fun startScan() = get("startScan.view")
|
||||||
|
|
||||||
suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs {
|
suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs {
|
||||||
val params = mutableListOf("name" to name)
|
val params = mutableListOf("name" to name)
|
||||||
songIds.forEach { params += "songId" to it }
|
songIds.forEach { params += "songId" to it }
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.novasonic.app.api.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A manual "pin" mapping a local Navidrome artist → a specific Deezer artist, fixing same-name
|
||||||
|
* collisions where NovaSonic would otherwise guess the wrong one (fans / top songs / albums / image).
|
||||||
|
* Stored server-side (shared across users) on the download backend at /api/artist-overrides, keyed
|
||||||
|
* by the Navidrome artist id. Mirrors the web PWA's override shape exactly.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ArtistOverride(
|
||||||
|
val deezerArtistId: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val image: String = "",
|
||||||
|
val fanCount: Long? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.novasonic.app.api.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// ─── DEEZER API MODELS ────────────────────────────────────────────────────────
|
||||||
|
// Apple's iTunes /search returns 403 from server IPs, so the Explore/search source is
|
||||||
|
// Deezer (free, no key, returns explicit flags + artist images + durations). Deezer JSON is
|
||||||
|
// mapped to the existing ItunesItem/ItunesRssEntry shapes so the screens + view models are
|
||||||
|
// unchanged. See ItunesClient for the endpoint calls.
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DzList<T>(val data: List<T> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DzArtist(
|
||||||
|
val id: Long? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val picture_medium: String? = null,
|
||||||
|
val picture_big: String? = null,
|
||||||
|
val picture_xl: String? = null,
|
||||||
|
val nb_fan: Long? = null, // popularity — disambiguates same-name artists (5× "Joji")
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DzGenre(val name: String? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DzGenres(val data: List<DzGenre> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DzAlbum(
|
||||||
|
val id: Long? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val cover_medium: String? = null,
|
||||||
|
val cover_big: String? = null,
|
||||||
|
val cover_xl: String? = null,
|
||||||
|
val release_date: String? = null,
|
||||||
|
val nb_tracks: Int? = null,
|
||||||
|
val record_type: String? = null,
|
||||||
|
val genres: DzGenres? = null,
|
||||||
|
val artist: DzArtist? = null,
|
||||||
|
val tracks: DzList<DzTrack>? = null,
|
||||||
|
val position: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DzTrack(
|
||||||
|
val id: Long? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val duration: Long? = null,
|
||||||
|
val track_position: Int? = null,
|
||||||
|
val disk_number: Int? = null,
|
||||||
|
val explicit_lyrics: Boolean = false,
|
||||||
|
val preview: String? = null,
|
||||||
|
val artist: DzArtist? = null,
|
||||||
|
val album: DzAlbum? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── MAPPERS to the legacy ItunesItem shape ───────────────────────────────────
|
||||||
|
|
||||||
|
private fun DzAlbum.coverXl(): String? = cover_xl ?: cover_big ?: cover_medium
|
||||||
|
private fun DzArtist.pictureXl(): String? = picture_xl ?: picture_big ?: picture_medium
|
||||||
|
|
||||||
|
/** A Deezer search-result track → ItunesItem (album cover for artwork). */
|
||||||
|
fun DzTrack.toItunesItem(): ItunesItem = ItunesItem(
|
||||||
|
wrapperType = "track",
|
||||||
|
kind = "song",
|
||||||
|
trackId = id,
|
||||||
|
artistId = artist?.id,
|
||||||
|
collectionId = album?.id,
|
||||||
|
artistName = artist?.name,
|
||||||
|
collectionName = album?.title,
|
||||||
|
trackName = title,
|
||||||
|
artworkUrl100 = album?.coverXl() ?: artist?.pictureXl(),
|
||||||
|
previewUrl = preview,
|
||||||
|
trackTimeMillis = duration?.let { it * 1000 },
|
||||||
|
trackNumber = track_position,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A Deezer album → ItunesItem collection. */
|
||||||
|
fun DzAlbum.toItunesItem(): ItunesItem = ItunesItem(
|
||||||
|
wrapperType = "collection",
|
||||||
|
kind = "album",
|
||||||
|
collectionId = id,
|
||||||
|
artistId = artist?.id,
|
||||||
|
artistName = artist?.name,
|
||||||
|
collectionName = title,
|
||||||
|
artworkUrl100 = coverXl(),
|
||||||
|
trackCount = nb_tracks,
|
||||||
|
releaseDate = release_date,
|
||||||
|
primaryGenreName = genres?.data?.firstOrNull()?.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A Deezer artist → ItunesItem artist (carries a real picture, unlike iTunes). */
|
||||||
|
fun DzArtist.toItunesItem(): ItunesItem = ItunesItem(
|
||||||
|
wrapperType = "artist",
|
||||||
|
artistId = id,
|
||||||
|
artistName = name,
|
||||||
|
artworkUrl100 = pictureXl(),
|
||||||
|
fanCount = nb_fan,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Deezer album track (from /album/{id}) → ItunesItem song. The embedded tracks carry no
|
||||||
|
* track_position, so the album ordering supplies the number; genre/release come from the album.
|
||||||
|
*/
|
||||||
|
fun DzTrack.toAlbumTrackItem(album: DzAlbum, index: Int): ItunesItem = ItunesItem(
|
||||||
|
wrapperType = "track",
|
||||||
|
kind = "song",
|
||||||
|
trackId = id,
|
||||||
|
artistId = artist?.id ?: album.artist?.id,
|
||||||
|
collectionId = album.id,
|
||||||
|
artistName = artist?.name ?: album.artist?.name,
|
||||||
|
collectionName = album.title,
|
||||||
|
trackName = title,
|
||||||
|
artworkUrl100 = album.coverXl(),
|
||||||
|
previewUrl = preview,
|
||||||
|
trackTimeMillis = duration?.let { it * 1000 },
|
||||||
|
trackNumber = track_position ?: (index + 1),
|
||||||
|
trackCount = album.nb_tracks,
|
||||||
|
releaseDate = album.release_date,
|
||||||
|
primaryGenreName = album.genres?.data?.firstOrNull()?.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A Deezer chart album → ItunesRssEntry (keeps the charts UI unchanged). */
|
||||||
|
fun DzAlbum.toRssEntry(): ItunesRssEntry = ItunesRssEntry(
|
||||||
|
name = ItunesLabel(title),
|
||||||
|
artist = ItunesLabel(artist?.name),
|
||||||
|
image = listOfNotNull(coverXl()?.let { ItunesLabel(it) }),
|
||||||
|
id = ItunesId(attributes = ItunesIdAttributes(imId = id?.toString())),
|
||||||
|
)
|
||||||
@@ -42,6 +42,15 @@ data class DownloadRequest(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class ResolveRequest(val url: String)
|
data class ResolveRequest(val url: String)
|
||||||
|
|
||||||
|
/** Body fuer POST {backend}/api/delete-song — loescht die Datei von der Platte (wie im Web). */
|
||||||
|
@Serializable
|
||||||
|
data class DeleteSongRequest(
|
||||||
|
val ndUrl: String,
|
||||||
|
val user: String,
|
||||||
|
val pass: String,
|
||||||
|
val songId: String,
|
||||||
|
)
|
||||||
|
|
||||||
/** Response from GET {backend}/api/status/{jobId} */
|
/** Response from GET {backend}/api/status/{jobId} */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DownloadStatus(
|
data class DownloadStatus(
|
||||||
|
|||||||
@@ -27,8 +27,18 @@ data class ItunesItem(
|
|||||||
val trackCount: Int? = null,
|
val trackCount: Int? = null,
|
||||||
val trackNumber: Int? = null,
|
val trackNumber: Int? = null,
|
||||||
val collectionPrice: Double? = null,
|
val collectionPrice: Double? = null,
|
||||||
|
val fanCount: Long? = null, // Deezer nb_fan — disambiguates same-name artists + shown in UI
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// German fan-count format: 559542 → "559.542", 1200000 → "1,2 Mio." (matches the web PWA).
|
||||||
|
fun fmtFanCount(n: Long): String {
|
||||||
|
if (n >= 1_000_000) {
|
||||||
|
val s = String.format(java.util.Locale.GERMAN, "%.1f", n / 1_000_000.0).removeSuffix(",0")
|
||||||
|
return "$s Mio."
|
||||||
|
}
|
||||||
|
return String.format(java.util.Locale.GERMAN, "%,d", n)
|
||||||
|
}
|
||||||
|
|
||||||
// iTunes RSS feed response
|
// iTunes RSS feed response
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ItunesRssResponse(
|
data class ItunesRssResponse(
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.novasonic.app.api.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Eine Veröffentlichung eines gefolgten Künstlers, wie sie das Backend liefert. */
|
||||||
|
@Serializable
|
||||||
|
data class ArtistRelease(
|
||||||
|
val artistId: String = "",
|
||||||
|
val artistName: String = "",
|
||||||
|
val deezerArtistId: String? = null,
|
||||||
|
val albumId: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val cover: String = "",
|
||||||
|
val releaseDate: String = "",
|
||||||
|
val recordType: String = "album",
|
||||||
|
val trackCount: Int? = null,
|
||||||
|
val explicit: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ArtistReleasesResponse(
|
||||||
|
val releases: List<ArtistRelease> = emptyList(),
|
||||||
|
/** Künstler, zu denen kein Deezer-Profil gefunden wurde — dort hilft nur ein Pin. */
|
||||||
|
val ohneZuordnung: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ArtistRef(val id: String, val name: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ArtistReleasesRequest(val artists: List<ArtistRef>, val days: Int)
|
||||||
|
|
||||||
|
/** Ein anstehendes Konzert eines gefolgten Künstlers. */
|
||||||
|
@Serializable
|
||||||
|
data class ArtistKonzert(
|
||||||
|
val artistId: String = "",
|
||||||
|
val artistName: String = "",
|
||||||
|
val eventId: String = "",
|
||||||
|
val titel: String = "",
|
||||||
|
/** Ortszeit ohne Zone, z. B. "2026-11-04T20:00:00" — bewusst nicht UTC. */
|
||||||
|
val datum: String = "",
|
||||||
|
val ort: String = "",
|
||||||
|
val stadt: String = "",
|
||||||
|
val land: String = "",
|
||||||
|
val url: String = "",
|
||||||
|
val abgesagt: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ArtistKonzerteResponse(
|
||||||
|
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||||
|
/** Künstler ohne anstehende Termine — nur zur Erklärung einer leeren Liste. */
|
||||||
|
val ohneTermine: List<String> = emptyList(),
|
||||||
|
/** Im Backend ist kein Ticketmaster-Schlüssel hinterlegt. */
|
||||||
|
val keinSchluessel: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ArtistKonzerteRequest(val artists: List<ArtistRef>)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.novasonic.app.api.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// Mirrors the JSON from the scrobble service GET /scrobble/stats.
|
||||||
|
@Serializable
|
||||||
|
data class ScrobbleStats(
|
||||||
|
val firstPlayedAt: Long? = null,
|
||||||
|
val totalPlays: Int = 0,
|
||||||
|
val totalSeconds: Long = 0,
|
||||||
|
val topArtists: List<StatArtist> = emptyList(),
|
||||||
|
val topAlbums: List<StatAlbum> = emptyList(),
|
||||||
|
val topTracks: List<StatTrack> = emptyList(),
|
||||||
|
val topGenres: List<StatGenre> = emptyList(),
|
||||||
|
val series: List<StatBucket> = emptyList(),
|
||||||
|
val byHour: List<StatHour> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StatGenre(
|
||||||
|
val name: String? = null,
|
||||||
|
val plays: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StatArtist(
|
||||||
|
val name: String? = null,
|
||||||
|
val artistId: String? = null,
|
||||||
|
val plays: Int = 0,
|
||||||
|
val seconds: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StatAlbum(
|
||||||
|
val name: String? = null,
|
||||||
|
val albumId: String? = null,
|
||||||
|
val artist: String? = null,
|
||||||
|
val coverArt: String? = null,
|
||||||
|
val plays: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StatTrack(
|
||||||
|
val title: String? = null,
|
||||||
|
val artist: String? = null,
|
||||||
|
val album: String? = null,
|
||||||
|
val albumId: String? = null,
|
||||||
|
val artistId: String? = null,
|
||||||
|
val coverArt: String? = null,
|
||||||
|
val songId: String? = null,
|
||||||
|
val plays: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StatBucket(
|
||||||
|
val bucket: String = "",
|
||||||
|
val plays: Int = 0,
|
||||||
|
val seconds: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StatHour(
|
||||||
|
val hour: Int = 0,
|
||||||
|
val plays: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Head-to-Head "Musik-Match" ─────────────────────────────────────────────
|
||||||
|
// GET /scrobble/users — distinct users in the log, for the partner picker.
|
||||||
|
@Serializable
|
||||||
|
data class ScrobbleUsersResponse(
|
||||||
|
val me: String = "",
|
||||||
|
val users: List<ScrobbleUser> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ScrobbleUser(
|
||||||
|
val user: String = "",
|
||||||
|
val plays: Int = 0,
|
||||||
|
val lastPlayedAt: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /scrobble/compare?with= — taste comparison of the authed user (A) vs another (B).
|
||||||
|
@Serializable
|
||||||
|
data class CompareResult(
|
||||||
|
val userA: String = "",
|
||||||
|
val userB: String = "",
|
||||||
|
val matchScore: Int = 0,
|
||||||
|
val battle: Battle? = null,
|
||||||
|
val totalsA: CompareTotals = CompareTotals(),
|
||||||
|
val totalsB: CompareTotals = CompareTotals(),
|
||||||
|
val sharedArtists: List<SharedArtist> = emptyList(),
|
||||||
|
val sharedTracks: List<SharedTrack> = emptyList(),
|
||||||
|
val onlyA: List<UniqueArtist> = emptyList(),
|
||||||
|
val onlyB: List<UniqueArtist> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Anzeigetafel: je Kategorie ein Sieger ("A"/"B"/null bei Gleichstand) plus Gesamtstand. */
|
||||||
|
@Serializable
|
||||||
|
data class Battle(
|
||||||
|
val kategorien: List<BattleKategorie> = emptyList(),
|
||||||
|
val punkteA: Int = 0,
|
||||||
|
val punkteB: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class BattleKategorie(
|
||||||
|
val key: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
val textA: String = "",
|
||||||
|
val textB: String = "",
|
||||||
|
val sieger: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CompareTotals(
|
||||||
|
val plays: Int = 0,
|
||||||
|
val seconds: Long = 0,
|
||||||
|
val artists: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SharedArtist(
|
||||||
|
val name: String? = null,
|
||||||
|
val artistId: String? = null,
|
||||||
|
val playsA: Int = 0,
|
||||||
|
val playsB: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SharedTrack(
|
||||||
|
val songId: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val artist: String? = null,
|
||||||
|
val artistId: String? = null,
|
||||||
|
val albumId: String? = null,
|
||||||
|
val coverArt: String? = null,
|
||||||
|
val playsA: Int = 0,
|
||||||
|
val playsB: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UniqueArtist(
|
||||||
|
val name: String? = null,
|
||||||
|
val artistId: String? = null,
|
||||||
|
val plays: Int = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.novasonic.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.novasonic.app.BuildConfig
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-app updater for the sideloaded APK. Talks ONLY to the backend the app already knows:
|
||||||
|
* GET {backend}/api/apk/info → is there a newer versionCode than this build?
|
||||||
|
* GET {backend}/api/apk → download it (the backend proxies the fixed Gitea release)
|
||||||
|
* The user still confirms the system install (Android requires that for sideloaded APKs).
|
||||||
|
*
|
||||||
|
* No APK hoarding: the download goes to a single file under cacheDir/updates that is wiped before
|
||||||
|
* each download. An update (same package + signature) is an in-place install, so app data / offline
|
||||||
|
* downloads survive — provided the signing key stays stable across builds.
|
||||||
|
*/
|
||||||
|
object AppUpdater {
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApkInfo(
|
||||||
|
val available: Boolean = false,
|
||||||
|
val version: String? = null,
|
||||||
|
val versionCode: Int? = null,
|
||||||
|
val size: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val _available = MutableStateFlow<ApkInfo?>(null)
|
||||||
|
val available: StateFlow<ApkInfo?> = _available
|
||||||
|
|
||||||
|
private val _downloading = MutableStateFlow(false)
|
||||||
|
val downloading: StateFlow<Boolean> = _downloading
|
||||||
|
|
||||||
|
// Versions the user waved away this session — don't nag again until app restart.
|
||||||
|
@Volatile private var dismissedCode: Int = -1
|
||||||
|
|
||||||
|
suspend fun check(backendUrl: String) {
|
||||||
|
if (backendUrl.isBlank()) return
|
||||||
|
val info = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val c = (URL("${backendUrl.trimEnd('/')}/api/apk/info").openConnection() as HttpURLConnection)
|
||||||
|
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||||
|
c.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
.let { json.decodeFromString<ApkInfo>(it) }
|
||||||
|
}.getOrNull()
|
||||||
|
} ?: return
|
||||||
|
val vc = info.versionCode ?: return // no reliable integer → don't prompt
|
||||||
|
if (info.available && vc > BuildConfig.VERSION_CODE && vc != dismissedCode) {
|
||||||
|
_available.value = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismiss() {
|
||||||
|
dismissedCode = _available.value?.versionCode ?: dismissedCode
|
||||||
|
_available.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadAndInstall(context: Context, backendUrl: String, onError: (String) -> Unit) {
|
||||||
|
if (_downloading.value || backendUrl.isBlank()) return
|
||||||
|
_downloading.value = true
|
||||||
|
try {
|
||||||
|
val apk = withContext(Dispatchers.IO) {
|
||||||
|
val dir = File(context.cacheDir, "updates").apply { mkdirs(); listFiles()?.forEach { it.delete() } }
|
||||||
|
val out = File(dir, "NovaSonic-update.apk")
|
||||||
|
val c = (URL("${backendUrl.trimEnd('/')}/api/apk").openConnection() as HttpURLConnection)
|
||||||
|
.apply { connectTimeout = 15000; readTimeout = 30000; instanceFollowRedirects = true }
|
||||||
|
if (c.responseCode !in 200..299) throw RuntimeException("HTTP ${c.responseCode}")
|
||||||
|
c.inputStream.use { input -> out.outputStream().use { input.copyTo(it) } }
|
||||||
|
out
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_available.value = null // installer takes over
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError(e.message ?: "Download fehlgeschlagen")
|
||||||
|
} finally {
|
||||||
|
_downloading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.novasonic.app.data
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebergabepunkt fuer geteilte Links (https://<host>/?song=… bzw. ?album=…).
|
||||||
|
*
|
||||||
|
* Die Activity kann nicht selbst navigieren — der NavController lebt in der Compose-Hierarchie.
|
||||||
|
* Deshalb legt MainActivity den eingehenden Link hier ab, und AppNavigation holt ihn sich, sobald
|
||||||
|
* es bereit ist. Ein einzelner Slot genuegt: der letzte geoeffnete Link gewinnt.
|
||||||
|
*/
|
||||||
|
object DeepLinkState {
|
||||||
|
val pending = MutableStateFlow<Uri?>(null)
|
||||||
|
|
||||||
|
fun offer(uri: Uri?) {
|
||||||
|
// Nur Links mit einer fuer uns verwertbaren Kennung annehmen — ein blosser Start ueber das
|
||||||
|
// App-Symbol darf keine Navigation ausloesen.
|
||||||
|
if (uri == null) return
|
||||||
|
if (uri.getQueryParameter("song") == null && uri.getQueryParameter("album") == null) return
|
||||||
|
pending.value = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holt den Link und leert den Slot, damit er bei Drehung o. ae. nicht erneut greift. */
|
||||||
|
fun consume(): Uri? = pending.value.also { pending.value = null }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.novasonic.app.data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einmalige Absicht beim Öffnen der Bibliothek. Das LibraryViewModel hängt am Navigationseintrag,
|
||||||
|
* überlebt also keinen Wechsel des Ziels — ein Zustand, den ein anderer Screen setzen will, muss
|
||||||
|
* deshalb hier durch. Wird beim Verarbeiten sofort zurückgesetzt, damit er nicht nachwirkt.
|
||||||
|
*/
|
||||||
|
object LibraryIntent {
|
||||||
|
/** Direkt in den Künstler-Reiter mit aktivem "Gefolgt"-Filter springen. */
|
||||||
|
var oeffneGefolgte: Boolean = false
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ object ClientHolder {
|
|||||||
class MusicRepository(private val context: Context) {
|
class MusicRepository(private val context: Context) {
|
||||||
|
|
||||||
private val prefs = PrefsStore(context)
|
private val prefs = PrefsStore(context)
|
||||||
|
private val playLog = PlayLogClient(context)
|
||||||
|
|
||||||
private suspend fun api(): SubsonicClient =
|
private suspend fun api(): SubsonicClient =
|
||||||
ClientHolder.get(prefs.credentials.first())
|
ClientHolder.get(prefs.credentials.first())
|
||||||
@@ -56,12 +57,59 @@ class MusicRepository(private val context: Context) {
|
|||||||
suspend fun star(id: String) = api().star(id)
|
suspend fun star(id: String) = api().star(id)
|
||||||
suspend fun unstar(id: String) = api().unstar(id)
|
suspend fun unstar(id: String) = api().unstar(id)
|
||||||
suspend fun scrobble(id: String) = api().scrobble(id)
|
suspend fun scrobble(id: String) = api().scrobble(id)
|
||||||
|
// Our own timestamped play-log (stats + Wrapped), in addition to the Navidrome scrobble above.
|
||||||
|
suspend fun logPlay(song: Song) = playLog.logPlay(song)
|
||||||
|
suspend fun flushPlayLog() = playLog.flush()
|
||||||
|
suspend fun fetchScrobbleStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int) =
|
||||||
|
playLog.fetchStats(from, to, tz, bucket, limit)
|
||||||
|
suspend fun fetchScrobbleUsers() = playLog.fetchUsers()
|
||||||
|
suspend fun fetchScrobbleCompare(with: String) = playLog.fetchCompare(with)
|
||||||
|
// ── Artist pins (shared, server-side same-name disambiguation) ───────────────
|
||||||
|
@Volatile private var _artistOverrides: Map<String, ArtistOverride>? = null
|
||||||
|
|
||||||
|
/** Full pin map, cached after first load. Empty if no backend / unreachable. */
|
||||||
|
suspend fun artistOverrides(): Map<String, ArtistOverride> {
|
||||||
|
_artistOverrides?.let { return it }
|
||||||
|
val url = getBackendUrl()
|
||||||
|
if (url.isBlank()) return emptyMap<String, ArtistOverride>().also { _artistOverrides = it }
|
||||||
|
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||||
|
val m = runCatching { c.loadAll() }.getOrDefault(emptyMap())
|
||||||
|
c.close()
|
||||||
|
return m.also { _artistOverrides = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getArtistOverride(ndArtistId: String): ArtistOverride? = artistOverrides()[ndArtistId]
|
||||||
|
|
||||||
|
suspend fun setArtistOverride(ndArtistId: String, ov: ArtistOverride): Boolean {
|
||||||
|
val url = getBackendUrl()
|
||||||
|
if (url.isBlank()) return false
|
||||||
|
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||||
|
val ok = runCatching { c.set(ndArtistId, ov) }.getOrDefault(false)
|
||||||
|
c.close()
|
||||||
|
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) + (ndArtistId to ov)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeArtistOverride(ndArtistId: String): Boolean {
|
||||||
|
val url = getBackendUrl()
|
||||||
|
if (url.isBlank()) return false
|
||||||
|
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||||
|
val ok = runCatching { c.remove(ndArtistId) }.getOrDefault(false)
|
||||||
|
c.close()
|
||||||
|
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) - ndArtistId
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
|
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
|
||||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
/** artistId ist die NAVIDROME-Kuenstler-ID (nicht Deezers) — ohne sie sucht Navidrome
|
||||||
|
* ueber den Namen und nimmt bei Gleichnamigen den ersten Treffer. */
|
||||||
|
suspend fun getTopSongs(artistName: String, artistId: String? = null) =
|
||||||
|
api().getTopSongs(artistName, artistId = artistId)
|
||||||
suspend fun getGenres() = api().getGenres()
|
suspend fun getGenres() = api().getGenres()
|
||||||
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
|
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
|
||||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
|
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
|
||||||
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
|
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
|
||||||
|
suspend fun startScan() = api().startScan()
|
||||||
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment)
|
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment)
|
||||||
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices)
|
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices)
|
||||||
suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId)
|
suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId)
|
||||||
@@ -76,8 +124,9 @@ class MusicRepository(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val creds = lastCredentials ?: return null
|
val creds = lastCredentials ?: return null
|
||||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
// Reuse the cached client — building a fresh SubsonicClient here spins up a whole Ktor
|
||||||
.coverArtUrl(id ?: return null, size)
|
// HttpClient per cover (and never closes it), which is heavy GC/alloc churn while scrolling.
|
||||||
|
return ClientHolder.get(creds).coverArtUrl(id ?: return null, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun streamUrl(id: String): String? {
|
fun streamUrl(id: String): String? {
|
||||||
@@ -86,7 +135,7 @@ class MusicRepository(private val context: Context) {
|
|||||||
if (f.exists()) return Uri.fromFile(f).toString()
|
if (f.exists()) return Uri.fromFile(f).toString()
|
||||||
}
|
}
|
||||||
val creds = lastCredentials ?: return null
|
val creds = lastCredentials ?: return null
|
||||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password).streamUrl(id)
|
return ClientHolder.get(creds).streamUrl(id) // reuse cached client (no per-call HttpClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun offlineRepo(): OfflineRepository? = runCatching {
|
private fun offlineRepo(): OfflineRepository? = runCatching {
|
||||||
@@ -123,6 +172,9 @@ class MusicRepository(private val context: Context) {
|
|||||||
|
|
||||||
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
|
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
|
||||||
|
|
||||||
|
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) =
|
||||||
|
prefs.saveQueueState(songs, index)
|
||||||
|
suspend fun readQueueState() = prefs.readQueueState()
|
||||||
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
|
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
|
||||||
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
|
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package com.novasonic.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.novasonic.app.api.model.ScrobbleStats
|
||||||
|
import com.novasonic.app.api.model.Song
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.android.Android
|
||||||
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.http.encodeURLParameter
|
||||||
|
import io.ktor.http.isSuccess
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records timestamped plays into NovaSonic's own scrobble service (separate origin,
|
||||||
|
* settings.scrobbleUrl) — the basis for day/month/year stats + Wrapped.
|
||||||
|
*
|
||||||
|
* Runs IN ADDITION to the existing Navidrome scrobble (playCount + Last.fm); never
|
||||||
|
* replaces it. Called at the same "counted play" moment as the Navidrome scrobble
|
||||||
|
* (see PlayerController.scheduleScrobble).
|
||||||
|
*
|
||||||
|
* Offline-safe: entries persist to a JSON file and flush as an idempotent batch
|
||||||
|
* (the service dedups by uuid), so a play made offline is delivered later exactly once.
|
||||||
|
*
|
||||||
|
* One instance lives on MusicRepository → a single reused HttpClient (never per-call).
|
||||||
|
*/
|
||||||
|
class PlayLogClient(context: Context) {
|
||||||
|
|
||||||
|
private val prefs = PrefsStore(context)
|
||||||
|
private val queueFile = File(context.filesDir, "play-queue.json")
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
private val http = HttpClient(Android) {
|
||||||
|
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlayEntry(
|
||||||
|
val uuid: String,
|
||||||
|
val songId: String,
|
||||||
|
val title: String? = null,
|
||||||
|
val artist: String? = null,
|
||||||
|
val artistId: String? = null,
|
||||||
|
val album: String? = null,
|
||||||
|
val albumId: String? = null,
|
||||||
|
val coverArt: String? = null,
|
||||||
|
val genre: String? = null,
|
||||||
|
val duration: Int? = null,
|
||||||
|
val playedAt: Long,
|
||||||
|
val source: String = "android",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class Batch(val plays: List<PlayEntry>)
|
||||||
|
|
||||||
|
/** Queue one counted play and try to deliver it. */
|
||||||
|
suspend fun logPlay(song: Song) {
|
||||||
|
val s = prefs.appSettings.first()
|
||||||
|
if (s.scrobbleUrl.isBlank()) return
|
||||||
|
val entry = PlayEntry(
|
||||||
|
uuid = UUID.randomUUID().toString(),
|
||||||
|
songId = song.id,
|
||||||
|
title = song.title,
|
||||||
|
artist = song.artist,
|
||||||
|
artistId = song.artistId,
|
||||||
|
album = song.album,
|
||||||
|
albumId = song.albumId,
|
||||||
|
coverArt = song.coverArt,
|
||||||
|
genre = song.genre,
|
||||||
|
duration = song.duration,
|
||||||
|
playedAt = System.currentTimeMillis() / 1000,
|
||||||
|
)
|
||||||
|
mutex.withLock {
|
||||||
|
val q = readQueue().toMutableList()
|
||||||
|
q.add(entry)
|
||||||
|
writeQueue(q.takeLast(MAX_QUEUE))
|
||||||
|
}
|
||||||
|
flush(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliver everything queued as one idempotent batch. Safe to call repeatedly. */
|
||||||
|
suspend fun flush(settings: PrefsStore.AppSettings? = null) {
|
||||||
|
val s = settings ?: prefs.appSettings.first()
|
||||||
|
val base = s.scrobbleUrl.trimEnd('/')
|
||||||
|
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return
|
||||||
|
|
||||||
|
val batch = mutex.withLock { readQueue() }
|
||||||
|
if (batch.isEmpty()) return
|
||||||
|
|
||||||
|
val auth = authParams(s.username, s.password)
|
||||||
|
val ok = runCatching {
|
||||||
|
val resp: HttpResponse = http.post("$base/scrobble/plays?$auth") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(json.encodeToString(Batch(batch)))
|
||||||
|
}
|
||||||
|
resp.status.isSuccess()
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
// Remove exactly what we sent (accepted + duplicates are durable), preserving
|
||||||
|
// any plays queued while the request was in flight.
|
||||||
|
val sent = batch.map { it.uuid }.toHashSet()
|
||||||
|
mutex.withLock { writeQueue(readQueue().filterNot { it.uuid in sent }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read aggregated stats for the current user. Returns null on any failure (unconfigured,
|
||||||
|
* unauthorized, network). Decodes with the tolerant json instance (ignoreUnknownKeys). */
|
||||||
|
suspend fun fetchStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int): ScrobbleStats? {
|
||||||
|
val s = prefs.appSettings.first()
|
||||||
|
val base = s.scrobbleUrl.trimEnd('/')
|
||||||
|
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
|
||||||
|
val auth = authParams(s.username, s.password)
|
||||||
|
val url = "$base/scrobble/stats?$auth&from=$from&to=$to&tz=$tz&bucket=$bucket&limit=$limit"
|
||||||
|
return runCatching {
|
||||||
|
val resp = http.get(url)
|
||||||
|
if (!resp.status.isSuccess()) return null
|
||||||
|
json.decodeFromString<ScrobbleStats>(resp.bodyAsText())
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Head-to-Head: list the users in the log (for the partner picker). Null on failure. */
|
||||||
|
suspend fun fetchUsers(): com.novasonic.app.api.model.ScrobbleUsersResponse? {
|
||||||
|
val s = prefs.appSettings.first()
|
||||||
|
val base = s.scrobbleUrl.trimEnd('/')
|
||||||
|
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
|
||||||
|
val auth = authParams(s.username, s.password)
|
||||||
|
return runCatching {
|
||||||
|
val resp = http.get("$base/scrobble/users?$auth")
|
||||||
|
if (!resp.status.isSuccess()) return null
|
||||||
|
json.decodeFromString<com.novasonic.app.api.model.ScrobbleUsersResponse>(resp.bodyAsText())
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Head-to-Head: compare the authed user against [with]. Null on failure. */
|
||||||
|
suspend fun fetchCompare(with: String): com.novasonic.app.api.model.CompareResult? {
|
||||||
|
val s = prefs.appSettings.first()
|
||||||
|
val base = s.scrobbleUrl.trimEnd('/')
|
||||||
|
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
|
||||||
|
val auth = authParams(s.username, s.password)
|
||||||
|
val url = "$base/scrobble/compare?$auth&with=${with.encodeURLParameter()}"
|
||||||
|
return runCatching {
|
||||||
|
val resp = http.get(url)
|
||||||
|
if (!resp.status.isSuccess()) return null
|
||||||
|
json.decodeFromString<com.novasonic.app.api.model.CompareResult>(resp.bodyAsText())
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readQueue(): List<PlayEntry> =
|
||||||
|
runCatching {
|
||||||
|
if (!queueFile.exists()) emptyList()
|
||||||
|
else json.decodeFromString<List<PlayEntry>>(queueFile.readText())
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
private fun writeQueue(q: List<PlayEntry>) {
|
||||||
|
runCatching { queueFile.writeText(json.encodeToString(q)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authParams(user: String, pass: String): String {
|
||||||
|
val salt = Random.nextInt(100_000, 999_999).toString()
|
||||||
|
val token = md5(pass + salt)
|
||||||
|
return "u=${user.encodeURLParameter()}&t=$token&s=$salt"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun md5(input: String): String =
|
||||||
|
MessageDigest.getInstance("MD5").digest(input.toByteArray())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
companion object { private const val MAX_QUEUE = 500 }
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ package com.novasonic.app.data
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
private val Context.dataStore by preferencesDataStore("novasonic_prefs")
|
private val Context.dataStore by preferencesDataStore("novasonic_prefs")
|
||||||
@@ -18,16 +20,26 @@ class PrefsStore(private val context: Context) {
|
|||||||
val KEY_PASSWORD = stringPreferencesKey("password")
|
val KEY_PASSWORD = stringPreferencesKey("password")
|
||||||
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
|
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
|
||||||
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
|
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
|
||||||
|
val KEY_SCROBBLE_URL = stringPreferencesKey("scrobble_url")
|
||||||
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
|
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
|
||||||
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
||||||
val KEY_THEME = stringPreferencesKey("theme")
|
val KEY_THEME = stringPreferencesKey("theme")
|
||||||
val KEY_ABS_URL = stringPreferencesKey("abs_url")
|
val KEY_ABS_URL = stringPreferencesKey("abs_url")
|
||||||
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
|
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
|
||||||
val KEY_ABS_CHAPTER_TRACK = booleanPreferencesKey("abs_chapter_track")
|
val KEY_ABS_CHAPTER_TRACK = booleanPreferencesKey("abs_chapter_track")
|
||||||
|
val KEY_DL_WIFI_ONLY = booleanPreferencesKey("download_wifi_only")
|
||||||
|
val KEY_WISCH_TITEL = booleanPreferencesKey("swipe_changes_track")
|
||||||
|
val KEY_LYRICS_FOLGEN = booleanPreferencesKey("lyrics_autoscroll")
|
||||||
val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id")
|
val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id")
|
||||||
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
|
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
|
||||||
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
|
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
|
||||||
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
|
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
|
||||||
|
// Warteschlange fortsetzen. Gespeichert werden die VOLLSTAENDIGEN Songs als JSON, nicht nur
|
||||||
|
// Ids: mit Ids musste beim Start jeder Titel einzeln ueber das Netz nachgeladen werden, was
|
||||||
|
// Sekunden dauerte und ohne Verbindung gar nicht ging. So ist es ein lokaler Lesevorgang.
|
||||||
|
// Ohne Sekunde im Titel — der zuletzt gehoerte Titel beginnt von vorn.
|
||||||
|
val KEY_QUEUE_JSON = stringPreferencesKey("queue_json")
|
||||||
|
val KEY_QUEUE_INDEX = intPreferencesKey("queue_index")
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Credentials(val serverUrl: String, val username: String, val password: String) {
|
data class Credentials(val serverUrl: String, val username: String, val password: String) {
|
||||||
@@ -40,12 +52,23 @@ class PrefsStore(private val context: Context) {
|
|||||||
val password: String = "",
|
val password: String = "",
|
||||||
val lastFmKey: String = "",
|
val lastFmKey: String = "",
|
||||||
val backendUrl: String = "",
|
val backendUrl: String = "",
|
||||||
|
val scrobbleUrl: String = "",
|
||||||
val scrobbleMode: String = "30s",
|
val scrobbleMode: String = "30s",
|
||||||
val showPlayCount: Boolean = false,
|
val showPlayCount: Boolean = false,
|
||||||
val theme: String = "dark-ocean",
|
val theme: String = "dark-ocean",
|
||||||
val absUrl: String = "",
|
val absUrl: String = "",
|
||||||
val absApiKey: String = "",
|
val absApiKey: String = "",
|
||||||
val absChapterTrackMode: Boolean = false,
|
val absChapterTrackMode: Boolean = false,
|
||||||
|
/** Offline-Downloads nur ueber WLAN. Standard an: eine grosse Playlist sind schnell
|
||||||
|
* mehrere Gigabyte, und die Wiederholkette laeuft ohne Zutun weiter. */
|
||||||
|
val downloadWifiOnly: Boolean = true,
|
||||||
|
/** Waagerechtes Wischen im Now-Playing wechselt den Titel statt der Ansicht.
|
||||||
|
* Standard an — Titelwechsel ist die Geste, die man dort staendig braucht. */
|
||||||
|
val wischenWechseltTitel: Boolean = true,
|
||||||
|
/** Im Lyrics-Tab automatisch zur laufenden Zeile scrollen. Standard AUS: wer selbst
|
||||||
|
* blaettert, will nicht, dass der Text unter dem Finger wegspringt. Umschalten direkt
|
||||||
|
* im Lyrics-Tab, wirkt nur bei synchronen Texten. */
|
||||||
|
val lyricsFolgenAutomatisch: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class LastPlayedSong(
|
data class LastPlayedSong(
|
||||||
@@ -57,7 +80,9 @@ class PrefsStore(private val context: Context) {
|
|||||||
|
|
||||||
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
|
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
|
||||||
Credentials(
|
Credentials(
|
||||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
// Adresse vorbelegt, Zugangsdaten bewusst nicht — die gehoeren pro Person.
|
||||||
|
serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
|
||||||
|
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
|
||||||
username = prefs[KEY_USERNAME] ?: "",
|
username = prefs[KEY_USERNAME] ?: "",
|
||||||
password = prefs[KEY_PASSWORD] ?: "",
|
password = prefs[KEY_PASSWORD] ?: "",
|
||||||
)
|
)
|
||||||
@@ -65,17 +90,26 @@ class PrefsStore(private val context: Context) {
|
|||||||
|
|
||||||
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||||
AppSettings(
|
AppSettings(
|
||||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
|
||||||
|
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
|
||||||
username = prefs[KEY_USERNAME] ?: "",
|
username = prefs[KEY_USERNAME] ?: "",
|
||||||
password = prefs[KEY_PASSWORD] ?: "",
|
password = prefs[KEY_PASSWORD] ?: "",
|
||||||
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
|
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
|
||||||
backendUrl = prefs[KEY_BACKEND_URL] ?: "",
|
backendUrl = prefs[KEY_BACKEND_URL]?.ifBlank { null }
|
||||||
|
?: com.novasonic.app.BuildConfig.DEFAULT_BACKEND_URL,
|
||||||
|
// Leerer Speicherwert => eingebaute Vorgabe. So muss auf einem frischen Geraet nichts
|
||||||
|
// eingetippt werden, ein eigener Eintrag hat aber weiterhin Vorrang.
|
||||||
|
scrobbleUrl = prefs[KEY_SCROBBLE_URL]?.ifBlank { null }
|
||||||
|
?: com.novasonic.app.BuildConfig.DEFAULT_SCROBBLE_URL,
|
||||||
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
||||||
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
||||||
theme = prefs[KEY_THEME] ?: "dark-ocean",
|
theme = prefs[KEY_THEME] ?: "dark-ocean",
|
||||||
absUrl = prefs[KEY_ABS_URL] ?: "",
|
absUrl = prefs[KEY_ABS_URL] ?: "",
|
||||||
absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
|
absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
|
||||||
absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false,
|
absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false,
|
||||||
|
downloadWifiOnly = prefs[KEY_DL_WIFI_ONLY] ?: true,
|
||||||
|
wischenWechseltTitel = prefs[KEY_WISCH_TITEL] ?: true,
|
||||||
|
lyricsFolgenAutomatisch = prefs[KEY_LYRICS_FOLGEN] ?: false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,15 +138,28 @@ class PrefsStore(private val context: Context) {
|
|||||||
prefs[KEY_PASSWORD] = settings.password
|
prefs[KEY_PASSWORD] = settings.password
|
||||||
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
|
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
|
||||||
prefs[KEY_BACKEND_URL] = settings.backendUrl
|
prefs[KEY_BACKEND_URL] = settings.backendUrl
|
||||||
|
prefs[KEY_SCROBBLE_URL] = settings.scrobbleUrl
|
||||||
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
|
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
|
||||||
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
||||||
prefs[KEY_THEME] = settings.theme
|
prefs[KEY_THEME] = settings.theme
|
||||||
prefs[KEY_ABS_URL] = settings.absUrl
|
prefs[KEY_ABS_URL] = settings.absUrl
|
||||||
prefs[KEY_ABS_API_KEY] = settings.absApiKey
|
prefs[KEY_ABS_API_KEY] = settings.absApiKey
|
||||||
prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode
|
prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode
|
||||||
|
prefs[KEY_DL_WIFI_ONLY] = settings.downloadWifiOnly
|
||||||
|
prefs[KEY_WISCH_TITEL] = settings.wischenWechseltTitel
|
||||||
|
// KEY_LYRICS_FOLGEN bewusst NICHT: der Schalter sitzt im Lyrics-Tab und wird mit
|
||||||
|
// saveLyricsFolgen() einzeln geschrieben. Wuerde er hier mitgeschrieben, setzte jedes
|
||||||
|
// Speichern in den Einstellungen ihn auf die Vorgabe zurueck — der Bildschirm fuehrt
|
||||||
|
// dieses Feld gar nicht mit.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Einzeln speicherbar: der Schalter sitzt im Lyrics-Tab, nicht in den Einstellungen, und
|
||||||
|
* darf dort nicht den ganzen Einstellungssatz ueberschreiben. */
|
||||||
|
suspend fun saveLyricsFolgen(an: Boolean) {
|
||||||
|
context.dataStore.edit { prefs -> prefs[KEY_LYRICS_FOLGEN] = an }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun saveTheme(themeId: String) {
|
suspend fun saveTheme(themeId: String) {
|
||||||
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
|
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
|
||||||
}
|
}
|
||||||
@@ -126,6 +173,39 @@ class PrefsStore(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val queueJson = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sichert die Warteschlange als VOLLSTAENDIGE Songs, nicht nur als Ids. Mit Ids musste beim
|
||||||
|
* Start jeder Titel einzeln ueber das Netz nachgeladen werden — das dauerte Sekunden und ging
|
||||||
|
* ohne Verbindung gar nicht. So ist das Wiederherstellen ein rein lokaler Lesevorgang.
|
||||||
|
* Der Preis: zwischenzeitlich geaenderte Titel oder Cover zeigen bis zum naechsten Abspielen
|
||||||
|
* den alten Stand. Das ist die deutlich kleinere Unschoenheit.
|
||||||
|
*/
|
||||||
|
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) {
|
||||||
|
val kodiert = runCatching {
|
||||||
|
queueJson.encodeToString(kotlinx.serialization.builtins.ListSerializer(
|
||||||
|
com.novasonic.app.api.model.Song.serializer()), songs)
|
||||||
|
}.getOrNull() ?: return
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_QUEUE_JSON] = kodiert
|
||||||
|
prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class QueueState(val songs: List<com.novasonic.app.api.model.Song>, val index: Int)
|
||||||
|
|
||||||
|
suspend fun readQueueState(): QueueState? {
|
||||||
|
val prefs = context.dataStore.data.first()
|
||||||
|
val roh = prefs[KEY_QUEUE_JSON]?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val songs = runCatching {
|
||||||
|
queueJson.decodeFromString(kotlinx.serialization.builtins.ListSerializer(
|
||||||
|
com.novasonic.app.api.model.Song.serializer()), roh)
|
||||||
|
}.getOrNull().orEmpty()
|
||||||
|
if (songs.isEmpty()) return null
|
||||||
|
return QueueState(songs, (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, songs.lastIndex))
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun clear() {
|
suspend fun clear() {
|
||||||
context.dataStore.edit { it.clear() }
|
context.dataStore.edit { it.clear() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.novasonic.app.data
|
||||||
|
|
||||||
|
import com.novasonic.app.api.model.Song
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global, reactive star/favorite overrides. Tapping a star flips the state here *immediately*
|
||||||
|
* (optimistic), so every screen showing that song updates at once — regardless of when each
|
||||||
|
* screen last fetched `song.starred`. The API call runs in the background and reverts on failure.
|
||||||
|
*/
|
||||||
|
object StarState {
|
||||||
|
private val _overrides = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
||||||
|
val overrides: StateFlow<Map<String, Boolean>> = _overrides.asStateFlow()
|
||||||
|
|
||||||
|
/** Current starred state for a song: the live override if present, else the song's own flag. */
|
||||||
|
fun isStarred(song: Song): Boolean = _overrides.value[song.id] ?: (song.starred != null)
|
||||||
|
fun isStarred(songId: String, fallback: Boolean): Boolean = _overrides.value[songId] ?: fallback
|
||||||
|
|
||||||
|
/** Optimistically toggle, then hit the API; revert the override if the call fails. */
|
||||||
|
fun toggle(repo: MusicRepository, scope: CoroutineScope, songId: String, fallbackStarred: Boolean) {
|
||||||
|
val current = _overrides.value[songId] ?: fallbackStarred
|
||||||
|
val next = !current
|
||||||
|
_overrides.value = _overrides.value + (songId to next)
|
||||||
|
scope.launch {
|
||||||
|
val ok = runCatching { if (next) repo.star(songId) else repo.unstar(songId) }.isSuccess
|
||||||
|
if (!ok) _overrides.value = _overrides.value + (songId to current) // revert on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,13 +21,18 @@ class DownloadWorker(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val EXTRA_GROUP_ID = "group_id"
|
const val EXTRA_GROUP_ID = "group_id"
|
||||||
|
private const val TAG = "DownloadWorker"
|
||||||
|
/** Obergrenze fuer selbst angeforderte Wiederholungen, damit dauerhaft kaputte Titel
|
||||||
|
* (auf dem Server geloescht, immer 404) die Kette nicht endlos weiterlaufen lassen. */
|
||||||
|
private const val MAX_VERSUCHE = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
private val offlineRepo = applicationContext.app.offlineRepository
|
private val offlineRepo = applicationContext.app.offlineRepository
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val groupId = inputData.getString(EXTRA_GROUP_ID) ?: return Result.failure()
|
val groupId = inputData.getString(EXTRA_GROUP_ID) ?: return Result.failure()
|
||||||
val group = offlineRepo.getGroup(groupId) ?: return Result.failure()
|
// Nur die Existenz zaehlt — der Fortschritt wird unten neu gezaehlt, nicht fortgeschrieben.
|
||||||
|
offlineRepo.getGroup(groupId) ?: return Result.failure()
|
||||||
val songIds = offlineRepo.songIdsForGroup(groupId)
|
val songIds = offlineRepo.songIdsForGroup(groupId)
|
||||||
if (songIds.isEmpty()) return Result.success()
|
if (songIds.isEmpty()) return Result.success()
|
||||||
|
|
||||||
@@ -35,14 +40,26 @@ class DownloadWorker(
|
|||||||
if (!creds.isValid) return Result.failure()
|
if (!creds.isValid) return Result.failure()
|
||||||
|
|
||||||
val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||||
var done = group.downloadedTracks
|
// Bei 0 anfangen und JEDEN fertigen Titel zaehlen — auch die aus frueheren Laeufen, die
|
||||||
|
// gleich uebersprungen werden. Frueher stand hier group.downloadedTracks, und die Schleife
|
||||||
|
// zaehlte dieselben Titel gleich nochmal: nach einem Fortsetzen mit 412 fertigen Titeln
|
||||||
|
// meldete die Gruppe 824. Ueberschreitet der Wert totalTracks, gilt die Gruppe als
|
||||||
|
// "Vollstaendig" und der Herunterladen-Knopf wird unantastbar, obwohl Titel fehlen.
|
||||||
|
var done = 0
|
||||||
val dao = OfflineDatabase.get(applicationContext).offlineDao()
|
val dao = OfflineDatabase.get(applicationContext).offlineDao()
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
|
var gestoppt = false
|
||||||
for (songId in songIds) {
|
for (songId in songIds) {
|
||||||
|
// WorkManager raeumt Hintergrundarbeit rund 10 Minuten ein und stoppt sie dann.
|
||||||
|
// Ohne diese Pruefung laeuft die Schleife blind weiter, bis sie hart abgebrochen
|
||||||
|
// wird — der angefangene Titel geht verloren und niemand startet neu. So steigen
|
||||||
|
// wir sauber aus und fordern den naechsten Lauf an; Fertiges wird uebersprungen.
|
||||||
|
if (isStopped) { gestoppt = true; break }
|
||||||
if (offlineRepo.getTrackState(songId) == DownloadState.DONE) {
|
if (offlineRepo.getTrackState(songId) == DownloadState.DONE) {
|
||||||
|
// Kein Schreibvorgang beim Ueberspringen: der Fortschritt aendert sich dabei
|
||||||
|
// nicht, und bei 1900 Titeln waeren das 1900 unnoetige Schreibzugriffe je Lauf.
|
||||||
done++
|
done++
|
||||||
dao.updateGroupProgress(groupId, done)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,17 +86,41 @@ class DownloadWorker(
|
|||||||
offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
|
offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
|
||||||
done++
|
done++
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("DownloadWorker", "Failed to download song $songId", e)
|
Log.e(TAG, "Titel $songId fehlgeschlagen", e)
|
||||||
audioFile.delete()
|
audioFile.delete()
|
||||||
offlineRepo.markFailed(songId)
|
offlineRepo.markFailed(songId)
|
||||||
}
|
}
|
||||||
|
|
||||||
dao.updateGroupProgress(groupId, done)
|
dao.updateGroupProgress(groupId, done)
|
||||||
}
|
}
|
||||||
Result.success()
|
// Beim Abbruch ist der letzte Stand sonst nicht gesichert.
|
||||||
|
if (gestoppt) dao.updateGroupProgress(groupId, done)
|
||||||
|
|
||||||
|
val offen = dao.offeneTitel(groupId)
|
||||||
|
when {
|
||||||
|
gestoppt -> {
|
||||||
|
Log.i(TAG, "Gestoppt bei $done/${songIds.size} — fordere naechsten Lauf an")
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
offen == 0 -> {
|
||||||
|
Log.i(TAG, "Gruppe $groupId vollstaendig (${songIds.size} Titel)")
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
runAttemptCount < MAX_VERSUCHE -> {
|
||||||
|
Log.i(TAG, "$offen Titel offen (Versuch $runAttemptCount) — noch ein Lauf")
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Aufgeben, aber nicht stillschweigend: die Gruppenzeile zeigt weiterhin
|
||||||
|
// "x/y Titel", und ein Tipp auf Herunterladen startet die Kette neu.
|
||||||
|
Log.w(TAG, "$offen Titel bleiben offen — nach $runAttemptCount Versuchen aufgegeben")
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("DownloadWorker", "Worker failed for group $groupId", e)
|
Log.e(TAG, "Lauf fuer Gruppe $groupId abgebrochen", e)
|
||||||
Result.failure()
|
// Frueher Result.failure(): ein einzelner Netzfehler beendete die ganze Kette endgueltig.
|
||||||
|
if (runAttemptCount < MAX_VERSUCHE) Result.retry() else Result.failure()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,26 @@ interface OfflineDao {
|
|||||||
@Query("SELECT * FROM download_groups WHERE groupId = :groupId")
|
@Query("SELECT * FROM download_groups WHERE groupId = :groupId")
|
||||||
suspend fun getGroup(groupId: String): DownloadGroupEntity?
|
suspend fun getGroup(groupId: String): DownloadGroupEntity?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnet den gespeicherten Fortschritt ALLER Gruppen aus dem tatsaechlichen Zustand der Titel
|
||||||
|
* neu. Noetig, weil der Worker bis 1.10 doppelt zaehlte: der Wert konnte totalTracks
|
||||||
|
* uebersteigen, die Gruppe galt als "Vollstaendig", der Herunterladen-Knopf wurde unantastbar
|
||||||
|
* und das Fortsetzen uebersprang sie — der falsche Wert konnte sich also nie mehr korrigieren.
|
||||||
|
* Eine Anweisung fuer alle Gruppen, laeuft beim App-Start.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
UPDATE download_groups SET downloadedTracks = (
|
||||||
|
SELECT COUNT(*) FROM group_track_cross_refs r
|
||||||
|
INNER JOIN downloaded_tracks t ON t.songId = r.songId
|
||||||
|
WHERE r.groupId = download_groups.groupId AND t.state = 'DONE'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
suspend fun fortschrittNeuBerechnen()
|
||||||
|
|
||||||
|
/** Gruppen, bei denen noch Titel fehlen — Grundlage fuers Fortsetzen beim App-Start. */
|
||||||
|
@Query("SELECT * FROM download_groups WHERE totalTracks > 0 AND downloadedTracks < totalTracks")
|
||||||
|
suspend fun incompleteGroups(): List<DownloadGroupEntity>
|
||||||
|
|
||||||
@Query("DELETE FROM download_groups WHERE groupId = :groupId")
|
@Query("DELETE FROM download_groups WHERE groupId = :groupId")
|
||||||
suspend fun deleteGroup(groupId: String)
|
suspend fun deleteGroup(groupId: String)
|
||||||
|
|
||||||
@@ -84,9 +104,21 @@ interface OfflineDao {
|
|||||||
""")
|
""")
|
||||||
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>>
|
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>>
|
||||||
|
|
||||||
|
/** Wie viele Titel der Gruppe noch nicht fertig sind — eine Abfrage statt einer pro Titel. */
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(*) FROM downloaded_tracks t
|
||||||
|
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
|
||||||
|
WHERE r.groupId = :groupId AND t.state != 'DONE'
|
||||||
|
""")
|
||||||
|
suspend fun offeneTitel(groupId: String): Int
|
||||||
|
|
||||||
@Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId")
|
@Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId")
|
||||||
suspend fun removeCrossRef(groupId: String, songId: String)
|
suspend fun removeCrossRef(groupId: String, songId: String)
|
||||||
|
|
||||||
|
/** Wie viele Titel dieses Cover noch benutzen — Cover werden geteilt gespeichert. */
|
||||||
|
@Query("SELECT COUNT(*) FROM downloaded_tracks WHERE coverArt = :coverArtId")
|
||||||
|
suspend fun titelMitCover(coverArtId: String): Int
|
||||||
|
|
||||||
/** Find a locally-stored cover path by Navidrome coverArt ID (deduped storage). */
|
/** Find a locally-stored cover path by Navidrome coverArt ID (deduped storage). */
|
||||||
@Query("SELECT coverPath FROM downloaded_tracks WHERE coverArt = :coverArtId AND state = 'DONE' AND coverPath IS NOT NULL LIMIT 1")
|
@Query("SELECT coverPath FROM downloaded_tracks WHERE coverArt = :coverArtId AND state = 'DONE' AND coverPath IS NOT NULL LIMIT 1")
|
||||||
suspend fun localCoverPathByCoverArt(coverArtId: String): String?
|
suspend fun localCoverPathByCoverArt(coverArtId: String): String?
|
||||||
|
|||||||
@@ -154,18 +154,98 @@ class OfflineRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
dao.updateGroupProgress(groupId, alreadyDone)
|
dao.updateGroupProgress(groupId, alreadyDone)
|
||||||
|
|
||||||
|
starteWorker(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet (oder setzt fort) die Downloadarbeit einer Gruppe. KEEP heisst: laeuft schon etwas
|
||||||
|
* fuer diese Gruppe, passiert nichts — sonst beginnt ein Lauf, der alles Fertige ueberspringt.
|
||||||
|
*/
|
||||||
|
private suspend fun starteWorker(groupId: String, ersetzen: Boolean = false) {
|
||||||
|
// UNMETERED = WLAN (bzw. jede nicht getaktete Verbindung). WorkManager pausiert den Download
|
||||||
|
// dann von selbst, sobald das WLAN weg ist, und setzt fort, sobald es zurueck ist. Mit
|
||||||
|
// CONNECTED liefe die Wiederholkette auch ueber Mobilfunk durch — bei einer grossen Playlist
|
||||||
|
// sind das schnell mehrere Gigabyte ohne Zutun des Nutzers.
|
||||||
|
val nurWlan = runCatching {
|
||||||
|
com.novasonic.app.data.PrefsStore(appContext).appSettings.first().downloadWifiOnly
|
||||||
|
}.getOrDefault(true)
|
||||||
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
|
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||||
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
|
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
|
||||||
.setConstraints(
|
.setConstraints(
|
||||||
Constraints.Builder()
|
Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.setRequiredNetworkType(if (nurWlan) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||||
.setRequiresStorageNotLow(true)
|
.setRequiresStorageNotLow(true)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
|
// Ohne Backoff wuerde ein Result.retry() sofort erneut anlaufen; 30 s linear geben dem
|
||||||
|
// Geraet Luft und halten die Kette trotzdem in Gang.
|
||||||
|
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
WorkManager.getInstance(appContext)
|
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
||||||
.enqueueUniqueWork(groupId, ExistingWorkPolicy.KEEP, request)
|
groupId,
|
||||||
|
if (ersetzen) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach einer Aenderung an "nur ueber WLAN" die laufende Arbeit neu einreihen. WorkManager
|
||||||
|
* aendert die Bedingungen einer bereits eingereihten Arbeit nicht mehr — ohne das bliebe der
|
||||||
|
* Schalter bis zum naechsten App-Start wirkungslos. REPLACE ist unbedenklich, weil der Worker
|
||||||
|
* ohnehin fortsetzt statt von vorn zu laden.
|
||||||
|
*/
|
||||||
|
fun netzbedingungAktualisieren() {
|
||||||
|
scope.launch {
|
||||||
|
runCatching { dao.incompleteGroups() }.getOrDefault(emptyList())
|
||||||
|
.forEach { starteWorker(it.groupId, ersetzen = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beim App-Start unvollstaendige Gruppen weiterlaufen lassen. WorkManager stoppt einen
|
||||||
|
* Hintergrund-Worker nach rund 10 Minuten; eine Playlist mit 1900 Titeln braucht daher viele
|
||||||
|
* Laeufe. Der Worker fordert selbst einen neuen an (Result.retry()), aber wenn die Kette doch
|
||||||
|
* einmal reisst — Neustart des Geraets, aufgebrauchte Versuche — faengt das hier es auf, statt
|
||||||
|
* darauf zu warten, dass jemand von Hand erneut auf Herunterladen tippt.
|
||||||
|
*/
|
||||||
|
fun setzeUnvollstaendigeFort() {
|
||||||
|
scope.launch {
|
||||||
|
// Erst den gespeicherten Fortschritt begradigen: Gruppen mit einem zu hohen Wert aus
|
||||||
|
// der Zeit vor 1.10 sehen sonst vollstaendig aus und werden hier gar nicht gefunden.
|
||||||
|
runCatching { dao.fortschrittNeuBerechnen() }
|
||||||
|
.onFailure { Log.w("OfflineRepository", "Fortschritt neu berechnen fehlgeschlagen", it) }
|
||||||
|
val offen = runCatching { dao.incompleteGroups() }.getOrDefault(emptyList())
|
||||||
|
offen.forEach { g ->
|
||||||
|
Log.i("OfflineRepository",
|
||||||
|
"Setze fort: ${g.groupId} (${g.downloadedTracks}/${g.totalTracks})")
|
||||||
|
starteWorker(g.groupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt alle Dateien eines Titels und dann die Datenbankzeile.
|
||||||
|
*
|
||||||
|
* Frueher lief das ueber _audioMap — die kennt aber nur FERTIGE Titel. Ein abgebrochener oder
|
||||||
|
* fehlgeschlagener Download hinterliess seine Datei also unberuehrt. Jetzt kommt der Pfad aus
|
||||||
|
* der Zeile selbst, dazu die angefangene .tmp und das Cover, sofern es kein anderer Titel mehr
|
||||||
|
* benutzt (Cover werden geteilt gespeichert).
|
||||||
|
*/
|
||||||
|
private suspend fun loescheTitelVollstaendig(songId: String) {
|
||||||
|
val track = dao.getTrackById(songId)
|
||||||
|
val pfade = listOfNotNull(track?.localPath, _audioMap.value[songId])
|
||||||
|
pfade.distinct().forEach { p ->
|
||||||
|
runCatching { File(p).delete(); File("$p.tmp").delete() }
|
||||||
|
}
|
||||||
|
track?.coverArt?.let { coverArtId ->
|
||||||
|
// Diese Zeile zaehlt noch mit, deshalb ist 1 der letzte Benutzer.
|
||||||
|
if (dao.titelMitCover(coverArtId) <= 1) {
|
||||||
|
runCatching { File(coversDir(), "$coverArtId.jpg").delete() }
|
||||||
|
track.coverPath?.let { runCatching { File(it).delete() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dao.deleteTrackById(songId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Delete with reference-count file cleanup ──────────────────────────────
|
// ── Delete with reference-count file cleanup ──────────────────────────────
|
||||||
@@ -173,22 +253,18 @@ class OfflineRepository(context: Context) {
|
|||||||
/** Removes a group and any tracks that are no longer referenced by another group. */
|
/** Removes a group and any tracks that are no longer referenced by another group. */
|
||||||
suspend fun deleteGroup(groupId: String) {
|
suspend fun deleteGroup(groupId: String) {
|
||||||
val songIds = dao.songIdsForGroup(groupId)
|
val songIds = dao.songIdsForGroup(groupId)
|
||||||
|
// Laufende Downloadarbeit stoppen, sonst laedt sie noch Minuten weiter ins Leere.
|
||||||
|
WorkManager.getInstance(appContext).cancelUniqueWork(groupId)
|
||||||
dao.deleteGroup(groupId) // cascades GroupTrackCrossRef rows
|
dao.deleteGroup(groupId) // cascades GroupTrackCrossRef rows
|
||||||
for (songId in songIds) {
|
for (songId in songIds) {
|
||||||
if (dao.groupCountForSong(songId) == 0) {
|
if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
|
||||||
_audioMap.value[songId]?.let { File(it).delete() }
|
|
||||||
dao.deleteTrackById(songId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes a single track from a group, deleting the file if no other group still needs it. */
|
/** Removes a single track from a group, deleting the file if no other group still needs it. */
|
||||||
suspend fun removeSongFromGroup(groupId: String, songId: String) {
|
suspend fun removeSongFromGroup(groupId: String, songId: String) {
|
||||||
dao.removeCrossRef(groupId, songId)
|
dao.removeCrossRef(groupId, songId)
|
||||||
if (dao.groupCountForSong(songId) == 0) {
|
if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
|
||||||
_audioMap.value[songId]?.let { File(it).delete() }
|
|
||||||
dao.deleteTrackById(songId)
|
|
||||||
}
|
|
||||||
recountGroup(groupId)
|
recountGroup(groupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class MusicService : MediaLibraryService() {
|
|||||||
// Pseudo-items for play-all / shuffle at top of each list
|
// Pseudo-items for play-all / shuffle at top of each list
|
||||||
const val PLAY_ALL_PREFIX = "playall:"
|
const val PLAY_ALL_PREFIX = "playall:"
|
||||||
const val SHUFFLE_PREFIX = "shuffle:"
|
const val SHUFFLE_PREFIX = "shuffle:"
|
||||||
|
// Encodes a song's browse context so a tap plays the whole list from that song:
|
||||||
|
// "songctx:<contextId>|<songId>" (contextId may itself be "album:…"/"playlist:…").
|
||||||
|
const val SONG_CTX_PREFIX = "songctx:"
|
||||||
// ABS session commands (handled via onCustomCommand)
|
// ABS session commands (handled via onCustomCommand)
|
||||||
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
|
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
|
||||||
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
|
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
|
||||||
@@ -235,7 +238,14 @@ class MusicService : MediaLibraryService() {
|
|||||||
|
|
||||||
override fun onGetSession(info: MediaSession.ControllerInfo) = session
|
override fun onGetSession(info: MediaSession.ControllerInfo) = session
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) { player.stop(); stopSelf() }
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
// Wischt jemand die App aus den letzten Apps, wird der Prozess gleich beendet — hier ist die
|
||||||
|
// letzte Gelegenheit, die Warteschlange festzuhalten. Ohne das ginge sie genau in dem Fall
|
||||||
|
// verloren, in dem man sie beim naechsten Oeffnen am ehesten erwartet.
|
||||||
|
runCatching { (application as? com.novasonic.app.NovaSonicApp)?.playerController?.saveQueueNow() }
|
||||||
|
player.stop()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
@@ -349,6 +359,10 @@ class MusicService : MediaLibraryService() {
|
|||||||
.build()
|
.build()
|
||||||
).build()
|
).build()
|
||||||
}
|
}
|
||||||
|
mediaId.startsWith(SONG_CTX_PREFIX) -> {
|
||||||
|
val songId = mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')
|
||||||
|
repo.getSong(songId).toMediaItem(repo).buildUpon().setMediaId(mediaId).build()
|
||||||
|
}
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
||||||
@@ -360,42 +374,78 @@ class MusicService : MediaLibraryService() {
|
|||||||
return settable
|
return settable
|
||||||
}
|
}
|
||||||
|
|
||||||
// When Android Auto selects a browsable item (album/playlist), load its songs.
|
// Android Auto "add to queue" path — resolve browsable items to their songs.
|
||||||
override fun onAddMediaItems(
|
override fun onAddMediaItems(
|
||||||
mediaSession: MediaSession,
|
mediaSession: MediaSession,
|
||||||
controller: MediaSession.ControllerInfo,
|
controller: MediaSession.ControllerInfo,
|
||||||
mediaItems: MutableList<MediaItem>,
|
mediaItems: MutableList<MediaItem>,
|
||||||
): ListenableFuture<MutableList<MediaItem>> {
|
): ListenableFuture<MutableList<MediaItem>> {
|
||||||
val settable = SettableFuture.create<MutableList<MediaItem>>()
|
val settable = SettableFuture.create<MutableList<MediaItem>>()
|
||||||
|
scope.launch {
|
||||||
|
try { settable.set(expandForPlayback(mediaItems).first.toMutableList()) }
|
||||||
|
catch (e: Exception) { settable.set(mediaItems) }
|
||||||
|
}
|
||||||
|
return settable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android Auto "tap to play" path — return the resolved list AND the index to start at, so
|
||||||
|
// tapping a song plays its whole list from that song (not just the one song).
|
||||||
|
override fun onSetMediaItems(
|
||||||
|
mediaSession: MediaSession,
|
||||||
|
controller: MediaSession.ControllerInfo,
|
||||||
|
mediaItems: MutableList<MediaItem>,
|
||||||
|
startIndex: Int,
|
||||||
|
startPositionMs: Long,
|
||||||
|
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
||||||
|
val settable = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val resolved = mediaItems.flatMap { item ->
|
val (list, idx) = expandForPlayback(mediaItems)
|
||||||
when {
|
// A single browse tap expands into a full list → start at the tapped index.
|
||||||
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> {
|
// Otherwise honour the caller's startIndex.
|
||||||
resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), shuffle = false)
|
val expanded = list.size != mediaItems.size
|
||||||
}
|
val start = if (expanded) idx else startIndex.coerceIn(0, (list.size - 1).coerceAtLeast(0))
|
||||||
item.mediaId.startsWith(SHUFFLE_PREFIX) -> {
|
settable.set(MediaSession.MediaItemsWithStartPosition(list, start, startPositionMs))
|
||||||
resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), shuffle = true)
|
|
||||||
}
|
|
||||||
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
|
||||||
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
|
|
||||||
album.song.map { song -> song.toMediaItem(repo) }
|
|
||||||
}
|
|
||||||
item.mediaId.startsWith(ITEM_PREFIX_PL) -> {
|
|
||||||
val pl = repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL))
|
|
||||||
pl.entry.map { song -> song.toMediaItem(repo) }
|
|
||||||
}
|
|
||||||
else -> listOf(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
settable.set(resolved.toMutableList())
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
settable.set(mediaItems)
|
settable.set(MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return settable
|
return settable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve browse items (play-all / shuffle / album / playlist / context-song / plain song)
|
||||||
|
// into a flat, playable song list plus the index to start at (the tapped song, else 0).
|
||||||
|
private suspend fun expandForPlayback(items: List<MediaItem>): Pair<List<MediaItem>, Int> {
|
||||||
|
if (items.size == 1 && items[0].mediaId.startsWith(SONG_CTX_PREFIX)) {
|
||||||
|
val rest = items[0].mediaId.removePrefix(SONG_CTX_PREFIX)
|
||||||
|
val ctx = rest.substringBefore('|')
|
||||||
|
val songId = rest.substringAfter('|')
|
||||||
|
val full = resolveSongs(ctx, shuffle = false)
|
||||||
|
if (full.isNotEmpty()) {
|
||||||
|
val idx = full.indexOfFirst { it.mediaId == songId }.coerceAtLeast(0)
|
||||||
|
return full to idx
|
||||||
|
}
|
||||||
|
return listOf(reattachUri(items[0], songId)) to 0
|
||||||
|
}
|
||||||
|
val resolved = items.flatMap { item ->
|
||||||
|
when {
|
||||||
|
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), false)
|
||||||
|
item.mediaId.startsWith(SHUFFLE_PREFIX) -> resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), true)
|
||||||
|
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM)).song.map { it.toMediaItem(repo) }
|
||||||
|
item.mediaId.startsWith(ITEM_PREFIX_PL) -> repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL)).entry.map { it.toMediaItem(repo) }
|
||||||
|
item.mediaId.startsWith(SONG_CTX_PREFIX) -> listOf(reattachUri(item, item.mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')))
|
||||||
|
else -> listOf(reattachUri(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved to 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android Auto strips the URI when a browse item crosses the process boundary — re-resolve it.
|
||||||
|
private fun reattachUri(item: MediaItem, songId: String = item.mediaId): MediaItem {
|
||||||
|
val uri = repo.streamUrl(songId)?.let { android.net.Uri.parse(it) }
|
||||||
|
return item.buildUpon().setMediaId(songId).setUri(uri).build()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
|
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
|
||||||
val songs: List<com.novasonic.app.api.model.Song> = when {
|
val songs: List<com.novasonic.app.api.model.Song> = when {
|
||||||
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
|
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
|
||||||
@@ -432,16 +482,16 @@ class MusicService : MediaLibraryService() {
|
|||||||
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
|
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
|
||||||
FAVORITES_ID -> {
|
FAVORITES_ID -> {
|
||||||
val songs = repo.getStarred().song
|
val songs = repo.getStarred().song
|
||||||
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { it.toMediaItem(repo) }
|
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { browseSong(it, FAVORITES_ID) }
|
||||||
}
|
}
|
||||||
else -> when {
|
else -> when {
|
||||||
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||||
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
|
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { it.toMediaItem(repo) }
|
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { browseSong(it, parentId) }
|
||||||
}
|
}
|
||||||
parentId.startsWith(ITEM_PREFIX_PL) -> {
|
parentId.startsWith(ITEM_PREFIX_PL) -> {
|
||||||
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
|
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
|
||||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { it.toMediaItem(repo) }
|
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { browseSong(it, parentId) }
|
||||||
}
|
}
|
||||||
else -> emptyList()
|
else -> emptyList()
|
||||||
}
|
}
|
||||||
@@ -449,6 +499,13 @@ class MusicService : MediaLibraryService() {
|
|||||||
return ImmutableList.copyOf(items)
|
return ImmutableList.copyOf(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A song shown in a browse list, with its context encoded in the mediaId so a tap can play the
|
||||||
|
// whole list from that song (Android Auto strips the URI, so it's re-resolved on playback).
|
||||||
|
private fun browseSong(song: com.novasonic.app.api.model.Song, contextId: String): MediaItem =
|
||||||
|
song.toMediaItem(repo).buildUpon()
|
||||||
|
.setMediaId("$SONG_CTX_PREFIX$contextId|${song.id}")
|
||||||
|
.build()
|
||||||
|
|
||||||
private fun browseFolder(id: String, title: String, mediaType: Int) =
|
private fun browseFolder(id: String, title: String, mediaType: Int) =
|
||||||
MediaItem.Builder()
|
MediaItem.Builder()
|
||||||
.setMediaId(id)
|
.setMediaId(id)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
private val _state = MutableStateFlow(PlayerState())
|
private val _state = MutableStateFlow(PlayerState())
|
||||||
val state: StateFlow<PlayerState> = _state.asStateFlow()
|
val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val TAG_QUEUE = "NsQueue"
|
||||||
|
|
||||||
private val scope = CoroutineScope(
|
private val scope = CoroutineScope(
|
||||||
Dispatchers.Main + SupervisorJob() +
|
Dispatchers.Main + SupervisorJob() +
|
||||||
CoroutineExceptionHandler { _, t -> android.util.Log.e("PlayerController", "Uncaught coroutine exception", t) }
|
CoroutineExceptionHandler { _, t -> android.util.Log.e("PlayerController", "Uncaught coroutine exception", t) }
|
||||||
@@ -56,24 +58,95 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
controller = runCatching { future.get() }.getOrNull()
|
controller = runCatching { future.get() }.getOrNull()
|
||||||
controller?.addListener(PlayerListener())
|
controller?.addListener(PlayerListener())
|
||||||
syncState()
|
syncState()
|
||||||
|
restoreQueue()
|
||||||
}, context.mainExecutor)
|
}, context.mainExecutor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haelt Warteschlange und Listenposition fest — ohne die Sekunde im Titel: der zuletzt gehoerte
|
||||||
|
* Titel beginnt von vorn. ABS-Hoerbuecher bleiben aussen vor, die haben ihren eigenen
|
||||||
|
* Fortschritt auf dem Server.
|
||||||
|
*/
|
||||||
|
private fun persistQueueState() {
|
||||||
|
val ctrl = controller ?: return
|
||||||
|
val songs = queue
|
||||||
|
if (songs.isEmpty() || songs.any { it.id.startsWith("abs:") }) return
|
||||||
|
val index = ctrl.currentMediaItemIndex
|
||||||
|
scope.launch {
|
||||||
|
runCatching { repo.saveQueueState(songs, index) }
|
||||||
|
.onSuccess { android.util.Log.i(TAG_QUEUE, "gesichert: ${songs.size} Titel, Index $index") }
|
||||||
|
.onFailure { android.util.Log.w(TAG_QUEUE, "Sichern fehlgeschlagen", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Von aussen aufrufbar (Dienst-Ende beim Wegwischen der App). */
|
||||||
|
fun saveQueueNow() = persistQueueState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt die zuletzt gehoerte Warteschlange wieder her — vorbereitet, aber NICHT abgespielt.
|
||||||
|
* Wer die App oeffnet, will nicht ueberfallen werden; ein Druck auf Play macht dort weiter.
|
||||||
|
* Laeuft schon etwas (etwa ueber Android Auto gestartet), passiert nichts.
|
||||||
|
*
|
||||||
|
* Die Aufloesung der Song-Ids braucht die Navidrome-Zugangsdaten. connect() laeuft aber schon in
|
||||||
|
* Application.onCreate(), wo der DataStore unter Umstaenden noch nicht gelesen ist — dann
|
||||||
|
* scheitern alle Abfragen und die Warteschlange bliebe still leer. Deshalb wird es mehrfach mit
|
||||||
|
* wachsendem Abstand versucht, statt beim ersten Fehlschlag aufzugeben.
|
||||||
|
*/
|
||||||
|
fun restoreQueue() {
|
||||||
|
scope.launch {
|
||||||
|
val zustand = runCatching { repo.readQueueState() }.getOrNull()
|
||||||
|
if (zustand == null) {
|
||||||
|
android.util.Log.i(TAG_QUEUE, "nichts gesichert — nichts wiederherzustellen")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val ctrl = controller
|
||||||
|
if (ctrl == null) {
|
||||||
|
android.util.Log.w(TAG_QUEUE, "kein Controller — Wiederherstellen verworfen")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (ctrl.mediaItemCount > 0) {
|
||||||
|
android.util.Log.i(TAG_QUEUE, "Player hat bereits Titel — nicht angefasst")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val songs = zustand.songs
|
||||||
|
val index = zustand.index.coerceIn(0, songs.lastIndex)
|
||||||
|
queue = songs
|
||||||
|
originalQueue = songs
|
||||||
|
runCatching {
|
||||||
|
ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, 0L)
|
||||||
|
ctrl.prepare()
|
||||||
|
}.onFailure { android.util.Log.w(TAG_QUEUE, "setMediaItems fehlgeschlagen", it) }
|
||||||
|
syncState()
|
||||||
|
android.util.Log.i(TAG_QUEUE, "wiederhergestellt: ${songs.size} Titel, Index $index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Playback commands ─────────────────────────────────────────────────────
|
// ── Playback commands ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet eine Liste. Ein `startIndex` KLEINER 0 bedeutet "gemischt starten".
|
||||||
|
*
|
||||||
|
* Warum das Mischen hier passiert und nicht beim Aufrufer: vorher mischte jeder
|
||||||
|
* Shuffle-Knopf selbst (`liste.shuffled()`) und uebergab die fertige Reihenfolge. Damit war
|
||||||
|
* `originalQueue` schon die gemischte Liste, `isManuallyShuffled` blieb false — das
|
||||||
|
* Shuffle-Symbol leuchtete nicht, obwohl gemischt gespielt wurde, und ein Druck auf
|
||||||
|
* "Shuffle aus" stellte die Albumreihenfolge nie wieder her, weil es keine mehr gab.
|
||||||
|
*/
|
||||||
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
|
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
|
||||||
// If ABS is active, close it — user explicitly switched to music
|
// If ABS is active, close it — user explicitly switched to music
|
||||||
(context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs ->
|
(context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs ->
|
||||||
if (abs.isActive) abs.close()
|
if (abs.isActive) abs.close()
|
||||||
}
|
}
|
||||||
val ctrl = controller ?: return
|
val ctrl = controller ?: return
|
||||||
queue = songs
|
val gemischt = startIndex < 0
|
||||||
originalQueue = songs
|
val reihenfolge = if (gemischt) songs.shuffled() else songs
|
||||||
isManuallyShuffled = false
|
queue = reihenfolge
|
||||||
|
originalQueue = songs // immer die UNGEMISCHTE Liste — Rueckweg fuer "Shuffle aus"
|
||||||
|
isManuallyShuffled = gemischt
|
||||||
ctrl.shuffleModeEnabled = false
|
ctrl.shuffleModeEnabled = false
|
||||||
val items = songs.map { it.toMediaItem(repo) }
|
val items = reihenfolge.map { it.toMediaItem(repo) }
|
||||||
ctrl.setMediaItems(items, startIndex, 0L)
|
ctrl.setMediaItems(items, if (gemischt) 0 else startIndex.coerceIn(0, maxOf(0, songs.lastIndex)), 0L)
|
||||||
ctrl.prepare()
|
ctrl.prepare()
|
||||||
ctrl.play()
|
ctrl.play()
|
||||||
syncState()
|
syncState()
|
||||||
@@ -142,6 +215,40 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
ctrl.addMediaItem(song.toMediaItem(repo))
|
ctrl.addMediaItem(song.toMediaItem(repo))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Warteschlange bearbeiten ────────────────────────────────────────────
|
||||||
|
// queue (lokal) und die MediaItems des Controllers muessen im Gleichschritt bleiben:
|
||||||
|
// syncState() liest den laufenden Index vom Controller, den Titel dazu aber aus queue.
|
||||||
|
// Liefen die beiden auseinander, zeigte die Oberflaeche den falschen Song an.
|
||||||
|
|
||||||
|
/** Entfernt Position [index]. Traf es den laufenden Titel, rueckt Media3 selbst weiter. */
|
||||||
|
fun entferneAusQueue(index: Int) {
|
||||||
|
val ctrl = controller ?: return
|
||||||
|
if (index !in queue.indices) return
|
||||||
|
val raus = queue[index]
|
||||||
|
queue = queue.toMutableList().apply { removeAt(index) }
|
||||||
|
// originalQueue haelt die Reihenfolge fuer das Zuruecknehmen des Mischens — dort denselben
|
||||||
|
// Titel EINMAL entfernen, sonst taucht er beim Entmischen wieder auf.
|
||||||
|
originalQueue = originalQueue.toMutableList().apply {
|
||||||
|
val i = indexOfFirst { it.id == raus.id }
|
||||||
|
if (i >= 0) removeAt(i)
|
||||||
|
}
|
||||||
|
ctrl.removeMediaItem(index)
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
ctrl.stop()
|
||||||
|
ctrl.clearMediaItems()
|
||||||
|
}
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verschiebt Position [von] auf [nach]. */
|
||||||
|
fun verschiebeInQueue(von: Int, nach: Int) {
|
||||||
|
val ctrl = controller ?: return
|
||||||
|
if (von == nach || von !in queue.indices || nach !in queue.indices) return
|
||||||
|
queue = queue.toMutableList().apply { add(nach, removeAt(von)) }
|
||||||
|
ctrl.moveMediaItem(von, nach)
|
||||||
|
syncState()
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleRepeat() {
|
fun toggleRepeat() {
|
||||||
val ctrl = controller ?: return
|
val ctrl = controller ?: return
|
||||||
ctrl.repeatMode = when (ctrl.repeatMode) {
|
ctrl.repeatMode = when (ctrl.repeatMode) {
|
||||||
@@ -155,22 +262,22 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
val ctrl = controller ?: return
|
val ctrl = controller ?: return
|
||||||
val currentIdx = ctrl.currentMediaItemIndex
|
val currentIdx = ctrl.currentMediaItemIndex
|
||||||
|
|
||||||
if (!isManuallyShuffled) {
|
// Der Rest hinter dem laufenden Titel wird in EINEM Zug ersetzt. Vorher lief das als
|
||||||
|
// Schleife aus removeMediaItem + addMediaItem, und dazwischen hatte der Controller
|
||||||
|
// voruebergehend weniger Titel als unsere Liste. Genau darauf reagiert onTimelineChanged
|
||||||
|
// mit "die Titel kamen von aussen" — es baute die Warteschlange neu auf und setzte dabei
|
||||||
|
// isManuallyShuffled ZURUECK. Das Mischen lief, aber das Symbol leuchtete nie.
|
||||||
|
val neuerRest = if (!isManuallyShuffled) {
|
||||||
isManuallyShuffled = true
|
isManuallyShuffled = true
|
||||||
val played = queue.take(currentIdx + 1)
|
queue.drop(currentIdx + 1).shuffled()
|
||||||
val remaining = queue.drop(currentIdx + 1).shuffled()
|
|
||||||
queue = played + remaining
|
|
||||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
|
||||||
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
|
||||||
} else {
|
} else {
|
||||||
isManuallyShuffled = false
|
isManuallyShuffled = false
|
||||||
val currentSong = queue.getOrNull(currentIdx)
|
val currentSong = queue.getOrNull(currentIdx)
|
||||||
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
|
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
|
||||||
val restOfOriginal = originalQueue.drop(origIdx + 1)
|
originalQueue.drop(origIdx + 1)
|
||||||
queue = originalQueue.take(origIdx + 1) + restOfOriginal
|
|
||||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
|
||||||
restOfOriginal.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
|
||||||
}
|
}
|
||||||
|
queue = queue.take(currentIdx + 1) + neuerRest
|
||||||
|
ctrl.replaceMediaItems(currentIdx + 1, ctrl.mediaItemCount, neuerRest.map { it.toMediaItem(repo) })
|
||||||
syncState()
|
syncState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +314,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.isEmpty()) {
|
if (candidates.isEmpty()) {
|
||||||
|
// Letzte Rueckfallebene: Wiederholungen sind erlaubt, damit Musik weiterlaeuft — aber
|
||||||
|
// nicht der gerade laufende Titel. Der haengte sich sonst direkt hinter sich selbst.
|
||||||
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
|
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
|
||||||
|
.filter { it.id != currentSong.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
val ctrl = controller ?: return
|
val ctrl = controller ?: return
|
||||||
@@ -227,11 +337,25 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(500)
|
delay(500)
|
||||||
val ctrl = controller ?: break
|
val ctrl = controller ?: break
|
||||||
_state.update { it.copy(positionMs = ctrl.currentPosition) }
|
// Refresh durationMs too — after a SEAMLESS track transition (skipping through
|
||||||
|
// already-buffered tracks) the player stays READY, so onPlaybackStateChanged never
|
||||||
|
// fires and syncState() isn't re-called; ctrl.duration was TIME_UNSET(→0) at the
|
||||||
|
// transition, leaving durationMs stuck at 0. Seek then computes fraction*0 = 0 and
|
||||||
|
// snaps to the start every time. Re-reading here lets it self-heal within 500ms.
|
||||||
|
_state.update { it.copy(positionMs = ctrl.currentPosition, durationMs = resolveDurationMs(ctrl)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Player duration, falling back to the current song's known duration while ctrl.duration is
|
||||||
|
* still TIME_UNSET (right after a media transition). Prevents a 0-duration seek → snap-to-0. */
|
||||||
|
private fun resolveDurationMs(ctrl: MediaController): Long {
|
||||||
|
val d = ctrl.duration
|
||||||
|
if (d > 0) return d
|
||||||
|
val songDurSecs = queue.getOrNull(ctrl.currentMediaItemIndex)?.duration?.toLong() ?: 0L
|
||||||
|
return songDurSecs * 1000L
|
||||||
|
}
|
||||||
|
|
||||||
private fun stopTicker() {
|
private fun stopTicker() {
|
||||||
tickerJob?.cancel()
|
tickerJob?.cancel()
|
||||||
tickerJob = null
|
tickerJob = null
|
||||||
@@ -245,6 +369,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
|
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
|
||||||
delay(delayMs)
|
delay(delayMs)
|
||||||
runCatching { repo.scrobble(song.id) }
|
runCatching { repo.scrobble(song.id) }
|
||||||
|
runCatching { repo.logPlay(song) } // our own play-log, same counted moment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +395,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
currentSong = queue.getOrNull(idx),
|
currentSong = queue.getOrNull(idx),
|
||||||
currentIndex = idx,
|
currentIndex = idx,
|
||||||
positionMs = ctrl.currentPosition,
|
positionMs = ctrl.currentPosition,
|
||||||
durationMs = ctrl.duration.coerceAtLeast(0),
|
durationMs = resolveDurationMs(ctrl),
|
||||||
queue = queue,
|
queue = queue,
|
||||||
repeatMode = ctrl.repeatMode,
|
repeatMode = ctrl.repeatMode,
|
||||||
shuffled = isManuallyShuffled,
|
shuffled = isManuallyShuffled,
|
||||||
@@ -280,9 +405,13 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Warm the LrcLib cache as soon as a track starts, so opening Lyrics feels instant. */
|
private var _lyricsPrefetchJob: Job? = null
|
||||||
|
/** Warm the LrcLib cache shortly after a track settles. Debounced so rapid skips don't
|
||||||
|
* hammer lrclib (which throttles the IP and then no lyrics load at all). */
|
||||||
private fun prefetchLyrics(song: Song) {
|
private fun prefetchLyrics(song: Song) {
|
||||||
scope.launch {
|
_lyricsPrefetchJob?.cancel()
|
||||||
|
_lyricsPrefetchJob = scope.launch {
|
||||||
|
delay(1500) // skip quickly without firing a request per track
|
||||||
runCatching {
|
runCatching {
|
||||||
LrcLibClient.getLyrics(
|
LrcLibClient.getLyrics(
|
||||||
artist = song.artist ?: "",
|
artist = song.artist ?: "",
|
||||||
@@ -298,6 +427,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
if (isPlaying) startTicker() else stopTicker()
|
if (isPlaying) startTicker() else stopTicker()
|
||||||
syncState()
|
syncState()
|
||||||
|
// Beim Pausieren die Position festhalten: von da soll es beim naechsten Start weitergehen.
|
||||||
|
if (!isPlaying) persistQueueState()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(state: Int) = syncState()
|
override fun onPlaybackStateChanged(state: Int) = syncState()
|
||||||
@@ -310,9 +441,14 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
val mediaId = ctrl.currentMediaItem?.mediaId ?: return
|
val mediaId = ctrl.currentMediaItem?.mediaId ?: return
|
||||||
if (mediaId.startsWith("abs:")) return
|
if (mediaId.startsWith("abs:")) return
|
||||||
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
|
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
|
||||||
val dur = ctrl.duration.coerceAtLeast(0)
|
// Use the song's KNOWN duration — at a media transition ctrl.duration is still
|
||||||
|
// TIME_UNSET (→0), which collapsed the 1/3 / 2/3 modes to the 15s floor and scrobbled
|
||||||
|
// far too early. Fall back to the player only if the song has no duration.
|
||||||
|
val songDurMs = (song.duration?.toLong() ?: 0L) * 1000L
|
||||||
|
val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0)
|
||||||
scheduleScrobble(song, dur)
|
scheduleScrobble(song, dur)
|
||||||
scope.launch { runCatching { repo.saveLastSong(song) } }
|
scope.launch { runCatching { repo.saveLastSong(song) } }
|
||||||
|
persistQueueState()
|
||||||
prefetchLyrics(song)
|
prefetchLyrics(song)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.novasonic.app.player
|
||||||
|
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-level 30s preview player (iTunes/Deezer preview clips). Kept on the Application — NOT in a
|
||||||
|
* screen ViewModel — so a preview keeps a **global control bar** (see AppNavigation) even after the
|
||||||
|
* user leaves the album/artist/Explore screen it started on. Previously the preview lived in
|
||||||
|
* ExploreViewModel and became uncontrollable once you navigated away.
|
||||||
|
*/
|
||||||
|
data class PreviewState(
|
||||||
|
val trackId: Long? = null,
|
||||||
|
val title: String = "",
|
||||||
|
val artist: String = "",
|
||||||
|
val artworkUrl: String? = null,
|
||||||
|
val isPlaying: Boolean = false, // true = actively playing, false = paused
|
||||||
|
)
|
||||||
|
|
||||||
|
class PreviewController {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(PreviewState())
|
||||||
|
val state: StateFlow<PreviewState> = _state
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var mp: MediaPlayer? = null
|
||||||
|
|
||||||
|
/** Tap on a track: same track → toggle play/pause; different track → start it. */
|
||||||
|
fun toggle(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) {
|
||||||
|
val cur = _state.value
|
||||||
|
when {
|
||||||
|
cur.trackId == trackId && cur.isPlaying -> pause()
|
||||||
|
cur.trackId == trackId && mp != null -> resume()
|
||||||
|
else -> start(trackId, url, title, artist, artworkUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) {
|
||||||
|
stop()
|
||||||
|
_state.value = PreviewState(trackId, title, artist, artworkUrl, isPlaying = true)
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val player = MediaPlayer().apply {
|
||||||
|
setDataSource(url)
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
setOnCompletionListener { stop() }
|
||||||
|
}
|
||||||
|
mp = player
|
||||||
|
} catch (e: Exception) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
mp?.runCatching { pause() }
|
||||||
|
_state.update { it.copy(isPlaying = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resume() {
|
||||||
|
val player = mp
|
||||||
|
if (player == null) return
|
||||||
|
player.runCatching { start() }
|
||||||
|
_state.update { it.copy(isPlaying = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
mp?.runCatching { stop(); release() }
|
||||||
|
mp = null
|
||||||
|
_state.value = PreviewState()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import androidx.compose.ui.text.TextStyle
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.*
|
import androidx.navigation.*
|
||||||
import androidx.navigation.compose.*
|
import androidx.navigation.compose.*
|
||||||
@@ -34,6 +36,7 @@ import com.novasonic.app.app
|
|||||||
import com.novasonic.app.data.AppNotification
|
import com.novasonic.app.data.AppNotification
|
||||||
import com.novasonic.app.data.PrefsStore
|
import com.novasonic.app.data.PrefsStore
|
||||||
import com.novasonic.app.ui.component.MiniPlayer
|
import com.novasonic.app.ui.component.MiniPlayer
|
||||||
|
import com.novasonic.app.ui.component.PreviewBar
|
||||||
import com.novasonic.app.ui.component.NotificationSheet
|
import com.novasonic.app.ui.component.NotificationSheet
|
||||||
import com.novasonic.app.ui.component.PlaylistPickerSheet
|
import com.novasonic.app.ui.component.PlaylistPickerSheet
|
||||||
import com.novasonic.app.ui.component.SongContextSheet
|
import com.novasonic.app.ui.component.SongContextSheet
|
||||||
@@ -53,6 +56,10 @@ private enum class TopLevel(val route: String, val icon: ImageVector, val label:
|
|||||||
|
|
||||||
private val NavBg = Color(0xF7080814)
|
private val NavBg = Color(0xF7080814)
|
||||||
|
|
||||||
|
// Position of a route among the top-level tabs (in bottom-bar order), or -1 if it isn't one.
|
||||||
|
// Used to slide tab switches toward the side the target tab sits on, instead of a push/pop feel.
|
||||||
|
private fun topLevelIndex(route: String?): Int = TopLevel.entries.indexOfFirst { it.route == route }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavigation() {
|
fun AppNavigation() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -73,6 +80,34 @@ fun AppNavigation() {
|
|||||||
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
|
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
|
||||||
val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null)
|
val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
|
// ── In-app updater — offer the newer APK the backend advertises ─────────────
|
||||||
|
val updateInfo by com.novasonic.app.data.AppUpdater.available.collectAsStateWithLifecycle()
|
||||||
|
val updating by com.novasonic.app.data.AppUpdater.downloading.collectAsStateWithLifecycle()
|
||||||
|
val updateScope = rememberCoroutineScope()
|
||||||
|
LaunchedEffect(appSettings.backendUrl) { com.novasonic.app.data.AppUpdater.check(appSettings.backendUrl) }
|
||||||
|
updateInfo?.let { info ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { com.novasonic.app.data.AppUpdater.dismiss() },
|
||||||
|
title = { Text("Update verfügbar") },
|
||||||
|
text = { Text("NovaSonic ${info.version ?: ""} ist verfügbar. Jetzt laden und installieren? Deine Downloads und Einstellungen bleiben erhalten.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(enabled = !updating, onClick = {
|
||||||
|
updateScope.launch {
|
||||||
|
com.novasonic.app.data.AppUpdater.downloadAndInstall(context, appSettings.backendUrl) { msg ->
|
||||||
|
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { Text(if (updating) "Lädt…" else "Laden & installieren") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { com.novasonic.app.data.AppUpdater.dismiss() }) { Text("Später") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30s preview (app-level, so its control bar survives navigation). Starting real playback
|
||||||
|
// cancels any active preview so the two don't overlap.
|
||||||
|
val previewState by app.previewController.state.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(playerState.isPlaying) { if (playerState.isPlaying) app.previewController.stop() }
|
||||||
|
|
||||||
val absConfigured = appSettings.absUrl.isNotBlank() && appSettings.absApiKey.isNotBlank()
|
val absConfigured = appSettings.absUrl.isNotBlank() && appSettings.absApiKey.isNotBlank()
|
||||||
val absClient: AbsClient? = remember(appSettings.absUrl, appSettings.absApiKey, appSettings.backendUrl) {
|
val absClient: AbsClient? = remember(appSettings.absUrl, appSettings.absApiKey, appSettings.backendUrl) {
|
||||||
if (absConfigured) AbsClient(appSettings.absUrl.trimEnd('/'), appSettings.absApiKey, appSettings.backendUrl.trimEnd('/'))
|
if (absConfigured) AbsClient(appSettings.absUrl.trimEnd('/'), appSettings.absApiKey, appSettings.backendUrl.trimEnd('/'))
|
||||||
@@ -88,17 +123,55 @@ fun AppNavigation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
// Geteilten Link aufloesen, sobald die Navigation steht: Song abspielen und sein Album oeffnen,
|
||||||
|
// bzw. direkt zum Album. Laeuft ueber denselben Weg wie im Web, damit sich beide gleich
|
||||||
|
// verhalten. Fehlschlaege (geloeschter Song, fremde Instanz) enden in einer Meldung statt still.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
com.novasonic.app.data.DeepLinkState.pending.collect { uri ->
|
||||||
|
if (uri == null) return@collect
|
||||||
|
com.novasonic.app.data.DeepLinkState.consume()
|
||||||
|
val songId = uri.getQueryParameter("song")
|
||||||
|
val albumId = uri.getQueryParameter("album")
|
||||||
|
runCatching {
|
||||||
|
when {
|
||||||
|
songId != null -> {
|
||||||
|
val song = repo.getSong(songId)
|
||||||
|
song.albumId?.let { navController.navigate("album/$it") }
|
||||||
|
player.playSongs(listOf(song), 0)
|
||||||
|
}
|
||||||
|
albumId != null -> navController.navigate("album/$albumId")
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Toast.makeText(context, "Link konnte nicht geöffnet werden: ${it.message}",
|
||||||
|
Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var showNowPlaying by remember { mutableStateOf(false) }
|
var showNowPlaying by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
|
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
|
||||||
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
||||||
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
|
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
|
||||||
|
var songToDelete by remember { mutableStateOf<Song?>(null) }
|
||||||
var showNotifications by remember { mutableStateOf(false) }
|
var showNotifications by remember { mutableStateOf(false) }
|
||||||
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
|
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
|
||||||
var showAbsPlayer by remember { mutableStateOf(false) }
|
var showAbsPlayer by remember { mutableStateOf(false) }
|
||||||
|
// Close action for the Erkunden (Apple) in-screen overlay (iTunes artist / album). Handled at the
|
||||||
|
// app root because a BackHandler inside the NavHost "erkunden" destination loses back priority to
|
||||||
|
// the NavHost, which pops Erkunden → Home instead of dismissing the overlay.
|
||||||
|
var exploreOverlayBack by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
|
||||||
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||||
DisposableEffect(showNowPlaying, backDispatcher) {
|
val currentBackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
// Returning from the background re-registers the NavController's OWN back callback on top of the
|
||||||
|
// dispatcher, which stole back-priority from the NowPlaying/ABS overlays: pressing back then popped
|
||||||
|
// the NavHost (invisibly, all the way to Home) before dismissing the overlay. Bump this tick on
|
||||||
|
// ON_RESUME (and re-key on navigation) so the overlay callbacks below re-add above it again.
|
||||||
|
var resumeTick by remember { mutableIntStateOf(0) }
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { resumeTick++ }
|
||||||
|
|
||||||
|
DisposableEffect(showNowPlaying, backDispatcher, currentBackEntry, resumeTick) {
|
||||||
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
|
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
val cb = object : OnBackPressedCallback(true) {
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() { showNowPlaying = false }
|
override fun handleOnBackPressed() { showNowPlaying = false }
|
||||||
@@ -106,7 +179,7 @@ fun AppNavigation() {
|
|||||||
backDispatcher.addCallback(cb)
|
backDispatcher.addCallback(cb)
|
||||||
onDispose { cb.remove() }
|
onDispose { cb.remove() }
|
||||||
}
|
}
|
||||||
DisposableEffect(showAbsPlayer, backDispatcher) {
|
DisposableEffect(showAbsPlayer, backDispatcher, currentBackEntry, resumeTick) {
|
||||||
if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
|
if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
val cb = object : OnBackPressedCallback(true) {
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() { showAbsPlayer = false }
|
override fun handleOnBackPressed() { showAbsPlayer = false }
|
||||||
@@ -114,7 +187,7 @@ fun AppNavigation() {
|
|||||||
backDispatcher.addCallback(cb)
|
backDispatcher.addCallback(cb)
|
||||||
onDispose { cb.remove() }
|
onDispose { cb.remove() }
|
||||||
}
|
}
|
||||||
DisposableEffect(selectedAbsItem, backDispatcher) {
|
DisposableEffect(selectedAbsItem, backDispatcher, currentBackEntry, resumeTick) {
|
||||||
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
val cb = object : OnBackPressedCallback(true) {
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() { selectedAbsItem = null }
|
override fun handleOnBackPressed() { selectedAbsItem = null }
|
||||||
@@ -122,22 +195,35 @@ fun AppNavigation() {
|
|||||||
backDispatcher.addCallback(cb)
|
backDispatcher.addCallback(cb)
|
||||||
onDispose { cb.remove() }
|
onDispose { cb.remove() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
|
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
|
||||||
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
|
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
|
||||||
|
|
||||||
fun starToggle(songId: String, currentlyStarred: Boolean) {
|
fun starToggle(songId: String, currentlyStarred: Boolean) {
|
||||||
scope.launch { runCatching { if (currentlyStarred) repo.unstar(songId) else repo.star(songId) } }
|
// Optimistic + reactive: flips StarState instantly so every star icon updates, then hits the API.
|
||||||
|
com.novasonic.app.data.StarState.toggle(repo, scope, songId, currentlyStarred)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showContextMenu(song: Song) { contextMenuSong = song }
|
fun showContextMenu(song: Song) { contextMenuSong = song }
|
||||||
|
|
||||||
var homeRefreshKey by remember { mutableIntStateOf(0) }
|
var homeRefreshKey by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
val currentBackEntry by navController.currentBackStackEntryAsState()
|
|
||||||
val currentRoute = currentBackEntry?.destination?.route
|
val currentRoute = currentBackEntry?.destination?.route
|
||||||
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
|
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
|
||||||
|
|
||||||
|
// Root-level back for the Erkunden (Apple) overlay. Keyed on currentBackEntry as well, so the
|
||||||
|
// callback is re-added (bumped to the top of the OnBackPressedDispatcher) after every navigation
|
||||||
|
// — otherwise, once the NavHost re-registers its own callback during an album drill-down or tab
|
||||||
|
// switch, it could regain priority and pop Erkunden → Home on the next back ("sometimes" bug).
|
||||||
|
DisposableEffect(exploreOverlayBack, backDispatcher, currentBackEntry, resumeTick) {
|
||||||
|
val action = exploreOverlayBack
|
||||||
|
if (action == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||||
|
val cb = object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() { action() }
|
||||||
|
}
|
||||||
|
backDispatcher.addCallback(cb)
|
||||||
|
onDispose { cb.remove() }
|
||||||
|
}
|
||||||
|
|
||||||
val bgGlow = Brush.radialGradient(
|
val bgGlow = Brush.radialGradient(
|
||||||
colors = listOf(Color(0x0AE8A020), Color(0x00000000)),
|
colors = listOf(Color(0x0AE8A020), Color(0x00000000)),
|
||||||
center = Offset.Unspecified, radius = 900f,
|
center = Offset.Unspecified, radius = 900f,
|
||||||
@@ -154,41 +240,58 @@ fun AppNavigation() {
|
|||||||
containerColor = NsBackground,
|
containerColor = NsBackground,
|
||||||
modifier = Modifier.background(NsBackground).background(bgGlow),
|
modifier = Modifier.background(NsBackground).background(bgGlow),
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (isTopLevel) {
|
// MiniPlayer shows on EVERY screen (incl. album/artist/playlist detail) so playback
|
||||||
Column {
|
// is always reachable; the tab bar stays top-level-only. On detail screens there is no
|
||||||
// ABS audio routes through MusicService — one notification, one mini player.
|
// NavigationBar to absorb the system-navigation inset, so pad the bar itself — otherwise
|
||||||
// When an ABS book is loaded, tapping the mini player opens the ABS overlay
|
// the MiniPlayer sits behind the phone's gesture/nav bar.
|
||||||
// instead of the music NowPlaying screen.
|
Column(modifier = if (isTopLevel) Modifier else Modifier.navigationBarsPadding()) {
|
||||||
val hasAbsItem = absState.item != null
|
// 30s preview control bar — above the MiniPlayer, on every screen, so a preview
|
||||||
val showMiniPlayer = playerState.isPlaying ||
|
// started on an album/artist/Explore screen can be paused/stopped from anywhere.
|
||||||
playerState.currentSong != null ||
|
AnimatedVisibility(
|
||||||
(lastPlayedSong != null && !showNowPlaying)
|
visible = previewState.trackId != null,
|
||||||
AnimatedVisibility(
|
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||||
visible = showMiniPlayer,
|
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
) {
|
||||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
PreviewBar(
|
||||||
) {
|
state = previewState,
|
||||||
MiniPlayer(
|
onToggle = { if (previewState.isPlaying) app.previewController.pause() else app.previewController.resume() },
|
||||||
state = playerState,
|
onClose = { app.previewController.stop() },
|
||||||
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
|
)
|
||||||
onTap = { if (hasAbsItem) showAbsPlayer = true else showNowPlaying = true },
|
}
|
||||||
onTogglePlay = { if (hasAbsItem) absPlayerController.togglePlay() else player.togglePlay() },
|
// ABS audio routes through MusicService — one notification, one mini player.
|
||||||
onSkipPrev = { if (hasAbsItem) absPlayerController.prevChapter() else player.skipPrev() },
|
// When an ABS book is loaded, tapping the mini player opens the ABS overlay
|
||||||
onSkipNext = { if (hasAbsItem) absPlayerController.nextChapter() else player.skipNext() },
|
// instead of the music NowPlaying screen.
|
||||||
lastSong = if (playerState.currentSong == null) lastPlayedSong else null,
|
val hasAbsItem = absState.item != null
|
||||||
lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) },
|
val showMiniPlayer = playerState.isPlaying ||
|
||||||
onPlayLast = {
|
playerState.currentSong != null ||
|
||||||
lastPlayedSong?.let { ls ->
|
(lastPlayedSong != null && !showNowPlaying)
|
||||||
scope.launch {
|
AnimatedVisibility(
|
||||||
runCatching {
|
visible = showMiniPlayer,
|
||||||
val song = repo.getSong(ls.id)
|
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||||
player.playSongs(listOf(song), 0)
|
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||||
}
|
) {
|
||||||
|
MiniPlayer(
|
||||||
|
state = playerState,
|
||||||
|
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
|
||||||
|
onTap = { if (hasAbsItem) showAbsPlayer = true else showNowPlaying = true },
|
||||||
|
onTogglePlay = { if (hasAbsItem) absPlayerController.togglePlay() else player.togglePlay() },
|
||||||
|
onSkipPrev = { if (hasAbsItem) absPlayerController.prevChapter() else player.skipPrev() },
|
||||||
|
onSkipNext = { if (hasAbsItem) absPlayerController.nextChapter() else player.skipNext() },
|
||||||
|
lastSong = if (playerState.currentSong == null) lastPlayedSong else null,
|
||||||
|
lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) },
|
||||||
|
onPlayLast = {
|
||||||
|
lastPlayedSong?.let { ls ->
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
val song = repo.getSong(ls.id)
|
||||||
|
player.playSongs(listOf(song), 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
},
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
if (isTopLevel) {
|
||||||
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
|
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
|
||||||
navItems.forEach { dest ->
|
navItems.forEach { dest ->
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
@@ -223,10 +326,36 @@ fun AppNavigation() {
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = TopLevel.HOME.route,
|
startDestination = TopLevel.HOME.route,
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.padding(innerPadding),
|
||||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(200)) },
|
// Direction of a tab switch: +1 if the target tab sits to the right of the current
|
||||||
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeOut(tween(180)) },
|
// one, -1 if to the left, 0 if this isn't a tab-to-tab move (detail push/pop).
|
||||||
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(200)) },
|
enterTransition = {
|
||||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeOut(tween(180)) },
|
val f = topLevelIndex(initialState.destination.route)
|
||||||
|
val t = topLevelIndex(targetState.destination.route)
|
||||||
|
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||||
|
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
|
||||||
|
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeIn(tween(200))
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
val f = topLevelIndex(initialState.destination.route)
|
||||||
|
val t = topLevelIndex(targetState.destination.route)
|
||||||
|
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||||
|
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
|
||||||
|
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeOut(tween(180))
|
||||||
|
},
|
||||||
|
popEnterTransition = {
|
||||||
|
val f = topLevelIndex(initialState.destination.route)
|
||||||
|
val t = topLevelIndex(targetState.destination.route)
|
||||||
|
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||||
|
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
|
||||||
|
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeIn(tween(200))
|
||||||
|
},
|
||||||
|
popExitTransition = {
|
||||||
|
val f = topLevelIndex(initialState.destination.route)
|
||||||
|
val t = topLevelIndex(targetState.destination.route)
|
||||||
|
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||||
|
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
|
||||||
|
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeOut(tween(180))
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
composable(TopLevel.HOME.route) {
|
composable(TopLevel.HOME.route) {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
@@ -247,7 +376,7 @@ fun AppNavigation() {
|
|||||||
onGenrePlay = { genre ->
|
onGenrePlay = { genre ->
|
||||||
scope.launch { runCatching {
|
scope.launch { runCatching {
|
||||||
val songs = repo.getSongsByGenre(genre.value)
|
val songs = repo.getSongsByGenre(genre.value)
|
||||||
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
|
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
|
||||||
}}
|
}}
|
||||||
},
|
},
|
||||||
onShowAllGenres = { navController.navigate("genres") },
|
onShowAllGenres = { navController.navigate("genres") },
|
||||||
@@ -271,6 +400,8 @@ fun AppNavigation() {
|
|||||||
onSongPlay = { songs, idx -> player.playSongs(songs, idx) },
|
onSongPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||||
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
||||||
|
onShowContextMenu = ::showContextMenu,
|
||||||
|
onOverlayBackChange = { exploreOverlayBack = it },
|
||||||
onOpenImporter = { navController.navigate("importer") },
|
onOpenImporter = { navController.navigate("importer") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -284,6 +415,19 @@ fun AppNavigation() {
|
|||||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||||
onShowContextMenu = ::showContextMenu,
|
onShowContextMenu = ::showContextMenu,
|
||||||
|
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||||
|
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||||
|
onOpenNews = { navController.navigate("artist-news") },
|
||||||
|
onOpenFollowed = {
|
||||||
|
com.novasonic.app.data.LibraryIntent.oeffneGefolgte = true
|
||||||
|
navController.navigate(TopLevel.LIBRARY.route)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable("artist-news") {
|
||||||
|
ArtistNewsScreen(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(TopLevel.ABS.route) {
|
composable(TopLevel.ABS.route) {
|
||||||
@@ -305,7 +449,28 @@ fun AppNavigation() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable(TopLevel.SETTINGS.route) { SettingsScreen() }
|
composable(TopLevel.SETTINGS.route) {
|
||||||
|
SettingsScreen(
|
||||||
|
onOpenStats = { navController.navigate("stats") },
|
||||||
|
onOpenCompare = { navController.navigate("compare") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable("stats") {
|
||||||
|
StatsScreen(
|
||||||
|
coverUrl = { id -> coverUrl(id) },
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||||
|
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable("compare") {
|
||||||
|
CompareScreen(
|
||||||
|
coverUrl = { id -> coverUrl(id) },
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||||
|
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
"album/{albumId}",
|
"album/{albumId}",
|
||||||
@@ -382,7 +547,7 @@ fun AppNavigation() {
|
|||||||
onGenrePlay = { genre ->
|
onGenrePlay = { genre ->
|
||||||
scope.launch { runCatching {
|
scope.launch { runCatching {
|
||||||
val songs = repo.getSongsByGenre(genre.value)
|
val songs = repo.getSongsByGenre(genre.value)
|
||||||
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
|
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
|
||||||
}}
|
}}
|
||||||
},
|
},
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
@@ -432,12 +597,12 @@ fun AppNavigation() {
|
|||||||
contextMenuSong?.let { song ->
|
contextMenuSong?.let { song ->
|
||||||
SongContextSheet(
|
SongContextSheet(
|
||||||
song = song,
|
song = song,
|
||||||
isStarred = song.starred != null,
|
isStarred = com.novasonic.app.data.StarState.isStarred(song),
|
||||||
onDismiss = { contextMenuSong = null },
|
onDismiss = { contextMenuSong = null },
|
||||||
onPlay = { player.playSongs(listOf(song), 0) },
|
onPlay = { player.playSongs(listOf(song), 0) },
|
||||||
onInsertNext = { player.insertNext(song) },
|
onInsertNext = { player.insertNext(song) },
|
||||||
onAddToQueue = { player.addToQueueEnd(song) },
|
onAddToQueue = { player.addToQueueEnd(song) },
|
||||||
onStarToggle = { starToggle(song.id, song.starred != null) },
|
onStarToggle = { starToggle(song.id, com.novasonic.app.data.StarState.isStarred(song)) },
|
||||||
onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
|
onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
|
||||||
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
|
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
|
||||||
onShowInfo = { infoSheetSong = song; contextMenuSong = null },
|
onShowInfo = { infoSheetSong = song; contextMenuSong = null },
|
||||||
@@ -456,6 +621,58 @@ fun AppNavigation() {
|
|||||||
Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show()
|
||||||
contextMenuSong = null
|
contextMenuSong = null
|
||||||
},
|
},
|
||||||
|
// Link auf die eigene Web-App, den der Empfaenger mit SEINEN Zugangsdaten oeffnet —
|
||||||
|
// uebertragen wird nur die Song-Id, kein Geheimnis. Der Host kommt aus BuildConfig
|
||||||
|
// (dieselbe Angabe wie fuer die App-Links im Manifest), damit es keine vierte
|
||||||
|
// Adresse zum Pflegen gibt und beide Richtungen nie auseinanderlaufen koennen.
|
||||||
|
onShare = {
|
||||||
|
val link = "https://${com.novasonic.app.BuildConfig.SHARE_HOST}/?song=${song.id}"
|
||||||
|
val text = listOfNotNull(song.title, song.artist).joinToString(" — ")
|
||||||
|
context.startActivity(android.content.Intent.createChooser(
|
||||||
|
android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(android.content.Intent.EXTRA_SUBJECT, text)
|
||||||
|
putExtra(android.content.Intent.EXTRA_TEXT, "$text\n$link")
|
||||||
|
}, "Song teilen"))
|
||||||
|
},
|
||||||
|
// Nur mit Backend: das Loeschen laeuft ueber /api/delete-song, Navidrome allein
|
||||||
|
// kann keine Dateien entfernen.
|
||||||
|
onDelete = if (appSettings.backendUrl.isNotBlank()) {
|
||||||
|
{ songToDelete = song; contextMenuSong = null }
|
||||||
|
} else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sicherheitsabfrage vor dem endgueltigen Loeschen — Wortlaut wie im Web.
|
||||||
|
songToDelete?.let { song ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { songToDelete = null },
|
||||||
|
title = { Text("Song löschen") },
|
||||||
|
text = { Text("\"${song.title}\" wird unwiderruflich von der Festplatte gelöscht. " +
|
||||||
|
"Leere Album- und Künstlerordner werden ebenfalls entfernt.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
songToDelete = null
|
||||||
|
scope.launch {
|
||||||
|
val creds = repo.lastCredentials
|
||||||
|
val client = com.novasonic.app.api.ImporterClient(appSettings.backendUrl)
|
||||||
|
val res = runCatching {
|
||||||
|
client.deleteSong(creds?.serverUrl ?: "", creds?.username ?: "",
|
||||||
|
creds?.password ?: "", song.id)
|
||||||
|
}
|
||||||
|
client.close()
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
res.fold({ "🗑 \"${song.title}\" gelöscht" },
|
||||||
|
{ "Löschen fehlgeschlagen: ${it.message}" }),
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
// Navidrome braucht einen Moment zum Nachscannen, dann die Ansicht neu laden.
|
||||||
|
if (res.isSuccess) { kotlinx.coroutines.delay(3000); homeRefreshKey++ }
|
||||||
|
}
|
||||||
|
}) { Text("Löschen", color = MaterialTheme.colorScheme.error) }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { songToDelete = null }) { Text("Abbrechen") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +680,7 @@ fun AppNavigation() {
|
|||||||
PlaylistPickerSheet(
|
PlaylistPickerSheet(
|
||||||
song = song,
|
song = song,
|
||||||
loadPlaylists = { repo.getPlaylists() },
|
loadPlaylists = { repo.getPlaylists() },
|
||||||
|
coverUrl = { id -> coverUrl(id) },
|
||||||
onAdd = { pl ->
|
onAdd = { pl ->
|
||||||
playlistPickerSong = null
|
playlistPickerSong = null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -502,13 +720,19 @@ fun AppNavigation() {
|
|||||||
val res = repo.search(n.navAlbum)
|
val res = repo.search(n.navAlbum)
|
||||||
val albums = res.album
|
val albums = res.album
|
||||||
val norm = { s: String -> s.lowercase().trim() }
|
val norm = { s: String -> s.lowercase().trim() }
|
||||||
|
val na = norm(n.navArtist)
|
||||||
|
// REQUIRE the artist match when we know it — several artists share an
|
||||||
|
// album title ("I LIKE IT!"); the old name-only / firstOrNull fallback
|
||||||
|
// opened a completely different artist's album (wrong song).
|
||||||
val match = albums.firstOrNull {
|
val match = albums.firstOrNull {
|
||||||
norm(it.name) == norm(n.navAlbum) &&
|
val aa = norm(it.artist ?: "")
|
||||||
(n.navArtist.isBlank() || norm(it.artist ?: "") == norm(n.navArtist))
|
norm(it.name) == norm(n.navAlbum) && na.isNotBlank() &&
|
||||||
} ?: albums.firstOrNull { norm(it.name) == norm(n.navAlbum) }
|
(aa == na || aa.contains(na) || na.contains(aa))
|
||||||
?: albums.firstOrNull()
|
} ?: if (na.isBlank()) albums.firstOrNull { norm(it.name) == norm(n.navAlbum) } else null
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
navController.navigate("album/${match.id}")
|
navController.navigate("album/${match.id}")
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Album noch nicht gefunden — evtl. wird die Library gerade gescannt", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.novasonic.app.ui
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gesamtdauer einer Playlist oder eines Albums, in Sekunden hereingereicht.
|
||||||
|
*
|
||||||
|
* Ab einer Stunde werden Stunden ausgewiesen: "134 Min." muss man sonst selbst umrechnen, und bei
|
||||||
|
* einer Playlist mit 2000 Titeln staende dort eine vierstellige Minutenzahl. Gleiche Regel und
|
||||||
|
* gleiche Schreibweise wie `formatGesamtdauer` im Web (src/utils.js).
|
||||||
|
*/
|
||||||
|
fun formatGesamtdauer(sekunden: Int): String {
|
||||||
|
val min = Math.round((sekunden.coerceAtLeast(0)) / 60.0).toInt()
|
||||||
|
if (min < 60) return "$min Min."
|
||||||
|
val std = min / 60
|
||||||
|
val rest = min % 60
|
||||||
|
return if (rest > 0) "$std Std. $rest Min." else "$std Std."
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
// Geteilter Link, mit dem die App gestartet wurde.
|
||||||
|
com.novasonic.app.data.DeepLinkState.offer(intent?.data)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
NovaSonicTheme {
|
NovaSonicTheme {
|
||||||
@@ -28,6 +30,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// launchMode=singleTask: laeuft die App bereits, kommt ein weiterer Link hier an statt in
|
||||||
|
// onCreate. Ohne das passierte beim zweiten geteilten Link schlicht nichts.
|
||||||
|
override fun onNewIntent(intent: android.content.Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
com.novasonic.app.data.DeepLinkState.offer(intent.data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.novasonic.app.api.model.ConcertEvent
|
import com.novasonic.app.api.model.ConcertEvent
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -31,6 +32,7 @@ fun ConcertsSheet(
|
|||||||
artistName: String,
|
artistName: String,
|
||||||
events: List<ConcertEvent>,
|
events: List<ConcertEvent>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
eventsError: String? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
@@ -47,17 +49,25 @@ fun ConcertsSheet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (events.isEmpty()) {
|
if (events.isEmpty()) {
|
||||||
Box(
|
Column(
|
||||||
contentAlignment = Alignment.Center,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 40.dp),
|
.padding(horizontal = 20.dp, vertical = 40.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Keine Konzerte angekündigt",
|
if (eventsError != null) "Konzerte konnten nicht geladen werden" else "Keine Konzerte angekündigt",
|
||||||
fontFamily = Outfit, fontSize = 14.sp,
|
fontFamily = Outfit, fontSize = 14.sp,
|
||||||
color = NsOnSurface2,
|
color = NsOnSurface2,
|
||||||
)
|
)
|
||||||
|
if (eventsError != null) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
eventsError,
|
||||||
|
fontFamily = Outfit, fontSize = 11.sp,
|
||||||
|
color = NsOnSurface2.copy(alpha = 0.6f),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -78,7 +88,7 @@ fun ConcertsSheet(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
||||||
val (dateStr, timeStr) = formatEventDateTime(event.datetime)
|
val w = formatEventWhen(event.datetime)
|
||||||
val venue = event.venue?.name.orEmpty()
|
val venue = event.venue?.name.orEmpty()
|
||||||
val city = listOfNotNull(event.venue?.city, event.venue?.country).joinToString(", ")
|
val city = listOfNotNull(event.venue?.city, event.venue?.country).joinToString(", ")
|
||||||
val offer = event.offers.firstOrNull { !it.url.isNullOrBlank() }
|
val offer = event.offers.firstOrNull { !it.url.isNullOrBlank() }
|
||||||
@@ -92,26 +102,33 @@ private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
|||||||
.background(NsSurface2)
|
.background(NsSurface2)
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
) {
|
) {
|
||||||
// Date block
|
// Date block — fixed width, calendar style (day big, month/year + time small) so every
|
||||||
|
// row is the same height and the divider/venue line up.
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.width(52.dp),
|
modifier = Modifier.width(56.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
dateStr,
|
w.day,
|
||||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||||
fontSize = 13.sp, color = NsAccent,
|
fontSize = 18.sp, color = NsAccent, maxLines = 1,
|
||||||
)
|
)
|
||||||
if (timeStr.isNotBlank()) {
|
if (w.monthYear.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
timeStr,
|
w.monthYear,
|
||||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (w.time.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
w.time,
|
||||||
|
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.width(14.dp))
|
Spacer(Modifier.width(14.dp))
|
||||||
Box(Modifier.width(1.dp).height(36.dp).background(NsBorder))
|
Box(Modifier.width(1.dp).height(40.dp).background(NsBorder))
|
||||||
Spacer(Modifier.width(14.dp))
|
Spacer(Modifier.width(14.dp))
|
||||||
|
|
||||||
// Venue
|
// Venue
|
||||||
@@ -156,14 +173,21 @@ private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatEventDateTime(datetime: String): Pair<String, String> {
|
private data class EventWhen(val day: String, val monthYear: String, val time: String)
|
||||||
|
|
||||||
|
private val DAY_FMT = DateTimeFormatter.ofPattern("dd", Locale.GERMAN)
|
||||||
|
private val MONTH_FMT = DateTimeFormatter.ofPattern("MMM yyyy", Locale.GERMAN)
|
||||||
|
private val TIME_FMT = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
|
||||||
|
private fun formatEventWhen(datetime: String): EventWhen {
|
||||||
return try {
|
return try {
|
||||||
val zdt = ZonedDateTime.parse(datetime)
|
// Backend now sends a naive venue-local datetime ("2026-09-16T20:00:00"); older/zoned
|
||||||
val date = zdt.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN))
|
// values ("…Z") still parse via the ZonedDateTime fallback.
|
||||||
val time = if (zdt.hour == 0 && zdt.minute == 0) ""
|
val ldt = runCatching { LocalDateTime.parse(datetime) }
|
||||||
else zdt.format(DateTimeFormatter.ofPattern("HH:mm"))
|
.getOrElse { ZonedDateTime.parse(datetime).toLocalDateTime() }
|
||||||
date to time
|
val time = if (ldt.hour == 0 && ldt.minute == 0) "" else ldt.format(TIME_FMT)
|
||||||
|
EventWhen(ldt.format(DAY_FMT), ldt.format(MONTH_FMT), time)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
datetime.take(10) to ""
|
EventWhen(datetime.take(10), "", "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,6 +40,9 @@ fun ItunesDownloadSheet(
|
|||||||
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
|
// Open fully so the "Herunterladen" button is visible right away (it sits above the system
|
||||||
|
// nav bar via navigationBarsPadding below) — no drag-up needed.
|
||||||
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
containerColor = NsSurface,
|
containerColor = NsSurface,
|
||||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2.copy(0.3f)) },
|
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2.copy(0.3f)) },
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -26,6 +25,8 @@ import com.novasonic.app.ui.theme.*
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import com.novasonic.app.R
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -49,7 +50,7 @@ fun NotificationSheet(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 20.dp, end = 8.dp, top = 18.dp, bottom = 12.dp),
|
.padding(start = 20.dp, end = 8.dp, top = 18.dp, bottom = 12.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Notifications, null,
|
Icon(painterResource(R.drawable.ic_honigtopf_brief), null,
|
||||||
tint = NsAccent, modifier = Modifier.size(18.dp))
|
tint = NsAccent, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
package com.novasonic.app.ui.component
|
package com.novasonic.app.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||||
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
import com.novasonic.app.api.model.Playlist
|
import com.novasonic.app.api.model.Playlist
|
||||||
import com.novasonic.app.api.model.Song
|
import com.novasonic.app.api.model.Song
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
@@ -23,6 +29,7 @@ import com.novasonic.app.ui.theme.*
|
|||||||
fun PlaylistPickerSheet(
|
fun PlaylistPickerSheet(
|
||||||
song: Song,
|
song: Song,
|
||||||
loadPlaylists: suspend () -> List<Playlist>,
|
loadPlaylists: suspend () -> List<Playlist>,
|
||||||
|
coverUrl: (String?) -> String?,
|
||||||
onAdd: (Playlist) -> Unit,
|
onAdd: (Playlist) -> Unit,
|
||||||
onCreate: (String) -> Unit,
|
onCreate: (String) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -65,15 +72,19 @@ fun PlaylistPickerSheet(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = newName,
|
value = newName,
|
||||||
onValueChange = { newName = it },
|
onValueChange = { newName = it },
|
||||||
placeholder = { Text("Neue Playlist…", fontSize = 14.sp) },
|
placeholder = { Text("Neue Playlist…", color = NsOnSurface2, fontSize = 14.sp) },
|
||||||
|
leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, null, tint = NsOnSurface2) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedBorderColor = NsAccent,
|
focusedBorderColor = NsAccent,
|
||||||
unfocusedBorderColor = NsBorder,
|
unfocusedBorderColor = NsOnSurface3,
|
||||||
cursorColor = NsAccent,
|
cursorColor = NsAccent,
|
||||||
focusedTextColor = NsOnSurface,
|
focusedTextColor = NsOnSurface,
|
||||||
unfocusedTextColor = NsOnSurface,
|
unfocusedTextColor = NsOnSurface,
|
||||||
|
focusedContainerColor = NsSurface2,
|
||||||
|
unfocusedContainerColor = NsSurface2,
|
||||||
),
|
),
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(
|
textStyle = androidx.compose.ui.text.TextStyle(
|
||||||
fontFamily = Outfit, fontSize = 14.sp,
|
fontFamily = Outfit, fontSize = 14.sp,
|
||||||
@@ -117,8 +128,22 @@ fun PlaylistPickerSheet(
|
|||||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(Icons.AutoMirrored.Filled.QueueMusic, null,
|
val cv = coverUrl(pl.coverArt)
|
||||||
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
Box(
|
||||||
|
Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (cv != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = cv, contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.QueueMusic, null,
|
||||||
|
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.width(14.dp))
|
Spacer(Modifier.width(14.dp))
|
||||||
Text(
|
Text(
|
||||||
pl.name,
|
pl.name,
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.novasonic.app.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.novasonic.app.player.PreviewState
|
||||||
|
import com.novasonic.app.ui.theme.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global 30s-preview control bar. Rendered above the MiniPlayer on every screen while a preview
|
||||||
|
* clip is active, so the user can pause/stop it after navigating away from the album/artist/Explore
|
||||||
|
* screen it was started on.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PreviewBar(
|
||||||
|
state: PreviewState,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.padding(top = 6.dp, bottom = 2.dp)
|
||||||
|
.shadow(10.dp, RoundedCornerShape(16.dp))
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(NsSurface2)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = state.artworkUrl, contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.size(38.dp).clip(RoundedCornerShape(7.dp)).background(NsSurface),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(11.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "30s Preview", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 9.sp, color = NsAccent, letterSpacing = 0.5.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = state.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 13.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (state.artist.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = state.artist, fontFamily = Outfit, fontSize = 11.sp,
|
||||||
|
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier.size(36.dp).clip(CircleShape).background(NsAccent).clickable(onClick = onToggle),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||||
|
contentDescription = if (state.isPlaying) "Pause" else "Abspielen",
|
||||||
|
tint = Color.White, modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(2.dp))
|
||||||
|
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Preview beenden", tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.novasonic.app.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.input.pointer.positionChange
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollbalken zum Ziehen fuer eine [LazyListState] — das Gegenstueck zum verbreiterten Balken im Web.
|
||||||
|
*
|
||||||
|
* Compose bringt keinen mit: eine LazyColumn zeigt gar nichts an, egal wie lang sie ist. Bei einer
|
||||||
|
* Playlist mit 2000 Titeln bleibt so nur Wischen, bis der Daumen glueht.
|
||||||
|
*
|
||||||
|
* Gerechnet wird in ITEMS, nicht in Pixeln: die tatsaechliche Gesamthoehe einer LazyColumn ist
|
||||||
|
* unbekannt, solange nicht alles gebaut wurde, und bei unterschiedlich hohen Zeilen laesst sie sich
|
||||||
|
* auch nicht ausrechnen. Ueber den Anteil sichtbarer an allen Eintraegen stimmt der Balken bei
|
||||||
|
* gleich hohen Zeilen — und genau die haben Song- und Albumlisten.
|
||||||
|
*
|
||||||
|
* Drei Fallen steckten hier drin, alle beim Ziehen:
|
||||||
|
*
|
||||||
|
* 1. **Die Greifflaeche muss breiter sein als der sichtbare Balken.** Sichtbar 6 dp, anfassbar
|
||||||
|
* [griffBreite]. 6 dp am Bildschirmrand trifft mit dem Finger niemand.
|
||||||
|
* 2. **Der erste Fingerkontakt muss verbraucht werden**, sonst startet die LazyColumn ihre eigene
|
||||||
|
* Scrollgeste, sobald die Wischschwelle faellt, und der Balken sieht die Bewegung nie.
|
||||||
|
* 3. **`pointerInput` darf NICHT auf die Geometrie geschluesselt sein.** Genau das war der Grund,
|
||||||
|
* warum das Ziehen unbrauchbar blieb: die Daumenposition steht in jedem Bild anders da, ein
|
||||||
|
* Schluessel darauf setzt die Gestenerkennung also mitten in der Geste neu auf — der Finger
|
||||||
|
* liegt noch auf dem Schirm, die Erkennung faengt aber wieder bei null an. Der Schluessel ist
|
||||||
|
* deshalb `Unit`; die wechselnden Werte liest die Geste ueber [rememberUpdatedState] zur
|
||||||
|
* Laufzeit nach.
|
||||||
|
*
|
||||||
|
* Gescrollt wird nicht im Zeigerereignis, sondern in einem eigenen Effekt, der [zielPos] verfolgt.
|
||||||
|
* Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte konkurrierende Scroll-Aufrufe.
|
||||||
|
*
|
||||||
|
* Und eine vierte, die erst nach dem Ausliefern auffiel: **nur der Daumen nimmt Beruehrungen an,
|
||||||
|
* nicht die ganze Spur.** Compose reicht eine Beruehrung nur an das oberste getroffene Element
|
||||||
|
* weiter — ein Streifen ueber die volle Hoehe verschluckte damit das ⋮ jeder Songzeile, obwohl er
|
||||||
|
* die Beruehrung gar nicht wollte. Der aeussere Rahmen hat deshalb KEIN `pointerInput` und ist
|
||||||
|
* dadurch durchlaessig; nur der Daumen selbst hoert zu, mit etwas Luft nach oben und unten.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Rollbalken(
|
||||||
|
state: LazyListState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
breite: Dp = 6.dp,
|
||||||
|
griffBreite: Dp = 24.dp,
|
||||||
|
minHoehe: Dp = 44.dp,
|
||||||
|
farbe: Color = Color.White,
|
||||||
|
) {
|
||||||
|
val dichte = LocalDensity.current
|
||||||
|
var spurPx by remember { mutableFloatStateOf(0f) }
|
||||||
|
var ziehen by remember { mutableStateOf(false) }
|
||||||
|
// Beim Ziehen fuehrt diese Kommazahl. Wuerde jeder Zeigerschritt aus firstVisibleItemIndex neu
|
||||||
|
// rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal verloren — der Balken
|
||||||
|
// bliebe bei langsamem Ziehen stehen.
|
||||||
|
var zielPos by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
val info = state.layoutInfo
|
||||||
|
val gesamt = info.totalItemsCount
|
||||||
|
val zuSehen = info.visibleItemsInfo.size
|
||||||
|
if (gesamt == 0 || zuSehen == 0 || gesamt <= zuSehen) return
|
||||||
|
|
||||||
|
val aktiv = state.isScrollInProgress || ziehen
|
||||||
|
val alpha by animateFloatAsState(
|
||||||
|
targetValue = if (aktiv) 0.9f else 0.3f,
|
||||||
|
animationSpec = tween(if (aktiv) 120 else 450),
|
||||||
|
label = "rollbalken-alpha",
|
||||||
|
)
|
||||||
|
|
||||||
|
val maxIndex = (gesamt - zuSehen).coerceAtLeast(1)
|
||||||
|
val minHoehePx = with(dichte) { minHoehe.toPx() }
|
||||||
|
val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx)
|
||||||
|
val frei = (spurPx - daumenPx).coerceAtLeast(0f)
|
||||||
|
val anteil = if (ziehen) zielPos / maxIndex
|
||||||
|
else state.firstVisibleItemIndex.toFloat() / maxIndex
|
||||||
|
val daumenY = frei * anteil.coerceIn(0f, 1f)
|
||||||
|
val spielPx = with(dichte) { 10.dp.toPx() } // Luft ober- und unterhalb des Daumens
|
||||||
|
|
||||||
|
// Was die Geste zur Laufzeit braucht — nicht als pointerInput-Schluessel, siehe oben.
|
||||||
|
val geo = rememberUpdatedState(Geometrie(maxIndex.toFloat(), frei, daumenY, daumenPx, spielPx))
|
||||||
|
|
||||||
|
// Die Liste folgt zielPos in EINER Koroutine statt in einer je Fingerbewegung.
|
||||||
|
LaunchedEffect(ziehen) {
|
||||||
|
if (!ziehen) return@LaunchedEffect
|
||||||
|
snapshotFlow { zielPos.roundToInt() }.collect { state.scrollToItem(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.width(griffBreite)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.onSizeChanged { spurPx = it.height.toFloat() }
|
||||||
|
) {
|
||||||
|
val luftDp = with(dichte) { spielPx.toDp() }
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
// Der Greifbereich beginnt eine Spielbreite ueber dem Daumen und ist entsprechend
|
||||||
|
// hoeher; der sichtbare Balken sitzt durch das Innenmass wieder genau auf daumenY.
|
||||||
|
.offset { IntOffset(0, (daumenY - spielPx).roundToInt()) }
|
||||||
|
.width(griffBreite)
|
||||||
|
.height(with(dichte) { (daumenPx + 2 * spielPx).toDp() })
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val start = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
if (geo.value.frei <= 0f) return@awaitEachGesture
|
||||||
|
start.consume()
|
||||||
|
zielPos = state.firstVisibleItemIndex.toFloat()
|
||||||
|
ziehen = true
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
val ereignis = awaitPointerEvent()
|
||||||
|
val punkt = ereignis.changes.firstOrNull { it.id == start.id } ?: break
|
||||||
|
if (!punkt.pressed) break
|
||||||
|
val delta = punkt.positionChange().y
|
||||||
|
punkt.consume()
|
||||||
|
if (delta != 0f) {
|
||||||
|
val jetzt = geo.value
|
||||||
|
if (jetzt.frei > 0f) {
|
||||||
|
zielPos = (zielPos + delta * jetzt.maxIndex / jetzt.frei)
|
||||||
|
.coerceIn(0f, jetzt.maxIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ziehen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(vertical = luftDp, horizontal = 0.dp),
|
||||||
|
contentAlignment = Alignment.CenterEnd,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(end = 2.dp)
|
||||||
|
.width(breite)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(farbe.copy(alpha = alpha))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur ein Buendel der Werte, die die Geste zur Laufzeit nachliest. */
|
||||||
|
private data class Geometrie(
|
||||||
|
val maxIndex: Float,
|
||||||
|
val frei: Float,
|
||||||
|
val daumenY: Float,
|
||||||
|
val daumenPx: Float,
|
||||||
|
val spiel: Float,
|
||||||
|
)
|
||||||
@@ -33,9 +33,14 @@ fun SongContextSheet(
|
|||||||
onGoToAlbum: (() -> Unit)? = null,
|
onGoToAlbum: (() -> Unit)? = null,
|
||||||
onShowInfo: (() -> Unit)? = null,
|
onShowInfo: (() -> Unit)? = null,
|
||||||
onDownload: (() -> Unit)? = null,
|
onDownload: (() -> Unit)? = null,
|
||||||
|
onDelete: (() -> Unit)? = null,
|
||||||
|
onShare: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
|
// Open fully expanded so all options (incl. "Herunterladen" at the bottom) are reachable
|
||||||
|
// right away — no extra drag-up needed.
|
||||||
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
containerColor = NsSurface,
|
containerColor = NsSurface,
|
||||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
||||||
) {
|
) {
|
||||||
@@ -74,6 +79,16 @@ fun SongContextSheet(
|
|||||||
onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) }
|
onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) }
|
||||||
onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", onClick = { onDismiss(); it() }) }
|
onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", onClick = { onDismiss(); it() }) }
|
||||||
onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) }
|
onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) }
|
||||||
|
onShare?.let { CtxItem(Icons.Default.Share, "Teilen", onClick = { onDismiss(); it() }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loeschen steht bewusst unten, abgesetzt und rot: es entfernt die Datei unwiderruflich von
|
||||||
|
// der Platte. Die Sicherheitsabfrage sitzt beim Aufrufer, wie im Web auch.
|
||||||
|
onDelete?.let {
|
||||||
|
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
CtxItem(Icons.Default.DeleteForever, "Von Festplatte löschen",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
onClick = { onDismiss(); it() })
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import androidx.compose.material.icons.filled.FavoriteBorder
|
|||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -34,13 +36,19 @@ fun SongRow(
|
|||||||
onStarToggle: (() -> Unit)? = null,
|
onStarToggle: (() -> Unit)? = null,
|
||||||
onMore: (() -> Unit)? = null,
|
onMore: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
// Reactive star: reflects an optimistic StarState override the instant it's toggled anywhere.
|
||||||
|
val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState()
|
||||||
|
val effectiveStarred = starOverrides[song.id] ?: starred
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onPlay)
|
.clickable(onClick = onPlay)
|
||||||
.background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent)
|
.background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent)
|
||||||
.padding(start = if (isPlaying) 0.dp else 4.dp),
|
// Rechts 24 dp frei halten: genau dort liegt der Rollbalken, und ein ⋮ darunter
|
||||||
|
// waere nicht mehr zu treffen — Compose gibt eine Beruehrung nur an das oberste
|
||||||
|
// getroffene Element. Gilt ueberall, damit die Zeilen gleich aussehen.
|
||||||
|
.padding(start = if (isPlaying) 0.dp else 4.dp, end = 24.dp),
|
||||||
) {
|
) {
|
||||||
// Accent left bar when playing
|
// Accent left bar when playing
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
@@ -113,9 +121,9 @@ fun SongRow(
|
|||||||
if (onStarToggle != null) {
|
if (onStarToggle != null) {
|
||||||
IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) {
|
IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
if (effectiveStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (starred) NsAccent else NsOnSurface2.copy(alpha = 0.6f),
|
tint = if (effectiveStarred) NsAccent else NsOnSurface2.copy(alpha = 0.6f),
|
||||||
modifier = Modifier.size(17.dp),
|
modifier = Modifier.size(17.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -11,6 +12,9 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import com.novasonic.app.data.StarState
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Shuffle
|
import androidx.compose.material.icons.filled.Shuffle
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -34,6 +38,8 @@ import com.novasonic.app.player.PlayerState
|
|||||||
import com.novasonic.app.ui.component.SongRow
|
import com.novasonic.app.ui.component.SongRow
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
import com.novasonic.app.viewmodel.AlbumViewModel
|
import com.novasonic.app.viewmodel.AlbumViewModel
|
||||||
|
import com.novasonic.app.ui.component.Rollbalken
|
||||||
|
import com.novasonic.app.ui.formatGesamtdauer
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AlbumScreen(
|
fun AlbumScreen(
|
||||||
@@ -72,152 +78,178 @@ fun AlbumScreen(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
val listState = rememberLazyListState()
|
||||||
item {
|
Box(Modifier.fillMaxSize()) {
|
||||||
// ── Hero ─────────────────────────────────────────────────────────
|
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||||
Box(Modifier.fillMaxWidth()) {
|
item {
|
||||||
AsyncImage(
|
// ── Hero ─────────────────────────────────────────────────────────
|
||||||
model = coverUrl(album.coverArt),
|
Box(Modifier.fillMaxWidth()) {
|
||||||
contentDescription = album.name,
|
AsyncImage(
|
||||||
contentScale = ContentScale.Crop,
|
model = coverUrl(album.coverArt),
|
||||||
modifier = Modifier
|
contentDescription = album.name,
|
||||||
.fillMaxWidth()
|
contentScale = ContentScale.Crop,
|
||||||
.aspectRatio(1f)
|
modifier = Modifier
|
||||||
.background(NsSurface2),
|
.fillMaxWidth()
|
||||||
)
|
.aspectRatio(1f)
|
||||||
// gradient overlay
|
.background(NsSurface2),
|
||||||
Box(
|
)
|
||||||
Modifier
|
// gradient overlay
|
||||||
.fillMaxWidth()
|
Box(
|
||||||
.aspectRatio(1f)
|
Modifier
|
||||||
.background(
|
.fillMaxWidth()
|
||||||
Brush.verticalGradient(
|
.aspectRatio(1f)
|
||||||
0.0f to Color.Transparent,
|
.background(
|
||||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
Brush.verticalGradient(
|
||||||
1.0f to NsBackground,
|
0.0f to Color.Transparent,
|
||||||
|
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||||
|
1.0f to NsBackground,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
// back button — frosted circle
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(start = 14.dp, top = 14.dp)
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.45f))
|
||||||
|
.clickable(onClick = onBack)
|
||||||
|
.align(Alignment.TopStart),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||||
|
tint = Color.White, modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
// album info overlaid at bottom
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
album.name,
|
||||||
|
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||||
|
fontSize = 26.sp, color = Color.White,
|
||||||
|
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (!album.artist.isNullOrBlank()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
album.artist!!, fontFamily = Outfit,
|
||||||
|
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
|
||||||
|
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
|
||||||
|
Modifier.clickable { onArtistClick(album.artistId!!) }
|
||||||
|
else Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val meta = listOfNotNull(
|
||||||
|
album.year?.toString(),
|
||||||
|
album.genre?.takeIf { it.isNotBlank() },
|
||||||
|
if (album.songCount > 0) "${album.songCount} Songs" else null,
|
||||||
|
if (album.duration > 0) formatGesamtdauer(album.duration) else null,
|
||||||
|
).joinToString(" · ")
|
||||||
|
if (meta.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(3.dp))
|
||||||
|
Text(
|
||||||
|
meta, fontFamily = Outfit, fontSize = 12.sp,
|
||||||
|
color = Color.White.copy(alpha = 0.55f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Play / Shuffle / Merken / Download ────────────────────────────
|
||||||
|
// Gleiche Leiste wie beim Playlist-Detail: vier gleich breite, reine Symbolknoepfe.
|
||||||
|
// Play und Shuffle trugen hier noch Beschriftungen, waehrend Herz und Download fest
|
||||||
|
// 46 dp breit waren — die Reihe war dadurch ungleich geteilt und die Schrift wurde auf
|
||||||
|
// schmalen Geraeten abgeschnitten. Play bleibt gefuellt als Hauptaktion; die uebrigen
|
||||||
|
// sind umrandet und faerben sich erst accent, wenn sie aktiv sind.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { onPlay(album.song, 0) },
|
||||||
|
modifier = Modifier.weight(1f).height(46.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onPlay(album.song, -1) }, // -1 = gemischt, der Player mischt
|
||||||
|
modifier = Modifier.weight(1f).height(46.dp),
|
||||||
|
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||||
|
}
|
||||||
|
// Album merken — derselbe Weg wie beim Song: Navidrome behandelt Song-, Album- und
|
||||||
|
// Kuenstler-IDs beim Markieren gleich.
|
||||||
|
val overrides by StarState.overrides.collectAsStateWithLifecycle()
|
||||||
|
val gemerkt = overrides[album.id] ?: (album.starred != null)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { vm.merkenUmschalten(album.id, album.starred != null) },
|
||||||
|
modifier = Modifier.weight(1f).height(46.dp),
|
||||||
|
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||||
|
brush = SolidColor(if (gemerkt) NsAccent else NsOnSurface3)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (gemerkt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||||
|
if (gemerkt) "Gemerkt" else "Album merken",
|
||||||
|
Modifier.size(20.dp), tint = if (gemerkt) NsAccent else NsOnSurface2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val isFullyDownloaded = downloadGroup != null &&
|
||||||
|
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
|
||||||
|
downloadGroup!!.totalTracks > 0
|
||||||
|
OutlinedButton(
|
||||||
|
// Tappable unless fully done — resumes / re-downloads any missing songs.
|
||||||
|
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
|
||||||
|
modifier = Modifier.weight(1f).height(46.dp),
|
||||||
|
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||||
|
brush = SolidColor(if (isFullyDownloaded) NsAccent else NsOnSurface3)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", Modifier.size(20.dp), tint = NsAccent)
|
||||||
|
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
|
||||||
|
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = NsBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsIndexed(album.song, key = { _, s -> s.id }) { index, song ->
|
||||||
|
SongRow(
|
||||||
|
song = song,
|
||||||
|
coverUrl = null,
|
||||||
|
isPlaying = playerState.currentSong?.id == song.id,
|
||||||
|
trackNumber = song.track ?: (index + 1),
|
||||||
|
starred = song.starred != null,
|
||||||
|
onPlay = { onPlay(album.song, index) },
|
||||||
|
onStarToggle = onStarToggle?.let { cb -> { cb(song) } },
|
||||||
|
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||||
)
|
)
|
||||||
// back button — frosted circle
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.statusBarsPadding()
|
|
||||||
.padding(start = 14.dp, top = 14.dp)
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.Black.copy(alpha = 0.45f))
|
|
||||||
.clickable(onClick = onBack)
|
|
||||||
.align(Alignment.TopStart),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
|
||||||
tint = Color.White, modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// album info overlaid at bottom
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
album.name,
|
|
||||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
|
||||||
fontSize = 26.sp, color = Color.White,
|
|
||||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
if (!album.artist.isNullOrBlank()) {
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
album.artist!!, fontFamily = Outfit,
|
|
||||||
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
|
|
||||||
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
|
|
||||||
Modifier.clickable { onArtistClick(album.artistId!!) }
|
|
||||||
else Modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val meta = listOfNotNull(
|
|
||||||
album.year?.toString(),
|
|
||||||
album.genre?.takeIf { it.isNotBlank() },
|
|
||||||
if (album.songCount > 0) "${album.songCount} Songs" else null,
|
|
||||||
).joinToString(" · ")
|
|
||||||
if (meta.isNotBlank()) {
|
|
||||||
Spacer(Modifier.height(3.dp))
|
|
||||||
Text(
|
|
||||||
meta, fontFamily = Outfit, fontSize = 12.sp,
|
|
||||||
color = Color.White.copy(alpha = 0.55f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
item { Spacer(Modifier.height(16.dp)) }
|
||||||
// ── Play / Shuffle row ────────────────────────────────────────────
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = { onPlay(album.song, 0) },
|
|
||||||
modifier = Modifier.weight(1f).height(46.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { onPlay(album.song.shuffled(), 0) },
|
|
||||||
modifier = Modifier.weight(1f).height(46.dp),
|
|
||||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
|
||||||
}
|
|
||||||
val isFullyDownloaded = downloadGroup != null &&
|
|
||||||
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
|
|
||||||
downloadGroup!!.totalTracks > 0
|
|
||||||
OutlinedButton(
|
|
||||||
// Tappable unless fully done — resumes / re-downloads any missing songs.
|
|
||||||
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
|
|
||||||
modifier = Modifier.height(46.dp).width(46.dp),
|
|
||||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
|
||||||
brush = SolidColor(
|
|
||||||
when {
|
|
||||||
isFullyDownloaded -> MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
|
||||||
else -> NsAccent.copy(alpha = 0.55f)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
contentPadding = PaddingValues(0.dp),
|
|
||||||
) {
|
|
||||||
when {
|
|
||||||
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Downloaded", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
|
||||||
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
|
|
||||||
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsAccent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(color = NsBorder)
|
|
||||||
}
|
}
|
||||||
|
Rollbalken(
|
||||||
itemsIndexed(album.song, key = { _, s -> s.id }) { index, song ->
|
state = listState,
|
||||||
SongRow(
|
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||||
song = song,
|
farbe = NsOnSurface2,
|
||||||
coverUrl = null,
|
)
|
||||||
isPlaying = playerState.currentSong?.id == song.id,
|
|
||||||
trackNumber = song.track ?: (index + 1),
|
|
||||||
starred = song.starred != null,
|
|
||||||
onPlay = { onPlay(album.song, index) },
|
|
||||||
onStarToggle = onStarToggle?.let { cb -> { cb(song) } },
|
|
||||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item { Spacer(Modifier.height(16.dp)) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,12 +20,16 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Clear
|
import androidx.compose.material.icons.filled.Clear
|
||||||
import androidx.compose.material.icons.filled.Event
|
import androidx.compose.material.icons.filled.Event
|
||||||
|
import com.novasonic.app.data.StarState
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Shuffle
|
import androidx.compose.material.icons.filled.Shuffle
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -42,6 +46,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.novasonic.app.api.model.Song
|
import com.novasonic.app.api.model.Song
|
||||||
|
import com.novasonic.app.api.model.fmtFanCount
|
||||||
import com.novasonic.app.player.PlayerState
|
import com.novasonic.app.player.PlayerState
|
||||||
import com.novasonic.app.ui.component.AlbumCard
|
import com.novasonic.app.ui.component.AlbumCard
|
||||||
import com.novasonic.app.ui.component.ConcertsSheet
|
import com.novasonic.app.ui.component.ConcertsSheet
|
||||||
@@ -49,6 +54,7 @@ import com.novasonic.app.ui.component.ItunesDownloadSheet
|
|||||||
import com.novasonic.app.ui.component.SongRow
|
import com.novasonic.app.ui.component.SongRow
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
import com.novasonic.app.viewmodel.ArtistViewModel
|
import com.novasonic.app.viewmodel.ArtistViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailScreen(
|
fun ArtistDetailScreen(
|
||||||
@@ -64,20 +70,22 @@ fun ArtistDetailScreen(
|
|||||||
val vm: ArtistViewModel = viewModel()
|
val vm: ArtistViewModel = viewModel()
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
||||||
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
|
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
|
||||||
|
|
||||||
var showAllAlbums by remember { mutableStateOf(false) }
|
// Which "all albums" section is expanded (by title), or null. rememberSaveable so it survives
|
||||||
var allAlbumsTitle by remember { mutableStateOf("") }
|
// navigating into an album and back — otherwise back would land on the collapsed artist page.
|
||||||
var allAlbumEntries by remember { mutableStateOf<List<AllAlbumEntry>>(emptyList()) }
|
var openAllAlbums by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val showAllAlbums = openAllAlbums != null
|
||||||
BackHandler(enabled = showAllAlbums && state.partialAlbum == null && state.subAlbumItem == null) {
|
BackHandler(enabled = showAllAlbums && state.partialAlbum == null && state.subAlbumItem == null) {
|
||||||
showAllAlbums = false
|
openAllAlbums = null
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(state.pendingShufflePlay) {
|
LaunchedEffect(state.pendingShufflePlay) {
|
||||||
state.pendingShufflePlay?.let { songs ->
|
state.pendingShufflePlay?.let { songs ->
|
||||||
onPlay(songs, 0)
|
onPlay(songs, -1) // -1 = gemischt starten, der Player mischt
|
||||||
vm.consumeShufflePlay()
|
vm.consumeShufflePlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,9 +95,10 @@ fun ArtistDetailScreen(
|
|||||||
val artist = state.artist
|
val artist = state.artist
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
ConcertsSheet(
|
ConcertsSheet(
|
||||||
artistName = artist.name,
|
artistName = artist.name,
|
||||||
events = state.events,
|
events = state.events,
|
||||||
onDismiss = vm::dismissEventsSheet,
|
onDismiss = vm::dismissEventsSheet,
|
||||||
|
eventsError = state.eventsError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +117,22 @@ fun ArtistDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Artist-pin picker
|
||||||
|
if (state.showPinPicker) {
|
||||||
|
ArtistPinSheet(
|
||||||
|
artistName = state.artist?.name ?: "",
|
||||||
|
pinned = state.pin,
|
||||||
|
searching = state.pinSearching,
|
||||||
|
candidates = state.pinCandidates,
|
||||||
|
onPick = vm::pinCandidate,
|
||||||
|
onPinLink = { link -> vm.pinByLink(link) { ok ->
|
||||||
|
if (!ok) Toast.makeText(context, "Deezer-Künstler nicht gefunden", Toast.LENGTH_SHORT).show()
|
||||||
|
} },
|
||||||
|
onClear = vm::clearPin,
|
||||||
|
onDismiss = vm::dismissPinPicker,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator(color = NsAccent)
|
CircularProgressIndicator(color = NsAccent)
|
||||||
@@ -134,6 +159,7 @@ fun ArtistDetailScreen(
|
|||||||
onPreview = vm::togglePreview,
|
onPreview = vm::togglePreview,
|
||||||
onDownload = vm::showDownloadSheet,
|
onDownload = vm::showDownloadSheet,
|
||||||
onBack = vm::dismissPartialAlbum,
|
onBack = vm::dismissPartialAlbum,
|
||||||
|
onShowContextMenu = onShowContextMenu,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -163,16 +189,43 @@ fun ArtistDetailScreen(
|
|||||||
// Album categories (computed once iTunes data arrives)
|
// Album categories (computed once iTunes data arrives)
|
||||||
val cats = if (state.iAlbums.isNotEmpty()) categorizeAlbums(artist.album, state.iAlbums) else null
|
val cats = if (state.iAlbums.isNotEmpty()) categorizeAlbums(artist.album, state.iAlbums) else null
|
||||||
|
|
||||||
|
// Rebuild the overlay entries from the (surviving) categorized albums for whichever section is
|
||||||
|
// open. Derived — not stored — so it's correct after returning from an album.
|
||||||
|
val allAlbumEntries: List<AllAlbumEntry> = when (openAllAlbums) {
|
||||||
|
"In Library" -> cats?.downloaded?.map { (nd, _) ->
|
||||||
|
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
|
||||||
|
}.orEmpty()
|
||||||
|
"Unvollständig" -> cats?.partial?.map { (nd, ia, counts) ->
|
||||||
|
AllAlbumEntry(
|
||||||
|
name = nd.name, year = nd.year?.toString(),
|
||||||
|
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
|
||||||
|
badge = "${counts.first}/${counts.second}",
|
||||||
|
onClick = { vm.loadPartialAlbum(nd, ia) }, onDownload = { vm.showDownloadSheet(ia) },
|
||||||
|
)
|
||||||
|
}.orEmpty()
|
||||||
|
"Nicht heruntergeladen" -> cats?.notDownloaded?.map { ia ->
|
||||||
|
AllAlbumEntry(
|
||||||
|
name = ia.collectionName ?: "—", year = ia.releaseDate?.take(4),
|
||||||
|
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"), dimmed = true,
|
||||||
|
onClick = { vm.loadSubAlbum(ia) }, onDownload = { vm.showDownloadSheet(ia) },
|
||||||
|
)
|
||||||
|
}.orEmpty()
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
if (showAllAlbums) {
|
if (showAllAlbums) {
|
||||||
AllAlbumsOverlay(
|
AllAlbumsOverlay(
|
||||||
title = allAlbumsTitle,
|
title = openAllAlbums ?: "",
|
||||||
entries = allAlbumEntries,
|
entries = allAlbumEntries,
|
||||||
onBack = { showAllAlbums = false },
|
onBack = { openAllAlbums = null },
|
||||||
|
gridState = vm.allAlbumsGrid,
|
||||||
|
query = vm.allAlbumsQuery.value,
|
||||||
|
onQueryChange = { vm.allAlbumsQuery.value = it },
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
LazyColumn(state = vm.pageListState, modifier = Modifier.fillMaxSize()) {
|
||||||
// Back link
|
// Back link
|
||||||
item {
|
item {
|
||||||
Row(
|
Row(
|
||||||
@@ -191,7 +244,10 @@ fun ArtistDetailScreen(
|
|||||||
|
|
||||||
// Hero card
|
// Hero card
|
||||||
item {
|
item {
|
||||||
val imgUrl = state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
// A pin's Deezer picture wins over the Last.fm image (fixes same-name artists whose
|
||||||
|
// Navidrome/Last.fm image is the wrong person).
|
||||||
|
val imgUrl = state.pinnedImage?.takeIf { it.isNotBlank() }
|
||||||
|
?: state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
||||||
?: coverUrl(artist.coverArt)
|
?: coverUrl(artist.coverArt)
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -207,6 +263,38 @@ fun ArtistDetailScreen(
|
|||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
// Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste darunter: die Leiste
|
||||||
|
// ist "was ich jetzt tue", Folgen ist ein Zustand. Deshalb als Herz ins Banner,
|
||||||
|
// direkt neben das Zahnrad — genau wie im Web.
|
||||||
|
val overrides by StarState.overrides.collectAsStateWithLifecycle()
|
||||||
|
val folgt = overrides[artist.id] ?: (artist.starred != null)
|
||||||
|
Surface(
|
||||||
|
onClick = { vm.folgenUmschalten(artist.id, artist.starred != null) },
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = Color(0x59000000),
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd).padding(top = 10.dp, end = 52.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (folgt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||||
|
if (folgt) "Du folgst ${artist.name}" else "${artist.name} folgen",
|
||||||
|
tint = if (folgt) NsAccent else Color.White.copy(0.72f),
|
||||||
|
modifier = Modifier.padding(7.dp).size(17.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// "Wrong artist?" pin — minimal icon-only chip, top-right over the hero.
|
||||||
|
Surface(
|
||||||
|
onClick = { vm.openPinPicker() },
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = Color(0x59000000),
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd).padding(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"⚙",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = if (state.pin != null) Color(0xFF7EB8FF) else Color.White.copy(0.72f),
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
@@ -215,13 +303,21 @@ fun ArtistDetailScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Column(modifier = Modifier.align(Alignment.BottomStart).padding(20.dp)) {
|
||||||
artist.name,
|
Text(
|
||||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp,
|
artist.name,
|
||||||
letterSpacing = (-0.5).sp, color = Color.White,
|
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp,
|
||||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
letterSpacing = (-0.5).sp, color = Color.White,
|
||||||
modifier = Modifier.align(Alignment.BottomStart).padding(20.dp),
|
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
state.fanCount?.takeIf { it > 0 }?.let {
|
||||||
|
Text(
|
||||||
|
"${fmtFanCount(it)} Fans",
|
||||||
|
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||||
|
color = Color.White.copy(0.58f), modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
@@ -255,11 +351,52 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Similar artists (exclude the current artist)
|
// Similar artists. When pinned, use Deezer "related" for the pinned artist — Last.fm's list
|
||||||
|
// is keyed to the (possibly merged/wrong) Navidrome artist, so it goes "weird".
|
||||||
|
val usePinnedRelated = state.pin != null && state.pinnedRelated.isNotEmpty()
|
||||||
val similarArtists = state.info?.similarArtist
|
val similarArtists = state.info?.similarArtist
|
||||||
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
|
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
if (similarArtists.isNotEmpty()) {
|
if (usePinnedRelated) {
|
||||||
|
item {
|
||||||
|
SectionLabel("Similar Artists")
|
||||||
|
LazyRow(
|
||||||
|
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.padding(bottom = 20.dp),
|
||||||
|
) {
|
||||||
|
items(state.pinnedRelated, key = { it.artistId ?: 0L }) { rel ->
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(70.dp)
|
||||||
|
// Deezer-related has no Navidrome id → resolve by name on tap; navigate
|
||||||
|
// if the artist is in the library, else say so.
|
||||||
|
.clickable {
|
||||||
|
scope.launch {
|
||||||
|
val id = vm.libraryArtistId(rel.artistName)
|
||||||
|
if (id != null && onArtistClick != null) onArtistClick(id)
|
||||||
|
else Toast.makeText(context, "Nicht in deiner Bibliothek", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = rel.artworkUrl100,
|
||||||
|
contentDescription = rel.artistName,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.size(60.dp).clip(CircleShape).background(NsSurface2),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
rel.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
|
||||||
|
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (similarArtists.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
SectionLabel("Similar Artists")
|
SectionLabel("Similar Artists")
|
||||||
LazyRow(
|
LazyRow(
|
||||||
@@ -338,7 +475,7 @@ fun ArtistDetailScreen(
|
|||||||
color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
|
color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
Text("iTunes Daten werden geladen…", fontFamily = Outfit,
|
Text("Deezer Daten werden geladen…", fontFamily = Outfit,
|
||||||
fontSize = 12.sp, color = NsOnSurface2)
|
fontSize = 12.sp, color = NsOnSurface2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,13 +486,7 @@ fun ArtistDetailScreen(
|
|||||||
if (cats.downloaded.isNotEmpty()) {
|
if (cats.downloaded.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
ArtistSectionHeader("In Library", cats.downloaded.size,
|
ArtistSectionHeader("In Library", cats.downloaded.size,
|
||||||
onShowAll = if (cats.downloaded.size > 4) ({
|
onShowAll = if (cats.downloaded.size > 4) ({ openAllAlbums = "In Library" }) else null
|
||||||
allAlbumsTitle = "In Library"
|
|
||||||
allAlbumEntries = cats.downloaded.map { (nd, _) ->
|
|
||||||
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
|
|
||||||
}
|
|
||||||
showAllAlbums = true
|
|
||||||
}) else null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -379,20 +510,7 @@ fun ArtistDetailScreen(
|
|||||||
if (cats.partial.isNotEmpty()) {
|
if (cats.partial.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
ArtistSectionHeader("Unvollständig", cats.partial.size,
|
ArtistSectionHeader("Unvollständig", cats.partial.size,
|
||||||
onShowAll = if (cats.partial.size > 4) ({
|
onShowAll = if (cats.partial.size > 4) ({ openAllAlbums = "Unvollständig" }) else null
|
||||||
allAlbumsTitle = "Unvollständig"
|
|
||||||
allAlbumEntries = cats.partial.map { (nd, ia, counts) ->
|
|
||||||
AllAlbumEntry(
|
|
||||||
name = nd.name,
|
|
||||||
year = nd.year?.toString(),
|
|
||||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
|
|
||||||
badge = "${counts.first}/${counts.second}",
|
|
||||||
onClick = { vm.loadPartialAlbum(nd, ia) },
|
|
||||||
onDownload = { vm.showDownloadSheet(ia) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
showAllAlbums = true
|
|
||||||
}) else null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -419,20 +537,7 @@ fun ArtistDetailScreen(
|
|||||||
if (cats.notDownloaded.isNotEmpty()) {
|
if (cats.notDownloaded.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size,
|
ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size,
|
||||||
onShowAll = if (cats.notDownloaded.size > 4) ({
|
onShowAll = if (cats.notDownloaded.size > 4) ({ openAllAlbums = "Nicht heruntergeladen" }) else null
|
||||||
allAlbumsTitle = "Nicht heruntergeladen"
|
|
||||||
allAlbumEntries = cats.notDownloaded.map { ia ->
|
|
||||||
AllAlbumEntry(
|
|
||||||
name = ia.collectionName ?: "—",
|
|
||||||
year = ia.releaseDate?.take(4),
|
|
||||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
|
||||||
dimmed = true,
|
|
||||||
onClick = { vm.loadSubAlbum(ia) },
|
|
||||||
onDownload = { vm.showDownloadSheet(ia) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
showAllAlbums = true
|
|
||||||
}) else null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -543,8 +648,10 @@ internal fun AllAlbumsOverlay(
|
|||||||
title: String,
|
title: String,
|
||||||
entries: List<AllAlbumEntry>,
|
entries: List<AllAlbumEntry>,
|
||||||
onBack: () -> Unit,
|
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) {
|
val filtered = remember(query, entries) {
|
||||||
if (query.isBlank()) entries
|
if (query.isBlank()) entries
|
||||||
else entries.filter { it.name.contains(query, ignoreCase = true) }
|
else entries.filter { it.name.contains(query, ignoreCase = true) }
|
||||||
@@ -579,11 +686,11 @@ internal fun AllAlbumsOverlay(
|
|||||||
// Search bar
|
// Search bar
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = { query = it },
|
onValueChange = onQueryChange,
|
||||||
placeholder = { Text("Suchen…", color = NsOnSurface2) },
|
placeholder = { Text("Suchen…", color = NsOnSurface2) },
|
||||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||||
trailingIcon = if (query.isNotEmpty()) { {
|
trailingIcon = if (query.isNotEmpty()) { {
|
||||||
IconButton(onClick = { query = "" }) {
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
Icon(Icons.Default.Clear, null, tint = NsOnSurface2)
|
Icon(Icons.Default.Clear, null, tint = NsOnSurface2)
|
||||||
}
|
}
|
||||||
} } else null,
|
} } else null,
|
||||||
@@ -603,6 +710,7 @@ internal fun AllAlbumsOverlay(
|
|||||||
)
|
)
|
||||||
|
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
|
state = gridState,
|
||||||
columns = GridCells.Fixed(2),
|
columns = GridCells.Fixed(2),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -626,3 +734,93 @@ internal fun AllAlbumsOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Artist-pin picker ────────────────────────────────────────────────────────
|
||||||
|
// Pin a local (Navidrome) artist to a specific Deezer artist, fixing same-name collisions.
|
||||||
|
// A pasted Deezer link/id is the reliable route when /search/artist can't surface the artist.
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ArtistPinSheet(
|
||||||
|
artistName: String,
|
||||||
|
pinned: com.novasonic.app.api.model.ArtistOverride?,
|
||||||
|
searching: Boolean,
|
||||||
|
candidates: List<com.novasonic.app.api.model.ItunesItem>,
|
||||||
|
onPick: (com.novasonic.app.api.model.ItunesItem) -> Unit,
|
||||||
|
onPinLink: (String) -> Unit,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
var link by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = NsSurface) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
Text("Richtigen Künstler wählen", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp, color = NsOnSurface)
|
||||||
|
Text("Für „$artistName\"", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
|
modifier = Modifier.padding(top = 2.dp, bottom = 14.dp))
|
||||||
|
|
||||||
|
if (pinned != null) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClear,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
Text("Pin entfernen (aktuell: ${pinned.name.ifBlank { "gepinnt" }})",
|
||||||
|
color = NsError, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste a Deezer link/id → pin directly (reliable when search misses the artist).
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = link, onValueChange = { link = it },
|
||||||
|
placeholder = { Text("Deezer-Link oder ID", fontFamily = Outfit, fontSize = 13.sp) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = Outfit, fontSize = 14.sp, color = NsOnSurface),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { if (link.isNotBlank()) onPinLink(link) },
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
) { Text("Pinnen", fontFamily = Outfit, fontWeight = FontWeight.Bold) }
|
||||||
|
}
|
||||||
|
Text("Nicht in der Liste? Künstler auf deezer.com öffnen und Link einfügen.",
|
||||||
|
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||||
|
modifier = Modifier.padding(top = 6.dp, bottom = 14.dp))
|
||||||
|
|
||||||
|
when {
|
||||||
|
searching -> Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(28.dp))
|
||||||
|
}
|
||||||
|
candidates.isEmpty() -> Text("Keine Deezer-Künstler gefunden.", fontFamily = Outfit,
|
||||||
|
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 12.dp))
|
||||||
|
else -> Column(Modifier.fillMaxWidth()) {
|
||||||
|
candidates.forEach { c ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable { onPick(c) }.padding(vertical = 7.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(48.dp).clip(CircleShape).background(NsSurface2)) {
|
||||||
|
AsyncImage(model = c.artworkUrl100, contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(c.artistName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(c.fanCount?.let { "${fmtFanCount(it)} Fans" } ?: "Deezer",
|
||||||
|
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package com.novasonic.app.ui.screen
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.novasonic.app.api.model.ArtistRelease
|
||||||
|
import com.novasonic.app.ui.theme.*
|
||||||
|
import com.novasonic.app.viewmodel.ArtistNewsViewModel
|
||||||
|
import com.novasonic.app.viewmodel.NewsReiter
|
||||||
|
import com.novasonic.app.api.model.ArtistKonzert
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. Nach Monat gruppiert,
|
||||||
|
* neueste zuerst. Was schon in der Bibliothek liegt, ist markiert statt anklickbar.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ArtistNewsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onArtistClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val vm: ArtistNewsViewModel = viewModel()
|
||||||
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.statusBarsPadding().padding(start = 4.dp, top = 4.dp),
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
val konzerte = state.reiter == NewsReiter.KONZERTE
|
||||||
|
Text(
|
||||||
|
if (konzerte) "Konzerte deiner Künstler" else "Neu von deinen Künstlern",
|
||||||
|
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind dasselbe
|
||||||
|
// Thema. Bewusst NICHT in eine gemeinsame Liste gemischt — Veroeffentlichungen liegen
|
||||||
|
// hinter, Konzerte vor dem heutigen Tag.
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
|
||||||
|
) {
|
||||||
|
listOf(
|
||||||
|
NewsReiter.RELEASES to "Neu erschienen",
|
||||||
|
NewsReiter.KONZERTE to "Konzerte",
|
||||||
|
).forEach { (r, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = state.reiter == r,
|
||||||
|
onClick = { vm.zeigeReiter(r) },
|
||||||
|
label = { Text(label, fontSize = 12.sp) },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||||
|
selectedLabelColor = NsAccent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Zeitfenster gilt nur fuer die Veroeffentlichungen. Konzerte sind ohnehin Zukunft,
|
||||||
|
// dort will man alles Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
|
||||||
|
if (state.reiter == NewsReiter.RELEASES) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 10.dp),
|
||||||
|
) {
|
||||||
|
// 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn.
|
||||||
|
listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = state.tage == tage,
|
||||||
|
onClick = { vm.setzeTage(tage) },
|
||||||
|
label = { Text(label, fontSize = 12.sp) },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||||
|
selectedLabelColor = NsAccent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = NsAccent)
|
||||||
|
}
|
||||||
|
state.keinBackend -> Hinweis("🔌", "Kein Backend konfiguriert",
|
||||||
|
"Neue Veröffentlichungen kommen über das Backend von Deezer.")
|
||||||
|
state.keineGefolgten -> Hinweis("❤", "Du folgst noch niemandem",
|
||||||
|
"Auf einer Künstlerseite oben rechts aufs Herz tippen.")
|
||||||
|
state.error != null -> Hinweis("⚠", "Laden fehlgeschlagen", state.error!!)
|
||||||
|
|
||||||
|
state.reiter == NewsReiter.KONZERTE && state.keinSchluessel -> Hinweis("🎟",
|
||||||
|
"Kein Ticketmaster-Schlüssel hinterlegt", "Im Backend TICKETMASTER_API_KEY setzen.")
|
||||||
|
state.reiter == NewsReiter.KONZERTE && state.konzerte.isEmpty() -> Hinweis("🎪",
|
||||||
|
"Keine anstehenden Termine",
|
||||||
|
"Gesucht wird weltweit über den Künstlernamen.")
|
||||||
|
state.reiter == NewsReiter.KONZERTE -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||||
|
var letzterMonat = ""
|
||||||
|
state.konzerte.forEach { k ->
|
||||||
|
val monat = monatVon(k.datum)
|
||||||
|
if (monat != letzterMonat) {
|
||||||
|
letzterMonat = monat
|
||||||
|
item(key = "km_$monat") {
|
||||||
|
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 13.sp, color = NsOnSurface2,
|
||||||
|
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "k_${k.eventId}_${k.artistId}") {
|
||||||
|
KonzertZeile(k) { onArtistClick(k.artistId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.releases.isEmpty() -> Hinweis("🌙",
|
||||||
|
"Nichts Neues in den letzten ${state.tage} Tagen", "")
|
||||||
|
else -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||||
|
if (state.ohneZuordnung.isNotEmpty()) {
|
||||||
|
item { ZuordnungsHinweis(state.ohneZuordnung) }
|
||||||
|
}
|
||||||
|
var letzterMonat = ""
|
||||||
|
state.releases.forEach { rel ->
|
||||||
|
val monat = monatVon(rel.releaseDate)
|
||||||
|
if (monat != letzterMonat) {
|
||||||
|
letzterMonat = monat
|
||||||
|
item(key = "m_$monat") {
|
||||||
|
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 13.sp, color = NsOnSurface2,
|
||||||
|
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "${rel.artistId}_${rel.albumId}") {
|
||||||
|
ReleaseZeile(
|
||||||
|
rel = rel,
|
||||||
|
schonDa = vm.hatSchon(rel),
|
||||||
|
onArtist = { onArtistClick(rel.artistId) },
|
||||||
|
onDownload = {
|
||||||
|
vm.herunterladen(rel) { _, msg ->
|
||||||
|
Toast.makeText(ctx, msg, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Hinweis(symbol: String, titel: String, text: String) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(symbol, fontSize = 34.sp)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(titel, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
if (text.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(text, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ZuordnungsHinweis(namen: List<String>) {
|
||||||
|
Box(
|
||||||
|
Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(NsAccent.copy(alpha = 0.08f))
|
||||||
|
.border(1.dp, NsAccent.copy(alpha = 0.28f), RoundedCornerShape(10.dp))
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"⚠ Für ${namen.size} Künstler wurde kein Deezer-Profil gefunden: " +
|
||||||
|
namen.take(4).joinToString(", ") + (if (namen.size > 4) " …" else "") +
|
||||||
|
". Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.",
|
||||||
|
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, lineHeight = 17.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TYP = mapOf("album" to "Album", "single" to "Single", "ep" to "EP",
|
||||||
|
"compile" to "Compilation")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReleaseZeile(
|
||||||
|
rel: ArtistRelease,
|
||||||
|
schonDa: Boolean,
|
||||||
|
onArtist: () -> Unit,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 7.dp),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = rel.cover.takeIf { it.isNotBlank() },
|
||||||
|
contentDescription = rel.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(rel.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(rel.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.clickable(onClick = onArtist))
|
||||||
|
Text(
|
||||||
|
" · " + (TYP[rel.recordType] ?: rel.recordType) +
|
||||||
|
(rel.trackCount?.let { " · $it Titel" } ?: ""),
|
||||||
|
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(datumVon(rel.releaseDate), fontFamily = Outfit, fontSize = 11.sp,
|
||||||
|
color = NsOnSurface3, modifier = Modifier.padding(top = 2.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
if (schonDa) {
|
||||||
|
Icon(Icons.Default.CheckCircle, "In deiner Bibliothek",
|
||||||
|
tint = NsOnSurface3, modifier = Modifier.size(20.dp))
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = onDownload) {
|
||||||
|
Icon(Icons.Default.Download, "Herunterladen", tint = NsAccent,
|
||||||
|
modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deezer liefert "JJJJ-MM-TT".
|
||||||
|
private val EIN = SimpleDateFormat("yyyy-MM-dd", Locale.GERMAN)
|
||||||
|
private val MONAT = SimpleDateFormat("LLLL yyyy", Locale.GERMAN)
|
||||||
|
private val DATUM = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN)
|
||||||
|
|
||||||
|
private fun monatVon(s: String) = runCatching { MONAT.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||||
|
private fun datumVon(s: String) = runCatching { DATUM.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||||
|
|
||||||
|
// Ticketmaster liefert die ORTSZEIT ohne Zone: "2026-11-04T20:00:00".
|
||||||
|
private val EIN_ZEIT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMAN)
|
||||||
|
private val TAG_KURZ = SimpleDateFormat("d", Locale.GERMAN)
|
||||||
|
private val MON_KURZ = SimpleDateFormat("MMM", Locale.GERMAN)
|
||||||
|
private val UHR = SimpleDateFormat("HH:mm", Locale.GERMAN)
|
||||||
|
|
||||||
|
private fun zeitVon(s: String) = runCatching { EIN_ZEIT.parse(s) }.getOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Konzertzeile. Statt eines Covers steht links ein Kalenderblatt — ein Konzert hat kein Bild,
|
||||||
|
* aber ein Datum, und danach sucht das Auge in einer Liste, die nach vorn läuft.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun KonzertZeile(k: ArtistKonzert, onArtist: () -> Unit) {
|
||||||
|
val uri = LocalUriHandler.current
|
||||||
|
val d = zeitVon(k.datum)
|
||||||
|
val ort = listOf(k.ort, k.stadt, k.land).filter { it.isNotBlank() }.joinToString(" · ")
|
||||||
|
val uhr = d?.let { UHR.format(it) }?.takeIf { it != "00:00" }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(50.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(NsSurface)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(10.dp))
|
||||||
|
.alpha(if (k.abgesagt) 0.45f else 1f),
|
||||||
|
) {
|
||||||
|
Text(d?.let { TAG_KURZ.format(it) } ?: "?", fontFamily = Outfit,
|
||||||
|
fontWeight = FontWeight.Bold, fontSize = 17.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Text(d?.let { MON_KURZ.format(it) }?.uppercase() ?: "", fontFamily = Outfit,
|
||||||
|
fontSize = 10.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
k.titel.ifBlank { k.artistName },
|
||||||
|
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||||
|
color = if (k.abgesagt) NsOnSurface2 else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
textDecoration = if (k.abgesagt) TextDecoration.LineThrough else null,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(k.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.clickable(onClick = onArtist))
|
||||||
|
val rest = listOfNotNull(ort.ifBlank { null }, uhr?.let { "$it Uhr" })
|
||||||
|
if (rest.isNotEmpty()) {
|
||||||
|
Text(" · " + rest.joinToString(" · "), fontFamily = Outfit, fontSize = 12.sp,
|
||||||
|
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (k.abgesagt) {
|
||||||
|
Text("abgesagt", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||||
|
} else if (k.url.isNotBlank()) {
|
||||||
|
TextButton(onClick = { uri.openUri(k.url) }) {
|
||||||
|
Text("Tickets", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 12.sp, color = NsAccent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
package com.novasonic.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.novasonic.app.api.model.CompareResult
|
||||||
|
import com.novasonic.app.api.model.SharedArtist
|
||||||
|
import com.novasonic.app.api.model.SharedTrack
|
||||||
|
import com.novasonic.app.api.model.UniqueArtist
|
||||||
|
import com.novasonic.app.ui.theme.*
|
||||||
|
import com.novasonic.app.viewmodel.CompareViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CompareScreen(
|
||||||
|
coverUrl: (String?) -> String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onArtistClick: (String) -> Unit = {},
|
||||||
|
onAlbumClick: (String) -> Unit = {},
|
||||||
|
vm: CompareViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||||
|
// ── Top bar ──
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Musik-Match",
|
||||||
|
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
|
||||||
|
color = NsOnSurface, modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
// Show "wechseln" once a partner is picked so you can go back to the list.
|
||||||
|
if (state.partner != null) {
|
||||||
|
Text(
|
||||||
|
"↺ Wechseln",
|
||||||
|
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||||
|
color = NsOnSurface2,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { vm.clearPartner() }
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
|
||||||
|
!state.configured -> CenterMsg(
|
||||||
|
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
|
||||||
|
)
|
||||||
|
state.error != null -> CenterMsg(state.error!!, NsError)
|
||||||
|
state.partner == null -> PartnerPicker(state.me, state.users, vm::choosePartner)
|
||||||
|
state.result != null -> CompareContent(state.result!!, coverUrl, onArtistClick, onAlbumClick)
|
||||||
|
else -> CenterBox { CircularProgressIndicator(color = NsAccent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PartnerPicker(
|
||||||
|
me: String,
|
||||||
|
users: List<com.novasonic.app.api.model.ScrobbleUser>,
|
||||||
|
onPick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
if (users.isEmpty()) {
|
||||||
|
CenterMsg("Noch niemand sonst hat Wiedergaben aufgezeichnet.\nSobald eine andere Person NovaSonic nutzt, könnt ihr euch hier vergleichen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Mit wem willst du dich vergleichen?",
|
||||||
|
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||||
|
modifier = Modifier.padding(vertical = 6.dp),
|
||||||
|
)
|
||||||
|
users.forEach { u ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.padding(vertical = 5.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(NsSurface2)
|
||||||
|
.clickable { onPick(u.user) }
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(40.dp).clip(CircleShape).background(NsAccent),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
(u.user.firstOrNull()?.uppercase() ?: "?"),
|
||||||
|
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Color.Black,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(u.user, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||||
|
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text("${u.plays} Plays", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
Text("›", fontFamily = Outfit, fontSize = 20.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompareContent(
|
||||||
|
d: CompareResult,
|
||||||
|
coverUrl: (String?) -> String?,
|
||||||
|
onArtistClick: (String) -> Unit,
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
Scoreboard(d)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Totals(d)
|
||||||
|
Spacer(Modifier.height(22.dp))
|
||||||
|
|
||||||
|
SectionHeader("Gemeinsame Künstler")
|
||||||
|
if (d.sharedArtists.isEmpty()) {
|
||||||
|
EmptyLine("Keine gemeinsamen Künstler in diesem Zeitraum.")
|
||||||
|
} else {
|
||||||
|
val max = (d.sharedArtists.maxOfOrNull { maxOf(it.playsA, it.playsB) } ?: 1).coerceAtLeast(1)
|
||||||
|
d.sharedArtists.take(15).forEach { a ->
|
||||||
|
SharedArtistRow(a, max, a.artistId?.let { id -> { onArtistClick(id) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(22.dp))
|
||||||
|
|
||||||
|
if (d.sharedTracks.isNotEmpty()) {
|
||||||
|
SectionHeader("Gemeinsame Songs")
|
||||||
|
d.sharedTracks.take(12).forEach { t ->
|
||||||
|
SharedTrackRow(t, coverUrl(t.coverArt ?: t.albumId ?: t.songId),
|
||||||
|
t.albumId?.let { id -> { onAlbumClick(id) } })
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(22.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Streitpunkte(d, onArtistClick)
|
||||||
|
|
||||||
|
SectionHeader("Nur bei…")
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||||
|
UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f))
|
||||||
|
UniqueColumn("Nur ${d.userB}", d.onlyB, onArtistClick, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Streitpunkte(d: CompareResult, onArtistClick: (String) -> Unit) {
|
||||||
|
// Gemeinsame Kuenstler mit dem groessten Ungleichgewicht — das Gegenstueck zu "Gemeinsame
|
||||||
|
// Kuenstler", das nach Summe sortiert. Mindestmasse, damit ein einzelner Play gegen null nicht
|
||||||
|
// als Streit gilt. Identische Schwellen wie im Web, damit beide dasselbe zeigen.
|
||||||
|
val items = d.sharedArtists
|
||||||
|
.map { a ->
|
||||||
|
val summe = a.playsA + a.playsB
|
||||||
|
Triple(a, summe, if (summe > 0) kotlin.math.abs(a.playsA - a.playsB) / summe.toFloat() else 0f)
|
||||||
|
}
|
||||||
|
.filter { (_, summe, unwucht) -> summe >= 4 && unwucht >= 0.5f }
|
||||||
|
.sortedWith(compareByDescending<Triple<SharedArtist, Int, Float>> { it.third }.thenByDescending { it.second })
|
||||||
|
.take(8)
|
||||||
|
|
||||||
|
SectionHeader("Streitpunkte")
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
Text("Ihr hört gemeinsame Künstler erstaunlich ausgewogen.",
|
||||||
|
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.forEach { (a, summe, _) ->
|
||||||
|
val anteilA = a.playsA / summe.toFloat()
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.clickable { a.artistId?.let(onArtistClick) }
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("${a.playsA}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
|
fontWeight = FontWeight.Bold)
|
||||||
|
Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.weight(1f).padding(horizontal = 8.dp))
|
||||||
|
Text("${a.playsB}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
|
fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp))) {
|
||||||
|
Box(Modifier.weight(anteilA.coerceAtLeast(0.001f)).fillMaxHeight().background(NsAccent))
|
||||||
|
Box(Modifier.weight((1f - anteilA).coerceAtLeast(0.001f)).fillMaxHeight()
|
||||||
|
.background(NsAccent.copy(alpha = .35f)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Scoreboard(d: CompareResult) {
|
||||||
|
val b = d.battle
|
||||||
|
if (b == null || b.kategorien.isEmpty()) return
|
||||||
|
val urteil = when {
|
||||||
|
b.punkteA == b.punkteB -> "Kopf-an-Kopf — kein Sieger."
|
||||||
|
b.punkteA > b.punkteB -> "${d.userA} gewinnt ${b.punkteA}:${b.punkteB}."
|
||||||
|
else -> "${d.userB} gewinnt ${b.punkteB}:${b.punkteA}."
|
||||||
|
}
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(d.userA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||||
|
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
|
||||||
|
Text(" ${b.punkteA} : ${b.punkteB} ", fontFamily = Outfit, fontWeight = FontWeight.Black,
|
||||||
|
fontSize = 30.sp, color = NsOnSurface)
|
||||||
|
Text(d.userB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||||
|
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
|
||||||
|
}
|
||||||
|
Text(urteil, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp),
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
|
||||||
|
// Eine Zeile je Kategorie: Wert · Bezeichnung · Wert. Der Sieger wird farblich
|
||||||
|
// hervorgehoben, der Verlierer zurueckgenommen — identisch zum Web.
|
||||||
|
b.kategorien.forEach { k ->
|
||||||
|
val gewinnA = k.sieger == "A"
|
||||||
|
val gewinnB = k.sieger == "B"
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(vertical = 5.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(k.textA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||||
|
color = if (gewinnA) NsAccent else if (gewinnB) NsOnSurface3 else NsOnSurface,
|
||||||
|
textAlign = TextAlign.End, modifier = Modifier.weight(1f))
|
||||||
|
Text(k.label.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 11.sp, color = NsOnSurface2, textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1, modifier = Modifier.weight(1.3f))
|
||||||
|
Text(k.textB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||||
|
color = if (gewinnB) NsAccent else if (gewinnA) NsOnSurface3 else NsOnSurface,
|
||||||
|
modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
private fun Totals(d: CompareResult) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
TotalCard(d.userA, d.totalsA.plays, d.totalsA.artists, Modifier.weight(1f))
|
||||||
|
TotalCard(d.userB, d.totalsB.plays, d.totalsB.artists, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TotalCard(name: String, plays: Int, artists: Int, modifier: Modifier) {
|
||||||
|
Column(modifier.clip(RoundedCornerShape(12.dp)).background(NsSurface2).padding(14.dp)) {
|
||||||
|
Text(name, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||||
|
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text("$plays Plays · $artists Künstler", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SharedArtistRow(a: SharedArtist, max: Int, onClick: (() -> Unit)?) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(vertical = 7.dp),
|
||||||
|
) {
|
||||||
|
Text("${a.playsA}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||||
|
color = NsOnSurface2, textAlign = TextAlign.End, modifier = Modifier.width(30.dp))
|
||||||
|
// left (A) bar grows toward the centre
|
||||||
|
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||||
|
Box(Modifier.fillMaxWidth(a.playsA.toFloat() / max).height(6.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp)).background(NsAccent))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
a.name ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||||
|
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.weight(1.6f).padding(horizontal = 6.dp),
|
||||||
|
)
|
||||||
|
// right (B) bar grows from the centre
|
||||||
|
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
|
||||||
|
Box(Modifier.fillMaxWidth(a.playsB.toFloat() / max).height(6.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp)).background(NsAccent.copy(alpha = 0.55f)))
|
||||||
|
}
|
||||||
|
Text("${a.playsB}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||||
|
color = NsOnSurface2, textAlign = TextAlign.Start, modifier = Modifier.width(30.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SharedTrackRow(t: SharedTrack, cover: String?, onClick: (() -> Unit)?) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(vertical = 5.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2)) {
|
||||||
|
AsyncImage(model = cover, contentDescription = null, contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(t.title ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||||
|
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (!t.artist.isNullOrBlank()) {
|
||||||
|
Text(t.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("${t.playsA} · ${t.playsB}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 13.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UniqueColumn(title: String, items: List<UniqueArtist>, onArtistClick: (String) -> Unit, modifier: Modifier) {
|
||||||
|
Column(modifier) {
|
||||||
|
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||||
|
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(bottom = 6.dp))
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
Text("—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
} else {
|
||||||
|
items.take(6).forEach { a ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.then(if (a.artistId != null) Modifier.clickable { onArtistClick(a.artistId) } else Modifier)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||||
|
Text("${a.plays}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 12.sp,
|
||||||
|
color = NsOnSurface2, modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(t: String) {
|
||||||
|
Text(t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||||
|
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyLine(msg: String) {
|
||||||
|
Text(msg, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenterMsg(msg: String, color: Color = NsOnSurface2) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.novasonic.app.ui.screen
|
package com.novasonic.app.ui.screen
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -49,6 +50,8 @@ fun ExploreScreen(
|
|||||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
||||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||||
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
||||||
|
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
|
||||||
|
onOverlayBackChange: ((() -> Unit)?) -> Unit = {},
|
||||||
onOpenImporter: () -> Unit = {},
|
onOpenImporter: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val vm: ExploreViewModel = viewModel()
|
val vm: ExploreViewModel = viewModel()
|
||||||
@@ -56,10 +59,31 @@ fun ExploreScreen(
|
|||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
// BackHandlers for state-based navigation (must be unconditional)
|
// Overlay back is handled at the app root (see AppNavigation.exploreOverlayBack): a BackHandler
|
||||||
BackHandler(enabled = state.artistDetail != null && state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
// inside this NavHost destination loses back priority to the NavHost, which pops Erkunden → Home.
|
||||||
BackHandler(enabled = state.artistDetail != null && state.albumTracks != null && state.partialAlbum == null) { vm.dismissAlbum() }
|
// Report the current overlay's close action up; clear it when this screen leaves composition
|
||||||
BackHandler(enabled = state.artistDetail != null && state.albumTracks == null && state.partialAlbum == null) { vm.closeArtistDetail() }
|
// (e.g. drilling into a real album route) so that screen's own back works normally.
|
||||||
|
val overlayKind = when {
|
||||||
|
state.artistDetail != null && state.partialAlbum != null -> 5 // artist → partial album
|
||||||
|
state.artistDetail != null && state.albumTracks != null -> 4 // artist → iTunes album
|
||||||
|
state.artistDetail != null && state.artistAllAlbums != null -> 3 // artist → all-albums grid
|
||||||
|
state.artistDetail != null -> 2 // iTunes artist
|
||||||
|
state.albumTracks != null -> 1 // album straight from search
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
DisposableEffect(overlayKind) {
|
||||||
|
onOverlayBackChange(
|
||||||
|
when (overlayKind) {
|
||||||
|
5 -> vm::dismissPartialAlbum
|
||||||
|
4 -> vm::dismissAlbum
|
||||||
|
3 -> vm::closeArtistAllAlbums
|
||||||
|
2 -> vm::closeArtistDetail
|
||||||
|
1 -> vm::dismissAlbum
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
onDispose { onOverlayBackChange(null) }
|
||||||
|
}
|
||||||
|
|
||||||
// Download sheet
|
// Download sheet
|
||||||
state.downloadSheetItem?.let { item ->
|
state.downloadSheetItem?.let { item ->
|
||||||
@@ -91,6 +115,7 @@ fun ExploreScreen(
|
|||||||
onPreview = vm::togglePreview,
|
onPreview = vm::togglePreview,
|
||||||
onDownload = vm::showDownloadSheet,
|
onDownload = vm::showDownloadSheet,
|
||||||
onBack = vm::dismissPartialAlbum,
|
onBack = vm::dismissPartialAlbum,
|
||||||
|
onShowContextMenu = onShowContextMenu,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
albumTracks != null && albumItem != null -> {
|
albumTracks != null && albumItem != null -> {
|
||||||
@@ -101,6 +126,7 @@ fun ExploreScreen(
|
|||||||
previewingId = state.previewingId,
|
previewingId = state.previewingId,
|
||||||
onPreview = vm::togglePreview,
|
onPreview = vm::togglePreview,
|
||||||
onDownload = vm::showDownloadSheet,
|
onDownload = vm::showDownloadSheet,
|
||||||
|
onArtistClick = vm::openArtistOfAlbum,
|
||||||
onBack = vm::dismissAlbum,
|
onBack = vm::dismissAlbum,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -117,28 +143,47 @@ fun ExploreScreen(
|
|||||||
onItunesAlbumOpen = vm::loadAlbum,
|
onItunesAlbumOpen = vm::loadAlbum,
|
||||||
onItunesTrackPreview = vm::togglePreview,
|
onItunesTrackPreview = vm::togglePreview,
|
||||||
previewingId = state.previewingId,
|
previewingId = state.previewingId,
|
||||||
|
allAlbumsOpen = state.artistAllAlbums,
|
||||||
|
onOpenAllAlbums = vm::openArtistAllAlbums,
|
||||||
|
onCloseAllAlbums = vm::closeArtistAllAlbums,
|
||||||
|
allAlbumsGrid = vm.allAlbumsGrid,
|
||||||
|
allAlbumsQuery = vm.allAlbumsQuery.value,
|
||||||
|
onAllAlbumsQueryChange = { vm.allAlbumsQuery.value = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swipe left/right to switch between the Bibliothek and Erkunden sub-tabs, kept in sync with the
|
||||||
|
// TabRow: a chip tap animates the pager; a settled swipe updates the selected tab.
|
||||||
|
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { ExploreTab.values().size }
|
||||||
|
LaunchedEffect(state.tab) {
|
||||||
|
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
|
||||||
|
}
|
||||||
|
LaunchedEffect(pagerState.settledPage) {
|
||||||
|
val t = ExploreTab.values()[pagerState.settledPage]
|
||||||
|
if (t != state.tab) { vm.selectTab(t); vm.stopPreview() }
|
||||||
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
// ── Tabs ─────────────────────────────────────────────────────────────
|
// ── Tabs ─────────────────────────────────────────────────────────────
|
||||||
|
// Drive the indicator off the pager's current page so it moves WITH the swipe (at the
|
||||||
|
// half-way point), not only once the swipe settles — that lag read as "not smooth".
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = state.tab.ordinal,
|
selectedTabIndex = pagerState.currentPage,
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
contentColor = NsAccent,
|
contentColor = NsAccent,
|
||||||
) {
|
) {
|
||||||
Tab(
|
Tab(
|
||||||
selected = state.tab == ExploreTab.LIBRARY,
|
selected = pagerState.currentPage == ExploreTab.LIBRARY.ordinal,
|
||||||
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
|
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
|
||||||
text = { Text("Bibliothek", maxLines = 1) },
|
text = { Text("Bibliothek", maxLines = 1) },
|
||||||
selectedContentColor = NsAccent,
|
selectedContentColor = NsAccent,
|
||||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Tab(
|
Tab(
|
||||||
selected = state.tab == ExploreTab.GLOBAL,
|
selected = pagerState.currentPage == ExploreTab.GLOBAL.ordinal,
|
||||||
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
|
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
|
||||||
text = { Text("Erkunden", maxLines = 1) },
|
text = { Text("Erkunden", maxLines = 1) },
|
||||||
selectedContentColor = NsAccent,
|
selectedContentColor = NsAccent,
|
||||||
@@ -205,25 +250,38 @@ fun ExploreScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Content ──────────────────────────────────────────────────────────
|
// ── Content (swipeable) ────────────────────────────────────────────────
|
||||||
when (state.tab) {
|
HorizontalPager(
|
||||||
ExploreTab.LIBRARY -> LibrarySearchContent(
|
state = pagerState,
|
||||||
query = state.query,
|
// Pre-compose the neighbour page so the swipe doesn't hitch while its (heavy) LazyColumn
|
||||||
coverUrl = coverUrl,
|
// builds on first drag.
|
||||||
playerState = playerState,
|
beyondViewportPageCount = 1,
|
||||||
onSongPlay = onSongPlay,
|
modifier = Modifier.weight(1f),
|
||||||
onAlbumClick = onAlbumClick,
|
) { page ->
|
||||||
onArtistClick = onArtistClick,
|
// fillMaxSize so short content (e.g. the Top-Alben charts row) sits at the top, not
|
||||||
)
|
// vertically centered in the page.
|
||||||
ExploreTab.GLOBAL -> GlobalExploreContent(
|
Box(Modifier.fillMaxSize()) {
|
||||||
state = state,
|
when (page) {
|
||||||
vm = vm,
|
ExploreTab.LIBRARY.ordinal -> LibrarySearchContent(
|
||||||
coverUrl = coverUrl,
|
query = state.query,
|
||||||
playerState = playerState,
|
coverUrl = coverUrl,
|
||||||
onAlbumClick = onAlbumClick,
|
playerState = playerState,
|
||||||
onSongPlay = onSongPlay,
|
onSongPlay = onSongPlay,
|
||||||
onDownload = vm::showDownloadSheet,
|
onAlbumClick = onAlbumClick,
|
||||||
)
|
onArtistClick = onArtistClick,
|
||||||
|
onShowContextMenu = onShowContextMenu,
|
||||||
|
)
|
||||||
|
else -> GlobalExploreContent(
|
||||||
|
state = state,
|
||||||
|
vm = vm,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
playerState = playerState,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
onSongPlay = onSongPlay,
|
||||||
|
onDownload = vm::showDownloadSheet,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +296,7 @@ private fun LibrarySearchContent(
|
|||||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
||||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||||
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
||||||
|
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
// Reuse SearchScreen's ViewModel
|
// Reuse SearchScreen's ViewModel
|
||||||
val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel()
|
val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel()
|
||||||
@@ -262,7 +321,7 @@ private fun LibrarySearchContent(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn {
|
LazyColumn(state = vm.listState) {
|
||||||
val artists = state.searchResult?.artist ?: emptyList()
|
val artists = state.searchResult?.artist ?: emptyList()
|
||||||
val albums = state.searchResult?.album ?: emptyList()
|
val albums = state.searchResult?.album ?: emptyList()
|
||||||
val songs = state.searchResult?.song ?: emptyList()
|
val songs = state.searchResult?.song ?: emptyList()
|
||||||
@@ -283,7 +342,11 @@ private fun LibrarySearchContent(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
items(artists, key = { it.id }) { a ->
|
items(artists, key = { it.id }) { a ->
|
||||||
ArtistCard(name = a.name, imageUrl = coverUrl(a.coverArt)) { onArtistClick(a) }
|
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image
|
||||||
|
// is the wrong person). Otherwise: search3 usually omits coverArt → fall back
|
||||||
|
// to the artist id, which Navidrome's getCoverArt resolves to the artist image.
|
||||||
|
val pinImg = state.overrides[a.id]?.image?.takeIf { it.isNotBlank() }
|
||||||
|
ArtistCard(name = a.name, imageUrl = pinImg ?: coverUrl(a.coverArt ?: a.id)) { onArtistClick(a) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -313,7 +376,10 @@ private fun LibrarySearchContent(
|
|||||||
song = song,
|
song = song,
|
||||||
coverUrl = coverUrl(song.coverArt),
|
coverUrl = coverUrl(song.coverArt),
|
||||||
isPlaying = playerState.currentSong?.id == song.id,
|
isPlaying = playerState.currentSong?.id == song.id,
|
||||||
onPlay = { onSongPlay(songs, idx) },
|
starred = song.starred != null,
|
||||||
|
// Play only the tapped song; Radio mode (if on) fills the rest.
|
||||||
|
onPlay = { onSongPlay(listOf(song), 0) },
|
||||||
|
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,6 +408,7 @@ private fun GlobalExploreContent(
|
|||||||
previewingId = state.previewingId,
|
previewingId = state.previewingId,
|
||||||
onPreview = vm::togglePreview,
|
onPreview = vm::togglePreview,
|
||||||
onDownload = onDownload,
|
onDownload = onDownload,
|
||||||
|
onArtistClick = vm::openArtistOfAlbum,
|
||||||
onBack = vm::dismissAlbum,
|
onBack = vm::dismissAlbum,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -399,7 +466,7 @@ private fun GlobalExploreContent(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn {
|
LazyColumn(state = vm.searchListState) {
|
||||||
if (state.error != null) {
|
if (state.error != null) {
|
||||||
item { Text(state.error, color = MaterialTheme.colorScheme.error,
|
item { Text(state.error, color = MaterialTheme.colorScheme.error,
|
||||||
modifier = Modifier.padding(16.dp)) }
|
modifier = Modifier.padding(16.dp)) }
|
||||||
@@ -415,6 +482,7 @@ private fun GlobalExploreContent(
|
|||||||
ArtistCard(
|
ArtistCard(
|
||||||
name = a.artistName ?: "",
|
name = a.artistName ?: "",
|
||||||
imageUrl = a.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
imageUrl = a.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
||||||
|
subtitle = a.fanCount?.takeIf { it > 0 }?.let { "${fmtFanCount(it)} Fans" },
|
||||||
) { vm.openArtistDetail(a) }
|
) { vm.openArtistDetail(a) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,6 +526,7 @@ internal fun ItunesAlbumDetail(
|
|||||||
previewingId: Long?,
|
previewingId: Long?,
|
||||||
onPreview: (ItunesItem) -> Unit,
|
onPreview: (ItunesItem) -> Unit,
|
||||||
onDownload: (ItunesItem) -> Unit = {},
|
onDownload: (ItunesItem) -> Unit = {},
|
||||||
|
onArtistClick: ((ItunesItem) -> Unit)? = null,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
@@ -469,9 +538,25 @@ internal fun ItunesAlbumDetail(
|
|||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||||
tint = MaterialTheme.colorScheme.onSurface)
|
tint = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
// Anklickbar nur, wenn Deezer eine Künstler-id mitgeliefert hat — /artist/{id}/albums
|
||||||
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
// lässt das artist-Objekt weg, dort bleibt der Name einfach Text.
|
||||||
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
|
val artistName = albumItem?.artistName
|
||||||
|
val artistTippbar = onArtistClick != null && albumItem?.artistId != null &&
|
||||||
|
!artistName.isNullOrBlank()
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).then(
|
||||||
|
if (artistTippbar) Modifier.clickable { onArtistClick!!(albumItem!!) } else Modifier
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
if (!artistName.isNullOrBlank()) {
|
||||||
|
Text(artistName, fontFamily = Outfit, fontSize = 12.sp,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (artistTippbar) NsAccent else NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (albumItem != null) {
|
if (albumItem != null) {
|
||||||
IconButton(onClick = { onDownload(albumItem) }) {
|
IconButton(onClick = { onDownload(albumItem) }) {
|
||||||
Icon(Icons.Default.Download, "Album herunterladen",
|
Icon(Icons.Default.Download, "Album herunterladen",
|
||||||
@@ -590,28 +675,47 @@ private fun ItunesTrackRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistCard(name: String, imageUrl: String?, onClick: () -> Unit) {
|
private fun ArtistCard(name: String, imageUrl: String?, subtitle: String? = null, onClick: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(80.dp)
|
.width(80.dp)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
Box(
|
||||||
model = imageUrl,
|
contentAlignment = Alignment.Center,
|
||||||
contentDescription = name,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(72.dp)
|
.size(72.dp)
|
||||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||||
.background(NsSurface2),
|
.background(NsSurface2),
|
||||||
)
|
) {
|
||||||
|
if (imageUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = name,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Person, contentDescription = null,
|
||||||
|
tint = NsOnSurface2, modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
name, fontFamily = Outfit, fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
name, fontFamily = Outfit, fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||||
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
subtitle, fontFamily = Outfit, fontSize = 10.sp, fontWeight = FontWeight.Medium,
|
||||||
|
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
@@ -18,6 +19,7 @@ import com.novasonic.app.app
|
|||||||
import com.novasonic.app.player.PlayerState
|
import com.novasonic.app.player.PlayerState
|
||||||
import com.novasonic.app.ui.component.SongRow
|
import com.novasonic.app.ui.component.SongRow
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
|
import com.novasonic.app.ui.component.Rollbalken
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GenreSongsScreen(
|
fun GenreSongsScreen(
|
||||||
@@ -77,19 +79,29 @@ fun GenreSongsScreen(
|
|||||||
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||||
Text("Keine Songs gefunden", color = NsOnSurface2)
|
Text("Keine Songs gefunden", color = NsOnSurface2)
|
||||||
}
|
}
|
||||||
else -> LazyColumn {
|
else -> {
|
||||||
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
|
val listState = rememberLazyListState()
|
||||||
SongRow(
|
Box(Modifier.fillMaxSize()) {
|
||||||
song = song,
|
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||||
coverUrl = coverUrl(song.coverArt),
|
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
|
||||||
isPlaying = playerState.currentSong?.id == song.id,
|
SongRow(
|
||||||
starred = song.starred != null,
|
song = song,
|
||||||
onPlay = { onPlay(songs, idx) },
|
coverUrl = coverUrl(song.coverArt),
|
||||||
onStarToggle = { onStarToggle(song) },
|
isPlaying = playerState.currentSong?.id == song.id,
|
||||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
starred = song.starred != null,
|
||||||
|
onPlay = { onPlay(songs, idx) },
|
||||||
|
onStarToggle = { onStarToggle(song) },
|
||||||
|
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(8.dp)) }
|
||||||
|
}
|
||||||
|
Rollbalken(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||||
|
farbe = NsOnSurface2,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(8.dp)) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import androidx.compose.foundation.lazy.LazyRow
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -33,6 +32,8 @@ import com.novasonic.app.data.NotificationStore
|
|||||||
import com.novasonic.app.ui.component.AlbumCard
|
import com.novasonic.app.ui.component.AlbumCard
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
import com.novasonic.app.viewmodel.HomeViewModel
|
import com.novasonic.app.viewmodel.HomeViewModel
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import com.novasonic.app.R
|
||||||
|
|
||||||
private val greetingsWithName = listOf(
|
private val greetingsWithName = listOf(
|
||||||
"Ready for some beats,",
|
"Ready for some beats,",
|
||||||
@@ -120,7 +121,7 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onBellClick) {
|
IconButton(onClick = onBellClick) {
|
||||||
Icon(Icons.Default.Notifications, "Benachrichtigungen",
|
Icon(painterResource(R.drawable.ic_honigtopf_brief), "Benachrichtigungen",
|
||||||
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
|
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.PlayCircle
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -180,6 +182,38 @@ fun ImporterScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Playlist aus dem Import anlegen — wie im Web-Importer. Nur ab zwei Titeln sinnvoll;
|
||||||
|
// ein einzelner Track ergibt keine Playlist.
|
||||||
|
if (r.tracks.size > 1) {
|
||||||
|
item {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(bottom = 10.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(
|
||||||
|
checked = state.createPlaylist,
|
||||||
|
onCheckedChange = vm::setCreatePlaylist,
|
||||||
|
colors = CheckboxDefaults.colors(checkedColor = brand,
|
||||||
|
checkmarkColor = Color.Black),
|
||||||
|
)
|
||||||
|
Text("Playlist daraus anlegen", fontFamily = Outfit, fontSize = 14.sp,
|
||||||
|
color = NsOnSurface)
|
||||||
|
}
|
||||||
|
if (state.createPlaylist) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.playlistName.ifBlank { r.name ?: "" },
|
||||||
|
onValueChange = vm::setPlaylistName,
|
||||||
|
label = { Text("Playlist-Name", fontFamily = Outfit) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.playlistStatus?.let {
|
||||||
|
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
|
modifier = Modifier.padding(top = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Download button / job progress
|
// Download button / job progress
|
||||||
item {
|
item {
|
||||||
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
|
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
|
||||||
@@ -205,7 +239,12 @@ fun ImporterScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
itemsIndexed(r.tracks) { idx, track ->
|
itemsIndexed(r.tracks) { idx, track ->
|
||||||
ImportTrackRow(idx + 1, track)
|
ImportTrackRow(
|
||||||
|
num = idx + 1,
|
||||||
|
track = track,
|
||||||
|
isPreviewing = state.previewingId != null && state.previewingId == vm.previewId(track),
|
||||||
|
onPreview = { vm.togglePreview(track) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +297,12 @@ private fun JobProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
private fun ImportTrackRow(
|
||||||
|
num: Int,
|
||||||
|
track: ImportTrack,
|
||||||
|
isPreviewing: Boolean,
|
||||||
|
onPreview: () -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
@@ -272,11 +316,22 @@ private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
|||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
fontSize = 14.sp, color = if (isPreviewing) NsAccent else NsOnSurface,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
if (!track.artistName.isNullOrBlank()) {
|
if (!track.artistName.isNullOrBlank()) {
|
||||||
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 30s preview — only when the source provided a clip
|
||||||
|
if (track.previewUrl != null) {
|
||||||
|
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(
|
||||||
|
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
|
||||||
|
if (isPreviewing) "Stop" else "Preview",
|
||||||
|
tint = if (isPreviewing) NsAccent else NsOnSurface2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,12 +34,15 @@ import coil.compose.AsyncImage
|
|||||||
import com.novasonic.app.api.model.Album
|
import com.novasonic.app.api.model.Album
|
||||||
import com.novasonic.app.api.model.ItunesItem
|
import com.novasonic.app.api.model.ItunesItem
|
||||||
import com.novasonic.app.api.model.Song
|
import com.novasonic.app.api.model.Song
|
||||||
|
import com.novasonic.app.api.model.fmtFanCount
|
||||||
import com.novasonic.app.player.PlayerState
|
import com.novasonic.app.player.PlayerState
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
import com.novasonic.app.viewmodel.ItunesArtistState
|
import com.novasonic.app.viewmodel.ItunesArtistState
|
||||||
|
|
||||||
private fun normAlbum(s: String?): String =
|
private fun normAlbum(s: String?): String =
|
||||||
(s ?: "").lowercase().replace(Regex("[^a-z0-9\\s]"), " ").replace(Regex("\\s+"), " ").trim()
|
// Keep Unicode letters/numbers (\p{L}\p{N}) — stripping to a-z0-9 emptied every Japanese/CJK
|
||||||
|
// title, so all of an artist's JP singles collapsed into one and "matched" each other.
|
||||||
|
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}\\s]"), " ").replace(Regex("\\s+"), " ").trim()
|
||||||
|
|
||||||
data class ArtistAlbumCategories(
|
data class ArtistAlbumCategories(
|
||||||
val downloaded: List<Pair<Album, ItunesItem?>>,
|
val downloaded: List<Pair<Album, ItunesItem?>>,
|
||||||
@@ -57,6 +60,9 @@ fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAl
|
|||||||
|
|
||||||
for (ia in uniqueIAlbums) {
|
for (ia in uniqueIAlbums) {
|
||||||
val iName = normAlbum(ia.collectionName)
|
val iName = normAlbum(ia.collectionName)
|
||||||
|
// Deezer's /artist/{id}/albums omits nb_tracks, so trackCount is usually unknown (0) here.
|
||||||
|
// Only mark "Unvollständig" when we KNOW the album has more tracks than we own — an unknown
|
||||||
|
// total counts as complete, never a false "1 / 0".
|
||||||
val iCount = ia.trackCount ?: 0
|
val iCount = ia.trackCount ?: 0
|
||||||
val match = ndAlbums.find { a ->
|
val match = ndAlbums.find { a ->
|
||||||
val n = normAlbum(a.name)
|
val n = normAlbum(a.name)
|
||||||
@@ -64,11 +70,15 @@ fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAl
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
match == null -> notDl.add(ia)
|
match == null -> notDl.add(ia)
|
||||||
iCount > 0 && match.songCount >= iCount -> {
|
// Deezer returns the same album as several variants (single/EP/album/regional); distinctBy
|
||||||
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
|
// above only catches identical ids, so also skip an owned album we've already classified —
|
||||||
|
// otherwise it shows 2-3x ("PIXELATED KISSES").
|
||||||
|
match.id in usedNdIds -> Unit
|
||||||
|
iCount > match.songCount -> {
|
||||||
|
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
|
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,21 +106,50 @@ fun ItunesArtistScreen(
|
|||||||
onItunesAlbumOpen: (ItunesItem) -> Unit,
|
onItunesAlbumOpen: (ItunesItem) -> Unit,
|
||||||
onItunesTrackPreview: (ItunesItem) -> Unit,
|
onItunesTrackPreview: (ItunesItem) -> Unit,
|
||||||
previewingId: Long?,
|
previewingId: Long?,
|
||||||
|
allAlbumsOpen: String?,
|
||||||
|
onOpenAllAlbums: (String) -> Unit,
|
||||||
|
onCloseAllAlbums: () -> Unit,
|
||||||
|
allAlbumsGrid: androidx.compose.foundation.lazy.grid.LazyGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState(),
|
||||||
|
allAlbumsQuery: String = "",
|
||||||
|
onAllAlbumsQueryChange: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val artist = artistState.artist
|
val artist = artistState.artist
|
||||||
val cats = if (!artistState.isLoading)
|
val cats = if (!artistState.isLoading)
|
||||||
categorizeAlbums(artistState.ndAlbums, artistState.iAlbums)
|
categorizeAlbums(artistState.ndAlbums, artistState.iAlbums)
|
||||||
else null
|
else null
|
||||||
|
|
||||||
var showAllAlbums by remember { mutableStateOf(false) }
|
// "All albums" state is hoisted (ExploreViewModel.artistAllAlbums) so it survives an album
|
||||||
var allAlbumsTitle by remember { mutableStateOf("") }
|
// drill-down and back; entries are re-derived from cats for whichever section is open.
|
||||||
var allAlbumEntries by remember { mutableStateOf<List<AllAlbumEntry>>(emptyList()) }
|
val allAlbumEntries: List<AllAlbumEntry> = when (allAlbumsOpen) {
|
||||||
|
"In Library" -> cats?.downloaded?.map { (nd, _) ->
|
||||||
|
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
|
||||||
|
}.orEmpty()
|
||||||
|
"Unvollständig" -> cats?.partial?.map { (nd, ia, counts) ->
|
||||||
|
AllAlbumEntry(
|
||||||
|
name = nd.name, year = nd.year?.toString(),
|
||||||
|
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
|
||||||
|
badge = "${counts.first}/${counts.second}",
|
||||||
|
onClick = { onPartialAlbumOpen(nd, ia) }, onDownload = { onDownload(ia) },
|
||||||
|
)
|
||||||
|
}.orEmpty()
|
||||||
|
"Nicht heruntergeladen" -> cats?.notDownloaded?.map { ia ->
|
||||||
|
AllAlbumEntry(
|
||||||
|
name = ia.collectionName ?: "—", year = ia.releaseDate?.take(4),
|
||||||
|
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"), dimmed = true,
|
||||||
|
onClick = { onItunesAlbumOpen(ia) }, onDownload = { onDownload(ia) },
|
||||||
|
)
|
||||||
|
}.orEmpty()
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
if (showAllAlbums) {
|
if (allAlbumsOpen != null) {
|
||||||
AllAlbumsOverlay(
|
AllAlbumsOverlay(
|
||||||
title = allAlbumsTitle,
|
title = allAlbumsOpen,
|
||||||
entries = allAlbumEntries,
|
entries = allAlbumEntries,
|
||||||
onBack = { showAllAlbums = false },
|
onBack = onCloseAllAlbums,
|
||||||
|
gridState = allAlbumsGrid,
|
||||||
|
query = allAlbumsQuery,
|
||||||
|
onQueryChange = onAllAlbumsQueryChange,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -162,6 +201,10 @@ fun ItunesArtistScreen(
|
|||||||
Text(artist.primaryGenreName!!, fontFamily = Outfit,
|
Text(artist.primaryGenreName!!, fontFamily = Outfit,
|
||||||
fontSize = 13.sp, color = Color.White.copy(0.6f))
|
fontSize = 13.sp, color = Color.White.copy(0.6f))
|
||||||
}
|
}
|
||||||
|
artist.fanCount?.takeIf { it > 0 }?.let {
|
||||||
|
Text("${fmtFanCount(it)} Fans", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 12.sp, color = Color.White.copy(0.55f))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +225,7 @@ fun ItunesArtistScreen(
|
|||||||
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||||
}
|
}
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { onSongPlay(artistState.topSongs.shuffled(), 0) },
|
onClick = { onSongPlay(artistState.topSongs, -1) },
|
||||||
modifier = Modifier.weight(1f).height(42.dp),
|
modifier = Modifier.weight(1f).height(42.dp),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true)
|
border = ButtonDefaults.outlinedButtonBorder(enabled = true)
|
||||||
@@ -208,7 +251,7 @@ fun ItunesArtistScreen(
|
|||||||
|
|
||||||
// iTunes top tracks
|
// iTunes top tracks
|
||||||
if (artistState.iTracks.isNotEmpty()) {
|
if (artistState.iTracks.isNotEmpty()) {
|
||||||
item { ArtistSectionHeader("Weltweit populär (iTunes)") }
|
item { ArtistSectionHeader("Weltweit populär (Deezer)") }
|
||||||
itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
|
itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
|
||||||
ItunesArtistTrackRow(
|
ItunesArtistTrackRow(
|
||||||
track = track,
|
track = track,
|
||||||
@@ -224,13 +267,7 @@ fun ItunesArtistScreen(
|
|||||||
if (c.downloaded.isNotEmpty()) {
|
if (c.downloaded.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
ArtistSectionHeader("In Library", c.downloaded.size,
|
ArtistSectionHeader("In Library", c.downloaded.size,
|
||||||
onShowAll = if (c.downloaded.size > 4) ({
|
onShowAll = if (c.downloaded.size > 4) ({ onOpenAllAlbums("In Library") }) else null
|
||||||
allAlbumsTitle = "In Library"
|
|
||||||
allAlbumEntries = c.downloaded.map { (nd, _) ->
|
|
||||||
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
|
|
||||||
}
|
|
||||||
showAllAlbums = true
|
|
||||||
}) else null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -255,20 +292,7 @@ fun ItunesArtistScreen(
|
|||||||
if (c.partial.isNotEmpty()) {
|
if (c.partial.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
ArtistSectionHeader("Unvollständig", c.partial.size,
|
ArtistSectionHeader("Unvollständig", c.partial.size,
|
||||||
onShowAll = if (c.partial.size > 4) ({
|
onShowAll = if (c.partial.size > 4) ({ onOpenAllAlbums("Unvollständig") }) else null
|
||||||
allAlbumsTitle = "Unvollständig"
|
|
||||||
allAlbumEntries = c.partial.map { (nd, ia, counts) ->
|
|
||||||
AllAlbumEntry(
|
|
||||||
name = nd.name,
|
|
||||||
year = nd.year?.toString(),
|
|
||||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
|
|
||||||
badge = "${counts.first}/${counts.second}",
|
|
||||||
onClick = { onPartialAlbumOpen(nd, ia) },
|
|
||||||
onDownload = { onDownload(ia) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
showAllAlbums = true
|
|
||||||
}) else null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -295,20 +319,7 @@ fun ItunesArtistScreen(
|
|||||||
if (c.notDownloaded.isNotEmpty()) {
|
if (c.notDownloaded.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size,
|
ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size,
|
||||||
onShowAll = if (c.notDownloaded.size > 4) ({
|
onShowAll = if (c.notDownloaded.size > 4) ({ onOpenAllAlbums("Nicht heruntergeladen") }) else null
|
||||||
allAlbumsTitle = "Nicht heruntergeladen"
|
|
||||||
allAlbumEntries = c.notDownloaded.map { ia ->
|
|
||||||
AllAlbumEntry(
|
|
||||||
name = ia.collectionName ?: "—",
|
|
||||||
year = ia.releaseDate?.take(4),
|
|
||||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
|
||||||
dimmed = true,
|
|
||||||
onClick = { onItunesAlbumOpen(ia) },
|
|
||||||
onDownload = { onDownload(ia) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
showAllAlbums = true
|
|
||||||
}) else null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
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.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
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -64,6 +64,15 @@ fun LibraryScreen(
|
|||||||
val vm: LibraryViewModel = viewModel()
|
val vm: LibraryViewModel = viewModel()
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val offlineVm: OfflineViewModel = viewModel()
|
val offlineVm: OfflineViewModel = viewModel()
|
||||||
|
|
||||||
|
// Absicht aus den Favoriten ("Alle anzeigen" bei den gefolgten Künstlern) einmalig einlösen.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (com.novasonic.app.data.LibraryIntent.oeffneGefolgte) {
|
||||||
|
com.novasonic.app.data.LibraryIntent.oeffneGefolgte = false
|
||||||
|
vm.selectTab(LibraryTab.ARTISTS)
|
||||||
|
vm.nurGefolgteUmschalten(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle()
|
val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Fully-downloaded source IDs per type — drives the "downloaded" badge on cards.
|
// Fully-downloaded source IDs per type — drives the "downloaded" badge on cards.
|
||||||
@@ -79,16 +88,28 @@ fun LibraryScreen(
|
|||||||
var showCreateDialog by remember { mutableStateOf(false) }
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
|
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
|
||||||
|
|
||||||
|
// Swipe left/right to switch library tabs, kept in sync with the TabRow (like Erkunden): a chip
|
||||||
|
// tap animates the pager; a settled swipe updates the selected tab. Indicator is driven off the
|
||||||
|
// pager's current page so it moves WITH the swipe.
|
||||||
|
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { LibraryTab.entries.size }
|
||||||
|
LaunchedEffect(state.tab) {
|
||||||
|
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
|
||||||
|
}
|
||||||
|
LaunchedEffect(pagerState.settledPage) {
|
||||||
|
val t = LibraryTab.entries[pagerState.settledPage]
|
||||||
|
if (t != state.tab) vm.selectTab(t)
|
||||||
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = state.tab.ordinal,
|
selectedTabIndex = pagerState.currentPage,
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
contentColor = NsAccent,
|
contentColor = NsAccent,
|
||||||
) {
|
) {
|
||||||
LibraryTab.entries.forEach { tab ->
|
LibraryTab.entries.forEach { tab ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = state.tab == tab,
|
selected = pagerState.currentPage == tab.ordinal,
|
||||||
onClick = { vm.selectTab(tab) },
|
onClick = { vm.selectTab(tab) },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
@@ -108,14 +129,15 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.isLoading) {
|
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||||
|
val pageTab = LibraryTab.entries[page]
|
||||||
|
// The Offline tab is fed by the local Room DB (offlineGroups) and must stay usable even while
|
||||||
|
// the online library is still loading or unreachable — only gate the network-backed tabs.
|
||||||
|
if (state.isLoading && pageTab != LibraryTab.OFFLINE) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator(color = NsAccent)
|
CircularProgressIndicator(color = NsAccent)
|
||||||
}
|
}
|
||||||
return@Column
|
} else when (pageTab) {
|
||||||
}
|
|
||||||
|
|
||||||
when (state.tab) {
|
|
||||||
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
|
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
|
||||||
// Sort chips
|
// Sort chips
|
||||||
androidx.compose.foundation.lazy.LazyRow(
|
androidx.compose.foundation.lazy.LazyRow(
|
||||||
@@ -151,8 +173,10 @@ fun LibraryScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val albumGridState = rememberLazyGridState()
|
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(
|
LazyVerticalGrid(
|
||||||
state = albumGridState,
|
state = albumGridState,
|
||||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||||
@@ -174,14 +198,22 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LibraryTab.ARTISTS -> {
|
LibraryTab.ARTISTS -> {
|
||||||
val listState = rememberLazyListState()
|
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
|
// "Gefolgt" filtert die vorhandene Liste — ein eigener Reiter wäre in einer Leiste,
|
||||||
|
// die schon vier Kacheln trägt, nur Gedränge.
|
||||||
|
val indizes = remember(state.artistIndexes, state.nurGefolgte, state.gefolgteIds) {
|
||||||
|
if (!state.nurGefolgte) state.artistIndexes
|
||||||
|
else state.artistIndexes
|
||||||
|
.map { idx -> idx.copy(artist = idx.artist.filter { it.id in state.gefolgteIds }) }
|
||||||
|
.filter { it.artist.isNotEmpty() }
|
||||||
|
}
|
||||||
|
val letters = remember(indizes) { indizes.map { it.name } }
|
||||||
// Map letter → flat LazyColumn index of its header item
|
// Map letter → flat LazyColumn index of its header item
|
||||||
val letterToIndex = remember(state.artistIndexes) {
|
val letterToIndex = remember(indizes) {
|
||||||
buildMap<String, Int> {
|
buildMap<String, Int> {
|
||||||
var flat = 0
|
var flat = 0
|
||||||
state.artistIndexes.forEach { idx ->
|
indizes.forEach { idx ->
|
||||||
put(idx.name, flat)
|
put(idx.name, flat)
|
||||||
flat += 1 + idx.artist.size
|
flat += 1 + idx.artist.size
|
||||||
}
|
}
|
||||||
@@ -189,13 +221,54 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
var activeLetter by remember { mutableStateOf<String?>(null) }
|
var activeLetter by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 6.dp, bottom = 6.dp),
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = !state.nurGefolgte,
|
||||||
|
onClick = { vm.nurGefolgteUmschalten(false) },
|
||||||
|
label = { Text("Alle", fontSize = 12.sp) },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||||
|
selectedLabelColor = NsAccent),
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = state.nurGefolgte,
|
||||||
|
onClick = { vm.nurGefolgteUmschalten(true) },
|
||||||
|
label = {
|
||||||
|
val n = state.gefolgteIds.size
|
||||||
|
Text("❤ Gefolgt" + if (state.nurGefolgte && n > 0) " ($n)" else "",
|
||||||
|
fontSize = 12.sp)
|
||||||
|
},
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||||
|
selectedLabelColor = NsAccent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.nurGefolgte && indizes.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("❤", fontSize = 30.sp)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Du folgst noch niemandem", fontFamily = Outfit,
|
||||||
|
fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text("Auf einer Künstlerseite oben rechts aufs Herz tippen.",
|
||||||
|
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
state.artistIndexes.forEach { idx ->
|
indizes.forEach { idx ->
|
||||||
item(key = "header_${idx.name}") {
|
item(key = "header_${idx.name}") {
|
||||||
Text(
|
Text(
|
||||||
text = idx.name,
|
text = idx.name,
|
||||||
@@ -301,6 +374,7 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,9 +386,11 @@ fun LibraryScreen(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
items(state.playlists, key = { it.id }) { pl ->
|
items(state.playlists, key = { it.id }) { pl ->
|
||||||
|
// Besitzer nur bei fremden (geteilten) Listen — bei den eigenen waere er Rauschen.
|
||||||
|
val von = pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }
|
||||||
PlaylistCard(
|
PlaylistCard(
|
||||||
name = pl.name,
|
name = pl.name,
|
||||||
subtitle = "${pl.songCount} Titel",
|
subtitle = "${pl.songCount} Titel" + (von?.let { " · von $it" } ?: ""),
|
||||||
coverUrl = coverUrl(pl.coverArt),
|
coverUrl = coverUrl(pl.coverArt),
|
||||||
downloaded = pl.id in downloadedPlaylistIds,
|
downloaded = pl.id in downloadedPlaylistIds,
|
||||||
onClick = { onPlaylistClick(pl) },
|
onClick = { onPlaylistClick(pl) },
|
||||||
@@ -386,9 +462,10 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // end HorizontalPager
|
||||||
} // end Column
|
} // end Column
|
||||||
|
|
||||||
if (state.tab == LibraryTab.PLAYLISTS) {
|
if (pagerState.currentPage == LibraryTab.PLAYLISTS.ordinal) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { showCreateDialog = true },
|
onClick = { showCreateDialog = true },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ fun LoginScreen(onLoggedIn: () -> Unit) {
|
|||||||
val vm: LoginViewModel = viewModel()
|
val vm: LoginViewModel = viewModel()
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var serverUrl by remember { mutableStateOf("") }
|
// Serveradresse vorbelegen, falls im Build hinterlegt — dann bleiben nur Benutzer und Passwort.
|
||||||
|
var serverUrl by remember { mutableStateOf(com.novasonic.app.BuildConfig.DEFAULT_ND_URL) }
|
||||||
var username by remember { mutableStateOf("") }
|
var username by remember { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
var showPw by remember { mutableStateOf(false) }
|
var showPw by remember { mutableStateOf(false) }
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -58,9 +60,35 @@ import com.novasonic.app.player.PlayerState
|
|||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
import com.novasonic.app.viewmodel.LyricsViewModel
|
import com.novasonic.app.viewmodel.LyricsViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
|
import androidx.compose.ui.input.pointer.positionChange
|
||||||
|
|
||||||
private enum class NpTab { PLAYER, LYRICS, QUEUE }
|
private enum class NpTab { PLAYER, LYRICS, QUEUE }
|
||||||
|
|
||||||
|
// ── Obergrenzen fuer grosse Anzeigen (Fold aufgeklappt, Tablet) ──────────────────────────────
|
||||||
|
// Bis 1.26 hatte diese Ansicht keine Breitengrenze: das Cover war `fillMaxWidth` mal
|
||||||
|
// `aspectRatio(1f)`. Auf einem Handy sind das knappe 360 dp und passt; auf dem inneren Display
|
||||||
|
// eines Fold sind es ueber 800 dp — ein quadratisches Cover dieser Groesse schob Titel,
|
||||||
|
// Fortschrittsbalken und Knoepfe aus dem Bild, und die ganze Seite wirkte ueberdimensional.
|
||||||
|
// Die Web-Ansicht deckelt dasselbe seit immer bei 480 px (`.np-inner { max-width: 480px }`).
|
||||||
|
// Zusaetzlich wird das Cover gegen die HOEHE gedeckelt: bei einem fast quadratischen Display ist
|
||||||
|
// die Breite kein gutes Mass mehr fuer ein quadratisches Bild.
|
||||||
|
private val NP_SPALTE_MAX = 480.dp // Player-Seite: Cover, Titel, Fortschritt, Knoepfe
|
||||||
|
private val NP_LISTE_MAX = 560.dp // Liedtext und Warteschlange — Listen duerfen etwas breiter
|
||||||
|
private val NP_COVER_MAX = 400.dp
|
||||||
|
// Was Titel, Album, Kuenstler, Fortschrittsbalken, Zeiten, Knopfreihe und die Abstaende
|
||||||
|
// dazwischen zusammen brauchen (gemessen an den gesetzten Groessen, mit etwas Luft). Das Cover
|
||||||
|
// bekommt den Rest — so passt die Seite auch auf kurze Anzeigen, wo bisher die Knopfreihe
|
||||||
|
// unten abgeschnitten wurde.
|
||||||
|
private val NP_RESTBEDARF = 260.dp
|
||||||
|
// Auf kurzen Anzeigen (Flip-Frontdisplay, Querformat) schrumpfen Abstaende und Abspielknopf
|
||||||
|
// zusaetzlich, der Rest braucht dann weniger — und wenn es trotzdem nicht reicht, laesst sich
|
||||||
|
// die Seite rollen.
|
||||||
|
private val NP_RESTBEDARF_KURZ = 200.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NowPlayingScreen(
|
fun NowPlayingScreen(
|
||||||
state: PlayerState,
|
state: PlayerState,
|
||||||
@@ -76,6 +104,23 @@ fun NowPlayingScreen(
|
|||||||
val song = state.currentSong
|
val song = state.currentSong
|
||||||
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
||||||
|
|
||||||
|
// Waagerechtes Wischen: standardmaessig wechselt es den TITEL — das ist die Geste, die man hier
|
||||||
|
// staendig braucht. Wer lieber zwischen Player, Warteschlange und Text blaettert, stellt es in
|
||||||
|
// den Einstellungen um; dann uebernimmt wieder der Pager. Die Reiter oben schalten in beiden
|
||||||
|
// Faellen weiter, es geht also nie eine Ansicht verloren.
|
||||||
|
val prefsCtx = LocalContext.current
|
||||||
|
val wischTitel by produceState(initialValue = true, prefsCtx) {
|
||||||
|
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings.collect { value = it.wischenWechseltTitel }
|
||||||
|
}
|
||||||
|
val pagerState = rememberPagerState(initialPage = tab.ordinal) { NpTab.entries.size }
|
||||||
|
LaunchedEffect(tab) {
|
||||||
|
if (pagerState.currentPage != tab.ordinal) pagerState.animateScrollToPage(tab.ordinal)
|
||||||
|
}
|
||||||
|
LaunchedEffect(pagerState.settledPage) {
|
||||||
|
val t = NpTab.entries[pagerState.settledPage]
|
||||||
|
if (t != tab) tab = t
|
||||||
|
}
|
||||||
|
|
||||||
// Preload lyrics as soon as NowPlaying opens — so the Lyrics tab shows instantly
|
// Preload lyrics as soon as NowPlaying opens — so the Lyrics tab shows instantly
|
||||||
val lyricsVm: LyricsViewModel = viewModel()
|
val lyricsVm: LyricsViewModel = viewModel()
|
||||||
LaunchedEffect(song?.id) {
|
LaunchedEffect(song?.id) {
|
||||||
@@ -143,11 +188,22 @@ fun NowPlayingScreen(
|
|||||||
// Light scrim for readability
|
// Light scrim for readability
|
||||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.15f)))
|
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.15f)))
|
||||||
|
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
// Zwei Groessenfragen entscheiden das Layout, nicht ein Geraetename:
|
||||||
|
// schmal — passt die Kopfzeile mit drei Reitern noch in voller Groesse? (Flip-Deckel)
|
||||||
|
// kurz — bleibt nach Kopfzeile und Raendern ueberhaupt Platz? (Flip-Deckel, Querformat)
|
||||||
|
// Die Schwelle liegt bewusst UNTER 360 dp: 360 ist noch ein gewoehnliches Handy und
|
||||||
|
// soll die Kopfzeile unveraendert behalten. Erfasst werden damit der Deckel eines Fold
|
||||||
|
// (~352 dp) und — ueber `kurz` — das Frontdisplay eines Flip, das bei aehnlicher Breite
|
||||||
|
// nur ein Drittel der Hoehe hat und wo drei Reiter in voller Groesse nicht hinpassen.
|
||||||
|
val schmal = maxWidth < 356.dp || maxHeight < 560.dp
|
||||||
|
val kurz = maxHeight < 560.dp
|
||||||
|
val obenPad = if (kurz) maxOf(statusBarTop + 4.dp, 20.dp) else topPad
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(top = topPad, bottom = maxOf(navBarBottom, 20.dp))
|
.padding(top = obenPad, bottom = maxOf(navBarBottom, if (kurz) 8.dp else 20.dp))
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
var dragY = 0f
|
var dragY = 0f
|
||||||
detectVerticalDragGestures(
|
detectVerticalDragGestures(
|
||||||
@@ -161,9 +217,13 @@ fun NowPlayingScreen(
|
|||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier
|
||||||
.padding(horizontal = 20.dp)
|
// Ohne Grenze stehen Schliessen-Knopf und Menue auf einem breiten Display
|
||||||
.padding(bottom = 28.dp),
|
// ganz aussen und die Reiter einsam in der Mitte.
|
||||||
|
.widthIn(max = NP_LISTE_MAX)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = if (schmal) 8.dp else 20.dp)
|
||||||
|
.padding(bottom = if (kurz) 10.dp else 28.dp),
|
||||||
) {
|
) {
|
||||||
NpCircleBtn(onClick = onDismiss) {
|
NpCircleBtn(onClick = onDismiss) {
|
||||||
Icon(Icons.Default.KeyboardArrowDown, "Schließen",
|
Icon(Icons.Default.KeyboardArrowDown, "Schließen",
|
||||||
@@ -178,14 +238,17 @@ fun NowPlayingScreen(
|
|||||||
.padding(3.dp),
|
.padding(3.dp),
|
||||||
) {
|
) {
|
||||||
NpTab.entries.forEach { t ->
|
NpTab.entries.forEach { t ->
|
||||||
val sel = tab == t
|
val sel = pagerState.currentPage == t.ordinal
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
|
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
|
||||||
.clickable { tab = t }
|
.clickable { tab = t }
|
||||||
.padding(horizontal = 18.dp, vertical = 9.dp),
|
.padding(
|
||||||
|
horizontal = if (schmal) 9.dp else 18.dp,
|
||||||
|
vertical = if (kurz) 6.dp else 9.dp,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
when (t) {
|
when (t) {
|
||||||
@@ -193,7 +256,8 @@ fun NowPlayingScreen(
|
|||||||
NpTab.LYRICS -> "Lyrics"
|
NpTab.LYRICS -> "Lyrics"
|
||||||
NpTab.QUEUE -> "Queue"
|
NpTab.QUEUE -> "Queue"
|
||||||
},
|
},
|
||||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = if (schmal) 11.sp else 13.sp,
|
||||||
color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
|
color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -210,21 +274,83 @@ 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()
|
||||||
|
.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 ─────────────────────────────────────────────────
|
// ── PLAYER TAB ─────────────────────────────────────────────────
|
||||||
if (tab == NpTab.PLAYER) {
|
NpTab.PLAYER -> BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
var dragX by remember { mutableStateOf(0f) }
|
var dragX by remember { mutableStateOf(0f) }
|
||||||
|
// Das Cover nimmt das KLEINSTE aus drei Maszen: verfuegbare Breite (minus der
|
||||||
|
// bisherigen 24 dp Rand je Seite), die Hoehe abzueglich dessen, was der Rest der
|
||||||
|
// Seite braucht, und eine feste Obergrenze. Auf einem hohen Handy gewinnt die
|
||||||
|
// Breite — dort bleibt alles wie bisher. Aufgeklappt greifen die anderen beiden.
|
||||||
|
val coverKante = minOf(
|
||||||
|
maxWidth - 48.dp,
|
||||||
|
(maxHeight - if (kurz) NP_RESTBEDARF_KURZ else NP_RESTBEDARF).coerceAtLeast(120.dp),
|
||||||
|
NP_COVER_MAX,
|
||||||
|
)
|
||||||
|
val spalte = minOf(maxWidth, NP_SPALTE_MAX)
|
||||||
|
// Vor dem Rollbereich festhalten: innerhalb des Box-Lambdas ist `maxHeight` des
|
||||||
|
// aeusseren Messrahmens nicht mehr erreichbar (Compose sperrt gleich markierte
|
||||||
|
// Layout-Bereiche gegeneinander ab).
|
||||||
|
val seitenHoehe = maxHeight
|
||||||
|
// Greift ueberhaupt eine der neuen Grenzen? Nur dann weicht das Layout von dem
|
||||||
|
// ab, was ein hohes Handy bisher zeigte. Solange die BREITE das engste Mass ist
|
||||||
|
// — also auf jedem gewoehnlichen Handy —, bleibt es beim alten Aufbau: oben
|
||||||
|
// beginnend mit 28 dp Abstand. Erst wenn Hoehe oder Obergrenze beissen (Fold
|
||||||
|
// aufgeklappt, Tablet, Querformat), wird senkrecht zentriert; oben klebend mit
|
||||||
|
// einem Loch darunter saehe dort falsch aus.
|
||||||
|
val gedeckelt = coverKante < maxWidth - 48.dp || kurz
|
||||||
|
|
||||||
Spacer(Modifier.height(28.dp))
|
// Passt es, wird es angeordnet wie oben beschrieben; passt es nicht, wird
|
||||||
|
// gescrollt. Beides mit einem Aufbau: der Inhalt ist mindestens so hoch wie die
|
||||||
// Cover art — full content width, scale-animates like Apple Music
|
// Seite, darf aber darueber hinauswachsen — dann hat der Rollbereich etwas zu
|
||||||
|
// rollen. Auf dem Frontdisplay eines Flip bleibt sonst nichts uebrig, was ohne
|
||||||
|
// Abschneiden darstellbar waere. Passt alles, ist der Rollweg null: dann
|
||||||
|
// verhaelt sich die Seite wie ohne Rollbereich.
|
||||||
|
// Die Wischgeste zum Schliessen bleibt erhalten: ein Rollbereich ohne Weg
|
||||||
|
// verbraucht die Geste nicht, sie erreicht also weiter die Seite darunter.
|
||||||
|
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = if (gedeckelt) Arrangement.Center else Arrangement.Top,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = seitenHoehe),
|
||||||
|
) {
|
||||||
|
if (!gedeckelt) Spacer(Modifier.height(28.dp))
|
||||||
|
// Cover art — gedeckelt, scale-animates like Apple Music
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = coverUrl,
|
model = coverUrl,
|
||||||
contentDescription = song?.title,
|
contentDescription = song?.title,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 24.dp)
|
.size(coverKante)
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(1f)
|
|
||||||
.graphicsLayer { scaleX = coverScale; scaleY = coverScale }
|
.graphicsLayer { scaleX = coverScale; scaleY = coverScale }
|
||||||
.shadow(40.dp, RoundedCornerShape(20.dp))
|
.shadow(40.dp, RoundedCornerShape(20.dp))
|
||||||
.clip(RoundedCornerShape(20.dp))
|
.clip(RoundedCornerShape(20.dp))
|
||||||
@@ -242,10 +368,10 @@ fun NowPlayingScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(if (kurz) 12.dp else 24.dp))
|
||||||
|
|
||||||
// ── Song info ──────────────────────────────────────────────
|
// ── Song info ──────────────────────────────────────────────
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = 28.dp)) {
|
Column(Modifier.width(spalte).padding(horizontal = 28.dp)) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -258,6 +384,16 @@ fun NowPlayingScreen(
|
|||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(3.dp))
|
Spacer(Modifier.height(3.dp))
|
||||||
|
// Order matches the web player: title → album → artists.
|
||||||
|
if (!song?.album.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
song!!.album!!,
|
||||||
|
fontFamily = Outfit, fontSize = 12.sp,
|
||||||
|
color = Color.White.copy(alpha = 0.45f),
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(3.dp))
|
||||||
|
}
|
||||||
if (song != null && !song.artist.isNullOrBlank()) {
|
if (song != null && !song.artist.isNullOrBlank()) {
|
||||||
// Prefer OpenSubsonic artists array (has IDs per contributor).
|
// Prefer OpenSubsonic artists array (has IDs per contributor).
|
||||||
// Fall back to splitting artist string by ';' (same as web app).
|
// Fall back to splitting artist string by ';' (same as web app).
|
||||||
@@ -303,17 +439,10 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!song?.album.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
song!!.album!!,
|
|
||||||
fontFamily = Outfit, fontSize = 12.sp,
|
|
||||||
color = Color.White.copy(alpha = 0.45f),
|
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (onStarToggle != null && song != null) {
|
if (onStarToggle != null && song != null) {
|
||||||
val starred = song.starred != null
|
val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState()
|
||||||
|
val starred = starOverrides[song.id] ?: (song.starred != null)
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -321,7 +450,7 @@ fun NowPlayingScreen(
|
|||||||
.clickable(onClick = onStarToggle),
|
.clickable(onClick = onStarToggle),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
if (starred) Icons.Default.Star else Icons.Default.StarBorder,
|
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||||
null,
|
null,
|
||||||
tint = if (starred) NsAccent else Color.White.copy(0.5f),
|
tint = if (starred) NsAccent else Color.White.copy(0.5f),
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
@@ -331,10 +460,10 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(if (kurz) 10.dp else 20.dp))
|
||||||
|
|
||||||
// ── Seek bar ───────────────────────────────────────────────
|
// ── Seek bar ───────────────────────────────────────────────
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp)) {
|
Column(Modifier.width(spalte).padding(horizontal = 24.dp)) {
|
||||||
val progress = if (state.durationMs > 0)
|
val progress = if (state.durationMs > 0)
|
||||||
(state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 0f
|
(state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 0f
|
||||||
|
|
||||||
@@ -351,15 +480,15 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(if (kurz) 4.dp else 12.dp))
|
||||||
|
|
||||||
// ── Controls ───────────────────────────────────────────────
|
// ── Controls ───────────────────────────────────────────────
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
modifier = Modifier.width(spalte).padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
NpCircleBtn(onClick = { controller.toggleShuffle() }) {
|
NpCircleBtn(onClick = { controller.toggleShuffle() }, aktiv = state.shuffled) {
|
||||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
|
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
|
||||||
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
|
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
|
||||||
}
|
}
|
||||||
@@ -371,7 +500,7 @@ fun NowPlayingScreen(
|
|||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(68.dp)
|
.size(if (kurz) 56.dp else 68.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color.White)
|
.background(Color.White)
|
||||||
.clickable { controller.togglePlay() },
|
.clickable { controller.togglePlay() },
|
||||||
@@ -385,7 +514,8 @@ fun NowPlayingScreen(
|
|||||||
NpCircleBtn(onClick = { controller.skipNext() }) {
|
NpCircleBtn(onClick = { controller.skipNext() }) {
|
||||||
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
|
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
|
||||||
}
|
}
|
||||||
NpCircleBtn(onClick = { controller.toggleRepeat() }) {
|
val wiederholt = state.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF
|
||||||
|
NpCircleBtn(onClick = { controller.toggleRepeat() }, aktiv = wiederholt) {
|
||||||
val (icon, tint) = when (state.repeatMode) {
|
val (icon, tint) = when (state.repeatMode) {
|
||||||
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
|
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
|
||||||
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
|
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
|
||||||
@@ -394,15 +524,23 @@ fun NowPlayingScreen(
|
|||||||
Icon(icon, null, modifier = Modifier.size(22.dp), tint = tint)
|
Icon(icon, null, modifier = Modifier.size(22.dp), tint = tint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // end Column (gedeckelte Player-Spalte)
|
||||||
|
} // end Box (Rollbereich, falls die Seite nicht reicht)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── LYRICS TAB ─────────────────────────────────────────────────
|
// ── LYRICS TAB ─────────────────────────────────────────────────
|
||||||
if (tab == NpTab.LYRICS && song != null) {
|
NpTab.LYRICS -> if (song != null) {
|
||||||
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
||||||
val lyricsListState = rememberLazyListState()
|
val lyricsListState = rememberLazyListState()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
var lyricsViewH by remember { mutableIntStateOf(0) }
|
var lyricsViewH by remember { mutableIntStateOf(0) }
|
||||||
|
// Mitlaufen: Standard AUS. Der Schalter sitzt hier im Reiter, nicht in den
|
||||||
|
// Einstellungen — man entscheidet beim Lesen, nicht vorher.
|
||||||
|
val folgtMit by produceState(initialValue = false, prefsCtx) {
|
||||||
|
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings
|
||||||
|
.collect { value = it.lyricsFolgenAutomatisch }
|
||||||
|
}
|
||||||
|
val folgenScope = rememberCoroutineScope()
|
||||||
// Anchor the active line at 40% from the top — slightly above middle reads more naturally.
|
// Anchor the active line at 40% from the top — slightly above middle reads more naturally.
|
||||||
val anchorFrac = 0.40f
|
val anchorFrac = 0.40f
|
||||||
|
|
||||||
@@ -413,7 +551,10 @@ fun NowPlayingScreen(
|
|||||||
|
|
||||||
// Center the active line using its *measured* position — no cumulative drift, so
|
// Center the active line using its *measured* position — no cumulative drift, so
|
||||||
// wrapped (multi-line) lyrics stay centered. Re-runs once the viewport is measured.
|
// wrapped (multi-line) lyrics stay centered. Re-runs once the viewport is measured.
|
||||||
LaunchedEffect(activeIdx, lyricsViewH) {
|
LaunchedEffect(activeIdx, lyricsViewH, folgtMit) {
|
||||||
|
// Ohne Mitlaufen wird nur hervorgehoben, nicht gescrollt — sonst springt der
|
||||||
|
// Text unter dem Finger weg, sobald die naechste Zeile beginnt.
|
||||||
|
if (!folgtMit) return@LaunchedEffect
|
||||||
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
|
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
|
||||||
val info = lyricsListState.layoutInfo
|
val info = lyricsListState.layoutInfo
|
||||||
val anchor = info.viewportStartOffset +
|
val anchor = info.viewportStartOffset +
|
||||||
@@ -431,6 +572,42 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lyricsState.lines.isNotEmpty()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.widthIn(max = NP_LISTE_MAX)
|
||||||
|
.fillMaxWidth().padding(bottom = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(
|
||||||
|
if (folgtMit) NsAccent.copy(0.18f) else Color.White.copy(0.08f)
|
||||||
|
)
|
||||||
|
.clickable {
|
||||||
|
folgenScope.launch {
|
||||||
|
com.novasonic.app.data.PrefsStore(prefsCtx)
|
||||||
|
.saveLyricsFolgen(!folgtMit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 11.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.KeyboardArrowDown, null,
|
||||||
|
modifier = Modifier.size(15.dp),
|
||||||
|
tint = if (folgtMit) NsAccent else Color.White.copy(0.55f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(5.dp))
|
||||||
|
Text(
|
||||||
|
if (folgtMit) "Folgt" else "Mitlaufen",
|
||||||
|
fontFamily = Outfit, fontSize = 11.sp, fontWeight = FontWeight.Bold,
|
||||||
|
color = if (folgtMit) NsAccent else Color.White.copy(0.55f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
lyricsState.isLoading -> Box(
|
lyricsState.isLoading -> Box(
|
||||||
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
|
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
|
||||||
@@ -455,6 +632,8 @@ fun NowPlayingScreen(
|
|||||||
state = lyricsListState,
|
state = lyricsListState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
// Gedeckelt: eine Textzeile ueber 800 dp liest sich nicht mehr.
|
||||||
|
.widthIn(max = NP_LISTE_MAX)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onSizeChanged { lyricsViewH = it.height },
|
.onSizeChanged { lyricsViewH = it.height },
|
||||||
// Asymmetric padding so the first/last lines can still reach the anchor point.
|
// Asymmetric padding so the first/last lines can still reach the anchor point.
|
||||||
@@ -475,7 +654,8 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
lyricsState.plainText.isNotBlank() -> LazyColumn(
|
lyricsState.plainText.isNotBlank() -> LazyColumn(
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f)
|
||||||
|
.widthIn(max = NP_LISTE_MAX).fillMaxWidth(),
|
||||||
contentPadding = PaddingValues(vertical = 24.dp),
|
contentPadding = PaddingValues(vertical = 24.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
@@ -492,12 +672,13 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── QUEUE TAB ──────────────────────────────────────────────────
|
// ── QUEUE TAB ──────────────────────────────────────────────────
|
||||||
if (tab == NpTab.QUEUE) {
|
NpTab.QUEUE -> {
|
||||||
// Radio mode toggle
|
// Radio mode toggle
|
||||||
if (onToggleRadio != null) {
|
if (onToggleRadio != null) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.widthIn(max = NP_LISTE_MAX)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
@@ -546,18 +727,39 @@ fun NowPlayingScreen(
|
|||||||
color = Color.White.copy(0.4f))
|
color = Color.White.copy(0.4f))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// ── Warteschlange bearbeiten ────────────────────────────
|
||||||
|
// Gezogen wird am Griff, nicht an der Zeile: die Liste selbst scrollt, und
|
||||||
|
// beides an derselben Stelle liesse sich nicht auseinanderhalten.
|
||||||
|
// Getauscht wird LAUFEND, sobald der Finger eine Zeilenhoehe zurueckgelegt
|
||||||
|
// hat — dadurch braucht es kein Geisterbild, die Liste zeigt die neue
|
||||||
|
// Reihenfolge sofort.
|
||||||
|
val zeilePx = with(LocalDensity.current) { 56.dp.toPx() }
|
||||||
|
var ziehIdx by remember { mutableIntStateOf(-1) }
|
||||||
|
var ziehOffset by remember { mutableFloatStateOf(0f) }
|
||||||
|
val queueLaenge = rememberUpdatedState(state.queue.size)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f)
|
||||||
|
.widthIn(max = NP_LISTE_MAX).fillMaxWidth(),
|
||||||
contentPadding = PaddingValues(vertical = 4.dp),
|
contentPadding = PaddingValues(vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
|
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
|
||||||
val active = state.currentIndex == idx
|
val active = state.currentIndex == idx
|
||||||
|
val wirdGezogen = ziehIdx == idx
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer {
|
||||||
|
if (wirdGezogen) { translationY = ziehOffset; shadowElevation = 12f }
|
||||||
|
}
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(if (active) Color.White.copy(0.08f) else Color.Transparent)
|
.background(
|
||||||
|
when {
|
||||||
|
wirdGezogen -> Color.White.copy(0.16f)
|
||||||
|
active -> Color.White.copy(0.08f)
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
)
|
||||||
.clickable { controller.seekToItem(idx) }
|
.clickable { controller.seekToItem(idx) }
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
@@ -585,12 +787,63 @@ fun NowPlayingScreen(
|
|||||||
Icon(Icons.AutoMirrored.Filled.VolumeUp, null,
|
Icon(Icons.AutoMirrored.Filled.VolumeUp, null,
|
||||||
tint = NsAccent, modifier = Modifier.size(16.dp))
|
tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||||
}
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { controller.entferneAusQueue(idx) },
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Close, "Aus der Warteschlange entfernen",
|
||||||
|
tint = Color.White.copy(0.45f), modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
Icons.Default.DragHandle, "Zum Verschieben ziehen",
|
||||||
|
tint = Color.White.copy(if (wirdGezogen) 0.9f else 0.45f),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.padding(6.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
// Der erste Kontakt wird sofort verbraucht, sonst
|
||||||
|
// beansprucht die LazyColumn die Geste, sobald die
|
||||||
|
// Wischschwelle faellt.
|
||||||
|
val start = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
start.consume()
|
||||||
|
var aktuell = idx
|
||||||
|
var rest = 0f
|
||||||
|
ziehIdx = aktuell
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
val ev = awaitPointerEvent()
|
||||||
|
val p = ev.changes.firstOrNull { it.id == start.id } ?: break
|
||||||
|
if (!p.pressed) break
|
||||||
|
rest += p.positionChange().y
|
||||||
|
p.consume()
|
||||||
|
while (rest >= zeilePx && aktuell < queueLaenge.value - 1) {
|
||||||
|
controller.verschiebeInQueue(aktuell, aktuell + 1)
|
||||||
|
aktuell++; rest -= zeilePx
|
||||||
|
}
|
||||||
|
while (rest <= -zeilePx && aktuell > 0) {
|
||||||
|
controller.verschiebeInQueue(aktuell, aktuell - 1)
|
||||||
|
aktuell--; rest += zeilePx
|
||||||
|
}
|
||||||
|
ziehIdx = aktuell
|
||||||
|
ziehOffset = rest
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ziehIdx = -1; ziehOffset = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // end when
|
||||||
|
} // end inner Column
|
||||||
|
} // end HorizontalPager
|
||||||
}
|
}
|
||||||
|
} // end BoxWithConstraints (schmal/kurz)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,11 +919,34 @@ private fun NpSeekBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NpCircleBtn(onClick: () -> Unit, content: @Composable () -> Unit) {
|
private fun NpCircleBtn(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
aktiv: Boolean = false,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
// Ein eingefaerbtes Symbol allein ist als "an" schwer zu erkennen — auf dem dunklen Cover
|
||||||
|
// sieht Akzentfarbe wie eine Schattierung aus. Ist der Schalter an, liegt deshalb zusaetzlich
|
||||||
|
// ein getoenter Kreis darunter und ein Punkt darunter, wie in gaengigen Playern.
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(onClick = onClick),
|
modifier = Modifier
|
||||||
) { content() }
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (aktiv) NsAccent.copy(alpha = 0.22f) else Color.Transparent)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
if (aktiv) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 5.dp)
|
||||||
|
.size(4.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(NsAccent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatMs(ms: Long): String {
|
private fun formatMs(ms: Long): String {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -31,11 +32,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.novasonic.app.api.model.Song
|
import com.novasonic.app.api.model.Song
|
||||||
|
import com.novasonic.app.offline.DownloadState
|
||||||
import com.novasonic.app.offline.toSong
|
import com.novasonic.app.offline.toSong
|
||||||
import com.novasonic.app.player.PlayerState
|
import com.novasonic.app.player.PlayerState
|
||||||
import com.novasonic.app.ui.component.SongRow
|
import com.novasonic.app.ui.component.SongRow
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
import com.novasonic.app.viewmodel.OfflineDetailViewModel
|
import com.novasonic.app.viewmodel.OfflineDetailViewModel
|
||||||
|
import com.novasonic.app.ui.component.Rollbalken
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OfflineDetailScreen(
|
fun OfflineDetailScreen(
|
||||||
@@ -62,124 +65,154 @@ fun OfflineDetailScreen(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val songs = remember(tracks) { tracks.map { it.toSong() } }
|
val songs = remember(tracks) { tracks.map { it.toSong() } }
|
||||||
|
// Nur FERTIGE Titel sind offline abspielbar. Die Liste zeigt weiter alle (auch wartende und
|
||||||
|
// fehlgeschlagene), aber in die Warteschlange dürfen sie nicht: für sie gibt es keine lokale
|
||||||
|
// Datei, also liefert streamUrl() die Netz-Adresse — und die tut offline genau nichts.
|
||||||
|
val spielbar = remember(tracks) {
|
||||||
|
tracks.filter { it.state == DownloadState.DONE }.map { it.toSong() }
|
||||||
|
}
|
||||||
|
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val spieleAb: (List<com.novasonic.app.api.model.Song>, Int) -> Unit = { liste, i ->
|
||||||
|
val fehlend = songs.size - spielbar.size
|
||||||
|
if (fehlend > 0) android.widget.Toast.makeText(
|
||||||
|
ctx, "$fehlend Titel noch nicht heruntergeladen — übersprungen",
|
||||||
|
android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
onPlay(liste, i)
|
||||||
|
}
|
||||||
|
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
val listState = rememberLazyListState()
|
||||||
item {
|
Box(Modifier.fillMaxSize()) {
|
||||||
// ── Hero ─────────────────────────────────────────────────────────
|
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||||
Box(Modifier.fillMaxWidth()) {
|
item {
|
||||||
AsyncImage(
|
// ── Hero ─────────────────────────────────────────────────────────
|
||||||
model = coverUrl(g.coverArt),
|
Box(Modifier.fillMaxWidth()) {
|
||||||
contentDescription = g.name,
|
AsyncImage(
|
||||||
contentScale = ContentScale.Crop,
|
model = coverUrl(g.coverArt),
|
||||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2),
|
contentDescription = g.name,
|
||||||
)
|
contentScale = ContentScale.Crop,
|
||||||
Box(
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2),
|
||||||
Modifier
|
)
|
||||||
.fillMaxWidth()
|
Box(
|
||||||
.aspectRatio(1f)
|
Modifier
|
||||||
.background(
|
.fillMaxWidth()
|
||||||
Brush.verticalGradient(
|
.aspectRatio(1f)
|
||||||
0.0f to Color.Transparent,
|
.background(
|
||||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
Brush.verticalGradient(
|
||||||
1.0f to NsBackground,
|
0.0f to Color.Transparent,
|
||||||
|
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||||
|
1.0f to NsBackground,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(start = 14.dp, top = 14.dp)
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.45f))
|
||||||
|
.clickable(onClick = onBack)
|
||||||
|
.align(Alignment.TopStart),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||||
|
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
g.name,
|
||||||
|
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||||
|
fontSize = 26.sp, color = Color.White,
|
||||||
|
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
)
|
Spacer(Modifier.height(4.dp))
|
||||||
Box(
|
Text(
|
||||||
Modifier
|
"Offline · ${tracks.size} Titel",
|
||||||
.statusBarsPadding()
|
fontFamily = Outfit, fontSize = 13.sp,
|
||||||
.padding(start = 14.dp, top = 14.dp)
|
color = Color.White.copy(alpha = 0.65f),
|
||||||
.size(36.dp)
|
)
|
||||||
.clip(CircleShape)
|
}
|
||||||
.background(Color.Black.copy(alpha = 0.45f))
|
|
||||||
.clickable(onClick = onBack)
|
|
||||||
.align(Alignment.TopStart),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
|
||||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
|
||||||
}
|
}
|
||||||
Column(
|
|
||||||
Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp),
|
// ── Play / Shuffle / Edit row ─────────────────────────────────────
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Button(
|
||||||
g.name,
|
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, 0) },
|
||||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
modifier = Modifier.weight(1f).height(46.dp),
|
||||||
fontSize = 26.sp, color = Color.White,
|
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
shape = RoundedCornerShape(14.dp),
|
||||||
)
|
) {
|
||||||
Spacer(Modifier.height(4.dp))
|
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||||
Text(
|
Spacer(Modifier.width(6.dp))
|
||||||
"Offline · ${tracks.size} Titel",
|
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||||
fontFamily = Outfit, fontSize = 13.sp,
|
}
|
||||||
color = Color.White.copy(alpha = 0.65f),
|
OutlinedButton(
|
||||||
)
|
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, -1) },
|
||||||
|
modifier = Modifier.weight(1f).height(46.dp),
|
||||||
|
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { editMode = !editMode },
|
||||||
|
modifier = Modifier.height(46.dp),
|
||||||
|
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||||
|
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 14.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (editMode) Icons.Default.Done else Icons.Default.Edit, null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = if (editMode) NsAccent else NsOnSurface2,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = NsBorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Play / Shuffle / Edit row ─────────────────────────────────────
|
itemsIndexed(songs, key = { _, s -> s.id }) { index, song ->
|
||||||
Row(
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
Box(Modifier.weight(1f)) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
SongRow(
|
||||||
) {
|
song = song,
|
||||||
Button(
|
coverUrl = coverUrl(song.coverArt),
|
||||||
onClick = { if (songs.isNotEmpty()) onPlay(songs, 0) },
|
isPlaying = playerState.currentSong?.id == song.id,
|
||||||
modifier = Modifier.weight(1f).height(46.dp),
|
// Position in der abspielbaren Liste suchen — die Anzeige enthält auch
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
// Titel, die noch fehlen, die Indizes decken sich also nicht.
|
||||||
shape = RoundedCornerShape(14.dp),
|
onPlay = {
|
||||||
) {
|
val i = spielbar.indexOfFirst { it.id == song.id }
|
||||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
if (i >= 0) onPlay(spielbar, i)
|
||||||
Spacer(Modifier.width(6.dp))
|
else android.widget.Toast.makeText(
|
||||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
ctx, "Dieser Titel ist noch nicht heruntergeladen",
|
||||||
}
|
android.widget.Toast.LENGTH_SHORT).show()
|
||||||
OutlinedButton(
|
},
|
||||||
onClick = { if (songs.isNotEmpty()) onPlay(songs.shuffled(), 0) },
|
)
|
||||||
modifier = Modifier.weight(1f).height(46.dp),
|
}
|
||||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
if (editMode) {
|
||||||
shape = RoundedCornerShape(14.dp),
|
IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) {
|
||||||
) {
|
Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen",
|
||||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp))
|
||||||
Spacer(Modifier.width(6.dp))
|
}
|
||||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { editMode = !editMode },
|
|
||||||
modifier = Modifier.height(46.dp),
|
|
||||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
|
||||||
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
contentPadding = PaddingValues(horizontal = 14.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
if (editMode) Icons.Default.Done else Icons.Default.Edit, null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
tint = if (editMode) NsAccent else NsOnSurface2,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(color = NsBorder)
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsIndexed(songs, key = { _, s -> s.id }) { index, song ->
|
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(Modifier.weight(1f)) {
|
|
||||||
SongRow(
|
|
||||||
song = song,
|
|
||||||
coverUrl = coverUrl(song.coverArt),
|
|
||||||
isPlaying = playerState.currentSong?.id == song.id,
|
|
||||||
onPlay = { onPlay(songs, index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (editMode) {
|
|
||||||
IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) {
|
|
||||||
Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen",
|
|
||||||
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item { Spacer(Modifier.height(16.dp)) }
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(16.dp)) }
|
Rollbalken(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||||
|
farbe = NsOnSurface2,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,12 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Shuffle
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -27,7 +30,9 @@ import com.novasonic.app.ui.theme.*
|
|||||||
import com.novasonic.app.viewmodel.PartialAlbumState
|
import com.novasonic.app.viewmodel.PartialAlbumState
|
||||||
|
|
||||||
private fun normTrack(s: String?) =
|
private fun normTrack(s: String?) =
|
||||||
(s ?: "").lowercase().replace(Regex("[^a-z0-9]"), "").trim()
|
// Keep Unicode letters/numbers so Japanese track titles aren't all emptied to "" (which made
|
||||||
|
// every downloaded JP track "match" every missing one in the partial-album split).
|
||||||
|
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "").trim()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PartialAlbumScreen(
|
fun PartialAlbumScreen(
|
||||||
@@ -39,6 +44,7 @@ fun PartialAlbumScreen(
|
|||||||
onPreview: (ItunesItem) -> Unit,
|
onPreview: (ItunesItem) -> Unit,
|
||||||
onDownload: (ItunesItem) -> Unit,
|
onDownload: (ItunesItem) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val ia = partialAlbum.itunesAlbum
|
val ia = partialAlbum.itunesAlbum
|
||||||
val nd = partialAlbum.ndAlbum
|
val nd = partialAlbum.ndAlbum
|
||||||
@@ -94,6 +100,50 @@ fun PartialAlbumScreen(
|
|||||||
.sortedBy { it.trackNumber ?: 999 }
|
.sortedBy { it.trackNumber ?: 999 }
|
||||||
|
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
LazyColumn(Modifier.fillMaxSize()) {
|
||||||
|
// ── Play / Shuffle the downloaded tracks (you can still enjoy the album even when not
|
||||||
|
// every track is downloaded yet) ─────────────────────────────────────────────────
|
||||||
|
if (downloadedSongs.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(NsAccent)
|
||||||
|
.clickable { onPlay(downloadedSongs, 0) }
|
||||||
|
.padding(vertical = 11.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PlayArrow, null, tint = NsBackground, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Abspielen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 14.sp, color = NsBackground)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(NsSurface2)
|
||||||
|
.clickable { onPlay(downloadedSongs, -1) }
|
||||||
|
.padding(vertical = 11.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Shuffle, null, tint = NsOnSurface, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Zufall", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 14.sp, color = NsOnSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Downloaded tracks ─────────────────────────────────────────────
|
// ── Downloaded tracks ─────────────────────────────────────────────
|
||||||
if (downloadedSongs.isNotEmpty()) {
|
if (downloadedSongs.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
@@ -111,6 +161,7 @@ fun PartialAlbumScreen(
|
|||||||
starred = song.starred != null,
|
starred = song.starred != null,
|
||||||
onPlay = { onPlay(downloadedSongs, idx) },
|
onPlay = { onPlay(downloadedSongs, idx) },
|
||||||
onStarToggle = {},
|
onStarToggle = {},
|
||||||
|
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -39,6 +40,8 @@ import com.novasonic.app.player.PlayerState
|
|||||||
import com.novasonic.app.ui.component.SongRow
|
import com.novasonic.app.ui.component.SongRow
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
import com.novasonic.app.viewmodel.PlaylistViewModel
|
import com.novasonic.app.viewmodel.PlaylistViewModel
|
||||||
|
import com.novasonic.app.ui.component.Rollbalken
|
||||||
|
import com.novasonic.app.ui.formatGesamtdauer
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaylistDetailScreen(
|
fun PlaylistDetailScreen(
|
||||||
@@ -91,204 +94,218 @@ fun PlaylistDetailScreen(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
val listState = rememberLazyListState()
|
||||||
item {
|
Box(Modifier.fillMaxSize()) {
|
||||||
// ── Hero ─────────────────────────────────────────────────────────
|
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||||
Box(Modifier.fillMaxWidth()) {
|
item {
|
||||||
AsyncImage(
|
// ── Hero ─────────────────────────────────────────────────────────
|
||||||
model = coverUrl(pl.coverArt),
|
Box(Modifier.fillMaxWidth()) {
|
||||||
contentDescription = pl.name,
|
AsyncImage(
|
||||||
contentScale = ContentScale.Crop,
|
model = coverUrl(pl.coverArt),
|
||||||
modifier = Modifier
|
contentDescription = pl.name,
|
||||||
.fillMaxWidth()
|
contentScale = ContentScale.Crop,
|
||||||
.aspectRatio(1f)
|
modifier = Modifier
|
||||||
.background(NsSurface2),
|
.fillMaxWidth()
|
||||||
)
|
.aspectRatio(1f)
|
||||||
Box(
|
.background(NsSurface2),
|
||||||
Modifier
|
)
|
||||||
.fillMaxWidth()
|
Box(
|
||||||
.aspectRatio(1f)
|
Modifier
|
||||||
.background(
|
.fillMaxWidth()
|
||||||
Brush.verticalGradient(
|
.aspectRatio(1f)
|
||||||
0.0f to Color.Transparent,
|
.background(
|
||||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
Brush.verticalGradient(
|
||||||
1.0f to NsBackground,
|
0.0f to Color.Transparent,
|
||||||
|
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||||
|
1.0f to NsBackground,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.statusBarsPadding()
|
|
||||||
.padding(start = 14.dp, top = 14.dp)
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.Black.copy(alpha = 0.45f))
|
|
||||||
.clickable(onClick = onBack)
|
|
||||||
.align(Alignment.TopStart),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
|
||||||
tint = Color.White, modifier = Modifier.size(18.dp),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
// Edit + Delete buttons (top-right)
|
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.statusBarsPadding()
|
|
||||||
.padding(end = 14.dp, top = 14.dp)
|
|
||||||
.align(Alignment.TopEnd),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(start = 14.dp, top = 14.dp)
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color.Black.copy(alpha = 0.45f))
|
.background(Color.Black.copy(alpha = 0.45f))
|
||||||
.clickable { showRenameDialog = true },
|
.clickable(onClick = onBack)
|
||||||
|
.align(Alignment.TopStart),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Edit, "Bearbeiten",
|
|
||||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.Black.copy(alpha = 0.45f))
|
|
||||||
.clickable { showDeleteDialog = true },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Delete, "Löschen",
|
|
||||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
pl.name,
|
|
||||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
|
||||||
fontSize = 26.sp, color = Color.White,
|
|
||||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
val meta = listOfNotNull(
|
|
||||||
pl.owner?.takeIf { it.isNotBlank() },
|
|
||||||
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
|
|
||||||
).joinToString(" · ")
|
|
||||||
if (meta.isNotBlank()) {
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
meta, fontFamily = Outfit, fontSize = 13.sp,
|
|
||||||
color = Color.White.copy(alpha = 0.65f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!pl.comment.isNullOrBlank()) {
|
|
||||||
Spacer(Modifier.height(3.dp))
|
|
||||||
Text(
|
|
||||||
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
|
|
||||||
color = Color.White.copy(alpha = 0.45f),
|
|
||||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Play / Shuffle / Edit-mode row ────────────────────────────────
|
|
||||||
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),
|
|
||||||
) {
|
|
||||||
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 (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),
|
|
||||||
) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
// Toggle edit mode for removing songs
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// 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.height(46.dp),
|
|
||||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
|
||||||
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
contentPadding = PaddingValues(horizontal = 14.dp),
|
|
||||||
) {
|
|
||||||
when {
|
|
||||||
dlDone -> Icon(Icons.Default.CheckCircle, null, modifier = Modifier.size(18.dp), tint = NsAccent)
|
|
||||||
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = NsAccent)
|
|
||||||
else -> Icon(Icons.Default.Download, null, modifier = Modifier.size(18.dp), tint = NsOnSurface2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(color = NsBorder)
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
|
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(Modifier.weight(1f)) {
|
|
||||||
SongRow(
|
|
||||||
song = song,
|
|
||||||
coverUrl = coverUrl(song.coverArt),
|
|
||||||
isPlaying = playerState.currentSong?.id == song.id,
|
|
||||||
starred = song.starred != null,
|
|
||||||
onPlay = { onPlay(pl.entry, idx) },
|
|
||||||
onStarToggle = { onStarToggle(song) },
|
|
||||||
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (editMode) {
|
|
||||||
IconButton(
|
|
||||||
onClick = { vm.removeSong(idx) },
|
|
||||||
modifier = Modifier.padding(end = 4.dp),
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.RemoveCircleOutline,
|
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||||
"Entfernen",
|
tint = Color.White, modifier = Modifier.size(18.dp),
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Edit + Delete buttons (top-right)
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(end = 14.dp, top = 14.dp)
|
||||||
|
.align(Alignment.TopEnd),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.45f))
|
||||||
|
.clickable { showRenameDialog = true },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Edit, "Bearbeiten",
|
||||||
|
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Black.copy(alpha = 0.45f))
|
||||||
|
.clickable { showDeleteDialog = true },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Delete, "Löschen",
|
||||||
|
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
pl.name,
|
||||||
|
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||||
|
fontSize = 26.sp, color = Color.White,
|
||||||
|
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
// Der Besitzer stand hier bisher bei JEDER Liste, auch bei den eigenen, und ohne
|
||||||
|
// Beschriftung — bei einer geteilten Liste ist genau das die interessante Angabe.
|
||||||
|
val meta = listOfNotNull(
|
||||||
|
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
|
||||||
|
// Gesamtdauer: Subsonic liefert sie beim Playlist-Objekt mit, es kostet
|
||||||
|
// also keinen zusaetzlichen Aufruf.
|
||||||
|
if (pl.duration > 0) formatGesamtdauer(pl.duration) else null,
|
||||||
|
pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }?.let { "von $it" },
|
||||||
|
).joinToString(" · ")
|
||||||
|
if (meta.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
meta, fontFamily = Outfit, fontSize = 13.sp,
|
||||||
|
color = Color.White.copy(alpha = 0.65f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!pl.comment.isNullOrBlank()) {
|
||||||
|
Spacer(Modifier.height(3.dp))
|
||||||
|
Text(
|
||||||
|
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||||
|
color = Color.White.copy(alpha = 0.45f),
|
||||||
|
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Play / Shuffle / Edit / Download ──────────────────────────────
|
||||||
|
// Four buttons in one row left no room for labels (the Play/Shuffle text was being
|
||||||
|
// clipped away), so all four are equal-width and icon-only. Play stays filled as the
|
||||||
|
// primary action; the rest are outlined and tint accent when active/done.
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) },
|
||||||
|
modifier = Modifier.weight(1f).height(46.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, -1) }, // -1 = gemischt, der Player mischt
|
||||||
|
modifier = Modifier.weight(1f).height(46.dp),
|
||||||
|
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||||
|
}
|
||||||
|
// Toggle edit mode for removing songs
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { editMode = !editMode },
|
||||||
|
modifier = Modifier.weight(1f).height(46.dp),
|
||||||
|
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||||
|
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (editMode) Icons.Default.Done else Icons.Default.Edit,
|
||||||
|
if (editMode) "Bearbeiten beenden" else "Titel entfernen",
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = if (editMode) NsAccent else NsOnSurface2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Download button — tappable unless fully done; resumes / re-downloads missing songs
|
||||||
|
val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { if (!dlDone) vm.downloadPlaylist() },
|
||||||
|
modifier = Modifier.weight(1f).height(46.dp),
|
||||||
|
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||||
|
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||||
|
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent)
|
||||||
|
else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = NsBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
SongRow(
|
||||||
|
song = song,
|
||||||
|
coverUrl = coverUrl(song.coverArt),
|
||||||
|
isPlaying = playerState.currentSong?.id == song.id,
|
||||||
|
starred = song.starred != null,
|
||||||
|
onPlay = { onPlay(pl.entry, idx) },
|
||||||
|
onStarToggle = { onStarToggle(song) },
|
||||||
|
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (editMode) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { vm.removeSong(idx) },
|
||||||
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.RemoveCircleOutline,
|
||||||
|
"Entfernen",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item { Spacer(Modifier.height(16.dp)) }
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(16.dp)) }
|
Rollbalken(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||||
|
farbe = NsOnSurface2,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rename dialog ─────────────────────────────────────────────────────────
|
// ── Rename dialog ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -57,12 +57,12 @@ fun SearchScreen(
|
|||||||
if (r.song.isNotEmpty()) {
|
if (r.song.isNotEmpty()) {
|
||||||
item { ResultHeader("Songs") }
|
item { ResultHeader("Songs") }
|
||||||
items(r.song, key = { it.id }) { song ->
|
items(r.song, key = { it.id }) { song ->
|
||||||
val idx = r.song.indexOf(song)
|
|
||||||
SongRow(
|
SongRow(
|
||||||
song = song,
|
song = song,
|
||||||
coverUrl = coverUrl(song.coverArt),
|
coverUrl = coverUrl(song.coverArt),
|
||||||
isPlaying = playerState.currentSong?.id == song.id,
|
isPlaying = playerState.currentSong?.id == song.id,
|
||||||
onPlay = { onSongPlay(r.song, idx) },
|
// Play only the tapped song; Radio mode (if on) fills the rest.
|
||||||
|
onPlay = { onSongPlay(listOf(song), 0) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ private val SCROBBLE_OPTIONS = listOf(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen() {
|
fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {}) {
|
||||||
val vm: SettingsViewModel = viewModel()
|
val vm: SettingsViewModel = viewModel()
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -80,7 +80,23 @@ fun SettingsScreen() {
|
|||||||
color = NsOnSurface,
|
color = NsOnSurface,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── 📊 Statistics (TOP) ──────────────────────────────────────────────
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onOpenStats,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
|
) {
|
||||||
|
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onOpenCompare,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
|
) {
|
||||||
|
Text("🎧 Musik-Match", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Save button (TOP) ────────────────────────────────────────────────
|
// ── Save button (TOP) ────────────────────────────────────────────────
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
||||||
|
|
||||||
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
||||||
@@ -106,6 +122,76 @@ fun SettingsScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ▶ Wiedergabe ────────────────────────────────────────────────────
|
||||||
|
SectionHeader("▶ Wiedergabe")
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Wischen wechselt den Titel", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||||
|
Text(
|
||||||
|
if (state.wischenWechseltTitel)
|
||||||
|
"Im Now-Playing: seitwärts wischen springt zum nächsten oder vorherigen Titel"
|
||||||
|
else
|
||||||
|
"Im Now-Playing: seitwärts wischen blättert zwischen Player, Warteschlange und Text",
|
||||||
|
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = state.wischenWechseltTitel,
|
||||||
|
onCheckedChange = { vm.onWischenWechseltTitel(it) },
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||||
|
uncheckedThumbColor = NsOnSurface2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 📥 Downloads ─────────────────────────────────────────────────────
|
||||||
|
SectionHeader("📥 Offline-Downloads")
|
||||||
|
|
||||||
|
var wlanWarnung by remember { mutableStateOf(false) }
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Nur über WLAN herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||||
|
Text("Pausiert unterwegs und macht im WLAN von selbst weiter", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
// Ausschalten nur mit Rueckfrage: eine grosse Playlist sind schnell mehrere Gigabyte,
|
||||||
|
// und der Downloader arbeitet sich ohne Zutun durch, bis alles da ist.
|
||||||
|
checked = state.downloadWifiOnly,
|
||||||
|
onCheckedChange = { an -> if (an) vm.onDownloadWifiOnly(true) else wlanWarnung = true },
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||||
|
uncheckedThumbColor = NsOnSurface2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (wlanWarnung) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { wlanWarnung = false },
|
||||||
|
title = { Text("Über mobile Daten laden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Downloads laufen dann auch ohne WLAN weiter — und zwar so lange, bis alles " +
|
||||||
|
"geladen ist. Eine Playlist mit 1000 Titeln sind leicht mehrere Gigabyte.",
|
||||||
|
fontFamily = Outfit, fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { vm.onDownloadWifiOnly(false); wlanWarnung = false }) {
|
||||||
|
Text("Trotzdem erlauben", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { wlanWarnung = false }) {
|
||||||
|
Text("Abbrechen", fontFamily = Outfit, color = NsAccent)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) {
|
ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
|
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
|
||||||
@@ -178,6 +264,9 @@ fun SettingsScreen() {
|
|||||||
label = "Last.fm API Key",
|
label = "Last.fm API Key",
|
||||||
hint = "Kostenlos unter last.fm/api/account/create",
|
hint = "Kostenlos unter last.fm/api/account/create",
|
||||||
)
|
)
|
||||||
|
// Vorbelegt aus BuildConfig (siehe build.gradle.kts): auf einem frischen Geraet muss nichts
|
||||||
|
// eingetippt werden. Das Feld bleibt trotzdem bedienbar — zieht der Dienst um, laesst sich
|
||||||
|
// das hier korrigieren, ohne ein neues APK zu bauen.
|
||||||
NsTextField(
|
NsTextField(
|
||||||
value = state.backendUrl, onValueChange = vm::onBackendUrl,
|
value = state.backendUrl, onValueChange = vm::onBackendUrl,
|
||||||
label = "NovaSonic Backend URL",
|
label = "NovaSonic Backend URL",
|
||||||
@@ -186,6 +275,15 @@ fun SettingsScreen() {
|
|||||||
)
|
)
|
||||||
ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend)
|
ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend)
|
||||||
|
|
||||||
|
NsTextField(
|
||||||
|
value = state.scrobbleUrl, onValueChange = vm::onScrobbleUrl,
|
||||||
|
label = "NovaSonic Scrobble URL",
|
||||||
|
hint = "Play-Log für Statistiken (Port 3710)",
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
)
|
||||||
|
ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble)
|
||||||
|
|
||||||
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
package com.novasonic.app.ui.screen
|
package com.novasonic.app.ui.screen
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.novasonic.app.ui.component.AlbumCard
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -18,9 +29,13 @@ import com.novasonic.app.app
|
|||||||
import com.novasonic.app.player.PlayerState
|
import com.novasonic.app.player.PlayerState
|
||||||
import com.novasonic.app.ui.component.SongRow
|
import com.novasonic.app.ui.component.SongRow
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
|
import com.novasonic.app.ui.component.Rollbalken
|
||||||
|
|
||||||
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
|
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
|
||||||
|
|
||||||
|
/** So viele gefolgte Kuenstler stehen in der Reihe, der Rest hinter "Alle anzeigen". */
|
||||||
|
private const val GEZEIGT = 10
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StarredScreen(
|
fun StarredScreen(
|
||||||
coverUrl: (String?) -> String?,
|
coverUrl: (String?) -> String?,
|
||||||
@@ -28,18 +43,29 @@ fun StarredScreen(
|
|||||||
onPlay: (List<Song>, Int) -> Unit,
|
onPlay: (List<Song>, Int) -> Unit,
|
||||||
onStarToggle: (Song) -> Unit,
|
onStarToggle: (Song) -> Unit,
|
||||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||||
|
onArtistClick: (String) -> Unit = {},
|
||||||
|
onAlbumClick: (String) -> Unit = {},
|
||||||
|
onOpenNews: () -> Unit = {},
|
||||||
|
onOpenFollowed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val repo = context.app.repository
|
val repo = context.app.repository
|
||||||
|
|
||||||
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
|
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
|
||||||
|
// getStarred2 liefert Kuenstler und Alben im SELBEN Paket wie die Songs — die Uebersicht
|
||||||
|
// "wem folge ich" kostet also keinen zusaetzlichen Aufruf.
|
||||||
|
var gefolgteKuenstler by remember { mutableStateOf<List<com.novasonic.app.api.model.Artist>>(emptyList()) }
|
||||||
|
var gemerkteAlben by remember { mutableStateOf<List<com.novasonic.app.api.model.Album>>(emptyList()) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var sort by remember { mutableStateOf(StarredSort.NEWEST) }
|
var sort by remember { mutableStateOf(StarredSort.NEWEST) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
try {
|
try {
|
||||||
songs = repo.getStarred().song
|
val markiert = repo.getStarred()
|
||||||
|
songs = markiert.song
|
||||||
|
gefolgteKuenstler = markiert.artist.sortedBy { it.name.lowercase() }
|
||||||
|
gemerkteAlben = markiert.album.sortedByDescending { it.starred ?: "" }
|
||||||
loading = false
|
loading = false
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = e.message
|
error = e.message
|
||||||
@@ -57,12 +83,25 @@ fun StarredScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Text(
|
Row(
|
||||||
"Favoriten",
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
|
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 24.dp, bottom = 8.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
) {
|
||||||
modifier = Modifier.padding(start = 20.dp, top = 24.dp, bottom = 8.dp),
|
Text(
|
||||||
)
|
"Favoriten",
|
||||||
|
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
// Einstieg in die Zeitleiste: hier stehen die gefolgten Künstler, also liegt die Frage
|
||||||
|
// "was gibt es Neues von denen" nahe.
|
||||||
|
if (gefolgteKuenstler.isNotEmpty()) {
|
||||||
|
TextButton(onClick = onOpenNews) {
|
||||||
|
Text("✨ Neu", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 13.sp, color = NsAccent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sort chips
|
// Sort chips
|
||||||
Row(
|
Row(
|
||||||
@@ -100,25 +139,118 @@ fun StarredScreen(
|
|||||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||||
Text(error!!, color = MaterialTheme.colorScheme.error)
|
Text(error!!, color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
} else if (sorted.isEmpty()) {
|
} else if (sorted.isEmpty() && gefolgteKuenstler.isEmpty() && gemerkteAlben.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||||
Text("Noch keine Favoriten", color = NsOnSurface2)
|
Text("Noch keine Favoriten", color = NsOnSurface2)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn {
|
val listState = rememberLazyListState()
|
||||||
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
|
Box(Modifier.fillMaxSize()) {
|
||||||
SongRow(
|
LazyColumn(state = listState) {
|
||||||
song = song,
|
if (gefolgteKuenstler.isNotEmpty()) {
|
||||||
coverUrl = coverUrl(song.coverArt),
|
item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") }
|
||||||
isPlaying = playerState.currentSong?.id == song.id,
|
item {
|
||||||
starred = song.starred != null,
|
LazyRow(
|
||||||
onPlay = { onPlay(sorted, idx) },
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
onStarToggle = { onStarToggle(song) },
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
) {
|
||||||
)
|
// Die LazyRow baut zwar nur Sichtbares, aber eine endlose Reihe ist
|
||||||
|
// zum Durchsehen untauglich — der Rest gehoert in die gefilterte
|
||||||
|
// Kuenstlerliste. GEZEIGT deckt zwei Bildschirmbreiten ab.
|
||||||
|
items(gefolgteKuenstler.take(GEZEIGT), key = { it.id }) { a ->
|
||||||
|
GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) }
|
||||||
|
}
|
||||||
|
if (gefolgteKuenstler.size > GEZEIGT) {
|
||||||
|
item(key = "alle_kuenstler") {
|
||||||
|
AlleAnzeigen(gefolgteKuenstler.size - GEZEIGT, onOpenFollowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (gemerkteAlben.isNotEmpty()) {
|
||||||
|
item { FavoritenLabel("Gemerkte Alben (${gemerkteAlben.size})") }
|
||||||
|
item {
|
||||||
|
LazyRow(
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(gemerkteAlben, key = { it.id }) { al ->
|
||||||
|
AlbumCard(album = al, coverUrl = coverUrl(al.coverArt)) { onAlbumClick(al.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sorted.isNotEmpty() && (gefolgteKuenstler.isNotEmpty() || gemerkteAlben.isNotEmpty())) {
|
||||||
|
item { FavoritenLabel("Songs (${sorted.size})") }
|
||||||
|
}
|
||||||
|
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
|
||||||
|
SongRow(
|
||||||
|
song = song,
|
||||||
|
coverUrl = coverUrl(song.coverArt),
|
||||||
|
isPlaying = playerState.currentSong?.id == song.id,
|
||||||
|
starred = song.starred != null,
|
||||||
|
onPlay = { onPlay(sorted, idx) },
|
||||||
|
onStarToggle = { onStarToggle(song) },
|
||||||
|
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(8.dp)) }
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(8.dp)) }
|
Rollbalken(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||||
|
farbe = NsOnSurface2,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FavoritenLabel(titel: String) {
|
||||||
|
Text(
|
||||||
|
titel,
|
||||||
|
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||||
|
color = NsOnSurface2,
|
||||||
|
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GefolgterKuenstler(name: String, bild: String?, albumCount: Int, onClick: () -> Unit) {
|
||||||
|
Column(Modifier.width(110.dp).clickable(onClick = onClick)) {
|
||||||
|
AsyncImage(
|
||||||
|
model = bild, contentDescription = name,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Text("$albumCount Alben", fontFamily = Outfit, fontSize = 11.sp,
|
||||||
|
color = NsOnSurface2, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AlleAnzeigen(weitere: Int, onClick: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.width(110.dp).clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("→", fontSize = 26.sp, color = NsAccent)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text("Alle anzeigen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||||
|
maxLines = 1, color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Text("+$weitere weitere", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package com.novasonic.app.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.novasonic.app.api.model.ScrobbleStats
|
||||||
|
import com.novasonic.app.ui.theme.*
|
||||||
|
import com.novasonic.app.viewmodel.StatsView
|
||||||
|
import com.novasonic.app.viewmodel.StatsViewModel
|
||||||
|
|
||||||
|
private val MONTHS = listOf(
|
||||||
|
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||||
|
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatsScreen(
|
||||||
|
coverUrl: (String?) -> String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onArtistClick: (String) -> Unit = {},
|
||||||
|
onAlbumClick: (String) -> Unit = {},
|
||||||
|
vm: StatsViewModel = viewModel(),
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||||
|
// ── Top bar ──
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Meine Statistiken",
|
||||||
|
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
|
||||||
|
color = NsOnSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigator: scope chips + year/month stepper ──
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
listOf(StatsView.ALL to "Alles", StatsView.YEAR to "Jahr", StatsView.MONTH to "Monat").forEach { (v, l) ->
|
||||||
|
val active = v == state.view
|
||||||
|
Text(
|
||||||
|
l,
|
||||||
|
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||||
|
color = if (active) Color.White else NsOnSurface2,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
.clip(RoundedCornerShape(999.dp))
|
||||||
|
.background(if (active) NsAccent else Color.Transparent)
|
||||||
|
.clickable { vm.setView(v) }
|
||||||
|
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.view != StatsView.ALL) {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
StepArrow("‹", enabled = !vm.atStart()) { vm.step(-1) }
|
||||||
|
Text(
|
||||||
|
if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}",
|
||||||
|
fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
)
|
||||||
|
StepArrow("›", enabled = !vm.atCurrent()) { vm.step(1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
|
||||||
|
!state.configured -> CenterMessage(
|
||||||
|
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
|
||||||
|
)
|
||||||
|
state.error != null -> CenterMessage(state.error!!, NsError)
|
||||||
|
(state.stats?.totalPlays ?: 0) == 0 -> CenterMessage(
|
||||||
|
"Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.",
|
||||||
|
)
|
||||||
|
else -> StatsContent(state.stats!!, coverUrl, onArtistClick, onAlbumClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatsContent(
|
||||||
|
d: ScrobbleStats,
|
||||||
|
coverUrl: (String?) -> String?,
|
||||||
|
onArtistClick: (String) -> Unit,
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
// Tiles
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(bottom = 18.dp)) {
|
||||||
|
Tile(d.totalPlays.toString(), "Plays", Modifier.weight(1f))
|
||||||
|
Tile(fmtTime(d.totalSeconds), "Hörzeit", Modifier.weight(1f))
|
||||||
|
Tile(if (d.topArtists.size >= 10) "10+" else d.topArtists.size.toString(), "Künstler", Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.series.isNotEmpty()) {
|
||||||
|
SectionHeader("Verlauf")
|
||||||
|
SeriesChart(d.series)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.topArtists.isNotEmpty()) {
|
||||||
|
SectionHeader("Top-Künstler")
|
||||||
|
d.topArtists.forEachIndexed { i, a ->
|
||||||
|
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true,
|
||||||
|
onClick = a.artistId?.let { id -> { onArtistClick(id) } })
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.topTracks.isNotEmpty()) {
|
||||||
|
SectionHeader("Top-Songs")
|
||||||
|
d.topTracks.forEachIndexed { i, t ->
|
||||||
|
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays,
|
||||||
|
onClick = t.albumId?.let { id -> { onAlbumClick(id) } })
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.topAlbums.isNotEmpty()) {
|
||||||
|
SectionHeader("Top-Alben")
|
||||||
|
d.topAlbums.forEachIndexed { i, a ->
|
||||||
|
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays,
|
||||||
|
onClick = a.albumId?.let { id -> { onAlbumClick(id) } })
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.topGenres.isNotEmpty()) {
|
||||||
|
SectionHeader("Top-Genres")
|
||||||
|
d.topGenres.forEachIndexed { i, g -> GenreRow(i + 1, g.name ?: "—", g.plays) }
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.byHour.any { it.plays > 0 }) {
|
||||||
|
SectionHeader("Hör-Uhr (Stunde)")
|
||||||
|
HourClock(d.byHour.map { it.plays })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenreRow(rank: Int, name: String, plays: Int) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||||
|
color = NsOnSurface2, modifier = Modifier.width(22.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||||
|
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StepArrow(glyph: String, enabled: Boolean, onClick: () -> Unit) {
|
||||||
|
Text(
|
||||||
|
glyph,
|
||||||
|
fontFamily = Outfit, fontSize = 20.sp, fontWeight = FontWeight.Bold,
|
||||||
|
color = if (enabled) NsOnSurface else NsOnSurface2.copy(alpha = 0.35f),
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Tile(value: String, label: String, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(NsSurface2)
|
||||||
|
.padding(14.dp),
|
||||||
|
) {
|
||||||
|
Text(value, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, color = NsOnSurface, maxLines = 1)
|
||||||
|
Text(label, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, modifier = Modifier.padding(top = 2.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(t: String) {
|
||||||
|
Text(
|
||||||
|
t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||||
|
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TopRow(
|
||||||
|
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(vertical = 5.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||||
|
color = NsOnSurface2, modifier = Modifier.width(22.dp),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(if (circular) CircleShape else RoundedCornerShape(6.dp))
|
||||||
|
.background(NsSurface2),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = cover, contentDescription = null, contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||||
|
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (!subtitle.isNullOrBlank()) {
|
||||||
|
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SeriesChart(series: List<com.novasonic.app.api.model.StatBucket>) {
|
||||||
|
val max = (series.maxOfOrNull { it.plays } ?: 1).coerceAtLeast(1)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(110.dp).horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
series.forEach { s ->
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(30.dp)) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(20.dp)
|
||||||
|
.height((6 + (84 * s.plays / max)).dp)
|
||||||
|
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
|
||||||
|
.background(NsAccent),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
seriesLabel(s.bucket), fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
|
||||||
|
maxLines = 1, modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HourClock(hours: List<Int>) {
|
||||||
|
val max = (hours.maxOrNull() ?: 1).coerceAtLeast(1)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(72.dp),
|
||||||
|
) {
|
||||||
|
hours.forEachIndexed { h, plays ->
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height((2 + (48 * plays / max)).dp)
|
||||||
|
.clip(RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp))
|
||||||
|
.background(if (plays > 0) NsAccent else NsSurface2),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (h % 6 == 0) h.toString() else "",
|
||||||
|
fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
|
||||||
|
modifier = Modifier.padding(top = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenterMessage(msg: String, color: Color = NsOnSurface2) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "3h 12m" / "12m" / "45s"
|
||||||
|
private fun fmtTime(sec: Long): String {
|
||||||
|
val h = sec / 3600
|
||||||
|
val m = (sec % 3600) / 60
|
||||||
|
return when {
|
||||||
|
h > 0 -> "${h}h ${m}m"
|
||||||
|
m > 0 -> "${m}m"
|
||||||
|
else -> "${sec}s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "2025-08" -> "08.25", "2025-08-03" -> "03.08", "2025" -> "2025"
|
||||||
|
private fun seriesLabel(b: String): String = when {
|
||||||
|
b.length == 7 -> b.substring(5) + "." + b.substring(2, 4)
|
||||||
|
b.length == 10 -> b.substring(8) + "." + b.substring(5, 7)
|
||||||
|
else -> b
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
|
|
||||||
// ── Dynamic theme state — Compose tracks reads automatically ─────────────────
|
// ── Dynamic theme state — Compose tracks reads automatically ─────────────────
|
||||||
object NsColors {
|
object NsColors {
|
||||||
|
var themeId by mutableStateOf("dark-ocean") // currently applied theme id (readable)
|
||||||
var accent by mutableStateOf(Color(0xFF4F8EF7))
|
var accent by mutableStateOf(Color(0xFF4F8EF7))
|
||||||
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
|
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
|
||||||
var bg by mutableStateOf(Color(0xFF070D14))
|
var bg by mutableStateOf(Color(0xFF070D14))
|
||||||
@@ -84,10 +85,25 @@ val NS_THEMES = listOf(
|
|||||||
Color(0xFF162A21), Color(0xFF1E3A2D),
|
Color(0xFF162A21), Color(0xFF1E3A2D),
|
||||||
Color(0xFFE8FFF1), Color(0xFF8FBFA4), Color(0xFF557A63),
|
Color(0xFFE8FFF1), Color(0xFF8FBFA4), Color(0xFF557A63),
|
||||||
),
|
),
|
||||||
|
NsTheme(
|
||||||
|
"violet", "Violet",
|
||||||
|
Color(0xFFA78BFA), Color(0xFF7C5CF0),
|
||||||
|
Color(0xFF0D0916), Color(0xFF140E20),
|
||||||
|
Color(0xFF221835), Color(0xFF2D2145),
|
||||||
|
Color(0xFFF2EDFF), Color(0xFF9B8BBD), Color(0xFF655780),
|
||||||
|
),
|
||||||
|
NsTheme(
|
||||||
|
"crimson", "Crimson",
|
||||||
|
Color(0xFFE05263), Color(0xFFB03445),
|
||||||
|
Color(0xFF120708), Color(0xFF1A0B0D),
|
||||||
|
Color(0xFF2B1316), Color(0xFF3A1A1E),
|
||||||
|
Color(0xFFFFEEF0), Color(0xFFBF8D94), Color(0xFF7D545C),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun applyNsTheme(themeId: String) {
|
fun applyNsTheme(themeId: String) {
|
||||||
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
|
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
|
||||||
|
NsColors.themeId = t.id
|
||||||
NsColors.accent = t.accent
|
NsColors.accent = t.accent
|
||||||
NsColors.accentDim = t.accentDim
|
NsColors.accentDim = t.accentDim
|
||||||
NsColors.bg = t.bg
|
NsColors.bg = t.bg
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ data class AlbumUiState(
|
|||||||
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val repo = app.app.repository
|
private val repo = app.app.repository
|
||||||
|
|
||||||
|
/** Album merken — optimistisch ueber StarState, faellt bei einem Fehlschlag zurueck. */
|
||||||
|
fun merkenUmschalten(albumId: String, aktuellMarkiert: Boolean) =
|
||||||
|
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, albumId, aktuellMarkiert)
|
||||||
private val offlineRepo = app.app.offlineRepository
|
private val offlineRepo = app.app.offlineRepository
|
||||||
private val albumId: String? = savedState["albumId"]
|
private val albumId: String? = savedState["albumId"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novasonic.app.api.ImporterClient
|
||||||
|
import com.novasonic.app.api.ReleasesClient
|
||||||
|
import com.novasonic.app.api.model.ArtistRef
|
||||||
|
import com.novasonic.app.api.model.ArtistKonzert
|
||||||
|
import com.novasonic.app.api.model.ArtistRelease
|
||||||
|
import com.novasonic.app.api.model.DownloadRequest
|
||||||
|
import com.novasonic.app.app
|
||||||
|
import com.novasonic.app.data.NotificationStore
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Welcher Reiter gezeigt wird. Getrennte Listen, weil die Zeit in verschiedene Richtungen läuft. */
|
||||||
|
enum class NewsReiter { RELEASES, KONZERTE }
|
||||||
|
|
||||||
|
data class ArtistNewsUiState(
|
||||||
|
val reiter: NewsReiter = NewsReiter.RELEASES,
|
||||||
|
val releases: List<ArtistRelease> = emptyList(),
|
||||||
|
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||||
|
val keinSchluessel: Boolean = false,
|
||||||
|
/** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */
|
||||||
|
val vorhanden: Set<String> = emptySet(),
|
||||||
|
val ohneZuordnung: List<String> = emptyList(),
|
||||||
|
val tage: Int = 90,
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val keineGefolgten: Boolean = false,
|
||||||
|
val keinBackend: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Neu von deinen Künstlern". Gefolgt = Navidromes Stern, die Künstlerliste kommt also aus
|
||||||
|
* getStarred2. Die Deezer-Auflösung macht das Backend — dort liegen die Pins und der gemeinsame
|
||||||
|
* Cache, sonst würde jedes Gerät bei jedem Öffnen dieselben Abfragen wiederholen.
|
||||||
|
*/
|
||||||
|
class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
private val repo = app.app.repository
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ArtistNewsUiState())
|
||||||
|
val state: StateFlow<ArtistNewsUiState> = _state
|
||||||
|
|
||||||
|
init { laden(90) }
|
||||||
|
|
||||||
|
/** Reiter wechseln und, falls noch nichts geladen ist, nachladen. */
|
||||||
|
fun zeigeReiter(r: NewsReiter) {
|
||||||
|
_state.value = _state.value.copy(reiter = r)
|
||||||
|
if (r == NewsReiter.KONZERTE && _state.value.konzerte.isEmpty()) ladeKonzerte()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeitfenster setzen — gibt es nur bei den Veroeffentlichungen. */
|
||||||
|
fun setzeTage(tage: Int) = laden(tage)
|
||||||
|
|
||||||
|
// Kein Zeitfenster: Ticketmaster liefert ohnehin nur Anstehendes, und dort will man alles
|
||||||
|
// Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
|
||||||
|
fun ladeKonzerte() = viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(isLoading = true, error = null,
|
||||||
|
keineGefolgten = false, keinBackend = false, keinSchluessel = false)
|
||||||
|
try {
|
||||||
|
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||||
|
if (backendUrl.isBlank()) {
|
||||||
|
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val kuenstler = repo.getStarred().artist.map { ArtistRef(it.id, it.name) }
|
||||||
|
if (kuenstler.isEmpty()) {
|
||||||
|
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val antwort = ReleasesClient.konzerte(backendUrl, kuenstler)
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
konzerte = antwort.konzerte,
|
||||||
|
keinSchluessel = antwort.keinSchluessel,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(isLoading = false, error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun laden(tage: Int = _state.value.tage) = viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(isLoading = true, tage = tage, error = null,
|
||||||
|
reiter = NewsReiter.RELEASES, keineGefolgten = false, keinBackend = false)
|
||||||
|
try {
|
||||||
|
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||||
|
if (backendUrl.isBlank()) {
|
||||||
|
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val markiert = repo.getStarred()
|
||||||
|
val kuenstler = markiert.artist.map { ArtistRef(it.id, it.name) }
|
||||||
|
if (kuenstler.isEmpty()) {
|
||||||
|
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val antwort = ReleasesClient.fetch(backendUrl, kuenstler, tage)
|
||||||
|
|
||||||
|
// Was liegt schon in der Bibliothek? Verglichen wird über Künstler + Albumname —
|
||||||
|
// Deezers Album-Id hat mit Navidromes nichts zu tun.
|
||||||
|
val vorhanden = withContext(Dispatchers.IO) {
|
||||||
|
val ausFavoriten = markiert.album.map { schluessel(it.artist ?: "", it.name) }
|
||||||
|
val ausBibliothek = kuenstler.chunked(6).flatMap { haeppchen ->
|
||||||
|
haeppchen.map { k ->
|
||||||
|
async {
|
||||||
|
runCatching { repo.getArtist(k.id).album.map { schluessel(k.name, it.name) } }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
}.awaitAll().flatten()
|
||||||
|
}
|
||||||
|
(ausFavoriten + ausBibliothek).toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
releases = antwort.releases,
|
||||||
|
ohneZuordnung = antwort.ohneZuordnung,
|
||||||
|
vorhanden = vorhanden,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.value = _state.value.copy(isLoading = false, error = e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hatSchon(rel: ArtistRelease): Boolean =
|
||||||
|
schluessel(rel.artistName, rel.title) in _state.value.vorhanden
|
||||||
|
|
||||||
|
fun herunterladen(rel: ArtistRelease, onDone: (Boolean, String) -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||||
|
if (backendUrl.isBlank()) { onDone(false, "Kein Backend konfiguriert"); return@launch }
|
||||||
|
val request = DownloadRequest(
|
||||||
|
jobId = "job_${System.currentTimeMillis()}",
|
||||||
|
type = "album",
|
||||||
|
title = rel.title,
|
||||||
|
artist = rel.artistName,
|
||||||
|
artworkUrl = rel.cover.takeIf { it.isNotBlank() },
|
||||||
|
collectionId = rel.albumId.toLongOrNull(),
|
||||||
|
itunesId = rel.albumId.toLongOrNull(),
|
||||||
|
)
|
||||||
|
val client = ImporterClient(backendUrl)
|
||||||
|
runCatching { client.download(request) }
|
||||||
|
.onSuccess {
|
||||||
|
NotificationStore.startPoll(
|
||||||
|
backendUrl, request.jobId, request.title, request.artist ?: "",
|
||||||
|
navType = "album", navAlbum = rel.title, navArtist = rel.artistName,
|
||||||
|
)
|
||||||
|
onDone(true, "✓ Download gestartet")
|
||||||
|
}
|
||||||
|
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun norm(s: String) = s.lowercase()
|
||||||
|
.replace(Regex("[^a-z0-9]"), "")
|
||||||
|
fun schluessel(kuenstler: String, album: String) = "${norm(kuenstler)}~~${norm(album)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.novasonic.app.viewmodel
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.media.MediaPlayer
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -9,6 +8,7 @@ import com.novasonic.app.api.EventsClient
|
|||||||
import com.novasonic.app.api.ImporterClient
|
import com.novasonic.app.api.ImporterClient
|
||||||
import com.novasonic.app.api.ItunesClient
|
import com.novasonic.app.api.ItunesClient
|
||||||
import com.novasonic.app.api.model.Artist
|
import com.novasonic.app.api.model.Artist
|
||||||
|
import com.novasonic.app.api.model.ArtistOverride
|
||||||
import com.novasonic.app.api.model.ConcertEvent
|
import com.novasonic.app.api.model.ConcertEvent
|
||||||
import com.novasonic.app.api.model.ArtistInfo
|
import com.novasonic.app.api.model.ArtistInfo
|
||||||
import com.novasonic.app.api.model.DownloadRequest
|
import com.novasonic.app.api.model.DownloadRequest
|
||||||
@@ -41,22 +41,46 @@ data class ArtistUiState(
|
|||||||
val partialAlbum: PartialAlbumState? = null,
|
val partialAlbum: PartialAlbumState? = null,
|
||||||
// Concerts
|
// Concerts
|
||||||
val events: List<ConcertEvent> = emptyList(),
|
val events: List<ConcertEvent> = emptyList(),
|
||||||
|
val eventsError: String? = null, // non-null → the fetch failed (vs. genuinely no concerts)
|
||||||
val showEventsSheet: Boolean = false,
|
val showEventsSheet: Boolean = false,
|
||||||
|
// Deezer fan count of the matched artist (shown under the name), null if unknown
|
||||||
|
val fanCount: Long? = null,
|
||||||
// Preview + download
|
// Preview + download
|
||||||
val previewingId: Long? = null,
|
val previewingId: Long? = null,
|
||||||
val downloadSheetItem: ItunesItem? = null,
|
val downloadSheetItem: ItunesItem? = null,
|
||||||
|
// Artist pin (same-name disambiguation): the active pin, the image it overrides the hero with,
|
||||||
|
// and the picker state (link/id paste + Deezer search candidates).
|
||||||
|
val pin: ArtistOverride? = null,
|
||||||
|
val pinnedImage: String? = null,
|
||||||
|
// When pinned, the Similar-Artists row uses Deezer "related" (correct for the pinned artist)
|
||||||
|
// instead of Last.fm's list (keyed to the wrong/merged Navidrome artist).
|
||||||
|
val pinnedRelated: List<ItunesItem> = emptyList(),
|
||||||
|
val showPinPicker: Boolean = false,
|
||||||
|
val pinSearching: Boolean = false,
|
||||||
|
val pinCandidates: List<ItunesItem> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val repo = app.app.repository
|
private val repo = app.app.repository
|
||||||
|
|
||||||
|
/** Folgen = Navidromes Stern auf dem Kuenstler. Laeuft ueber StarState, damit der Zustand
|
||||||
|
* sofort umspringt und bei einem Fehlschlag von selbst zurueckfaellt. */
|
||||||
|
fun folgenUmschalten(artistId: String, aktuellMarkiert: Boolean) =
|
||||||
|
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, artistId, aktuellMarkiert)
|
||||||
|
|
||||||
private val player = app.app.playerController
|
private val player = app.app.playerController
|
||||||
|
private val preview = app.app.previewController
|
||||||
private val artistId: String? = savedState["artistId"]
|
private val artistId: String? = savedState["artistId"]
|
||||||
|
|
||||||
private val _state = MutableStateFlow(ArtistUiState())
|
private val _state = MutableStateFlow(ArtistUiState())
|
||||||
val state: StateFlow<ArtistUiState> = _state
|
val state: StateFlow<ArtistUiState> = _state
|
||||||
|
|
||||||
private var mediaPlayer: MediaPlayer? = null
|
// 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 {
|
init {
|
||||||
if (artistId.isNullOrBlank()) {
|
if (artistId.isNullOrBlank()) {
|
||||||
@@ -64,9 +88,10 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
} else {
|
} else {
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
// Mirror the app-level preview so track rows show which clip plays (bar lives in AppNavigation).
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
player.state.collect { ps ->
|
preview.state.collect { ps ->
|
||||||
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
|
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,20 +107,31 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
val artist = artistDef.await()
|
val artist = artistDef.await()
|
||||||
val info = infoDef.await()
|
val info = infoDef.await()
|
||||||
|
|
||||||
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
|
val topDef = async { runCatching { repo.getTopSongs(artist.name, artist.id) }.getOrDefault(emptyList()) }
|
||||||
val top = topDef.await()
|
// A manual pin (shared, server-side) fixes same-name collisions — resolve it up front
|
||||||
|
// so the Deezer enrichment below uses the pinned artist, not a name-guess.
|
||||||
|
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() }
|
||||||
|
// Navidrome can hold the same track twice (single + album track) → duplicate top-song
|
||||||
|
// rows ("PIXELATED KISSES" x2). Collapse by title+album (keeps the highest play count).
|
||||||
|
val top = topDef.await().distinctBy { "${it.title}|${it.album}".lowercase() }
|
||||||
|
val pin = pinDef.await()
|
||||||
|
|
||||||
_state.value = ArtistUiState(
|
_state.value = ArtistUiState(
|
||||||
artist = artist, info = info, topSongs = top,
|
artist = artist, info = info, topSongs = top,
|
||||||
isLoading = false, itunesLoading = true,
|
isLoading = false, itunesLoading = true,
|
||||||
|
pin = pin, pinnedImage = pin?.image?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch iTunes + events in parallel without blocking the main screen
|
// Fetch iTunes + events in parallel without blocking the main screen
|
||||||
launch { loadItunesData(artist.name) }
|
launch { loadItunesData(artist.name, pin) }
|
||||||
launch {
|
launch {
|
||||||
val backendUrl = repo.getBackendUrl()
|
val backendUrl = repo.getBackendUrl()
|
||||||
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
|
runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }
|
||||||
_state.value = _state.value.copy(events = ev)
|
.onSuccess { _state.value = _state.value.copy(events = it, eventsError = null) }
|
||||||
|
.onFailure { e ->
|
||||||
|
android.util.Log.w("NovaSonic", "Konzert-Fetch fehlgeschlagen für ${artist.name}", e)
|
||||||
|
_state.value = _state.value.copy(events = emptyList(), eventsError = e.message ?: e.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
|
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
|
||||||
@@ -103,20 +139,49 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadItunesData(artistName: String) {
|
private suspend fun loadItunesData(artistName: String, pin: ArtistOverride? = null) {
|
||||||
|
// Pinned: skip the name-guess entirely and enrich straight from the pinned Deezer artist id.
|
||||||
|
val pinnedId = pin?.deezerArtistId?.toLongOrNull()
|
||||||
|
if (pinnedId != null) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
fanCount = pin.fanCount ?: _state.value.fanCount,
|
||||||
|
pinnedImage = pin.image.takeIf { it.isNotBlank() } ?: _state.value.pinnedImage,
|
||||||
|
)
|
||||||
|
coroutineScope {
|
||||||
|
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(pinnedId) }.getOrDefault(emptyList()) }
|
||||||
|
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(pinnedId, 10) }.getOrDefault(emptyList()) }
|
||||||
|
val relatedD = async { runCatching { ItunesClient.lookupRelatedArtists(pinnedId, 12) }.getOrDefault(emptyList()) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
iAlbums = iAlbumsD.await(), iTracks = iTracksD.await(),
|
||||||
|
pinnedRelated = relatedD.await(), itunesLoading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
enrichPartialCounts()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not pinned → clear any stale pinned-related from a previous pin.
|
||||||
|
_state.value = _state.value.copy(pinnedRelated = emptyList())
|
||||||
|
|
||||||
val searchResp = runCatching {
|
val searchResp = runCatching {
|
||||||
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
|
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
||||||
val iArtistId = (
|
// artists are fan-sorted (ItunesClient), so firstOrNull gives the most popular match.
|
||||||
artists.find { it.artistName?.equals(artistName, ignoreCase = true) == true }
|
// Prefer an EXACT normalized-name match: "Flay!" must not resolve to the far more popular
|
||||||
?: artists.find {
|
// "K.FLAY" (its name merely contains "flay"). Only fall back to a loose contains-match.
|
||||||
val n = it.artistName ?: return@find false
|
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||||
|
val q = norm(artistName)
|
||||||
|
val iArtist = (
|
||||||
|
artists.firstOrNull { norm(it.artistName) == q }
|
||||||
|
?: artists.firstOrNull {
|
||||||
|
val n = it.artistName ?: return@firstOrNull false
|
||||||
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
|
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
|
||||||
}
|
}
|
||||||
?: artists.firstOrNull() // iTunes sorts by relevance — first hit is almost always right
|
?: artists.firstOrNull()
|
||||||
)?.artistId
|
)
|
||||||
|
val iArtistId = iArtist?.artistId
|
||||||
|
if (iArtist?.fanCount != null) _state.value = _state.value.copy(fanCount = iArtist.fanCount)
|
||||||
|
|
||||||
if (iArtistId != null) {
|
if (iArtistId != null) {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
@@ -128,11 +193,41 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
itunesLoading = false,
|
itunesLoading = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
enrichPartialCounts()
|
||||||
} else {
|
} else {
|
||||||
_state.value = _state.value.copy(itunesLoading = false)
|
_state.value = _state.value.copy(itunesLoading = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deezer's /artist/{id}/albums omits nb_tracks, so an owned album's real total is unknown and it
|
||||||
|
* never got flagged "Unvollständig" (iCount stayed 0). Mirror the web: for MATCHED (owned) albums
|
||||||
|
* whose Deezer trackCount is unknown, fetch the album's track list once to learn the real total,
|
||||||
|
* then update state so categorizeAlbums can detect partials. Only matched albums are enriched —
|
||||||
|
* a couple of calls, not the whole discography.
|
||||||
|
*/
|
||||||
|
private suspend fun enrichPartialCounts() {
|
||||||
|
val ndAlbums = _state.value.artist?.album ?: return
|
||||||
|
val iAlbums = _state.value.iAlbums
|
||||||
|
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return
|
||||||
|
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||||
|
val enriched = coroutineScope {
|
||||||
|
iAlbums.map { ia ->
|
||||||
|
val cid = ia.collectionId
|
||||||
|
val known = (ia.trackCount ?: 0) > 0
|
||||||
|
val matched = ndAlbums.any { a ->
|
||||||
|
val n = norm(a.name); val i = norm(ia.collectionName)
|
||||||
|
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
|
||||||
|
}
|
||||||
|
if (!known && matched && cid != null) async {
|
||||||
|
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
|
||||||
|
if (n > 0) ia.copy(trackCount = n) else ia
|
||||||
|
} else async { ia }
|
||||||
|
}.map { it.await() }
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(iAlbums = enriched)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
|
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
|
||||||
|
|
||||||
fun loadSubAlbum(album: ItunesItem) {
|
fun loadSubAlbum(album: ItunesItem) {
|
||||||
@@ -195,7 +290,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
}
|
}
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
isShuffling = false,
|
isShuffling = false,
|
||||||
pendingShufflePlay = allSongs.shuffled().ifEmpty { null },
|
pendingShufflePlay = allSongs.ifEmpty { null }, // gemischt wird im Player
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,41 +349,102 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── iTunes 30s preview ───────────────────────────────────────────────────
|
// ── 30s preview (app-level PreviewController → global control bar, survives navigation) ──
|
||||||
|
|
||||||
fun togglePreview(track: ItunesItem) {
|
fun togglePreview(track: ItunesItem) {
|
||||||
val url = track.previewUrl ?: return
|
val url = track.previewUrl ?: return
|
||||||
val id = track.trackId ?: return
|
val id = track.trackId ?: return
|
||||||
if (_state.value.previewingId == id) stopPreview() else startPreview(url, id)
|
player.pause()
|
||||||
|
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startPreview(url: String, id: Long) {
|
fun stopPreview() = preview.stop()
|
||||||
stopPreview()
|
|
||||||
player.pause()
|
// ── Artist pin (same-name disambiguation) ────────────────────────────────
|
||||||
_state.value = _state.value.copy(previewingId = id)
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
fun openPinPicker() {
|
||||||
try {
|
_state.value = _state.value.copy(showPinPicker = true, pinCandidates = emptyList(), pinSearching = true)
|
||||||
val mp = MediaPlayer().apply {
|
val name = _state.value.artist?.name ?: return
|
||||||
setDataSource(url)
|
viewModelScope.launch {
|
||||||
prepare()
|
val res = runCatching { ItunesClient.search(name, entity = "musicArtist", limit = 8) }.getOrNull()
|
||||||
start()
|
val cands = res?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
||||||
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
|
_state.value = _state.value.copy(pinCandidates = cands, pinSearching = false)
|
||||||
}
|
|
||||||
mediaPlayer = mp
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_state.value = _state.value.copy(previewingId = null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopPreview() {
|
fun dismissPinPicker() { _state.value = _state.value.copy(showPinPicker = false) }
|
||||||
mediaPlayer?.runCatching { stop(); release() }
|
|
||||||
mediaPlayer = null
|
/** Pin one of the searched candidates. */
|
||||||
_state.value = _state.value.copy(previewingId = null)
|
fun pinCandidate(item: ItunesItem) {
|
||||||
|
val dzId = item.artistId ?: return
|
||||||
|
applyPin(ArtistOverride(
|
||||||
|
deezerArtistId = dzId.toString(),
|
||||||
|
name = item.artistName ?: _state.value.artist?.name ?: "",
|
||||||
|
image = item.artworkUrl100 ?: "",
|
||||||
|
fanCount = item.fanCount,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
/** Pin by a pasted Deezer link or bare id (the reliable route when search can't surface it). */
|
||||||
super.onCleared()
|
fun pinByLink(raw: String, onResult: (Boolean) -> Unit) {
|
||||||
stopPreview()
|
val id = parseDeezerArtistId(raw)?.toLongOrNull()
|
||||||
|
if (id == null) { onResult(false); return }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val a = runCatching { ItunesClient.lookupArtist(id) }.getOrNull()
|
||||||
|
if (a?.artistId == null) { onResult(false); return@launch }
|
||||||
|
applyPin(ArtistOverride(
|
||||||
|
deezerArtistId = a.artistId.toString(),
|
||||||
|
name = a.artistName ?: _state.value.artist?.name ?: "",
|
||||||
|
image = a.artworkUrl100 ?: "",
|
||||||
|
fanCount = a.fanCount,
|
||||||
|
))
|
||||||
|
onResult(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a (Deezer-related) artist name to a Navidrome library artist id, so a pinned artist's
|
||||||
|
* "Similar" chips are tappable when that artist is actually in the library. Exact name match only
|
||||||
|
* (case-insensitive) to avoid navigating to the wrong artist; null if not in the library.
|
||||||
|
*/
|
||||||
|
suspend fun libraryArtistId(name: String?): String? {
|
||||||
|
val n = name?.trim()?.lowercase() ?: return null
|
||||||
|
if (n.isEmpty()) return null
|
||||||
|
val res = runCatching { repo.search(n) }.getOrNull() ?: return null
|
||||||
|
return res.artist.firstOrNull { it.name.trim().lowercase() == n }?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearPin() {
|
||||||
|
val id = artistId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repo.removeArtistOverride(id) }
|
||||||
|
_state.value = _state.value.copy(pin = null, pinnedImage = null, showPinPicker = false, itunesLoading = true)
|
||||||
|
loadItunesData(_state.value.artist?.name ?: "", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPin(ov: ArtistOverride) {
|
||||||
|
val id = artistId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repo.setArtistOverride(id, ov) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
pin = ov,
|
||||||
|
pinnedImage = ov.image.takeIf { it.isNotBlank() },
|
||||||
|
showPinPicker = false,
|
||||||
|
itunesLoading = true,
|
||||||
|
)
|
||||||
|
loadItunesData(_state.value.artist?.name ?: "", ov)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull a Deezer artist id out of a pasted URL (deezer.com/.../artist/12345) or a bare id.
|
||||||
|
private fun parseDeezerArtistId(raw: String): String? {
|
||||||
|
val s = raw.trim()
|
||||||
|
if (s.isEmpty()) return null
|
||||||
|
Regex("artist/(\\d+)").find(s)?.let { return it.groupValues[1] }
|
||||||
|
return if (s.matches(Regex("\\d+"))) s else null
|
||||||
|
}
|
||||||
|
|
||||||
|
// No onCleared stopPreview — the preview is app-level so it keeps playing (controllable via the
|
||||||
|
// global bar) after leaving this screen.
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novasonic.app.api.model.CompareResult
|
||||||
|
import com.novasonic.app.api.model.ScrobbleUser
|
||||||
|
import com.novasonic.app.app
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class CompareUiState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val configured: Boolean = true,
|
||||||
|
val error: String? = null,
|
||||||
|
val me: String = "",
|
||||||
|
val users: List<ScrobbleUser> = emptyList(), // candidates (excludes me)
|
||||||
|
val partner: String? = null, // chosen userB; null → show picker
|
||||||
|
val result: CompareResult? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Head-to-Head "Musik-Match": pick a partner from the play-log, then compare. */
|
||||||
|
class CompareViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
private val repo = app.app.repository
|
||||||
|
private val _state = MutableStateFlow(CompareUiState())
|
||||||
|
val state: StateFlow<CompareUiState> = _state
|
||||||
|
|
||||||
|
init { loadUsers() }
|
||||||
|
|
||||||
|
fun loadUsers() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = CompareUiState(loading = true)
|
||||||
|
val settings = repo.appSettingsFlow().first()
|
||||||
|
if (settings.scrobbleUrl.isBlank()) {
|
||||||
|
_state.value = _state.value.copy(loading = false, configured = false)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val resp = repo.fetchScrobbleUsers()
|
||||||
|
_state.value = if (resp == null)
|
||||||
|
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||||
|
else
|
||||||
|
_state.value.copy(
|
||||||
|
loading = false, configured = true, error = null,
|
||||||
|
me = resp.me,
|
||||||
|
users = resp.users.filter { it.user != resp.me },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to the partner picker. */
|
||||||
|
fun clearPartner() {
|
||||||
|
_state.value = _state.value.copy(partner = null, result = null, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun choosePartner(name: String) {
|
||||||
|
_state.value = _state.value.copy(partner = name, loading = true, error = null, result = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
val res = repo.fetchScrobbleCompare(name)
|
||||||
|
_state.value = if (res == null)
|
||||||
|
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||||
|
else
|
||||||
|
_state.value.copy(loading = false, result = res, error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.novasonic.app.viewmodel
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.media.MediaPlayer
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.novasonic.app.api.ImporterClient
|
import com.novasonic.app.api.ImporterClient
|
||||||
@@ -11,8 +10,10 @@ import com.novasonic.app.api.model.DownloadRequest
|
|||||||
import com.novasonic.app.api.model.ItunesItem
|
import com.novasonic.app.api.model.ItunesItem
|
||||||
import com.novasonic.app.api.model.ItunesRssEntry
|
import com.novasonic.app.api.model.ItunesRssEntry
|
||||||
import com.novasonic.app.api.model.Song
|
import com.novasonic.app.api.model.Song
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import com.novasonic.app.api.model.artworkHd
|
import com.novasonic.app.api.model.artworkHd
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
|
import com.novasonic.app.data.NotificationStore
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -50,6 +51,9 @@ data class ExploreUiState(
|
|||||||
val albumItem: ItunesItem? = null,
|
val albumItem: ItunesItem? = null,
|
||||||
// Artist detail (iTunes)
|
// Artist detail (iTunes)
|
||||||
val artistDetail: ItunesArtistState? = null,
|
val artistDetail: ItunesArtistState? = null,
|
||||||
|
// Which "all albums" section is expanded inside the iTunes artist overlay (by title), or null.
|
||||||
|
// Hoisted here so it survives drilling into an album (nav route OR overlay) and coming back.
|
||||||
|
val artistAllAlbums: String? = null,
|
||||||
// Partial album detail (opened from artist's "Unvollständig" section)
|
// Partial album detail (opened from artist's "Unvollständig" section)
|
||||||
val partialAlbum: PartialAlbumState? = null,
|
val partialAlbum: PartialAlbumState? = null,
|
||||||
// Download sheet
|
// Download sheet
|
||||||
@@ -66,20 +70,28 @@ data class ExploreUiState(
|
|||||||
|
|
||||||
class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val repo = app.app.repository
|
private val repo = app.app.repository
|
||||||
private val player = app.app.playerController
|
private val player = app.app.playerController
|
||||||
|
private val preview = app.app.previewController
|
||||||
|
|
||||||
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
||||||
val state: StateFlow<ExploreUiState> = _state
|
val state: StateFlow<ExploreUiState> = _state
|
||||||
|
|
||||||
|
// Global-search results scroll — hoisted so it survives an album/artist drill-in and back.
|
||||||
|
val searchListState = LazyListState()
|
||||||
|
// "Alle anzeigen" overlay (Deezer artist albums): scroll + search query, hoisted so an album
|
||||||
|
// drill-in and back keeps both (the overlay itself leaves composition on drill-in).
|
||||||
|
val allAlbumsGrid = androidx.compose.foundation.lazy.grid.LazyGridState()
|
||||||
|
val allAlbumsQuery = androidx.compose.runtime.mutableStateOf("")
|
||||||
|
|
||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
private var mediaPlayer: MediaPlayer? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadCharts()
|
loadCharts()
|
||||||
|
// Mirror the app-level preview state so the track rows still show which clip is playing.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
player.state.collect { ps ->
|
preview.state.collect { ps ->
|
||||||
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
|
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,23 +114,54 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun search(q: String) {
|
private fun search(q: String) {
|
||||||
|
// Strip leading aesthetic punctuation (".diedlonely", "†…") so iTunes actually surfaces the
|
||||||
|
// artist instead of nothing. Falls back to the raw query if it was only symbols.
|
||||||
|
val term = q.trimStart { !it.isLetterOrDigit() }.trim().ifBlank { q }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(isSearching = true, error = null)
|
_state.value = _state.value.copy(isSearching = true, error = null)
|
||||||
try {
|
try {
|
||||||
val resp = ItunesClient.search(q)
|
val resp = ItunesClient.search(term)
|
||||||
val results = resp.results
|
val results = resp.results
|
||||||
|
val artists = results.filter { it.wrapperType == "artist" }
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
artists = results.filter { it.wrapperType == "artist" },
|
artists = artists,
|
||||||
albums = results.filter { it.wrapperType == "collection" },
|
albums = results.filter { it.wrapperType == "collection" },
|
||||||
tracks = results.filter { it.wrapperType == "track" && it.kind == "song" },
|
tracks = results.filter { it.wrapperType == "track" && it.kind == "song" },
|
||||||
isSearching = false,
|
isSearching = false,
|
||||||
)
|
)
|
||||||
|
enrichArtistArt(artists)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.value = _state.value.copy(isSearching = false, error = e.message)
|
_state.value = _state.value.copy(isSearching = false, error = e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iTunes artist entities carry no artwork, so search results render as grey circles. Mirror the
|
||||||
|
* web PWA: fetch one album per artist and borrow its cover. Runs after the main results are shown
|
||||||
|
* (list appears instantly) and only applies if the user is still viewing the same artist set.
|
||||||
|
*/
|
||||||
|
private fun enrichArtistArt(artists: List<ItunesItem>) {
|
||||||
|
if (artists.none { it.artworkUrl100.isNullOrBlank() && it.artistName != null }) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val enriched = artists.map { a ->
|
||||||
|
async {
|
||||||
|
if (!a.artworkUrl100.isNullOrBlank()) return@async a
|
||||||
|
val name = a.artistName ?: return@async a
|
||||||
|
val art = runCatching {
|
||||||
|
ItunesClient.search(name, entity = "album", limit = 1).results
|
||||||
|
.firstOrNull { it.wrapperType == "collection" }?.artworkUrl100
|
||||||
|
}.getOrNull()
|
||||||
|
if (art != null) a.copy(artworkUrl100 = art) else a
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
val cur = _state.value.artists
|
||||||
|
if (cur.map { it.artistId } == artists.map { it.artistId }) {
|
||||||
|
_state.value = _state.value.copy(artists = enriched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadAlbum(album: ItunesItem) {
|
fun loadAlbum(album: ItunesItem) {
|
||||||
val id = album.collectionId ?: return
|
val id = album.collectionId ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -137,34 +180,92 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
_state.value = _state.value.copy(albumTracks = null, albumItem = null)
|
_state.value = _state.value.copy(albumTracks = null, albumItem = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vom geöffneten Album zu dessen Künstler springen. Deezer legt das artist-Objekt samt id
|
||||||
|
* jedem Albumtreffer bei, der Sprung kostet also keinen zusätzlichen Suchaufruf. Die
|
||||||
|
* Albumansicht wird geschlossen, sonst läge sie weiter über der Künstlerseite. */
|
||||||
|
fun openArtistOfAlbum(album: ItunesItem) {
|
||||||
|
val id = album.artistId ?: return
|
||||||
|
val name = album.artistName ?: return
|
||||||
|
_state.value = _state.value.copy(albumTracks = null, albumItem = null, partialAlbum = null)
|
||||||
|
openArtistDetail(ItunesItem(wrapperType = "artist", artistId = id, artistName = name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openArtistAllAlbums(title: String) { _state.value = _state.value.copy(artistAllAlbums = title) }
|
||||||
|
fun closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) }
|
||||||
|
|
||||||
fun openArtistDetail(artist: ItunesItem) {
|
fun openArtistDetail(artist: ItunesItem) {
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
artistDetail = ItunesArtistState(artist = artist, isLoading = true),
|
artistDetail = ItunesArtistState(artist = artist, isLoading = true),
|
||||||
|
artistAllAlbums = null,
|
||||||
)
|
)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val artistId = artist.artistId
|
val artistId = artist.artistId
|
||||||
val artistName = artist.artistName ?: return@launch
|
val artistName = artist.artistName ?: return@launch
|
||||||
|
|
||||||
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) }
|
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) }
|
||||||
|
// Bewusst ohne id: artistId ist hier DEEZERS Kuenstler-ID, nicht Navidromes. Sie hier
|
||||||
|
// mitzugeben wuerde Navidrome eine fremde ID unterschieben.
|
||||||
val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) }
|
val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) }
|
||||||
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
|
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
|
||||||
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
|
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
|
||||||
|
|
||||||
|
val ndAlbums = ndAlbumsD.await()
|
||||||
|
val iAlbums = iAlbumsD.await()
|
||||||
|
// Aus einem Album konstruierte Künstler tragen weder Bild noch Fanzahl — einmal
|
||||||
|
// nachschlagen, damit die Kopfzeile genauso aussieht wie aus der Künstlersuche.
|
||||||
|
val voll = if (artistId != null && artist.artworkUrl100 == null && artist.fanCount == null)
|
||||||
|
runCatching { ItunesClient.lookupArtist(artistId) }.getOrNull() ?: artist
|
||||||
|
else artist
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
artistDetail = ItunesArtistState(
|
artistDetail = ItunesArtistState(
|
||||||
artist = artist,
|
artist = voll,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
ndAlbums = ndAlbumsD.await(),
|
ndAlbums = ndAlbums,
|
||||||
iAlbums = iAlbumsD.await(),
|
iAlbums = iAlbums,
|
||||||
iTracks = iTracksD.await(),
|
iTracks = iTracksD.await(),
|
||||||
topSongs = topSongsD.await(),
|
topSongs = topSongsD.await(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Deezer's artist-albums list omits nb_tracks, so owned albums never show "Unvollständig".
|
||||||
|
// Resolve the real total for matched albums (like web) so partials are detected.
|
||||||
|
val enriched = enrichPartialCounts(ndAlbums, iAlbums)
|
||||||
|
if (enriched !== iAlbums) {
|
||||||
|
_state.value.artistDetail?.let { d ->
|
||||||
|
if (d.artist.artistId == artist.artistId) {
|
||||||
|
_state.value = _state.value.copy(artistDetail = d.copy(iAlbums = enriched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** For MATCHED (owned) albums whose Deezer trackCount is unknown, fetch the album's track list
|
||||||
|
* once to learn the real total. Only owned albums are enriched (a couple of calls). */
|
||||||
|
private suspend fun enrichPartialCounts(
|
||||||
|
ndAlbums: List<Album>, iAlbums: List<ItunesItem>,
|
||||||
|
): List<ItunesItem> {
|
||||||
|
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return iAlbums
|
||||||
|
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||||
|
var changed = false
|
||||||
|
val out = coroutineScope {
|
||||||
|
iAlbums.map { ia ->
|
||||||
|
val cid = ia.collectionId
|
||||||
|
val known = (ia.trackCount ?: 0) > 0
|
||||||
|
val matched = ndAlbums.any { a ->
|
||||||
|
val n = norm(a.name); val i = norm(ia.collectionName)
|
||||||
|
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
|
||||||
|
}
|
||||||
|
if (!known && matched && cid != null) async {
|
||||||
|
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
|
||||||
|
if (n > 0) { changed = true; ia.copy(trackCount = n) } else ia
|
||||||
|
} else async { ia }
|
||||||
|
}.map { it.await() }
|
||||||
|
}
|
||||||
|
return if (changed) out else iAlbums
|
||||||
|
}
|
||||||
|
|
||||||
fun closeArtistDetail() {
|
fun closeArtistDetail() {
|
||||||
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null)
|
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null, artistAllAlbums = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadPartialAlbum(nd: Album, ia: ItunesItem) {
|
fun loadPartialAlbum(nd: Album, ia: ItunesItem) {
|
||||||
@@ -240,7 +341,15 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
val client = ImporterClient(backendUrl)
|
val client = ImporterClient(backendUrl)
|
||||||
runCatching { client.download(request) }
|
runCatching { client.download(request) }
|
||||||
.onSuccess { onDone(true, "✓ Download gestartet") }
|
.onSuccess {
|
||||||
|
// App-lifetime poll → success/failure notification even after leaving the screen
|
||||||
|
// (previously only the artist page + importer notified; search/Bibliothek was silent).
|
||||||
|
NotificationStore.startPoll(
|
||||||
|
backendUrl, request.jobId, request.title, request.artist ?: "",
|
||||||
|
navType = type, navAlbum = item.collectionName ?: "", navArtist = item.artistName ?: "",
|
||||||
|
)
|
||||||
|
onDone(true, "✓ Download gestartet")
|
||||||
|
}
|
||||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||||
client.close()
|
client.close()
|
||||||
}
|
}
|
||||||
@@ -262,41 +371,12 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
fun togglePreview(track: ItunesItem) {
|
fun togglePreview(track: ItunesItem) {
|
||||||
val url = track.previewUrl ?: return
|
val url = track.previewUrl ?: return
|
||||||
val id = track.trackId ?: return
|
val id = track.trackId ?: return
|
||||||
val cur = _state.value.previewingId
|
player.pause() // don't overlap the 30s clip with library playback
|
||||||
if (cur == id) {
|
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
|
||||||
stopPreview()
|
|
||||||
} else {
|
|
||||||
startPreview(url, id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startPreview(url: String, id: Long) {
|
fun stopPreview() = preview.stop()
|
||||||
stopPreview()
|
|
||||||
player.pause()
|
|
||||||
_state.value = _state.value.copy(previewingId = id)
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val mp = MediaPlayer().apply {
|
|
||||||
setDataSource(url)
|
|
||||||
prepare()
|
|
||||||
start()
|
|
||||||
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
|
|
||||||
}
|
|
||||||
mediaPlayer = mp
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_state.value = _state.value.copy(previewingId = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopPreview() {
|
// NOTE: no onCleared stopPreview — the preview is app-level on purpose, so leaving the Explore
|
||||||
mediaPlayer?.runCatching { stop(); release() }
|
// tab keeps it playing with the global control bar (see AppNavigation).
|
||||||
mediaPlayer = null
|
|
||||||
_state.value = _state.value.copy(previewingId = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
stopPreview()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.novasonic.app.api.ImporterClient
|
import com.novasonic.app.api.ImporterClient
|
||||||
import com.novasonic.app.api.model.DownloadRequest
|
import com.novasonic.app.api.model.DownloadRequest
|
||||||
import com.novasonic.app.api.model.ImportResolveResponse
|
import com.novasonic.app.api.model.ImportResolveResponse
|
||||||
|
import com.novasonic.app.api.model.ImportTrack
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import com.novasonic.app.data.NotificationStore
|
import com.novasonic.app.data.NotificationStore
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -26,11 +27,20 @@ data class ImporterUiState(
|
|||||||
val jobMessage: String? = null,
|
val jobMessage: String? = null,
|
||||||
val jobCompleted: Int = 0,
|
val jobCompleted: Int = 0,
|
||||||
val jobTotal: Int = 0,
|
val jobTotal: Int = 0,
|
||||||
|
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null
|
||||||
|
val previewingId: Long? = null,
|
||||||
|
|
||||||
|
// Playlist aus dem Import anlegen (wie im Web-Importer). Nur sinnvoll ab zwei Titeln.
|
||||||
|
val createPlaylist: Boolean = true,
|
||||||
|
val playlistName: String = "",
|
||||||
|
val playlistStatus: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val repo = app.app.repository
|
private val repo = app.app.repository
|
||||||
|
private val player = app.app.playerController
|
||||||
|
private val preview = app.app.previewController
|
||||||
|
|
||||||
private val _state = MutableStateFlow(ImporterUiState())
|
private val _state = MutableStateFlow(ImporterUiState())
|
||||||
val state: StateFlow<ImporterUiState> = _state
|
val state: StateFlow<ImporterUiState> = _state
|
||||||
@@ -41,8 +51,32 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
_state.value = _state.value.copy(backendUrl = s.backendUrl)
|
_state.value = _state.value.copy(backendUrl = s.backendUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Mirror the app-level preview so the track rows show which clip is playing.
|
||||||
|
viewModelScope.launch {
|
||||||
|
preview.state.collect { ps ->
|
||||||
|
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 30s preview ─────────────────────────────────────────────────────────
|
||||||
|
// Import tracks have no numeric id, so key the app-level preview by the previewUrl's hash.
|
||||||
|
fun previewId(track: ImportTrack): Long = track.previewUrl?.hashCode()?.toLong() ?: 0L
|
||||||
|
|
||||||
|
fun togglePreview(track: ImportTrack) {
|
||||||
|
val url = track.previewUrl ?: return
|
||||||
|
player.pause() // don't overlap the 30s clip with library playback
|
||||||
|
preview.toggle(
|
||||||
|
previewId(track), url,
|
||||||
|
track.trackName ?: "", track.artistName ?: "", track.artworkUrl100,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopPreview() = preview.stop()
|
||||||
|
|
||||||
|
fun setCreatePlaylist(v: Boolean) { _state.value = _state.value.copy(createPlaylist = v) }
|
||||||
|
fun setPlaylistName(v: String) { _state.value = _state.value.copy(playlistName = v) }
|
||||||
|
|
||||||
fun onLink(v: String) {
|
fun onLink(v: String) {
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
link = v,
|
link = v,
|
||||||
@@ -147,6 +181,11 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
)
|
)
|
||||||
if (st.status == "done" || st.status == "error") {
|
if (st.status == "done" || st.status == "error") {
|
||||||
_state.value = _state.value.copy(isDownloading = false)
|
_state.value = _state.value.copy(isDownloading = false)
|
||||||
|
val cur = _state.value
|
||||||
|
if (st.status == "done" && cur.createPlaylist && cur.resolved!!.tracks.size > 1) {
|
||||||
|
buildPlaylist(cur.playlistName.ifBlank { cur.resolved.name ?: "Import" },
|
||||||
|
cur.resolved.tracks)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +195,47 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt nach dem Import eine Navidrome-Playlist aus den importierten Titeln an — dieselbe Abfolge
|
||||||
|
* wie im Web: Scan anstossen, kurz warten, jeden Titel suchen, gefundene IDs in eine neue
|
||||||
|
* Playlist. Titel, die Navidrome (noch) nicht kennt, werden gezaehlt statt zu scheitern.
|
||||||
|
*/
|
||||||
|
private suspend fun buildPlaylist(name: String, tracks: List<ImportTrack>) {
|
||||||
|
_state.value = _state.value.copy(playlistStatus = "Navidrome scannt…")
|
||||||
|
runCatching { repo.startScan() }
|
||||||
|
delay(4000)
|
||||||
|
|
||||||
|
val norm = { s: String -> s.lowercase().replace(Regex("[^\\p{L}\\p{N}]+"), "") }
|
||||||
|
val ids = mutableListOf<String>()
|
||||||
|
var fehlend = 0
|
||||||
|
for (t in tracks) {
|
||||||
|
val titel = t.trackName ?: continue
|
||||||
|
val treffer = runCatching {
|
||||||
|
val q = listOfNotNull(t.artistName, titel).joinToString(" ")
|
||||||
|
val gefunden = repo.search(q).song
|
||||||
|
gefunden.firstOrNull { norm(it.title) == norm(titel) }
|
||||||
|
?: repo.search(titel).song.firstOrNull { norm(it.title) == norm(titel) }
|
||||||
|
}.getOrNull()
|
||||||
|
if (treffer != null) ids += treffer.id else fehlend++
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
playlistStatus = "Playlist: ${ids.size}/${tracks.size} gefunden…")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
playlistStatus = "Keine Titel in Navidrome gefunden — Playlist nicht angelegt.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val res = runCatching { repo.createPlaylist(name, ids) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
playlistStatus = res.fold(
|
||||||
|
{ "Playlist \"$name\" angelegt: ${ids.size}/${tracks.size} Titel" +
|
||||||
|
if (fehlend > 0) " ($fehlend nicht gefunden)" else "" },
|
||||||
|
{ "Playlist fehlgeschlagen: ${it.message}" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun mapError(code: String): String = when (code) {
|
private fun mapError(code: String): String = when (code) {
|
||||||
"EDITORIAL_PLAYLIST" ->
|
"EDITORIAL_PLAYLIST" ->
|
||||||
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
|
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.novasonic.app.viewmodel
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.novasonic.app.api.model.Album
|
import com.novasonic.app.api.model.Album
|
||||||
@@ -9,6 +11,7 @@ import com.novasonic.app.api.model.Playlist
|
|||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -22,7 +25,12 @@ data class LibraryUiState(
|
|||||||
val albumsByName: List<Album> = emptyList(), // type=alphabeticalByName (for AZ + Oldest)
|
val albumsByName: List<Album> = emptyList(), // type=alphabeticalByName (for AZ + Oldest)
|
||||||
val albumSort: AlbumSort = AlbumSort.NEWEST,
|
val albumSort: AlbumSort = AlbumSort.NEWEST,
|
||||||
val artistIndexes: List<ArtistIndex> = emptyList(),
|
val artistIndexes: List<ArtistIndex> = emptyList(),
|
||||||
|
/** IDs der markierten ("gefolgten") Künstler — getArtists liefert kein `starred` mit. */
|
||||||
|
val gefolgteIds: Set<String> = emptySet(),
|
||||||
|
val nurGefolgte: Boolean = false,
|
||||||
val playlists: List<Playlist> = emptyList(),
|
val playlists: List<Playlist> = emptyList(),
|
||||||
|
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
|
||||||
|
val currentUser: String = "",
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
@@ -34,10 +42,27 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
|
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
|
||||||
val state: StateFlow<LibraryUiState> = _state
|
val state: StateFlow<LibraryUiState> = _state
|
||||||
|
|
||||||
init { loadPlaylists() }
|
// Scroll positions live in the VM so they survive drilling into an album/artist and back (the
|
||||||
|
// Library back-stack entry, hence this VM, is retained). A full teardown of the tab → fresh VM
|
||||||
|
// → back at the top, as expected.
|
||||||
|
val albumsGridState = LazyGridState()
|
||||||
|
val artistsListState = LazyListState()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadPlaylists()
|
||||||
|
viewModelScope.launch {
|
||||||
|
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
|
||||||
|
_state.value = _state.value.copy(currentUser = u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setAlbumSort(sort: AlbumSort) {
|
fun setAlbumSort(sort: AlbumSort) {
|
||||||
|
if (sort == _state.value.albumSort) return
|
||||||
_state.value = _state.value.copy(albumSort = sort)
|
_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) {
|
fun selectTab(tab: LibraryTab) {
|
||||||
@@ -71,6 +96,18 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun nurGefolgteUmschalten(an: Boolean) {
|
||||||
|
_state.value = _state.value.copy(nurGefolgte = an)
|
||||||
|
if (an) ladeGefolgte()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markierte Künstler nachladen. Frisch bei jedem Einschalten, damit ein eben entfolgter
|
||||||
|
* Künstler nicht noch in der Liste steht. */
|
||||||
|
private fun ladeGefolgte() = viewModelScope.launch {
|
||||||
|
runCatching { repo.getStarred().artist.map { it.id }.toSet() }
|
||||||
|
.onSuccess { _state.value = _state.value.copy(gefolgteIds = it) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadArtists() = viewModelScope.launch {
|
private fun loadArtists() = viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.novasonic.app.api.model.Song
|
import com.novasonic.app.api.model.Song
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import com.novasonic.app.offline.DownloadGroupEntity
|
import com.novasonic.app.offline.DownloadGroupEntity
|
||||||
|
import com.novasonic.app.offline.DownloadState
|
||||||
import com.novasonic.app.offline.toSong
|
import com.novasonic.app.offline.toSong
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
@@ -27,6 +28,11 @@ class OfflineViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
viewModelScope.launch { offlineRepo.deleteGroup(groupId) }
|
viewModelScope.launch { offlineRepo.deleteGroup(groupId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nur FERTIGE Titel: wartende oder fehlgeschlagene haben keine lokale Datei und wuerden aus
|
||||||
|
* dem Netz gezogen — offline schlaegt die Wiedergabe dann fehl, bei grossen Playlists
|
||||||
|
* gleich beim ersten Titel. */
|
||||||
suspend fun songsForGroup(groupId: String): List<Song> =
|
suspend fun songsForGroup(groupId: String): List<Song> =
|
||||||
offlineRepo.tracksForGroup(groupId).map { it.toSong() }
|
offlineRepo.tracksForGroup(groupId)
|
||||||
|
.filter { it.state == DownloadState.DONE }
|
||||||
|
.map { it.toSong() }
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import com.novasonic.app.offline.DownloadState
|
|||||||
import com.novasonic.app.offline.GroupType
|
import com.novasonic.app.offline.GroupType
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -22,6 +23,8 @@ data class PlaylistUiState(
|
|||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val deleted: Boolean = false,
|
val deleted: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
|
||||||
|
val currentUser: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||||
@@ -42,7 +45,13 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi
|
|||||||
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
|
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
init { load() }
|
init {
|
||||||
|
load()
|
||||||
|
viewModelScope.launch {
|
||||||
|
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
|
||||||
|
_state.value = _state.value.copy(currentUser = u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
val id = playlistId
|
val id = playlistId
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.novasonic.app.viewmodel
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novasonic.app.api.model.ArtistOverride
|
||||||
import com.novasonic.app.api.model.SearchResult3
|
import com.novasonic.app.api.model.SearchResult3
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -16,6 +18,8 @@ data class SearchUiState(
|
|||||||
val searchResult: SearchResult3? = null,
|
val searchResult: SearchResult3? = null,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
// Artist pins (by Navidrome artist id) so a pinned artist shows the correct picture in results.
|
||||||
|
val overrides: Map<String, ArtistOverride> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|
||||||
class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
@@ -25,8 +29,18 @@ class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val _state = MutableStateFlow(SearchUiState())
|
private val _state = MutableStateFlow(SearchUiState())
|
||||||
val state: StateFlow<SearchUiState> = _state
|
val state: StateFlow<SearchUiState> = _state
|
||||||
|
|
||||||
|
// Results scroll — hoisted so it survives a drill-in/back within the search.
|
||||||
|
val listState = LazyListState()
|
||||||
|
|
||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val ov = runCatching { repo.artistOverrides() }.getOrDefault(emptyMap())
|
||||||
|
if (ov.isNotEmpty()) _state.value = _state.value.copy(overrides = ov)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onQueryChange(q: String) = onSearch(q)
|
fun onQueryChange(q: String) = onSearch(q)
|
||||||
|
|
||||||
fun onSearch(q: String) {
|
fun onSearch(q: String) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.novasonic.app.api.AbsClient
|
import com.novasonic.app.api.AbsClient
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import com.novasonic.app.data.PrefsStore
|
import com.novasonic.app.data.PrefsStore
|
||||||
|
import com.novasonic.app.ui.theme.NsColors
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.android.Android
|
import io.ktor.client.engine.android.Android
|
||||||
import io.ktor.client.plugins.HttpTimeout
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
@@ -28,24 +29,30 @@ data class SettingsUiState(
|
|||||||
val password: String = "",
|
val password: String = "",
|
||||||
val lastFmKey: String = "",
|
val lastFmKey: String = "",
|
||||||
val backendUrl: String = "",
|
val backendUrl: String = "",
|
||||||
|
val scrobbleUrl: String = "",
|
||||||
val scrobbleMode: String = "30s",
|
val scrobbleMode: String = "30s",
|
||||||
val showPlayCount: Boolean = false,
|
val showPlayCount: Boolean = false,
|
||||||
val theme: String = "dark-ocean",
|
val theme: String = "dark-ocean",
|
||||||
val absUrl: String = "",
|
val absUrl: String = "",
|
||||||
val absApiKey: String = "",
|
val absApiKey: String = "",
|
||||||
val absChapterTrackMode: Boolean = false,
|
val absChapterTrackMode: Boolean = false,
|
||||||
|
val downloadWifiOnly: Boolean = true,
|
||||||
|
val wischenWechseltTitel: Boolean = true,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val savedOk: Boolean = false,
|
val savedOk: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val absStatus: ConnStatus = ConnStatus.Idle,
|
val absStatus: ConnStatus = ConnStatus.Idle,
|
||||||
val backendStatus: ConnStatus = ConnStatus.Idle,
|
val backendStatus: ConnStatus = ConnStatus.Idle,
|
||||||
|
val scrobbleStatus: ConnStatus = ConnStatus.Idle,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val repo = app.app.repository
|
private val repo = app.app.repository
|
||||||
|
private val offlineRepo = app.app.offlineRepository
|
||||||
|
|
||||||
private val _state = MutableStateFlow(SettingsUiState())
|
// Seed the theme with the already-applied one so the picker doesn't jump before DataStore emits.
|
||||||
|
private val _state = MutableStateFlow(SettingsUiState(theme = NsColors.themeId))
|
||||||
val state: StateFlow<SettingsUiState> = _state
|
val state: StateFlow<SettingsUiState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -57,12 +64,15 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
password = s.password,
|
password = s.password,
|
||||||
lastFmKey = s.lastFmKey,
|
lastFmKey = s.lastFmKey,
|
||||||
backendUrl = s.backendUrl,
|
backendUrl = s.backendUrl,
|
||||||
|
scrobbleUrl = s.scrobbleUrl,
|
||||||
scrobbleMode = s.scrobbleMode,
|
scrobbleMode = s.scrobbleMode,
|
||||||
showPlayCount = s.showPlayCount,
|
showPlayCount = s.showPlayCount,
|
||||||
theme = s.theme,
|
theme = s.theme,
|
||||||
absUrl = s.absUrl,
|
absUrl = s.absUrl,
|
||||||
absApiKey = s.absApiKey,
|
absApiKey = s.absApiKey,
|
||||||
absChapterTrackMode = s.absChapterTrackMode,
|
absChapterTrackMode = s.absChapterTrackMode,
|
||||||
|
downloadWifiOnly = s.downloadWifiOnly,
|
||||||
|
wischenWechseltTitel = s.wischenWechseltTitel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,11 +83,14 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
|
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
|
||||||
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) }
|
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) }
|
||||||
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) }
|
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) }
|
||||||
|
fun onScrobbleUrl(v: String) = update { it.copy(scrobbleUrl = v, savedOk = false, scrobbleStatus = ConnStatus.Idle) }
|
||||||
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
|
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
|
||||||
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
|
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
|
||||||
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
||||||
fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
||||||
fun onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) }
|
fun onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) }
|
||||||
|
fun onDownloadWifiOnly(v: Boolean) = update { it.copy(downloadWifiOnly = v, savedOk = false) }
|
||||||
|
fun onWischenWechseltTitel(v: Boolean) = update { it.copy(wischenWechseltTitel = v, savedOk = false) }
|
||||||
|
|
||||||
fun onTheme(id: String) {
|
fun onTheme(id: String) {
|
||||||
update { it.copy(theme = id) }
|
update { it.copy(theme = id) }
|
||||||
@@ -124,6 +137,31 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun testScrobble() {
|
||||||
|
val url = _state.value.scrobbleUrl.trim().trimEnd('/')
|
||||||
|
if (url.isBlank()) {
|
||||||
|
update { it.copy(scrobbleStatus = ConnStatus.Err("Scrobble URL eingeben")) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
update { it.copy(scrobbleStatus = ConnStatus.Checking) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val http = HttpClient(Android) {
|
||||||
|
install(HttpTimeout) { requestTimeoutMillis = 5_000; connectTimeoutMillis = 4_000 }
|
||||||
|
}
|
||||||
|
val resp = http.get("$url/scrobble/health")
|
||||||
|
http.close()
|
||||||
|
if (resp.status == HttpStatusCode.OK) {
|
||||||
|
update { it.copy(scrobbleStatus = ConnStatus.Ok("Verbunden · $url")) }
|
||||||
|
} else {
|
||||||
|
update { it.copy(scrobbleStatus = ConnStatus.Err("HTTP ${resp.status.value}")) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
update { it.copy(scrobbleStatus = ConnStatus.Err("Nicht erreichbar")) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun save() {
|
fun save() {
|
||||||
val s = _state.value
|
val s = _state.value
|
||||||
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
|
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
|
||||||
@@ -139,13 +177,19 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
password = s.password,
|
password = s.password,
|
||||||
lastFmKey = s.lastFmKey.trim(),
|
lastFmKey = s.lastFmKey.trim(),
|
||||||
backendUrl = s.backendUrl.trimEnd('/'),
|
backendUrl = s.backendUrl.trimEnd('/'),
|
||||||
|
scrobbleUrl = s.scrobbleUrl.trimEnd('/'),
|
||||||
scrobbleMode = s.scrobbleMode,
|
scrobbleMode = s.scrobbleMode,
|
||||||
showPlayCount = s.showPlayCount,
|
showPlayCount = s.showPlayCount,
|
||||||
theme = s.theme,
|
theme = s.theme,
|
||||||
absUrl = s.absUrl.trimEnd('/'),
|
absUrl = s.absUrl.trimEnd('/'),
|
||||||
absApiKey = s.absApiKey.trim(),
|
absApiKey = s.absApiKey.trim(),
|
||||||
absChapterTrackMode = s.absChapterTrackMode,
|
absChapterTrackMode = s.absChapterTrackMode,
|
||||||
|
downloadWifiOnly = s.downloadWifiOnly,
|
||||||
|
wischenWechseltTitel = s.wischenWechseltTitel,
|
||||||
))
|
))
|
||||||
|
// Die Netzbedingung steckt in der bereits eingereihten Arbeit — ohne Neueinreihen
|
||||||
|
// wuerde der WLAN-Schalter erst beim naechsten App-Start greifen.
|
||||||
|
offlineRepo.netzbedingungAktualisieren()
|
||||||
_state.value = _state.value.copy(isSaving = false, savedOk = true)
|
_state.value = _state.value.copy(isSaving = false, savedOk = true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.novasonic.app.api.model.ScrobbleStats
|
||||||
|
import com.novasonic.app.app
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
enum class StatsView { ALL, YEAR, MONTH }
|
||||||
|
|
||||||
|
data class StatsUiState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val configured: Boolean = true,
|
||||||
|
val error: String? = null,
|
||||||
|
val view: StatsView = StatsView.ALL,
|
||||||
|
val year: Int = Calendar.getInstance().get(Calendar.YEAR),
|
||||||
|
val month: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11
|
||||||
|
val firstPlayedAt: Long? = null, // earliest logged play → navigator lower bound
|
||||||
|
val stats: ScrobbleStats? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class StatsViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
private val repo = app.app.repository
|
||||||
|
private val _state = MutableStateFlow(StatsUiState())
|
||||||
|
val state: StateFlow<StatsUiState> = _state
|
||||||
|
|
||||||
|
init { load() }
|
||||||
|
|
||||||
|
fun setView(v: StatsView) {
|
||||||
|
if (v == _state.value.view) return
|
||||||
|
_state.value = _state.value.copy(view = v)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step the current year/month window by ±1 (no-op in ALL). */
|
||||||
|
fun step(dir: Int) {
|
||||||
|
val s = _state.value
|
||||||
|
when (s.view) {
|
||||||
|
StatsView.YEAR -> _state.value = s.copy(year = s.year + dir)
|
||||||
|
StatsView.MONTH -> {
|
||||||
|
var m = s.month + dir
|
||||||
|
var y = s.year
|
||||||
|
if (m < 0) { m = 11; y-- }
|
||||||
|
if (m > 11) { m = 0; y++ }
|
||||||
|
_state.value = s.copy(year = y, month = m)
|
||||||
|
}
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the shown window is the current one — used to disable stepping into the future. */
|
||||||
|
fun atCurrent(): Boolean {
|
||||||
|
val s = _state.value
|
||||||
|
val now = Calendar.getInstance()
|
||||||
|
return when (s.view) {
|
||||||
|
StatsView.YEAR -> s.year >= now.get(Calendar.YEAR)
|
||||||
|
StatsView.MONTH -> s.year > now.get(Calendar.YEAR) ||
|
||||||
|
(s.year == now.get(Calendar.YEAR) && s.month >= now.get(Calendar.MONTH))
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the shown window is at/before the first logged play — caps stepping backwards. */
|
||||||
|
fun atStart(): Boolean {
|
||||||
|
val s = _state.value
|
||||||
|
val first = s.firstPlayedAt ?: return false
|
||||||
|
val c = Calendar.getInstance().apply { timeInMillis = first * 1000 }
|
||||||
|
val fy = c.get(Calendar.YEAR)
|
||||||
|
val fm = c.get(Calendar.MONTH)
|
||||||
|
return when (s.view) {
|
||||||
|
StatsView.YEAR -> s.year <= fy
|
||||||
|
StatsView.MONTH -> s.year < fy || (s.year == fy && s.month <= fm)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startOf(year: Int, month: Int, day: Int): Long {
|
||||||
|
val c = Calendar.getInstance().apply {
|
||||||
|
clear()
|
||||||
|
set(year, month, day, 0, 0, 0)
|
||||||
|
}
|
||||||
|
return c.timeInMillis / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun range(): Triple<Long, Long, String> {
|
||||||
|
val s = _state.value
|
||||||
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
|
return when (s.view) {
|
||||||
|
StatsView.ALL -> Triple(0L, nowSec + 1, "month")
|
||||||
|
StatsView.YEAR -> Triple(startOf(s.year, 0, 1), startOf(s.year + 1, 0, 1), "month")
|
||||||
|
StatsView.MONTH -> Triple(startOf(s.year, s.month, 1), startOf(s.year, s.month + 1, 1), "day")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(loading = true, error = null)
|
||||||
|
val settings = repo.appSettingsFlow().first()
|
||||||
|
if (settings.scrobbleUrl.isBlank()) {
|
||||||
|
_state.value = _state.value.copy(loading = false, configured = false)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val (from, to, bucket) = range()
|
||||||
|
val tz = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000
|
||||||
|
val stats = repo.fetchScrobbleStats(from, to, tz, bucket, 10)
|
||||||
|
_state.value = if (stats == null)
|
||||||
|
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||||
|
else
|
||||||
|
_state.value.copy(
|
||||||
|
loading = false, configured = true, stats = stats,
|
||||||
|
// Keep the earliest-play bound once known (a windowed query may return null for it).
|
||||||
|
firstPlayedAt = stats.firstPlayedAt ?: _state.value.firstPlayedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<!-- The downloaded update APK lives in cacheDir/updates and is shared with the system installer. -->
|
||||||
|
<cache-path name="updates" path="updates/"/>
|
||||||
|
</paths>
|
||||||
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,175 @@
|
|||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
// ─── DEEZER METADATA SOURCE ───────────────────────────────────────────────────
|
||||||
|
// Apple's iTunes /search returns 403 from server/datacenter IPs, so all server-side
|
||||||
|
// metadata (single-track enrichment, album track lists, artist discography, the
|
||||||
|
// metadata-editor autofill) comes from Deezer. Free, no key, ~50 req/5s, returns
|
||||||
|
// explicit flags + artist images + durations. Results are mapped to the same
|
||||||
|
// "ItunesItem" shape the download pipeline (downloadAlbumTracks / downloadTrack)
|
||||||
|
// already consumes, so nothing downstream had to change.
|
||||||
|
|
||||||
|
const DZ = 'https://api.deezer.com';
|
||||||
|
|
||||||
|
function dzGet(subpath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(`${DZ}/${subpath}`, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, res => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', c => d += c);
|
||||||
|
res.on('end', () => { try { resolve(JSON.parse(d) || {}); } catch { reject(new Error('Deezer parse error')); } });
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── matching helpers (local copies to avoid a circular require with metadata.js) ─
|
||||||
|
function normalizeTitle(s) {
|
||||||
|
return (s || '')
|
||||||
|
.toLowerCase().trim()
|
||||||
|
.replace(/\s+-\s+([^-]+)$/, ' ($1)')
|
||||||
|
.replace(/[\[((][^\]))]*[\]))]/g, '')
|
||||||
|
.replace(/[,.'!?]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
|
||||||
|
const firstWord = s => stripLeadSym((s || '').toLowerCase()).split(/[\s,;/]/)[0];
|
||||||
|
const yearOf = d => { const y = d ? new Date(d).getFullYear() : NaN; return Number.isFinite(y) ? y : undefined; };
|
||||||
|
// Deezer covers come in fixed sizes; cover_xl is 1000x1000 (matches the iTunes hi-res artwork).
|
||||||
|
const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || a?.cover || '';
|
||||||
|
|
||||||
|
// Map one album track (from /album/{id} .tracks.data — these carry no track_position,
|
||||||
|
// so the album's own ordering supplies the number) to the iTunes track shape.
|
||||||
|
function mapAlbumTrack(t, al, index) {
|
||||||
|
return {
|
||||||
|
wrapperType: 'track',
|
||||||
|
trackId: t.id,
|
||||||
|
trackName: t.title,
|
||||||
|
artistName: t.artist?.name || al.artist?.name || '', // per-track artist (mixed albums)
|
||||||
|
collectionName: al.title,
|
||||||
|
collectionArtistName: al.artist?.name || '', // album-level artist (stable)
|
||||||
|
collectionType: al.record_type === 'compilation' ? 'Compilation' : 'Album',
|
||||||
|
artworkUrl100: coverXl(al),
|
||||||
|
releaseDate: al.release_date || undefined,
|
||||||
|
primaryGenreName: al.genres?.data?.[0]?.name || undefined,
|
||||||
|
trackNumber: index + 1,
|
||||||
|
trackCount: al.nb_tracks || undefined,
|
||||||
|
trackTimeMillis: t.duration ? t.duration * 1000 : undefined,
|
||||||
|
trackExplicitness: t.explicit_lyrics ? 'explicit' : 'notExplicit',
|
||||||
|
previewUrl: t.preview || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the best track from a Deezer /search result list for (artist, title). Pure + exported
|
||||||
|
// so the matching rules are unit-testable. Strict = same title AND same first artist word;
|
||||||
|
// falls back to a title-only match, but never for Japanese titles (same title, different artist
|
||||||
|
// is common for anime/OST). Returns the matched Deezer track or null.
|
||||||
|
function pickTrackMatch(results, artist, title) {
|
||||||
|
if (!Array.isArray(results) || !results.length) return null;
|
||||||
|
const normT = normalizeTitle(title);
|
||||||
|
const normA = stripLeadSym((artist || '').split(/[;,&]/)[0].trim()).toLowerCase().trim();
|
||||||
|
const strict = results.find(t => normalizeTitle(t.title) === normT && firstWord(t.artist?.name) === firstWord(normA));
|
||||||
|
const JP_RE = /[-ヿ㐀-䶿一-鿿]/;
|
||||||
|
const isJp = JP_RE.test(normT) || JP_RE.test(normA);
|
||||||
|
// Loose artist match: same title AND the artist names correspond (equal, or one contains the
|
||||||
|
// other after stripping all non-alphanumerics — covers "Okawari" vs "DJ Okawari"). This REPLACES
|
||||||
|
// the old blind title-only fallback, which grabbed a same-titled song by a completely different
|
||||||
|
// artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and applied its wrong album/cover/year.
|
||||||
|
const _n = s => stripLeadSym((s || '').toLowerCase()).replace(/[^\p{L}\p{N}]+/gu, '');
|
||||||
|
const wantA = _n(normA);
|
||||||
|
const artistOk = candA => {
|
||||||
|
if (!wantA || !candA) return false;
|
||||||
|
if (candA === wantA) return true;
|
||||||
|
const short = Math.min(candA.length, wantA.length);
|
||||||
|
return short >= 3 && (candA.includes(wantA) || wantA.includes(candA));
|
||||||
|
};
|
||||||
|
const loose = results.find(t => normalizeTitle(t.title) === normT && artistOk(_n(t.artist?.name)));
|
||||||
|
return strict || (isJp ? null : loose) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-track metadata — the Deezer equivalent of the old iTunes lookupTrackMeta.
|
||||||
|
// Fills album/year/trackNum/genre/duration/artwork when the caller only has artist+title.
|
||||||
|
// Returns {} when nothing convincing matches (callers treat that as "no enrichment").
|
||||||
|
async function lookupTrackMeta(artist, title) {
|
||||||
|
try {
|
||||||
|
const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
|
||||||
|
const searchTitle = baseTitle !== title ? baseTitle : title;
|
||||||
|
const searchArtist = stripLeadSym((artist || '').split(/[;,&]/)[0].trim());
|
||||||
|
const q = encodeURIComponent(`${searchArtist} ${searchTitle}`);
|
||||||
|
const results = (await dzGet(`search?q=${q}&limit=10`)).data || [];
|
||||||
|
if (!results.length) return {};
|
||||||
|
|
||||||
|
const match = pickTrackMatch(results, artist, title);
|
||||||
|
if (!match) return {};
|
||||||
|
|
||||||
|
// The search result lacks genre / track number / release date — the album endpoint carries them.
|
||||||
|
const al = match.album?.id ? await dzGet(`album/${match.album.id}`).catch(() => ({})) : {};
|
||||||
|
// track_position + disk_number live on the full /track record, not on the search hit.
|
||||||
|
const tr = match.id ? await dzGet(`track/${match.id}`).catch(() => ({})) : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
album: match.album?.title || al.title || '',
|
||||||
|
albumArtist: al.artist?.name || undefined,
|
||||||
|
trackName: match.title || undefined,
|
||||||
|
year: yearOf(tr.release_date || al.release_date),
|
||||||
|
trackNum: tr.track_position || undefined,
|
||||||
|
totalTracks: al.nb_tracks || undefined,
|
||||||
|
discNum: tr.disk_number || undefined,
|
||||||
|
discTotal: undefined,
|
||||||
|
genre: al.genres?.data?.[0]?.name || undefined,
|
||||||
|
artworkUrl: coverXl(al) || coverXl(match.album) || '',
|
||||||
|
durationMs: (tr.duration || match.duration) ? (tr.duration || match.duration) * 1000 : undefined,
|
||||||
|
metaArtistName: match.artist?.name || undefined,
|
||||||
|
};
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// All tracks of a Deezer album id, in the legacy ItunesItem track shape.
|
||||||
|
async function fetchAlbumTracks(albumId) {
|
||||||
|
try {
|
||||||
|
const al = await dzGet(`album/${albumId}`);
|
||||||
|
return (al.tracks?.data || []).map((t, i) => mapAlbumTrack(t, al, i));
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Album fallback when no album id is known: search Deezer for the album, then its tracks.
|
||||||
|
async function searchAlbumTracks(artist, album) {
|
||||||
|
try {
|
||||||
|
const q = encodeURIComponent(`${artist} ${album}`);
|
||||||
|
const results = (await dzGet(`search/album?q=${q}&limit=5`)).data || [];
|
||||||
|
const lc = s => (s || '').toLowerCase();
|
||||||
|
const match = results.find(r =>
|
||||||
|
lc(r.title).includes(lc(album)) || lc(r.artist?.name).includes(lc(artist))
|
||||||
|
) || results[0];
|
||||||
|
return match ? await fetchAlbumTracks(match.id) : [];
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artist discography for the artist-download: search the artist, take real albums
|
||||||
|
// (record_type "album", not singles/EPs), return {collectionId, collectionName, artworkUrl100}.
|
||||||
|
async function fetchArtistAlbums(artistName, limit = 10) {
|
||||||
|
try {
|
||||||
|
const artists = (await dzGet(`search/artist?q=${encodeURIComponent(artistName)}&limit=1`)).data || [];
|
||||||
|
if (!artists.length) return [];
|
||||||
|
const albums = (await dzGet(`artist/${artists[0].id}/albums?limit=100`)).data || [];
|
||||||
|
return albums
|
||||||
|
.filter(a => a.record_type === 'album')
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(a => ({
|
||||||
|
collectionId: a.id,
|
||||||
|
collectionName: a.title,
|
||||||
|
artistName: artists[0].name,
|
||||||
|
artworkUrl100: coverXl(a),
|
||||||
|
}));
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dzGet,
|
||||||
|
lookupTrackMeta,
|
||||||
|
fetchAlbumTracks,
|
||||||
|
searchAlbumTracks,
|
||||||
|
fetchArtistAlbums,
|
||||||
|
// exported for unit tests
|
||||||
|
mapAlbumTrack,
|
||||||
|
pickTrackMatch,
|
||||||
|
normalizeTitle,
|
||||||
|
};
|
||||||
@@ -18,6 +18,11 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU
|
|||||||
const result = await downloadTrack({
|
const result = await downloadTrack({
|
||||||
jobId,
|
jobId,
|
||||||
artist: track.artistName || artist,
|
artist: track.artistName || artist,
|
||||||
|
// Drive the YTM/YouTube search + channel filter with THIS track's own artist, not the album
|
||||||
|
// artist. On mixed-artist soundtracks/compilations (e.g. "Strategy" is TWICE, "Love, Maybe"
|
||||||
|
// is MeloMance) the album-artist blob + its channel filter matches nothing. album_artist tag
|
||||||
|
// still uses `albumArtist` below, so album grouping is unaffected.
|
||||||
|
rawArtist: track.artistName || artist,
|
||||||
albumArtist: artist,
|
albumArtist: artist,
|
||||||
title: track.trackName || track.title,
|
title: track.trackName || track.title,
|
||||||
album: albumTitle,
|
album: albumTitle,
|
||||||
|
|||||||
@@ -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 };
|
||||||
+114
-132
@@ -6,6 +6,19 @@ function sanitize(s) {
|
|||||||
return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100);
|
return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Welcher Kuenstler in den DATEINAMEN gehoert. Bisher stand dort die vollstaendige Gaesteliste
|
||||||
|
// ("K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine,League of Legends"). Das ist nirgends
|
||||||
|
// ueblich — Picard, beets und Lidarr schreiben hoechstens einen Kuenstler in den Namen —, und es
|
||||||
|
// hatte zwei handfeste Nachteile: sanitize() schneidet bei 100 Zeichen hart ab, also mitten im
|
||||||
|
// letzten Namen, und mit dem Dateisystemlimit von 255 Byte wird es bei mehrbytigen Namen eng.
|
||||||
|
// Die vollstaendige Liste steht ohnehin im artist-Tag, und nur den liest Navidrome.
|
||||||
|
// Genommen wird der ERSTE Kuenstler — bei gemischten Alben der des Titels, nicht der des Albums,
|
||||||
|
// damit ein Soundtrack-Titel im Namen weiterhin seinen eigenen Interpreten traegt.
|
||||||
|
function dateinamensKuenstler(artist, fallback) {
|
||||||
|
const erster = (s) => String(s || '').split(/[;,]|\s+&\s+/)[0].trim();
|
||||||
|
return erster(artist) || erster(fallback) || '';
|
||||||
|
}
|
||||||
|
|
||||||
function navidromeArtist(name) {
|
function navidromeArtist(name) {
|
||||||
if (!name) return name;
|
if (!name) return name;
|
||||||
return name
|
return name
|
||||||
@@ -17,7 +30,7 @@ function navidromeArtist(name) {
|
|||||||
.replace(/,\s*/g, ';');
|
.replace(/,\s*/g, ';');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null
|
// Download hi-res cover artwork (1000x1000) to cover.jpg, return path or null
|
||||||
async function downloadArtwork(url, dir) {
|
async function downloadArtwork(url, dir) {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const dest = path.join(dir, 'cover.jpg');
|
const dest = path.join(dir, 'cover.jpg');
|
||||||
@@ -51,14 +64,31 @@ const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
|
|||||||
// Strips release-type suffixes like "- Single", "- EP", "2024" from end of title/stem.
|
// Strips release-type suffixes like "- Single", "- EP", "2024" from end of title/stem.
|
||||||
const _RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
|
const _RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
|
||||||
|
|
||||||
|
// Version markers that make a track a DISTINCT recording — brackets containing these must NOT be
|
||||||
|
// stripped during duplicate detection, or a remix/live/acoustic/instrumental collapses onto the
|
||||||
|
// base track and gets falsely skipped (e.g. "Therapy (Remix)" was skipped because "Therapy" existed).
|
||||||
|
const _VERSION_KW = /\b(?:remix|live|acoustic|instrumental|version|vip|flip|bootleg|re-?work|reprise|unplugged|demo|mashup|cover|sped|slowed|nightcore|extended|edit|mix|dub)\b/i;
|
||||||
|
|
||||||
// Robust duplicate detection: search case-insensitively across all album folders
|
// Robust duplicate detection: search case-insensitively across all album folders
|
||||||
// under the artist folder matching firstArtist, compare normalized title ignoring
|
// under the artist folder matching firstArtist, compare normalized title ignoring
|
||||||
// release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
|
// release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
|
||||||
// "recognize - Single.mp3" vs "recognize.mp3" (drift between runs).
|
// "recognize - Single.mp3" vs "recognize.mp3" (drift between runs).
|
||||||
function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
||||||
const norm = s => (s || '').toLowerCase()
|
const norm = s => (s || '')
|
||||||
.replace(/[\[((][^\]))]*[\]))]/g, '')
|
// Akzente abtrennen und verwerfen: dieselbe Kuenstlerin kommt je nach Quelle als
|
||||||
.replace(/[,.'!?]/g, '')
|
// "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, ' ')
|
||||||
|
// Auch die typografischen Apostrophe: Anbieter schreiben "Arrietty’s Song" (U+2019), eine
|
||||||
|
// spaetere Anfrage kommt mit geradem "'" — ohne das galten beide als verschiedene Titel.
|
||||||
|
.replace(/[,.!'\u2018\u2019\u02bc\u00b4`]/g, '')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
const normArtist = norm(firstArtist);
|
const normArtist = norm(firstArtist);
|
||||||
@@ -80,11 +110,18 @@ function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
|||||||
if (!AUDIO_EXTS.includes(ext)) continue;
|
if (!AUDIO_EXTS.includes(ext)) continue;
|
||||||
// Strip extension + leading track prefix ("01 - ", "01-15 – " etc.)
|
// Strip extension + leading track prefix ("01 - ", "01-15 – " etc.)
|
||||||
const stem = f.slice(0, -(ext.length + 1)).replace(/^\d[\d-]*\s*[-–—]\s*/, '');
|
const stem = f.slice(0, -(ext.length + 1)).replace(/^\d[\d-]*\s*[-–—]\s*/, '');
|
||||||
// Stem may be "Artist - Title" or just "Title"
|
// Candidate titles from the filename. The old code only used the part AFTER the first
|
||||||
|
// " - " (assuming "Artist - Title"), which mis-parses titles that themselves contain
|
||||||
|
// " - " (e.g. "Kingdom Hearts 2 - Dearly Beloved - Main Theme") or filenames with a
|
||||||
|
// garbage/absent artist prefix ("01-01 - …") → the duplicate went undetected and got
|
||||||
|
// re-downloaded. Now match if ANY reading equals the query title:
|
||||||
|
// 1) the FULL stem (title with internal dashes, or no clean artist prefix)
|
||||||
|
// 2) the part after the first " - " (normal "Artist - Title")
|
||||||
const di = stem.indexOf(' - ');
|
const di = stem.indexOf(' - ');
|
||||||
const rawTitle = di >= 0 ? stem.slice(di + 3) : stem;
|
const candidates = di >= 0 ? [stem, stem.slice(di + 3)] : [stem];
|
||||||
const normStemTitle = norm(rawTitle.replace(_RELEASE_SUFFIX_RE, ''));
|
for (const cand of candidates) {
|
||||||
if (titleVariants.has(normStemTitle)) return path.join(albumPath, f);
|
if (titleVariants.has(norm(cand.replace(_RELEASE_SUFFIX_RE, '')))) return path.join(albumPath, f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -94,6 +131,70 @@ function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUDIO_EXT_RE = /\.(mp3|flac|opus|m4a|ogg|wav)$/i;
|
||||||
|
|
||||||
|
// Vereinheitlicht einen Dateinamen-Stamm fuer den lockeren Vergleich:
|
||||||
|
// 1. fuehrende Track-Praefixe wie "01-15 – " oder "01 - " abschneiden
|
||||||
|
// 2. alle Strichvarianten (en/em dash) auf " - " bringen
|
||||||
|
function normStem(s) {
|
||||||
|
return s
|
||||||
|
.replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '')
|
||||||
|
.replace(/\s*[–—]\s*/g, ' - ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiefensuche nach einer Audiodatei, deren Stamm zum Titel passt. Letzter Ausweg, wenn der von
|
||||||
|
// Navidrome gemeldete Pfad nicht existiert (Ordner umbenannt, Tag-Pfad statt echtem Pfad).
|
||||||
|
//
|
||||||
|
// WICHTIG: der Titel muss ein VOLLSTAENDIGES Feld des Dateinamens sein, nie nur dessen Ende.
|
||||||
|
// Ein blosses endsWith(titel) liess "Bad Omens - Like A Villain" als Treffer fuer den Titel
|
||||||
|
// "VILLAIN" durchgehen — ein voellig anderer Song derselben Endsilbe. Genau solche Fehlgriffe
|
||||||
|
// landen sonst im Metadaten-Editor oder, schlimmer, im Loeschen-Endpunkt.
|
||||||
|
function findFileByStem(dir, titleStem, depth = 0) {
|
||||||
|
if (depth > 4) return null;
|
||||||
|
const normTitle = normStem(titleStem);
|
||||||
|
try {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const found = findFileByStem(path.join(dir, entry.name), titleStem, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
} else if (AUDIO_EXT_RE.test(entry.name)) {
|
||||||
|
const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '');
|
||||||
|
const bareStem = normStem(bare.toLowerCase());
|
||||||
|
// Treffer: exakt, "Kuenstler - Titel", oder Release-Suffix-Versatz ("… - Single").
|
||||||
|
if (bareStem === normTitle ||
|
||||||
|
bareStem.endsWith(' - ' + normTitle) ||
|
||||||
|
bareStem.startsWith(normTitle + ' - ') ||
|
||||||
|
normTitle.startsWith(bareStem + ' - ')) {
|
||||||
|
return path.join(dir, entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navidrome gibt ueber die Subsonic-API standardmaessig keinen echten Pfad heraus, sondern baut
|
||||||
|
// einen aus den Tags: {AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{Endung}. Steht ein "/" IM Namen
|
||||||
|
// — "K/DA", "POP/STARS" —, wird daraus eine zusaetzliche Verzeichnisebene, waehrend auf der Platte
|
||||||
|
// sanitize() ein "_" geschrieben hat ("K_DA/POP_STARS"). Der gemeldete Pfad existiert dann nicht,
|
||||||
|
// und die Suche faellt auf Aehnlichkeit zurueck. Diese Funktion baut den Ordner stattdessen genau
|
||||||
|
// so zusammen, wie der Downloader ihn angelegt haette.
|
||||||
|
function albumDirFromTags(musicRoot, albumArtist, album) {
|
||||||
|
if (!musicRoot || !albumArtist) return null;
|
||||||
|
// Der Downloader legt den Ordner unter dem ERSTEN Kuenstler an (mainArtist.split(/[,;&]/)[0]).
|
||||||
|
const ersterKuenstler = String(albumArtist).split(/[,;&]/)[0].trim();
|
||||||
|
const kandidaten = [];
|
||||||
|
for (const k of new Set([ersterKuenstler, String(albumArtist).trim()])) {
|
||||||
|
if (!k) continue;
|
||||||
|
if (album) kandidaten.push(path.join(musicRoot, sanitize(k), sanitize(album)));
|
||||||
|
kandidaten.push(path.join(musicRoot, sanitize(k)));
|
||||||
|
}
|
||||||
|
for (const c of kandidaten) {
|
||||||
|
try { if (fs.statSync(c).isDirectory()) return c; } catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch plain + synced lyrics from LRCLIB
|
// Fetch plain + synced lyrics from LRCLIB
|
||||||
async function fetchLyrics(artist, title, album) {
|
async function fetchLyrics(artist, title, album) {
|
||||||
try {
|
try {
|
||||||
@@ -109,131 +210,12 @@ async function fetchLyrics(artist, title, album) {
|
|||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize a track title for comparison: unify variant suffixes so that
|
// Track/album/artist metadata now comes from Deezer (backend/lib/deezer.js) — Apple's
|
||||||
// "don't rush - slowed" and "don't rush (slowed)" are treated as equal.
|
// iTunes /search returns 403 from server IPs. See that module for lookupTrackMeta,
|
||||||
function normalizeTitle(s) {
|
// fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums.
|
||||||
return (s || '')
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
// convert "title - variant" → "title (variant)" so both forms compare equal
|
|
||||||
.replace(/\s+-\s+([^-]+)$/, ' ($1)')
|
|
||||||
// strip all remaining parens/brackets content for the base comparison
|
|
||||||
.replace(/[\[((][^\]))]*[\]))]/g, '')
|
|
||||||
// strip punctuation so "Mr. Lawrence" == "Mr Lawrence" and comma variants match
|
|
||||||
.replace(/[,.'!?]/g, '')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// iTunes JP store returns localized genre names — map them to English equivalents
|
|
||||||
const JP_GENRE_MAP = {
|
|
||||||
'アニメ': 'Anime', 'イージーリスニング': 'Easy Listening', 'ポップ': 'Pop',
|
|
||||||
'J-Pop': 'J-Pop', 'ロック': 'Rock', 'クラシック': 'Classical', 'ジャズ': 'Jazz',
|
|
||||||
'エレクトロニック': 'Electronic', 'R&B/ソウル': 'R&B/Soul', 'ヒップホップ/ラップ': 'Hip-Hop/Rap',
|
|
||||||
'オルタナティブ': 'Alternative', 'サウンドトラック': 'Soundtrack', 'ボーカル': 'Vocal',
|
|
||||||
'インストゥルメンタル': 'Instrumental', 'ワールド': 'World', 'フォーク': 'Folk',
|
|
||||||
'カントリー': 'Country', 'レゲエ': 'Reggae', 'ソウル': 'Soul', 'ブルース': 'Blues',
|
|
||||||
'メタル': 'Metal', 'パンク': 'Punk', 'ダンス': 'Dance', 'コメディ': 'Comedy',
|
|
||||||
'スピーチ': 'Speech', 'キッズ/ファミリー': 'Kids/Family',
|
|
||||||
};
|
|
||||||
function toEnglishGenre(g) { return JP_GENRE_MAP[g] || g; }
|
|
||||||
|
|
||||||
// iTunes track lookup — fills in album, year, trackNum, totalTracks, genre when missing
|
|
||||||
async function lookupItunesTrackMeta(artist, title) {
|
|
||||||
try {
|
|
||||||
// Search with base title (strip variant suffix) for better iTunes hit rate —
|
|
||||||
// e.g. "don't rush - slowed" → search "don't rush" so iTunes finds the single
|
|
||||||
const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
|
|
||||||
const searchTitle = baseTitle !== title ? baseTitle : title;
|
|
||||||
const searchArtist = artist.split(/[;,&]/)[0].trim();
|
|
||||||
const fetchItunes = (country) => new Promise((resolve, reject) => {
|
|
||||||
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchArtist + ' ' + searchTitle)}&media=music&entity=musicTrack&limit=10&country=${country}&explicit=Yes`;
|
|
||||||
https.get(url, res => {
|
|
||||||
let d = ''; res.on('data', c => d += c);
|
|
||||||
res.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { reject(new Error('parse')); } });
|
|
||||||
}).on('error', reject);
|
|
||||||
});
|
|
||||||
const normT = normalizeTitle(title);
|
|
||||||
const normA = (searchArtist || '').toLowerCase().trim();
|
|
||||||
// Split by space OR comma/semicolon/slash so "VXLLAIN, iGRES" → "vxllain" not "vxllain,"
|
|
||||||
const firstWord = s => (s || '').toLowerCase().split(/[\s,;/]/)[0];
|
|
||||||
const strictMatch = results => results.find(t =>
|
|
||||||
normalizeTitle(t.trackName) === normT && firstWord(t.artistName) === firstWord(normA)
|
|
||||||
);
|
|
||||||
const titleOnlyMatch = results => results.find(t => normalizeTitle(t.trackName) === normT);
|
|
||||||
|
|
||||||
// Fetch both in parallel — JP for correct Japanese titles/artwork, US for English genre names
|
|
||||||
const [jpResults, usResults] = await Promise.all([fetchItunes('jp'), fetchItunes('us')]);
|
|
||||||
let match = strictMatch(jpResults);
|
|
||||||
// titleOnlyMatch (no artist check) is too permissive for Japanese titles/artists —
|
|
||||||
// common anime/OST titles exist on both JP and US stores under different artists.
|
|
||||||
// Only use it as a fallback when neither title nor artist contains Japanese characters.
|
|
||||||
const JP_RE = /[-ヿ㐀-䶿一-鿿]/;
|
|
||||||
const isJpSearch = JP_RE.test(normT) || JP_RE.test(normA);
|
|
||||||
let usMatch = strictMatch(usResults) || (!isJpSearch && titleOnlyMatch(usResults));
|
|
||||||
if (!match) match = usMatch;
|
|
||||||
if (!match) return {};
|
|
||||||
// JP genre names are localized (e.g. イージーリスニング); prefer US genre, map JP→EN as fallback
|
|
||||||
const rawGenre = (usMatch || match).primaryGenreName;
|
|
||||||
const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined;
|
|
||||||
return {
|
|
||||||
album: match.collectionName || '',
|
|
||||||
albumArtist: match.collectionArtistName || undefined,
|
|
||||||
trackName: match.trackName || undefined,
|
|
||||||
year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined,
|
|
||||||
trackNum: match.trackNumber || undefined,
|
|
||||||
totalTracks: match.trackCount || undefined,
|
|
||||||
discNum: match.discNumber || undefined,
|
|
||||||
discTotal: match.discCount || undefined,
|
|
||||||
genre: genre,
|
|
||||||
artworkUrl: (match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'),
|
|
||||||
durationMs: match.trackTimeMillis || undefined,
|
|
||||||
itunesArtistName: match.artistName || undefined,
|
|
||||||
};
|
|
||||||
} catch { return {}; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch individual tracks for an iTunes collectionId
|
|
||||||
async function fetchItunesTracks(collectionId) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song&explicit=Yes`;
|
|
||||||
https.get(url, res => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', d => data += d);
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
resolve((parsed.results || []).filter(r => r.wrapperType === 'track'));
|
|
||||||
} catch { reject(new Error('iTunes API parse error')); }
|
|
||||||
});
|
|
||||||
}).on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search iTunes for album tracks by artist + album name (fallback when no collectionId)
|
|
||||||
async function searchItunesAlbumTracks(artist, album) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const q = encodeURIComponent(`${artist} ${album}`);
|
|
||||||
https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=jp&explicit=Yes`, res => {
|
|
||||||
let d = '';
|
|
||||||
res.on('data', c => d += c);
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
const results = JSON.parse(d).results || [];
|
|
||||||
const match = results.find(r =>
|
|
||||||
r.collectionName?.toLowerCase().includes(album.toLowerCase()) ||
|
|
||||||
r.artistName?.toLowerCase().includes(artist.toLowerCase())
|
|
||||||
);
|
|
||||||
if (match) fetchItunesTracks(match.collectionId).then(resolve).catch(() => resolve([]));
|
|
||||||
else resolve([]);
|
|
||||||
} catch { resolve([]); }
|
|
||||||
});
|
|
||||||
}).on('error', () => resolve([]));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sanitize, navidromeArtist,
|
sanitize, navidromeArtist, dateinamensKuenstler,
|
||||||
downloadArtwork, findExistingTrack,
|
downloadArtwork, findExistingTrack,
|
||||||
lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks,
|
normStem, findFileByStem, albumDirFromTags,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// MusicBrainz fallback for single-track metadata — used only when Deezer finds no artist-verified
|
||||||
|
// match (see trackmeta.js). Returns the SAME shape as deezer.lookupTrackMeta so the caller is
|
||||||
|
// agnostic. Same matching discipline as Deezer: the artist must correspond, or we return {} and let
|
||||||
|
// the download use its clean fallback (album_artist = song artist, album = "Singles", source cover).
|
||||||
|
//
|
||||||
|
// MusicBrainz rules: a descriptive User-Agent is REQUIRED and requests are rate-limited to ~1/sec.
|
||||||
|
// We do a single recording search per lookup and throttle to stay within that budget. Cover art
|
||||||
|
// comes from the Cover Art Archive (coverartarchive.org) by release MBID; a 404 there is tolerated
|
||||||
|
// downstream (the download falls back to the source thumbnail).
|
||||||
|
|
||||||
|
const MB_BASE = 'https://musicbrainz.org/ws/2';
|
||||||
|
const USER_AGENT = 'NovaSonic/2.0 ( https://github.com/novasonic )';
|
||||||
|
|
||||||
|
// ── normalization + artist matching (self-contained; mirrors deezer.js intent) ──
|
||||||
|
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
|
||||||
|
const _normTitle = s => (s || '').toLowerCase().replace(/[\[((].*?[\]))]/g, '')
|
||||||
|
.replace(/\s*[-–]\s*(feat|ft|featuring)\.?.*$/i, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
||||||
|
const _normArtist = s => stripLeadSym((s || '').split(/[;,&]/)[0].toLowerCase()).replace(/[^\p{L}\p{N}]+/gu, '');
|
||||||
|
|
||||||
|
function _artistOk(candA, wantA) {
|
||||||
|
if (!wantA || !candA) return false;
|
||||||
|
if (candA === wantA) return true;
|
||||||
|
const short = Math.min(candA.length, wantA.length);
|
||||||
|
return short >= 3 && (candA.includes(wantA) || wantA.includes(candA));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure: choose the best artist-verified recording for (artist, title). Prefers a recording that
|
||||||
|
// carries a release (so we get an album). Exported for unit tests.
|
||||||
|
function mbPickMatch(recordings, artist, title) {
|
||||||
|
if (!Array.isArray(recordings) || !recordings.length) return null;
|
||||||
|
const normT = _normTitle(title);
|
||||||
|
const wantA = _normArtist(artist);
|
||||||
|
const candArtist = rec => _normArtist(rec['artist-credit']?.[0]?.name || rec['artist-credit']?.[0]?.artist?.name);
|
||||||
|
const matches = recordings.filter(r => _normTitle(r.title) === normT && _artistOk(candArtist(r), wantA));
|
||||||
|
if (!matches.length) return null;
|
||||||
|
// Prefer one with a release (album); otherwise the first artist+title match.
|
||||||
|
return matches.find(r => Array.isArray(r.releases) && r.releases.length) || matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const _yearOf = d => { const m = /^(\d{4})/.exec(d || ''); return m ? Number(m[1]) : undefined; };
|
||||||
|
|
||||||
|
// Pick the most "album-like" release: an official Album release-group, else one with a date, else first.
|
||||||
|
function _pickRelease(rec) {
|
||||||
|
const rels = Array.isArray(rec.releases) ? rec.releases : [];
|
||||||
|
if (!rels.length) return null;
|
||||||
|
const isAlbum = r => (r['release-group']?.['primary-type'] || '').toLowerCase() === 'album';
|
||||||
|
return rels.find(r => isAlbum(r) && r.date) || rels.find(isAlbum) || rels.find(r => r.date) || rels[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure: map a chosen recording to the lookupTrackMeta shape. Exported for unit tests.
|
||||||
|
function mapMbRecording(rec) {
|
||||||
|
if (!rec) return {};
|
||||||
|
const rel = _pickRelease(rec);
|
||||||
|
const artistName = rec['artist-credit']?.[0]?.name || rec['artist-credit']?.[0]?.artist?.name || undefined;
|
||||||
|
return {
|
||||||
|
album: rel?.title || undefined,
|
||||||
|
albumArtist: artistName, // recording search rarely carries the release artist-credit
|
||||||
|
trackName: rec.title || undefined,
|
||||||
|
year: _yearOf(rel?.date),
|
||||||
|
genre: undefined, // omitted (extra call); "leave the rest alone" on fallback
|
||||||
|
artworkUrl: rel?.id ? `https://coverartarchive.org/release/${rel.id}/front-500` : '',
|
||||||
|
durationMs: Number.isFinite(rec.length) ? rec.length : undefined,
|
||||||
|
metaArtistName: artistName,
|
||||||
|
_source: 'musicbrainz',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── rate limiting (~1 request/sec, global to the process) ──
|
||||||
|
let _chain = Promise.resolve();
|
||||||
|
let _last = 0;
|
||||||
|
function _throttle() {
|
||||||
|
_chain = _chain.then(async () => {
|
||||||
|
const wait = Math.max(0, 1000 - (Date.now() - _last));
|
||||||
|
if (wait) await new Promise(r => setTimeout(r, wait));
|
||||||
|
_last = Date.now();
|
||||||
|
});
|
||||||
|
return _chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookupTrackMeta(artist, title) {
|
||||||
|
try {
|
||||||
|
const a = stripLeadSym((artist || '').split(/[;,&]/)[0].trim());
|
||||||
|
const t = (title || '').replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
|
||||||
|
if (!a || !t) return {};
|
||||||
|
const q = encodeURIComponent(`recording:"${t}" AND artist:"${a}"`);
|
||||||
|
await _throttle();
|
||||||
|
const r = await fetch(`${MB_BASE}/recording?query=${q}&fmt=json&limit=10&inc=releases`, {
|
||||||
|
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!r.ok) return {};
|
||||||
|
const recordings = (await r.json()).recordings || [];
|
||||||
|
return mapMbRecording(mbPickMatch(recordings, artist, title));
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { lookupTrackMeta, mbPickMatch, mapMbRecording };
|
||||||
@@ -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) {
|
async function _fetchNdSongs(ndUrl, user, pass) {
|
||||||
const salt = Math.random().toString(36).slice(2, 10);
|
const salt = Math.random().toString(36).slice(2, 10);
|
||||||
const token = createHash('md5').update(pass + salt).digest('hex');
|
const token = createHash('md5').update(pass + salt).digest('hex');
|
||||||
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json&albumCount=0&artistCount=0`;
|
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json&albumCount=0&artistCount=0`;
|
||||||
const songs = [];
|
const songs = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Single-track metadata resolver — the ONE place callers use to enrich a downloaded single.
|
||||||
|
// Order: Deezer (artist-verified) → MusicBrainz (artist-verified) → nothing.
|
||||||
|
// Returning {} is intentional and safe: the download then uses its clean fallback
|
||||||
|
// (album_artist = song artist, album = "Singles", cover = source thumbnail, year/genre untouched).
|
||||||
|
//
|
||||||
|
// Both sources apply the SAME discipline — the artist must correspond — so a same-titled song by a
|
||||||
|
// different artist can never contaminate album/cover/year again.
|
||||||
|
|
||||||
|
const dz = require('./deezer');
|
||||||
|
const mb = require('./musicbrainz');
|
||||||
|
|
||||||
|
async function lookupTrackMeta(artist, title) {
|
||||||
|
const d = await dz.lookupTrackMeta(artist, title).catch(() => ({}));
|
||||||
|
if (d && d.album) return d; // Deezer matched (artist-verified)
|
||||||
|
|
||||||
|
const m = await mb.lookupTrackMeta(artist, title).catch(() => ({}));
|
||||||
|
if (m && m.album) return m; // MusicBrainz matched (artist-verified)
|
||||||
|
|
||||||
|
return d || {}; // nothing convincing → clean fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { lookupTrackMeta };
|
||||||
+187
-53
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|||||||
const CONFIG = require('./config');
|
const CONFIG = require('./config');
|
||||||
const { log } = require('./jobs');
|
const { log } = require('./jobs');
|
||||||
const {
|
const {
|
||||||
sanitize, navidromeArtist,
|
sanitize, navidromeArtist, dateinamensKuenstler,
|
||||||
downloadArtwork, findExistingTrack,
|
downloadArtwork, findExistingTrack,
|
||||||
} = require('./metadata');
|
} = require('./metadata');
|
||||||
const { romajiForStorage, isTokenizerReady } = require('./romaji');
|
const { romajiForStorage, isTokenizerReady } = require('./romaji');
|
||||||
@@ -12,11 +12,37 @@ const { romajiForStorage, isTokenizerReady } = require('./romaji');
|
|||||||
// Matches hiragana, katakana, CJK — used to detect Japanese content
|
// Matches hiragana, katakana, CJK — used to detect Japanese content
|
||||||
const JP_RE = /[ぁ-んァ-ン一-龯]/;
|
const JP_RE = /[ぁ-んァ-ン一-龯]/;
|
||||||
|
|
||||||
function buildTitleKeywords(title) {
|
function buildTitleKeywords(title, romaji = '') {
|
||||||
// For Japanese titles: Art Tracks on YTM/YouTube are often romanized
|
// Japanische Titel haben keine Wortgrenzen, die Zwei-laengste-Woerter-Regel greift also nicht.
|
||||||
// ("Souiunatsu" for "そういう夏") — a Japanese keyword filter would always
|
// Frueher lieferte diese Funktion dafuer eine LEERE Liste — und damit hatte der Filter bei einem
|
||||||
// reject the correct video. Channel + duration are the quality gates instead.
|
// japanischen Titel gar keine Titelpruefung mehr. Zusammen mit den Kaskadenschritten ohne
|
||||||
if (JP_RE.test(title)) return [];
|
// Kuenstlerpruefung blieb allein die Dauer uebrig, und bei einem Soundtrack-Album passt fast
|
||||||
|
// jeder andere Titel desselben Komponisten in dasselbe Dauerfenster. Genau so kam am 24.9.2026
|
||||||
|
// ein falsches Stueck aus "HUNTER×HUNTER" herein.
|
||||||
|
//
|
||||||
|
// Stattdessen wird jetzt eine Alternative gefordert: der ORIGINALTITEL oder seine Romaji-Fassung.
|
||||||
|
// Art Tracks tragen ueblicherweise das eine oder das andere. Leerzeichen und Bindestriche der
|
||||||
|
// Romaji-Fassung bleiben dabei frei, weil Uploader sie unterschiedlich setzen
|
||||||
|
// ("Ginpatsu no Shounen" / "Ginpatsu-no-Shounen").
|
||||||
|
if (JP_RE.test(title)) {
|
||||||
|
const escRe = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const alternativen = [escRe(title.trim())];
|
||||||
|
const r = (romaji || '').trim();
|
||||||
|
if (r && r.toLowerCase() !== title.trim().toLowerCase() && !JP_RE.test(r)) {
|
||||||
|
// Die Umschrift des Tokenizers ist EINE Schreibweise von mehreren. Uploader schreiben
|
||||||
|
// "Shonen" statt "Shounen", setzen Bindestriche oder lassen den Katakana-Laengungsstrich
|
||||||
|
// weg. Das Muster laesst diese Abweichungen zu — sonst waere die Alternative so streng,
|
||||||
|
// dass sie praktisch nie greift.
|
||||||
|
alternativen.push(
|
||||||
|
escRe(r)
|
||||||
|
.replace(/ー/g, '') // kommt in lateinischen Titeln nie vor
|
||||||
|
.replace(/\s+/g, '[ -]?')
|
||||||
|
.replace(/ou/gi, '(?:ou|o|\u014d|oh)')
|
||||||
|
.replace(/uu/gi, '(?:uu|u|\u016b)')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [`(?:${alternativen.join('|')})`];
|
||||||
|
}
|
||||||
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
|
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
|
||||||
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with','single','ep','album','remaster','remastered','deluxe','edition','version','live','bonus']);
|
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with','single','ep','album','remaster','remastered','deluxe','edition','version','live','bonus']);
|
||||||
const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
|
const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
|
||||||
@@ -26,14 +52,57 @@ function buildTitleKeywords(title) {
|
|||||||
.replace(/[^\w\s]/g, ' ')
|
.replace(/[^\w\s]/g, ' ')
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(w => w.length >= 3 && !VARIANT_WORDS.has(w.toLowerCase()) && !STOPWORDS.has(w.toLowerCase()));
|
.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);
|
// Numbers are the most distinctive token a title has and they are usually SHORT, so the
|
||||||
const toPattern = w => (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
|
// longest-two rule threw them away: "700 Main Street" kept only Street+Main and happily matched
|
||||||
const result = top2.map(toPattern);
|
// "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;
|
const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null;
|
||||||
if (qualifier && !top2.some(w => w.toLowerCase() === qualifier)) result.push(qualifier);
|
if (qualifier && !top2.some(w => w.toLowerCase() === qualifier)) result.push(qualifier);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aus der YTM-Suchausgabe (--flat-playlist --print webpage_url) die Song-URLs herausziehen.
|
||||||
|
// Eine Suche liefert drei Sorten Zeilen: watch?v=… (ein Song), browse/MPREb_… (ein Album) und
|
||||||
|
// browse/UC… (ein Kuenstlerkanal). Nur die erste ist ein Kandidat; die anderen beiden sind
|
||||||
|
// Listeneintraege und werden uebersprungen.
|
||||||
|
//
|
||||||
|
// Frueher galt eine browse/UC-Zeile als "yt-dlp hat einen Kanal aufgeklappt" und die GESAMTE
|
||||||
|
// Trefferliste wurde verworfen — auch die bereits gesammelten Songs davor. Bei "DAT ADAM UFO" stand
|
||||||
|
// der Kanal auf Platz 9 und riss sieben gute Treffer mit, darunter den offiziellen Art Track auf
|
||||||
|
// Platz 1; uebrig blieb der YouTube-Fallback, der einen Live-Mitschnitt zog. Aufklappen kann hier
|
||||||
|
// ohnehin nicht passieren: mit --flat-playlist druckt yt-dlp einen Kanal als EINE Zeile.
|
||||||
|
function pickYtmVideoUrls(stdout) {
|
||||||
|
const urls = [];
|
||||||
|
let skipped = 0;
|
||||||
|
for (const line of (stdout || '').split('\n')) {
|
||||||
|
const u = line.trim();
|
||||||
|
if (!u) continue;
|
||||||
|
if (u.includes('watch?v=')) urls.push(u);
|
||||||
|
else if (u.includes('/browse/')) skipped++;
|
||||||
|
}
|
||||||
|
return { urls, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
function buildVariantFilter(title) {
|
function buildVariantFilter(title) {
|
||||||
const t = (title || '').toLowerCase();
|
const t = (title || '').toLowerCase();
|
||||||
// !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that
|
// !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that
|
||||||
@@ -50,8 +119,16 @@ function buildVariantFilter(title) {
|
|||||||
{ key: 'cover', expr: 'title !~= (?i)\\bcover\\b' },
|
{ key: 'cover', expr: 'title !~= (?i)\\bcover\\b' },
|
||||||
{ key: 'acoustic', expr: 'title !~= (?i)\\bacoustic\\b' },
|
{ key: 'acoustic', expr: 'title !~= (?i)\\bacoustic\\b' },
|
||||||
{ key: 'instrumental', expr: 'title !~= (?i)\\binstrumental\\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(' & ') : '';
|
return parts.length ? parts.join(' & ') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,11 +166,22 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
|
|
||||||
const safeTitle = sanitize(cleanTitle);
|
const safeTitle = sanitize(cleanTitle);
|
||||||
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||||
// Filename format: "{01 - }Artist1, Artist2 - Title.ext"
|
// Filename format: "{01 - }Artist - Title.ext" — nur der erste Kuenstler, siehe
|
||||||
const safeArtistForFile = sanitize((artist || '').replace(/;/g, ','));
|
// dateinamensKuenstler() in lib/metadata.js. Die volle Liste steht im artist-Tag.
|
||||||
|
const safeArtistForFile = sanitize(dateinamensKuenstler(artist, mainArtist));
|
||||||
const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
|
const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
|
||||||
|
|
||||||
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle);
|
// Dublettenpruefung: findExistingTrack durchsucht NUR den Ordner des uebergebenen Kuenstlers.
|
||||||
|
// Bei Soundtracks und Compilations weicht der Album-Kuenstler vom Titel-Kuenstler ab, und je
|
||||||
|
// nachdem, ob der Song als Album oder einzeln geladen wird, landet er unter dem einen ODER dem
|
||||||
|
// anderen. Die Pruefung sah dann jeweils im falschen Ordner nach und lud ihn erneut. Deshalb wird
|
||||||
|
// bei abweichendem Titel-Kuenstler auch dessen Ordner geprueft — zweiter Durchlauf nur in genau
|
||||||
|
// diesem Fall, gemessen ~0,6 ms zusaetzlich bei knapp 29.000 Dateien.
|
||||||
|
const trackArtist = sanitize((artist || '').split(/[,;&]/)[0].trim());
|
||||||
|
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle)
|
||||||
|
|| (trackArtist && trackArtist !== safeArtist
|
||||||
|
? findExistingTrack(outputDir || CONFIG.musicPath, trackArtist, cleanTitle)
|
||||||
|
: null);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
|
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
|
||||||
return { success: true, dir, skipped: true };
|
return { success: true, dir, skipped: true };
|
||||||
@@ -111,7 +199,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
const searchTitle = cleanTitle;
|
const searchTitle = cleanTitle;
|
||||||
|
|
||||||
const variantFilter = buildVariantFilter(searchTitle);
|
const variantFilter = buildVariantFilter(searchTitle);
|
||||||
const titleKws = buildTitleKeywords(searchTitle);
|
// Romaji nur, wenn der Tokenizer bereit ist — sonst liefert romajiForStorage den Text
|
||||||
|
// unveraendert zurueck und die Alternative waere doppelt.
|
||||||
|
const titleRomaji = isTokenizerReady() ? romajiForStorage(searchTitle) : '';
|
||||||
|
const titleKws = buildTitleKeywords(searchTitle, titleRomaji);
|
||||||
const variantPart = variantFilter ? ` & ${variantFilter}` : '';
|
const variantPart = variantFilter ? ` & ${variantFilter}` : '';
|
||||||
const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : '';
|
const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : '';
|
||||||
|
|
||||||
@@ -122,6 +213,28 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
const artistFirstWord = (rawArtist || mainArtist).split(/[\s,/]+/)[0];
|
const artistFirstWord = (rawArtist || mainArtist).split(/[\s,/]+/)[0];
|
||||||
const artistChannelPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord))
|
const artistChannelPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord))
|
||||||
? ` & channel ~= (?i)${escRe(artistFirstWord)}` : '';
|
? ` & channel ~= (?i)${escRe(artistFirstWord)}` : '';
|
||||||
|
// yt-dlp fills `artist` (music metadata) on YouTube "Art Tracks" — the label-provided official
|
||||||
|
// 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.
|
||||||
|
// 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
|
// Use a space (not " - ") between artist and title: this matches how users type
|
||||||
// in the YTM search box and ranks the exact official track first. A dash skews
|
// in the YTM search box and ranks the exact official track first. A dash skews
|
||||||
@@ -185,7 +298,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
log(jobId, `🔗 Direkter Download: ${sourceUrl}`);
|
log(jobId, `🔗 Direkter Download: ${sourceUrl}`);
|
||||||
await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]);
|
await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]);
|
||||||
const audioFile = hasAudio() ? expectedAudio : null;
|
const audioFile = hasAudio() ? expectedAudio : null;
|
||||||
if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: true, dir }; }
|
// Direct-link download failed (e.g. YouTube 403 / bot check) — must report FAILURE, not success.
|
||||||
|
// Previously returned { success: true }, so the job was marked "done/Fertig!" and the UI wrongly
|
||||||
|
// said the track was added.
|
||||||
|
if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: false, failed: true, dir }; }
|
||||||
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title: searchTitle });
|
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title: searchTitle });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,14 +309,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
|
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
|
||||||
// result and treating its uploads as the search result set.
|
// result and treating its uploads as the search result set.
|
||||||
//
|
//
|
||||||
// When YTM returns an artist channel as the top hit, yt-dlp prints the channel's
|
|
||||||
// browse URL (/browse/UC…) instead of video URLs. We detect this on stdout and
|
|
||||||
// return [] so the caller falls through to the ytsearch fallback.
|
|
||||||
// NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search
|
// NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search
|
||||||
// page processing, not just channel expansion, and would wrongly return [] always.
|
// page processing, not just channel expansion, and would wrongly return [] always.
|
||||||
const getYtmVideoUrls = (maxItems) => new Promise(resolve => {
|
const getYtmVideoUrls = (maxItems) => new Promise(resolve => {
|
||||||
const urls = [];
|
let out = '';
|
||||||
let channelExpanded = false;
|
|
||||||
const proc = spawn(CONFIG.ytdlpBin, [
|
const proc = spawn(CONFIG.ytdlpBin, [
|
||||||
ytmUrl,
|
ytmUrl,
|
||||||
'--flat-playlist',
|
'--flat-playlist',
|
||||||
@@ -212,21 +324,13 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
// native Songs shelf (where the actual artist track lives).
|
// native Songs shelf (where the actual artist track lives).
|
||||||
'--extractor-args', 'youtube:player_client=web_music,default',
|
'--extractor-args', 'youtube:player_client=web_music,default',
|
||||||
], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
|
], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
|
||||||
proc.stdout.on('data', d => {
|
proc.stdout.on('data', d => { out += d.toString(); });
|
||||||
const text = d.toString();
|
|
||||||
if (text.includes('/browse/UC')) { channelExpanded = true; return; }
|
|
||||||
if (!channelExpanded) {
|
|
||||||
text.split('\n').forEach(line => {
|
|
||||||
const u = line.trim();
|
|
||||||
if (u.includes('watch?v=')) urls.push(u);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
proc.on('error', () => resolve([]));
|
proc.on('error', () => resolve([]));
|
||||||
proc.on('close', () => {
|
proc.on('close', () => {
|
||||||
if (channelExpanded) log(jobId, `⚠️ YTM: Artist-Channel in Suchergebnissen — überspringe`);
|
const { urls, skipped } = pickYtmVideoUrls(out);
|
||||||
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden`);
|
if (!urls.length) log(jobId, `⚠️ YTM: keine Song-Treffer (${skipped} Listeneintrag/-eintraege)`);
|
||||||
resolve(channelExpanded ? [] : urls);
|
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden${skipped ? ` (${skipped} Album/Kanal uebersprungen)` : ''}`);
|
||||||
|
resolve(urls);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,10 +344,25 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
} else {
|
} else {
|
||||||
durPart = '';
|
durPart = '';
|
||||||
}
|
}
|
||||||
return ['--match-filter',
|
const base = `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`;
|
||||||
`${durPart}!is_live${titleKwPart}${strict ? artistChannelPart : ''}${variant ? variantPart : ''}`];
|
// 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 = ['--playlist-items', '1-5', '--max-downloads', '1'];
|
||||||
const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1'];
|
const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1'];
|
||||||
const PI_UNFILTERED = ['--playlist-items', '1'];
|
const PI_UNFILTERED = ['--playlist-items', '1'];
|
||||||
@@ -271,29 +390,41 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
|
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
|
||||||
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]);
|
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]);
|
||||||
}
|
}
|
||||||
|
// Findet sich im Dauerfenster nichts, wird ZUERST die DAUER gelockert und der Kuenstler
|
||||||
|
// BEHALTEN — nicht umgekehrt. Der Kuenstler ist das staerkere Merkmal: die erwartete Dauer
|
||||||
|
// stammt aus den Metadaten und gehoert oft zu einer anderen Fassung, waehrend Kanalname und
|
||||||
|
// artist-Feld den Song tatsaechlich benennen.
|
||||||
|
//
|
||||||
|
// Vorher lief es andersherum, und genau daran ist am 24.9.2026 "Dearly Beloved" von TPR
|
||||||
|
// gescheitert: erwartet wurden 230 s (Deezer kennt TPR nicht und lieferte "Dearly Beloved I"
|
||||||
|
// von Project Destati), der richtige Upload dauert 136 s und fiel aus dem Fenster — waehrend
|
||||||
|
// eine Rockband namens DEARLY BELOVED mit ihrem Stueck "LSD" bei 250 s mitten hinein passte.
|
||||||
|
// Deren BANDNAME erfuellte die Titelpruefung. Der Schritt ohne Kanalpruefung griff zuerst zu.
|
||||||
|
//
|
||||||
|
// Es faellt KEIN Schritt weg, nur die Reihenfolge dreht sich: was vorher gefunden wurde, wird
|
||||||
|
// weiter gefunden, nur gegebenenfalls eine Stufe spaeter (geprueft, siehe scripts/filter-probe.mjs).
|
||||||
if (!hasAudio()) {
|
if (!hasAudio()) {
|
||||||
// 3. YTM all-10 — no channel check (label channels, etc.)
|
// 3. YTM all — ohne Dauer, aber Kuenstler + Titel verlangt
|
||||||
log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…');
|
|
||||||
await tryYtm(0, mf(dur, true));
|
|
||||||
}
|
|
||||||
if (!hasAudio()) {
|
|
||||||
// 4. YT top-10 — no channel check
|
|
||||||
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
|
|
||||||
}
|
|
||||||
// No duration match. "Mittelweg": relax the duration constraint but REQUIRE the artist
|
|
||||||
// channel (+ title). This catches legit tracks whose YT upload duration drifts from the
|
|
||||||
// iTunes value, while never grabbing a same-titled video by someone else. If nothing
|
|
||||||
// matches title+channel we download NOTHING and the job fails cleanly (see end of fn) —
|
|
||||||
// far better than silently saving the wrong song (e.g. a "recognize" fan clip).
|
|
||||||
if (!hasAudio()) {
|
|
||||||
// 5. YTM all — no duration, but channel + title required
|
|
||||||
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…');
|
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…');
|
||||||
await tryYtm(0, mf(null, true, true));
|
await tryYtm(0, mf(null, true, true));
|
||||||
}
|
}
|
||||||
if (!hasAudio()) {
|
if (!hasAudio()) {
|
||||||
// 6. YT top-10 — no duration, but channel + title required
|
// 4. YT top-10 — ohne Dauer, aber Kuenstler + Titel verlangt
|
||||||
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(null, true, true), ...baseArgs]);
|
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(null, true, true), ...baseArgs]);
|
||||||
}
|
}
|
||||||
|
// Letzter Ausweg: Kanalpruefung fallen lassen, dafuer die Dauer wieder verlangen. Das faengt
|
||||||
|
// offizielle Uploads von Label-Kanaelen, die den Kuenstler weder im Kanalnamen noch im
|
||||||
|
// artist-Feld tragen. Bleibt auch das ohne Treffer, wird NICHTS geladen und der Auftrag
|
||||||
|
// scheitert sauber — besser als still den falschen Song zu speichern.
|
||||||
|
if (!hasAudio()) {
|
||||||
|
// 5. YTM all-10 — ohne Kanalpruefung, dafuer im Dauerfenster
|
||||||
|
log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…');
|
||||||
|
await tryYtm(0, mf(dur, true));
|
||||||
|
}
|
||||||
|
if (!hasAudio()) {
|
||||||
|
// 6. YT top-10 — ohne Kanalpruefung, dafuer im Dauerfenster
|
||||||
|
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log(jobId, `🔍 Suche YT Music: "${query}"`);
|
log(jobId, `🔍 Suche YT Music: "${query}"`);
|
||||||
// 1. YTM top-5 — strict: title + variant + channel check
|
// 1. YTM top-5 — strict: title + variant + channel check
|
||||||
@@ -359,7 +490,10 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
|
|||||||
'-metadata', `artist=${tagArtist}`,
|
'-metadata', `artist=${tagArtist}`,
|
||||||
'-metadata', `album_artist=${tagAlbumArtist}`,
|
'-metadata', `album_artist=${tagAlbumArtist}`,
|
||||||
);
|
);
|
||||||
if (album) ffArgs.push('-metadata', `album=${album}`);
|
// Always write an album tag, defaulting to "Singles" so it matches the on-disk folder
|
||||||
|
// (album || 'Singles'). Without this, no-album tracks land in a "Singles" folder but show
|
||||||
|
// as "[Unknown Album]" in Navidrome — a tag/folder mismatch that breaks the metadata editor.
|
||||||
|
ffArgs.push('-metadata', `album=${album || 'Singles'}`);
|
||||||
// Sort tags — only write when they differ from main (no-op for non-Japanese tracks)
|
// Sort tags — only write when they differ from main (no-op for non-Japanese tracks)
|
||||||
if (sortTitle !== title) ffArgs.push('-metadata', `TITLESORT=${sortTitle}`);
|
if (sortTitle !== title) ffArgs.push('-metadata', `TITLESORT=${sortTitle}`);
|
||||||
if (sortArtist !== tagArtist) ffArgs.push('-metadata', `ARTISTSORT=${sortArtist}`);
|
if (sortArtist !== tagArtist) ffArgs.push('-metadata', `ARTISTSORT=${sortArtist}`);
|
||||||
@@ -405,4 +539,4 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
|
|||||||
return { success: true, dir };
|
return { success: true, dir };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { downloadTrack, embedMetadata };
|
module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter, pickYtmVideoUrls };
|
||||||
+615
-158
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "novasonic-backend",
|
"name": "novasonic-backend",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.0",
|
"express": "^4.18.0",
|
||||||
"kuromoji": "^0.1.2"
|
"kuromoji": "^0.1.2"
|
||||||
|
|||||||
@@ -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,120 @@
|
|||||||
|
'use strict';
|
||||||
|
// Unit tests for the Deezer metadata mapping/matching (backend/lib/deezer.js).
|
||||||
|
// Pure logic only — no network. Run: `node --test` from backend/ (or `npm test`).
|
||||||
|
// A live smoke test against api.deezer.com is included but only runs with RUN_LIVE=1.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const dz = require('../lib/deezer');
|
||||||
|
|
||||||
|
// ── Fixtures: the exact Deezer shapes the mappers consume ─────────────────────
|
||||||
|
const ALBUM = {
|
||||||
|
id: 302127,
|
||||||
|
title: 'Discovery',
|
||||||
|
cover_xl: 'https://img/cover_xl.jpg',
|
||||||
|
cover_big: 'https://img/cover_big.jpg',
|
||||||
|
release_date: '2001-03-07',
|
||||||
|
nb_tracks: 14,
|
||||||
|
record_type: 'album',
|
||||||
|
genres: { data: [{ id: 113, name: 'Dance' }] },
|
||||||
|
artist: { id: 27, name: 'Daft Punk' },
|
||||||
|
};
|
||||||
|
const TRACK = {
|
||||||
|
id: 3135556,
|
||||||
|
title: 'Harder, Better, Faster, Stronger',
|
||||||
|
duration: 224,
|
||||||
|
explicit_lyrics: false,
|
||||||
|
preview: 'https://img/preview.mp3',
|
||||||
|
artist: { id: 27, name: 'Daft Punk' },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('mapAlbumTrack: numbers by album order and inherits album-level fields', () => {
|
||||||
|
const item = dz.mapAlbumTrack(TRACK, ALBUM, 3); // 4th track
|
||||||
|
assert.equal(item.wrapperType, 'track');
|
||||||
|
assert.equal(item.kind, undefined); // shape has no kind here; downloadAlbumTracks doesn't read it
|
||||||
|
assert.equal(item.trackNumber, 4); // index + 1 (Deezer album tracks carry no track_position)
|
||||||
|
assert.equal(item.trackName, 'Harder, Better, Faster, Stronger');
|
||||||
|
assert.equal(item.collectionName, 'Discovery');
|
||||||
|
assert.equal(item.collectionArtistName, 'Daft Punk');
|
||||||
|
assert.equal(item.primaryGenreName, 'Dance');
|
||||||
|
assert.equal(item.releaseDate, '2001-03-07');
|
||||||
|
assert.equal(item.trackCount, 14);
|
||||||
|
assert.equal(item.trackTimeMillis, 224000); // seconds → ms
|
||||||
|
assert.equal(item.trackExplicitness, 'notExplicit');
|
||||||
|
assert.equal(item.artworkUrl100, 'https://img/cover_xl.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mapAlbumTrack: per-track artist wins over the album artist (mixed-artist albums)', () => {
|
||||||
|
const feat = { ...TRACK, artist: { id: 99, name: 'MeloMance' } };
|
||||||
|
const item = dz.mapAlbumTrack(feat, ALBUM, 0);
|
||||||
|
assert.equal(item.artistName, 'MeloMance'); // drives the per-track YTM search
|
||||||
|
assert.equal(item.collectionArtistName, 'Daft Punk'); // album_artist stays stable → no album split
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mapAlbumTrack: explicit flag maps through', () => {
|
||||||
|
const item = dz.mapAlbumTrack({ ...TRACK, explicit_lyrics: true }, ALBUM, 0);
|
||||||
|
assert.equal(item.trackExplicitness, 'explicit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickTrackMatch: strict match on artist + title', () => {
|
||||||
|
const results = [
|
||||||
|
{ title: 'Some Other Song', artist: { name: 'Daft Punk' } },
|
||||||
|
{ title: 'Harder, Better, Faster, Stronger', artist: { name: 'Daft Punk' } },
|
||||||
|
];
|
||||||
|
const m = dz.pickTrackMatch(results, 'Daft Punk', 'Harder, Better, Faster, Stronger');
|
||||||
|
assert.equal(m, results[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickTrackMatch: NO match when the artist is completely different (no wrong album)', () => {
|
||||||
|
// Regression: "☆NoAki☆ – Memories" used to grab a same-titled song by an unrelated artist and
|
||||||
|
// apply its album/cover/year. A wholly different artist must now return null → clean fallback.
|
||||||
|
const results = [
|
||||||
|
{ title: 'Memories', artist: { name: 'Rab Noakes' } },
|
||||||
|
{ title: 'Memories', artist: { name: 'Maroon 5' } },
|
||||||
|
];
|
||||||
|
assert.equal(dz.pickTrackMatch(results, '☆NoAki☆', 'Memories'), null);
|
||||||
|
assert.equal(dz.pickTrackMatch(results, 'Some Cover Act', 'Levitating'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickTrackMatch: loose artist match — one name contained in the other', () => {
|
||||||
|
const results = [{ title: 'Flower Dance', artist: { name: 'DJ Okawari' } }];
|
||||||
|
const m = dz.pickTrackMatch(results, 'Okawari', 'Flower Dance');
|
||||||
|
assert.equal(m, results[0]); // "okawari" ⊂ "djokawari" → accepted
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickTrackMatch: NO title-only fallback for Japanese titles', () => {
|
||||||
|
const results = [{ title: '残酷な天使のテーゼ', artist: { name: 'Someone Else' } }];
|
||||||
|
const m = dz.pickTrackMatch(results, '高橋洋子', '残酷な天使のテーゼ');
|
||||||
|
assert.equal(m, null); // JP title + wrong artist must not match a same-titled cover
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickTrackMatch: Japanese title DOES match when the artist matches', () => {
|
||||||
|
const results = [{ title: '残酷な天使のテーゼ', artist: { name: '高橋洋子' } }];
|
||||||
|
const m = dz.pickTrackMatch(results, '高橋洋子', '残酷な天使のテーゼ');
|
||||||
|
assert.equal(m, results[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickTrackMatch: leading aesthetic punctuation is ignored on the artist', () => {
|
||||||
|
const results = [{ title: 'nostalgia', artist: { name: '.diedlonely' } }];
|
||||||
|
const m = dz.pickTrackMatch(results, '.diedlonely', 'nostalgia');
|
||||||
|
assert.equal(m, results[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickTrackMatch: empty / no match returns null', () => {
|
||||||
|
assert.equal(dz.pickTrackMatch([], 'x', 'y'), null);
|
||||||
|
assert.equal(dz.pickTrackMatch([{ title: 'A', artist: { name: 'B' } }], 'C', 'D'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeTitle: unifies variant suffixes and punctuation', () => {
|
||||||
|
assert.equal(dz.normalizeTitle("don't rush - slowed"), dz.normalizeTitle("don't rush (slowed)"));
|
||||||
|
assert.equal(dz.normalizeTitle('Mr. Lawrence'), dz.normalizeTitle('Mr Lawrence'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Live smoke test (opt-in): confirms the real Deezer shape still maps ────────
|
||||||
|
test('LIVE: lookupTrackMeta returns enriched fields', { skip: process.env.RUN_LIVE !== '1' }, async () => {
|
||||||
|
const meta = await dz.lookupTrackMeta('Daft Punk', 'Harder, Better, Faster, Stronger');
|
||||||
|
assert.ok(meta.album, 'album present');
|
||||||
|
assert.ok(meta.genre, 'genre present');
|
||||||
|
assert.ok(meta.year, 'year present');
|
||||||
|
assert.ok(meta.durationMs > 0, 'duration present');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
const { test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { findExistingTrack } = require('../lib/metadata');
|
||||||
|
|
||||||
|
let ROOT;
|
||||||
|
const touch = (artist, album, file) => {
|
||||||
|
const dir = path.join(ROOT, artist, album);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, file), '');
|
||||||
|
};
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-dedup-'));
|
||||||
|
// KH single: normal "01 - " prefix, title contains multiple " - "
|
||||||
|
touch('Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme - Single',
|
||||||
|
'01 - Kingdom Hearts 2 - Dearly Beloved - Main Theme.mp3');
|
||||||
|
// KH album variant: garbage "01-01" artist prefix, no track number
|
||||||
|
touch('Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme',
|
||||||
|
'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 {} });
|
||||||
|
|
||||||
|
test('detects a re-download when the title itself contains " - " (KH bug)', () => {
|
||||||
|
assert.ok(findExistingTrack(ROOT, 'Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects it even with a garbage "01-01 - " artist prefix', () => {
|
||||||
|
// Both KH files should resolve; at least one must be found → dedup skips the re-download.
|
||||||
|
const hit = findExistingTrack(ROOT, 'Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme');
|
||||||
|
assert.ok(hit && hit.includes('Kingdom Hearts 2 - Dearly Beloved - Main Theme'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normal "Artist - Title" filename still matches on title alone', () => {
|
||||||
|
assert.ok(findExistingTrack(ROOT, 'Some Artist', 'Therapy'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no false positive: a different title is not matched', () => {
|
||||||
|
assert.equal(findExistingTrack(ROOT, 'Geek Music', 'Some Unrelated Song'), null);
|
||||||
|
assert.equal(findExistingTrack(ROOT, 'Some Artist', 'Kingdom Hearts 2'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remix stays distinct from the base track (no regression from the version-marker fix)', () => {
|
||||||
|
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,48 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const mb = require('../lib/musicbrainz');
|
||||||
|
|
||||||
|
const rec = (title, artist, releases = []) => ({
|
||||||
|
id: 'rec-' + title, title, length: 200000,
|
||||||
|
'artist-credit': [{ name: artist, artist: { id: 'a', name: artist } }],
|
||||||
|
releases,
|
||||||
|
});
|
||||||
|
const albumRel = { id: 'rel-1', title: 'Real Album', date: '2021-05-01', 'release-group': { 'primary-type': 'Album' } };
|
||||||
|
const singleRel = { id: 'rel-2', title: 'Some Single', date: '2020-01-01', 'release-group': { 'primary-type': 'Single' } };
|
||||||
|
|
||||||
|
test('mbPickMatch: artist-verified match prefers a recording with a release', () => {
|
||||||
|
const recs = [rec('Memories', 'NoAki'), rec('Memories', 'NoAki', [albumRel])];
|
||||||
|
assert.equal(mb.mbPickMatch(recs, 'NoAki', 'Memories'), recs[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mbPickMatch: completely different artist → null (no wrong album)', () => {
|
||||||
|
const recs = [rec('Memories', 'Rab Noakes', [albumRel]), rec('Memories', 'Maroon 5', [albumRel])];
|
||||||
|
assert.equal(mb.mbPickMatch(recs, '☆NoAki☆', 'Memories'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mbPickMatch: loose artist match (one contained in the other)', () => {
|
||||||
|
const recs = [rec('Flower Dance', 'DJ Okawari', [albumRel])];
|
||||||
|
assert.equal(mb.mbPickMatch(recs, 'Okawari', 'Flower Dance'), recs[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mapMbRecording: maps album/year/cover/duration and prefers the Album release', () => {
|
||||||
|
const m = mb.mapMbRecording(rec('Memories', 'NoAki', [singleRel, albumRel]));
|
||||||
|
assert.equal(m.album, 'Real Album');
|
||||||
|
assert.equal(m.albumArtist, 'NoAki');
|
||||||
|
assert.equal(m.year, 2021);
|
||||||
|
assert.equal(m.trackName, 'Memories');
|
||||||
|
assert.equal(m.durationMs, 200000);
|
||||||
|
assert.equal(m.artworkUrl, 'https://coverartarchive.org/release/rel-1/front-500');
|
||||||
|
assert.equal(m._source, 'musicbrainz');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mapMbRecording: no release → no album, no artwork (clean fallback territory)', () => {
|
||||||
|
const m = mb.mapMbRecording(rec('Orphan Song', 'NoAki', []));
|
||||||
|
assert.equal(m.album, undefined);
|
||||||
|
assert.equal(m.artworkUrl, '');
|
||||||
|
assert.equal(m.metaArtistName, 'NoAki');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mapMbRecording: null in → {} out', () => {
|
||||||
|
assert.deepEqual(mb.mapMbRecording(null), {});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user