Compare commits
380
Commits
6391857fda
..
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 | ||
|
|
98d7c84269 | ||
|
|
687650518e | ||
|
|
d65fbcd11e | ||
|
|
7498e45b16 | ||
|
|
ffa4fb35ea | ||
|
|
58dacbabc3 | ||
|
|
211b8ec6c2 | ||
|
|
7a5197b776 | ||
|
|
73fcfae30f | ||
|
|
5a2ab54bbb | ||
|
|
ac8ce6b7d8 | ||
|
|
e246382caf | ||
|
|
c802ef99d7 | ||
|
|
e326ee6350 | ||
|
|
8e81404ab6 | ||
|
|
d4ebcf0447 | ||
|
|
db81595b02 | ||
|
|
3d6b74b873 | ||
|
|
9aa1947568 | ||
|
|
1616d98fa6 | ||
|
|
ff79428683 | ||
|
|
38d21cf2e0 | ||
|
|
d5b088a658 | ||
|
|
fa4c954ab9 | ||
|
|
8be3a515e6 | ||
|
|
a57e18198f | ||
|
|
6e7a18302a | ||
|
|
8b6b4b0839 | ||
|
|
f3fa68928c | ||
|
|
feaa8ee61f | ||
|
|
4c85fec4ca | ||
|
|
0fe2de5084 | ||
|
|
a870f50c87 | ||
|
|
cf6e75b312 | ||
|
|
1e8e5e8874 | ||
|
|
902243586b | ||
|
|
77bbb18c8d | ||
|
|
e93c0c7ab1 | ||
|
|
c4e4c8c090 | ||
|
|
c34123af38 | ||
|
|
9014659112 | ||
|
|
ce4577e8f4 | ||
|
|
bb0be52f46 | ||
|
|
3c54850cca | ||
|
|
4804023a98 | ||
|
|
88d3e336c8 | ||
|
|
d5bc887f31 | ||
|
|
4cbfa35655 | ||
|
|
86d3999628 | ||
|
|
5f257bf451 | ||
|
|
bff652a415 | ||
|
|
d1b107ae71 | ||
|
|
ecb7721e3b | ||
|
|
319a3804f3 | ||
|
|
f1bdd811ca | ||
|
|
925c173c15 | ||
|
|
85252e24df | ||
|
|
7030f029b7 | ||
|
|
93c5fb481d | ||
|
|
43cf11465e | ||
|
|
6a1bf33f31 | ||
|
|
9ffd071c58 | ||
|
|
dd484df636 | ||
|
|
6be7981b8a | ||
|
|
eacce487df | ||
|
|
ec9c0add9e | ||
|
|
72feac41bb | ||
|
|
820a75f40b | ||
|
|
3280ffee37 | ||
|
|
ba16f88355 | ||
|
|
613d5156fa | ||
|
|
a0d3c7a592 | ||
|
|
f7012dab49 | ||
|
|
0272b9e99f | ||
|
|
eb5b0ea360 | ||
|
|
d7f8b301ab | ||
|
|
d0df01a76d | ||
|
|
71667d1a03 | ||
|
|
8b9bfd3098 | ||
|
|
7e20062995 | ||
|
|
8c8f03503b | ||
|
|
55eef2f831 | ||
|
|
f15c005a3c | ||
|
|
0e651e8be3 | ||
|
|
3855207530 | ||
|
|
13e9ec9d9a | ||
|
|
b5aa484c20 | ||
|
|
42c12cbd3a | ||
|
|
f01fc71f54 | ||
|
|
afae5d94df | ||
|
|
b25e162806 | ||
|
|
98394bab75 | ||
|
|
4027827312 | ||
|
|
db80f41018 | ||
|
|
1856452d8f | ||
|
|
a48093d6d8 | ||
|
|
a65a3eeb5b | ||
|
|
2327778136 | ||
|
|
45ec25d257 | ||
|
|
fac3ab670d | ||
|
|
68912522f8 | ||
|
|
3c9602f861 | ||
|
|
131097550a | ||
|
|
1b96706a1c | ||
|
|
59adee60d5 | ||
|
|
85f8d758a5 | ||
|
|
9e6e81bc22 | ||
|
|
6851c37e15 | ||
|
|
b4ece2db85 | ||
|
|
228f58d6d5 | ||
|
|
44ef685922 | ||
|
|
e3987a6376 | ||
|
|
4c3ec5726c | ||
|
|
da68afe0e0 | ||
|
|
8f7f1e2635 | ||
|
|
28489f722a | ||
|
|
fc2c5de1fb | ||
|
|
d47a3404b3 | ||
|
|
7fe755379b | ||
|
|
2c9d3a9dde | ||
|
|
adcbb2e883 | ||
|
|
def625a622 | ||
|
|
972c6b9722 | ||
|
|
c8e3f5b641 | ||
|
|
1f9495f806 | ||
|
|
60e89fe3b6 | ||
|
|
76efa393ea | ||
|
|
128437821a | ||
|
|
f04d87254b | ||
|
|
4bb131b219 | ||
|
|
98c964ecae | ||
|
|
914297651e | ||
|
|
6bbebcc602 | ||
|
|
f808db7eae | ||
|
|
32bde3ce87 | ||
|
|
c3d0360946 | ||
|
|
55cfaf0392 | ||
|
|
28de177381 | ||
|
|
0a3c72e2fd | ||
|
|
f91176da2a | ||
|
|
b436e39f40 | ||
|
|
42fe5d802b | ||
|
|
e2964829bd | ||
|
|
4131907ebd | ||
|
|
0ac7fc5383 | ||
|
|
64aec78a43 | ||
|
|
79a4aa352f | ||
|
|
ce0697e00a | ||
|
|
aa20eec29d | ||
|
|
106fbf54bf | ||
|
|
e3c435420c | ||
|
|
920e385a8f |
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
-26
@@ -1,31 +1,36 @@
|
||||
# Copy to .env and fill in your values
|
||||
# Copy to .env next to your docker-compose file and fill in. cp .env.example .env
|
||||
# Used by docker-compose.yml (zieht fertige Images) und docker-compose.dev.yml (baut lokal).
|
||||
# REGISTRY/TAG unten braucht nur der Pull-Weg (docker-compose.yml).
|
||||
|
||||
# Pre-fills the URLs on the setup screen
|
||||
NAVIDROME_URL=https://music.yourdomain.com
|
||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
||||
# ── URLs pre-filled on the frontend setup screen ─────────────────────────────
|
||||
NAVIDROME_URL=https://music.example.com
|
||||
BACKEND_URL=https://backend.example.com
|
||||
SCROBBLE_URL=https://scrobble.example.com
|
||||
|
||||
# Absolute path to your Navidrome music library on the host
|
||||
MUSIC_PATH=/srv/navidrome/music
|
||||
|
||||
AUDIO_FMT=mp3 # mp3 | flac | opus | best (used by yt-dlp)
|
||||
AUDIO_QUALITY=0 # 0 = best quality (used by yt-dlp)
|
||||
# ── Library / downloads (backend) ────────────────────────────────────────────
|
||||
# Absolute music path — mounted 1:1 into the backend (no remapping), same value inside the container.
|
||||
MUSIC_PATH=/opt/navidrome/music
|
||||
AUDIO_FMT=mp3
|
||||
AUDIO_QUALITY=0
|
||||
MAX_PARALLEL=2
|
||||
DOWNLOAD_BACKEND=ytdlp
|
||||
TICKETMASTER_API_KEY=
|
||||
|
||||
# Download backend: ytdlp | squid | auto
|
||||
# ytdlp - always use yt-dlp
|
||||
# squid - always use squid.wtf (Qobuz)
|
||||
# auto - try squid.wtf first, fall back to yt-dlp if not found (default)
|
||||
DOWNLOAD_BACKEND=auto
|
||||
# ── 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
|
||||
|
||||
# Quality for squid.wtf / Qobuz downloads
|
||||
# mp3 - MP3 320 kbps
|
||||
# flac - FLAC 16-bit / 44.1 kHz
|
||||
# hi-res - FLAC 24-bit / 96 kHz
|
||||
SQUID_QUALITY=mp3
|
||||
|
||||
# Spotify Importer (optional — free, no Premium needed)
|
||||
# 1. Go to https://developer.spotify.com/dashboard and create an app
|
||||
# 2. Set any name, Redirect URI: http://localhost
|
||||
# 3. Copy the Client ID and Client Secret below
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
# ── 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
|
||||
node_modules/
|
||||
dist/
|
||||
scripts/.env.release
|
||||
.env
|
||||
.idea/
|
||||
+1518
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,251 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code when working in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
NovaSonic — a self-hosted music streaming **PWA** (Vite + vanilla ES modules) and
|
||||
**Android app** (Jetpack Compose + Media3/ExoPlayer) for Navidrome / Subsonic servers,
|
||||
plus a **Node.js/Express backend** that downloads tracks via yt-dlp and proxies APIs.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Never hardcode or mock frontend data.** All library data comes from the Navidrome
|
||||
(Subsonic) API. No placeholder songs/albums/artists, ever.
|
||||
- **Don't commit or push unless asked.** When asked, push to the Gitea remote.
|
||||
- **Two-part versioning** `MAJOR.MINOR` (e.g. `0.8`) — no patch component. Each release
|
||||
bumps the minor; features and fixes go in the same release. See `CHANGELOG.md`.
|
||||
- Keep the `CHANGELOG.md` Platform Parity Matrix accurate when adding cross-platform features.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/` — web PWA (vanilla ES modules). State in `src/state.js` (the `S` object);
|
||||
Subsonic/Deezer API in `src/api.js`; views in `src/views/`, UI in `src/ui/`.
|
||||
- **Client-side router** (`src/views/navigation.js`, History API): `navigate()` pushStates `{nsIdx}`
|
||||
only on genuine new navigation; `popstate` applies the target entry's `nsIdx` for **both** back and
|
||||
forward. Never `pushState` inside the `popstate`/back path — it wipes the browser's forward stack
|
||||
(that was the "forward doesn't work" bug). In-app back buttons call `history.back()` (one path).
|
||||
- `backend/novasonic-server.js` — Express API (downloads, metadata edit/delete, events/ABS proxy).
|
||||
- `backend/lib/` — `ytdlp.js` (download + cascade + ffmpeg tagging), `downloader.js`,
|
||||
`deezer.js` (Deezer metadata: track/album/artist lookups + matching),
|
||||
`metadata.js` (artwork, file resolution helpers, duplicate detection), `config.js`, `jobs.js`, `romaji.js`.
|
||||
- `scrobble/scrobble-server.js` — **separate** listening-stats service (own container + DB), see below.
|
||||
- `android/` — native Kotlin app (separate; backend-dependent web features like the
|
||||
metadata editor are **not** in the Android app).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server (HMR)
|
||||
npm run build # production build → dist/
|
||||
npm run preview # preview the production build
|
||||
```
|
||||
|
||||
Der Stack läuft in Docker. **`docker-compose.yml` ist der Normalfall und zieht fertige Images**
|
||||
(`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}`) — der Server baut nichts:
|
||||
`docker compose pull && docker compose up -d`. Ein einfacher Restart ist `docker compose restart
|
||||
novasonic-backend` (das Backend zieht bei jedem Start das neueste yt-dlp nach).
|
||||
|
||||
Gepusht wird von der Dev-Maschine mit `npm run push-images` (`scripts/build-push-images.mjs`, liest
|
||||
`REGISTRY`/`TAG` aus `.env`); ohne Node tut es direkt `docker build -t $REGISTRY/<name>:latest
|
||||
<context>` + `docker push` mit den Kontexten `.`, `backend/`, `scrobble/`. Registry ist die
|
||||
Gitea-Registry (`…/docker`, Owner klein geschrieben).
|
||||
|
||||
**`docker-compose.dev.yml`** ist die Variante, die stattdessen lokal aus den Dockerfiles baut
|
||||
(`backend/Dockerfile` + `scrobble/Dockerfile`), ohne Registry: `docker compose -f
|
||||
docker-compose.dev.yml up -d --build`. Die schweren Deps (ffmpeg, Deno, yt-dlp, node libs) sind in
|
||||
den Images gebacken, deshalb sind **Restarts sofort da** und Rebuilds nach `git pull` cache-schnell.
|
||||
|
||||
On the deploy host the music lives at `/opt/navidrome/music`.
|
||||
|
||||
## Download pipeline (hard-won knowledge)
|
||||
|
||||
- Downloads are **yt-dlp only** (the old squid.wtf/Qobuz backend was removed).
|
||||
- **Metadata source = Deezer, not iTunes** (as of 1.3). Apple's iTunes `/search` returns **403 from
|
||||
server/datacenter IPs**, which broke search + downloads + charts + editor autofill in production.
|
||||
All metadata now comes from Deezer (`backend/lib/deezer.js` server-side; `_deezerBase()` in
|
||||
`src/api.js`; Android `ItunesClient` is Deezer-backed). Free, no key, more requests, returns
|
||||
explicit versions + real artist images + durations. Deezer generally gives one consistent name per
|
||||
artist, so the old JP/US-store inconsistency (Western artists as katakana "DJスネイク" on some tracks,
|
||||
kanji on others) is gone. Titles come from the source, so a Japanese title the user picked stays
|
||||
Japanese; only Deezer-derived fields (album\_artist, genre, year, artwork) follow Deezer. The
|
||||
Deezer results are mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged.
|
||||
`lookupTrackMeta`/`fetchAlbumTracks`/`searchAlbumTracks`/`fetchArtistAlbums` replace the old
|
||||
`lookupItunesTrackMeta`/`fetchItunesTracks`/`searchItunesAlbumTracks`. Editor autofill endpoint:
|
||||
`/api/meta/lookup` (was `/api/itunes/lookup`). Matching logic (`pickTrackMatch`) is unit-tested in
|
||||
`backend/test/deezer.test.js` (`npm test`).
|
||||
- **File naming:** `{NN - }Artist - Title.ext` with the release suffix stripped from the
|
||||
title (no `- Single`/`- EP`). The **album folder keeps the full album name**. Original names
|
||||
are used for folder, filename, and all main tags (artist/title/album/album\_artist) — even
|
||||
for Japanese tracks. Romaji goes **only** into sort tags (TITLESORT / ARTISTSORT / etc.).
|
||||
- **Search cascade** (`ytdlp.js`): YTM search first (`web_music` player client, Songs filter
|
||||
param), then regular YouTube; each candidate filtered by duration (±10%, min 10s),
|
||||
title keyword, artist channel, and variant exclusions (remix/cover/sped/…).
|
||||
- **Official audio via `artist` metadata (not just channel).** The strict steps accept a candidate
|
||||
if the channel name matches the artist **OR** yt-dlp's music `artist` field does. YouTube "Art
|
||||
Tracks" (label-provided official audio) carry `artist=<Artist>` even when the channel is
|
||||
`BANGTANTV`/`HYBE LABELS`/… (no artist name). Confirmed on "BTS Pied Piper": only the official
|
||||
upload had `artist=BTS`; every fan/live/lyric reupload had `artist=NA`. `channel_is_verified` was
|
||||
rejected as a signal — lyric videos and fan MVs also run on verified channels. Implemented as two
|
||||
`--match-filter` args (yt-dlp OR's them) in `mf()`.
|
||||
- **"Mittelweg" rule** (with AND without duration): when nothing matches in the filtered steps,
|
||||
relax only the duration constraint while **still requiring the artist channel**. If nothing
|
||||
matches, download **nothing** and fail cleanly with "Nicht eindeutig gefunden — bitte
|
||||
Direktlink im Importer nutzen". Never fall back to a blind title-only/no-filter grab — it
|
||||
pulled same-titled covers by other uploaders, and wrong Piano Echoes for Japanese tracks.
|
||||
- **Numbers in a title are always required keywords.** `buildTitleKeywords` kept only the two
|
||||
LONGEST words, and numbers are short — so "700 Main Street" was filtered on Street+Main only and
|
||||
the cascade happily saved **"777 Main Street" by a different artist** (step 3 drops the channel
|
||||
check, so nothing else caught it). Numeric tokens are now emitted as `\b<n>\b` in addition to the
|
||||
two longest words. Same fix carries a small abbreviation map (street/st, saint/st, mount/mt,
|
||||
doctor/dr, versus/vs, number/no): uploaders write "700 Main St" where the release says "Street",
|
||||
which had rejected the correct official Art Track in every step. One exception: a **re-release year
|
||||
next to a qualifier** ("… - 2020 Remaster") is dropped again — it sits on the release but never in
|
||||
the YouTube title, so requiring it would reject the correct upload. A title that IS a year ("1999",
|
||||
"Berlin 2020") keeps it. Unit-tested in `backend/test/ytdlp.test.js`.
|
||||
- **Live recordings are excluded by title, not by `is_live`.** `!is_live` only means "not a running
|
||||
stream" — a concert RECORDING passes it. On the artist's own channel nothing else distinguishes it
|
||||
from the studio track, which is how "Dat Adam - Ufo Video Days 2015 Live" (channel `Dat Adam`,
|
||||
209 s, title contains UFO) got saved as the album track. `buildVariantFilter` therefore carries a
|
||||
`live` rule like remix/cover/…. It self-disables when the searched title itself says live, so
|
||||
"Live Forever" or a deliberately requested live version still works — that check uses a `\blive\b`
|
||||
regex (`skipRe`), because a plain substring test would let "deliver"/"delivery" switch the rule off.
|
||||
- **A channel hit in the YTM results is a line to skip, not a reason to abort.** `getYtmVideoUrls`
|
||||
used to treat any `/browse/UC…` in the output as "yt-dlp expanded an artist channel" and returned
|
||||
`[]` — discarding the song URLs it had already collected. Measured on 8 real searches, that fired
|
||||
in **7 of 8**, so step 1 (YTM, where the official Art Tracks live) was effectively dead and almost
|
||||
every download came from the YouTube fallback. With `--flat-playlist` a channel is printed as ONE
|
||||
line, so expansion cannot happen; `pickYtmVideoUrls()` now keeps every `watch?v=` line and skips
|
||||
`browse/UC…` (channel) and `browse/MPREb_…` (album) alike. It is a strict superset of the old
|
||||
behaviour — fuzz-checked over 20k random outputs — so it can only add candidates, never remove any.
|
||||
- **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2×
|
||||
expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through.
|
||||
- **Leading aesthetic characters:** artists often style their name with leading punctuation
|
||||
(`.diedlonely`, `†…`). `deezer.js` strips leading non-alphanumerics (`stripLeadSym`) before the
|
||||
Deezer lookup and in `firstWord`. Without it the lookup misses, falls through to a title-only
|
||||
match, and returns an unrelated same-titled track — whose wrong duration then *also* misleads the
|
||||
YouTube duration filter (observed: a 96 s track resolved to a 176 s slowed version). The same trim
|
||||
runs client-side in Android's `ExploreViewModel.search()`.
|
||||
- **Mixed-artist albums search per track.** `downloadAlbumTracks` passes `rawArtist = track.artistName`
|
||||
so the YTM/YouTube search query and the channel filter use each track's *own* performer, not the
|
||||
album artist. `ytdlp.js` derives both from `rawArtist || mainArtist`, and `mainArtist = albumArtist`,
|
||||
so without this every track on a soundtrack searched the album-artist blob + its channel filter and
|
||||
matched nothing (e.g. "Strategy" = TWICE, "Love, Maybe" = MeloMance failed). The `album_artist` tag
|
||||
still uses `albumArtist`, so album grouping is unaffected.
|
||||
- **Album/artist download `album_artist`** comes from the Deezer tracks' `collectionArtistName`
|
||||
(= the album-level `album.artist`, identical across the album), derived in `novasonic-server.js` —
|
||||
**not** the request's `artist`, which can be the lead single's performer (a KPop soundtrack got
|
||||
tagged `album_artist=TWICE` from track 1 "TAKEDOWN"). Falls back to the request artist only when
|
||||
Deezer omits it.
|
||||
- **Always write an `album` tag.** `ytdlp.js` defaults it to `Singles` when no album is known.
|
||||
Previously no tag was written at all, so Navidrome showed its own "Singles" fallback while the
|
||||
metadata editor showed "[Unknown Album]" — the two never matched and file resolution failed.
|
||||
- **Duplicate detection** (`metadata.js findExistingTrack`): searches case-insensitively across
|
||||
all album folders under the matching artist folder; compares normalized artist+title ignoring
|
||||
track prefix and release/variant suffix. Handles DJ OKAWARI vs DJ Okawari and "- Single" drift.
|
||||
- **Single-track metadata resolver = `backend/lib/trackmeta.js`** (Deezer → MusicBrainz → clean
|
||||
fallback). Deezer's `pickTrackMatch` requires an **artist correspondence** (equal, or one name
|
||||
contained in the other after stripping non-alphanumerics) — the old blind *title-only* fallback
|
||||
grabbed a same-titled song by a different artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and
|
||||
applied its wrong album/cover/year. On no match it returns `{}`; then `musicbrainz.js` (rate-limited
|
||||
~1/s, `User-Agent` required, Cover Art Archive for covers, **same** artist discipline) is tried; if
|
||||
that also misses, the download's built-in clean fallback runs (**album_artist = song artist, album =
|
||||
"Singles", cover = source thumbnail, year/genre left untouched**). Only the three *single-track*
|
||||
call sites use `trackmeta`; the album batch stays Deezer-only (avoids MB's rate limit). The clean
|
||||
fallback always existed but was unreachable while the match was too greedy. Matching is unit-tested
|
||||
(`deezer.test.js`, `musicbrainz.test.js`).
|
||||
- **Known limitation:** yt-dlp's YTM/YouTube *search* can't surface very obscure Art Tracks by
|
||||
ambiguously-named artists (e.g. "MINA") — the browser finds them, yt-dlp's API extraction
|
||||
doesn't (confirmed across params/clients/deno). For those, the **direct link in the importer**
|
||||
(`sourceUrl` path) is the reliable route.
|
||||
- **File resolution for edit/delete:** Navidrome's stored path may use a different mount root
|
||||
or a stale stem. `metadata.js` rebases the path and falls back to recursive/stem matching
|
||||
(tolerant of release-suffix mismatch). A ghost Navidrome entry needs a full library rescan.
|
||||
- **Album splitting:** prevented by consistent `album_artist` tag. All tracks in a download
|
||||
share the same `mainArtist` value (= Deezer `collectionArtistName` when available, else
|
||||
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
|
||||
|
||||
## Listening stats / scrobble system (v2.0)
|
||||
|
||||
- **Why separate:** Navidrome stores only a play *count* + *last* play date (no timestamps), so
|
||||
per-day/month/year stats and a "Wrapped" are impossible from it. NovaSonic *is* the player, so it
|
||||
records every counted play into its **own** timestamped, multi-user log.
|
||||
- **Own container `novasonic-scrobble`** (`scrobble/scrobble-server.js`) — deliberately isolated from
|
||||
the download backend: the backend is heavy (yt-dlp/ffmpeg) and restarts often; an archive needs
|
||||
uptime + data integrity. Runs on `node:22-slim` with the **built-in `node:sqlite`** (run with
|
||||
`--experimental-sqlite`; no native build). DB `/data/novasonic-plays.db` in its own volume.
|
||||
- **Routes:** `POST /scrobble/plays` (batch, **idempotent by uuid**), `GET /scrobble/history`,
|
||||
`GET /scrobble/stats` (totals, top artists/albums/tracks, day/month/year series, 24h clock),
|
||||
`DELETE /scrobble/plays?user=` (test cleanup), `GET /scrobble/health`.
|
||||
- **Multi-user auth:** every request carries the same Subsonic token creds (`u`, `t`, `s`) the app
|
||||
already uses; the service verifies them via Navidrome **`ping.view`** (cached 5 min) → the stored
|
||||
`user` is real, not spoofable. `node:sqlite` **throws on extra named params**, so each query gets
|
||||
exactly the params its SQL references. `from` defaults to all-time (do **not** route `0` through
|
||||
`_toEpochSec`, which maps `0`→now).
|
||||
- **Denormalized on purpose:** title/artist/album are stored per play so the archive survives even if
|
||||
the song is later deleted from Navidrome. Deleting a song does **not** touch the play-log.
|
||||
- **Capture point (Web + Android):** `logPlay()` fires at the **existing** counted-scrobble moment
|
||||
(respects the user's `scrobbleMode`, consistent with Last.fm) — **not** a separate 50%/4-min rule.
|
||||
It runs **in addition to** the Navidrome/Last.fm scrobble, never replacing it. Web:
|
||||
`src/player/scrobbleLog.js` (localStorage queue) + `subsonicAuthParams()` in `api.js`. Android:
|
||||
`data/PlayLogClient.kt` (JSON-file queue, **one reused Ktor client**) + `repo.logPlay/flushPlayLog`
|
||||
+ hook in `PlayerController.scheduleScrobble`. Both queue offline and flush as an **idempotent**
|
||||
batch (server dedups by uuid) on start / when connectivity returns.
|
||||
- **Stats view:** Web `src/ui/stats.js` (overlay from Settings), Android `ui/screen/StatsScreen.kt` +
|
||||
`StatsViewModel.kt` (from a Settings button). **Cover art is resolved client-side** from Navidrome
|
||||
(`coverUrl`/`coverArtUrl` via `artistId`/`albumId`/`songId`) — the scrobble service stays
|
||||
cover-agnostic; the stats endpoint just returns the IDs.
|
||||
- **Config:** `SCROBBLE_URL` env → `window.NS_SCROBBLE_URL` (entrypoint) → `S.cfg.scrobbleUrl` (web) /
|
||||
`scrobbleUrl` pref (Android), mirroring `backendUrl`. Port **3710**.
|
||||
- **Deploy gotcha:** the scrobble subdomain **must be excluded from any SSO / forward-auth gateway**
|
||||
(like the download backend) — it self-authenticates via `ping.view`, and a gateway in front
|
||||
302-redirects the browser's API calls to the login portal (breaks `fetch` → "nicht erreichbar").
|
||||
- **Planned:** `/scrobble/wrapped` (yearly story) + optional ListenBrainz relay. See
|
||||
`docs/scrobble-plan.md`.
|
||||
|
||||
## Android offline system
|
||||
|
||||
- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
|
||||
- **Database:** Room DB `novasonic_offline.db` (version 1) with three tables: `downloaded_tracks` (track metadata + local paths + state), `download_groups` (album/playlist/song group + progress), `group_track_cross_refs` (many-to-many). See [OfflineEntities.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt) and [OfflineDao.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt).
|
||||
- **OfflineRepository** ([OfflineRepository.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt)): holds hot `StateFlow` maps (`_audioMap`, `_coverMap`) keyed by songId/coverArtId for O(1) lookups in `MusicRepository.streamUrl()`. Call `enqueueGroup()` to schedule a download.
|
||||
- **DownloadWorker** ([DownloadWorker.kt](android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt)): `CoroutineWorker` using plain `HttpURLConnection` streaming to `.tmp` → rename. **No `setExpedited()` and no `setForeground()`** — `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned on Android 15 (targetSdk=35) and crashes the app; downloads are silent background work.
|
||||
- **Playback:** `MusicRepository.streamUrl()` checks `offlineRepo.localAudioPath(id)` first; if the file exists it returns a `file://` URI. This applies to every play path — album, playlist, Offline tab — no special offline mode needed.
|
||||
- **Offline tab:** Library → tab 2 (OFFLINE). Driven by `OfflineViewModel`. Play button on a completed group calls `offlineVm.songsForGroup()` (JOIN query → `DownloadedTrackEntity.toSong()`) then `player.playSongs()`.
|
||||
|
||||
## Android navigation, state & insets (hard-won)
|
||||
|
||||
- **A `BackHandler` inside a NavHost destination loses back priority to the NavHost.** The Erkunden
|
||||
(Apple) artist/album are in-screen *overlays* on the `erkunden` destination, not routes — their
|
||||
`BackHandler` never fired, so one back press popped the whole tab to Home. Fix: the overlay reports
|
||||
its close action upward via `onOverlayBackChange`, and `AppNavigation` registers a **root-level**
|
||||
`OnBackPressedCallback` for it (the same mechanism the NowPlaying overlay always used successfully).
|
||||
Real nav routes (`album/{id}`, `artist/{id}`) are unaffected — `popBackStack()` is correct there.
|
||||
- **Re-assert root-level back callbacks on `ON_RESUME`, not just on navigation.** Returning from the
|
||||
background re-registers the NavController's own `OnBackPressedCallback` on **top** of the dispatcher,
|
||||
which steals back-priority from the NowPlaying/ABS/Erkunden overlays — back then pops the NavHost
|
||||
(invisibly, to Home) before dismissing the visible overlay. Fix in `AppNavigation`: a `resumeTick`
|
||||
(`LifecycleEventEffect(ON_RESUME)`) plus `currentBackEntry` are keys on every overlay's
|
||||
`DisposableEffect`, so each re-adds (bumps itself above the NavController) after a resume or a nav.
|
||||
- **Overlay state belongs in the ViewModel, not `remember`.** Anything an overlay shows (e.g. the
|
||||
artist's "all albums" section) is destroyed when the user drills into an album, because the overlay
|
||||
leaves composition. `rememberSaveable` does **not** save you — it only survives for real nav
|
||||
destinations. Hoist it into the ViewModel (see `ExploreViewModel.artistAllAlbums`).
|
||||
- **`bottomBar` insets:** the tab `NavigationBar` absorbs the system-navigation inset. Detail screens
|
||||
have no tab bar, so the MiniPlayer needs `navigationBarsPadding()` itself — otherwise it sits behind
|
||||
the phone's gesture/navigation bar.
|
||||
- **`ModalBottomSheet`:** use `rememberModalBottomSheetState(skipPartiallyExpanded = true)` *and*
|
||||
`navigationBarsPadding()` on the content, or the bottom-most action (e.g. "Herunterladen") opens
|
||||
hidden behind the navigation bar and needs an extra drag-up.
|
||||
- **Reuse the Ktor client.** `MusicRepository.coverArtUrl()/streamUrl()` must go through
|
||||
`ClientHolder.get(creds)`. Constructing a `SubsonicClient` per call spins up (and never closes) an
|
||||
`HttpClient` for *every cover art request* — this was the cause of visible scroll/tab-switch lag.
|
||||
- **Star/favorite is optimistic and global** via `data/StarState.kt`. Toggle through it so every icon
|
||||
(song rows, NowPlaying, context sheet) flips instantly and rolls back if the API call fails.
|
||||
- **Deezer artists carry a real photo** (`picture_*`), so artist search cards show it directly — no
|
||||
album-cover borrowing needed. (The old iTunes artist entities had no `artworkUrl100`, which is why
|
||||
the `entity=album&limit=1` album-cover fallback exists; it now only runs if a picture is missing.)
|
||||
|
||||
## UI / references
|
||||
|
||||
When referencing files in chat, use clickable markdown links, not backticks:
|
||||
`[ytdlp.js](backend/lib/ytdlp.js)`.
|
||||
@@ -1,155 +1,188 @@
|
||||
# NovaSonic
|
||||
|
||||
A self-hosted music streaming PWA for Navidrome / Subsonic-compatible servers.
|
||||
Built with Vite + vanilla ES modules, served via nginx in Docker.
|
||||
A self-hosted music streaming PWA and Android app for Navidrome / Subsonic-compatible servers.
|
||||
Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlayer (Android).
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Home
|
||||
- Hero card — large banner featuring a random pick from your most-played and recently played albums, labelled "Meistgespielt" or "Zuletzt gehört" accordingly
|
||||
- Genre browser — top 15 genres (by song count) as a colored card grid; click to browse songs, hover-play to instantly shuffle the genre
|
||||
- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
|
||||
- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
|
||||
- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
|
||||
- **Artist pages** — Hero card, bio / similar artists / top songs, Ticketmaster events, and a Deezer cross-reference showing owned vs. missing tracks. Same-name artists can be **pinned** (Deezer search or a pasted Deezer link/id) to fix the wrong-discography and wrong-photo problem; a pinned artist's similar list comes from Deezer.
|
||||
- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
|
||||
- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with Deezer metadata enrichment and duplicate detection.
|
||||
- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save.
|
||||
- **Offline** (Android) — Download albums, playlists, or single songs to private storage; an Offline tab to play and manage them (works without a server connection); downloaded tracks play from disk on every screen.
|
||||
- **Listening stats** — A separate, self-hosted scrobble service records **timestamped plays** (multi-user; Navidrome only keeps a play *count*). A statistics view (Web + Android, from Settings) shows totals, top artists/tracks/albums with cover art, a day/month/year play series, and a 24h listening clock, plus a **Musik-Match** that compares two users' taste. Runs *in addition to* Navidrome/Last.fm scrobbling.
|
||||
- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync.
|
||||
|
||||
### Music Library
|
||||
- Stream music from your Navidrome / Subsonic library
|
||||
- Browse artists, albums, playlists, starred tracks, and random picks
|
||||
- Sort and filter views (albums, favorites) with chip filters
|
||||
- Search/filter bar inside playlist detail and favorites (instant, client-side)
|
||||
- Synced and plain lyrics via LRCLIB (auto-fetched per track)
|
||||
- Play count display — optional toggle in Settings; always shown on artist pages
|
||||
- Star and context-menu on every song row (artist top songs, all-songs view, playlists, albums)
|
||||
|
||||
### Playback
|
||||
- Queue management with shuffle, repeat (all / once), and radio mode (endless random)
|
||||
- Spotify-style shuffle — pre-shuffles the full queue so every song plays exactly once
|
||||
- Now Playing overlay with swipe-to-dismiss; queue tab stays active when selecting a song from the queue
|
||||
- Lock screen media controls (Android / iOS via Media Session API)
|
||||
- Gapless-style queue with prev/next, skip forward/back
|
||||
- Scrobbling — plays are reported to Navidrome after 15 seconds of continuous playback
|
||||
|
||||
### Discovery
|
||||
- iTunes / Last.fm integration — charts, global search, 30-second previews, similar artists, recommendations
|
||||
- Song Importer — paste a link from Spotify, YouTube, YouTube Music, or SoundCloud to preview and download tracks, enriched with iTunes metadata and cover art
|
||||
|
||||
### Downloads (yt-dlp backend)
|
||||
- Download individual tracks, full albums, or entire artists
|
||||
- Sources: YouTube Music (primary), YouTube, SoundCloud, Spotify (via playlist importer)
|
||||
- iTunes enrichment — album, year, track number, genre, 1000×1000 cover art
|
||||
- Duplicate detection — skips tracks already present on the server
|
||||
- Standard filename convention: `{01 - }Artist1, Artist2 - Title.ext`
|
||||
- Files organised into `MusicRoot/AlbumArtist/Album/` folders automatically
|
||||
|
||||
### Metadata Editor
|
||||
- Edit title, artist, album artist, album, genre, year, track number, disc number from within the app
|
||||
- Update embedded cover art via URL or file upload
|
||||
- Artist separator normalization — accepts `•`, `·`, `;`, `,` as input; stores as `;` for track artist and `,` for album artist (Navidrome/MusicBrainz style)
|
||||
- File renamed automatically on save to match the standard naming convention
|
||||
- File moved to the correct `AlbumArtist/Album/` folder when those fields change
|
||||
- Empty folders cleaned up after a move
|
||||
- Navidrome rescan triggered automatically after every save
|
||||
|
||||
### Playlists
|
||||
- Create, edit, and delete playlists
|
||||
- Edit playlist name, description, cover art, and public/private toggle
|
||||
- Add / remove tracks; reorder via drag and drop
|
||||
|
||||
### Audiobookshelf Integration
|
||||
- Connect to your Audiobookshelf server (URL + API key)
|
||||
- Browse and stream audiobooks from your ABS library
|
||||
- Full player: chapters list, playback speed, sleep timer (preset or custom minutes), progress sync
|
||||
|
||||
### App
|
||||
- PWA — installable on Android / iOS / desktop, offline-capable via service worker
|
||||
- Android APK (WebView wrapper with native Media3/ExoPlayer backend)
|
||||
- Robust Android queue sync — song identity verified via media ID on every resume to prevent stale-state mismatches
|
||||
- Multiple color themes
|
||||
- Dark theme, mobile-first responsive layout
|
||||
- German UI
|
||||
Web is an installable PWA (themes, German UI); Android is native Jetpack Compose + Media3/ExoPlayer with a mini player, media/ABS notifications, and Android Auto. See [CHANGELOG.md](CHANGELOG.md) for the full web/Android parity matrix.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Music_App/
|
||||
├── src/ # ES modules (Vite entry)
|
||||
NovaSonic/
|
||||
├── src/
|
||||
│ ├── player/
|
||||
│ │ ├── player.js # Playback, queue, transport, media session, preload
|
||||
│ │ ├── nowplaying.js # Now Playing overlay + synced lyrics
|
||||
│ │ └── scrobbleLog.js # Play-log capture + offline queue → scrobble service
|
||||
│ ├── views/
|
||||
│ │ ├── detail.js # Artist / album detail pages
|
||||
│ │ ├── views.js # Navigation, page loaders, search
|
||||
│ │ ├── events.js # Ticketmaster concert events
|
||||
│ │ ├── home.js # Home page
|
||||
│ │ ├── search.js # Search, artist/album navigation
|
||||
│ │ ├── playlists.js # Playlist management
|
||||
│ │ ├── spotify.js # Spotify importer
|
||||
│ │ ├── youtube.js # YouTube importer
|
||||
│ │ ├── soundcloud.js # SoundCloud importer
|
||||
│ │ ├── navigation.js # Client-side router
|
||||
│ │ └── abs.js # Audiobookshelf integration
|
||||
│ ├── ui/
|
||||
│ │ ├── ui.js # Modals, sidebar, context menu
|
||||
│ │ ├── components.js # Song rows, cards, shared UI
|
||||
│ │ ├── downloads.js # Download modal + job tracker
|
||||
│ │ ├── metadata.js # Metadata editor
|
||||
│ │ ├── modals.js # Song info, events modals
|
||||
│ │ ├── notify.js # Toast notifications
|
||||
│ │ ├── stats.js # Listening statistics view (overlay)
|
||||
│ │ └── settings.js # Settings page
|
||||
│ ├── state.js # Shared mutable state (S object)
|
||||
│ ├── utils.js # md5, esc, formatDur, toast
|
||||
│ ├── api.js # Subsonic API, Navidrome REST, iTunes API
|
||||
│ ├── player.js # Playback, transport, queue, media session, preview
|
||||
│ ├── views.js # Navigation, page loaders, card makers, search
|
||||
│ ├── ui.js # Modals, overlays, sidebar, settings, context menu, metadata editor
|
||||
│ ├── nowplaying.js # Now Playing overlay
|
||||
│ ├── abs.js # Audiobookshelf integration
|
||||
│ ├── spotify.js # Spotify importer (resolve + download)
|
||||
│ ├── youtube.js # YouTube / YouTube Music importer
|
||||
│ ├── soundcloud.js # SoundCloud importer
|
||||
│ ├── components.js # Shared UI components (song rows, cards)
|
||||
│ ├── themes.js # Theme switching
|
||||
│ ├── pwa.js # Service worker registration + update banner
|
||||
│ ├── utils.js # Helpers (md5, esc, formatDur, toast)
|
||||
│ ├── api.js # Subsonic API, Deezer API, cover art
|
||||
│ └── main.js # App init, audio wiring, window globals
|
||||
├── backend/
|
||||
│ ├── novasonic-server.js # Express API: download jobs, metadata edit, resolvers
|
||||
│ ├── spotify_resolve.py # Spotify page scraper (track/album/playlist metadata)
|
||||
│ ├── novasonic-server.js # Express API: downloads, metadata, events proxy, ABS proxy
|
||||
│ └── lib/
|
||||
│ ├── config.js # Backend config (env vars, paths, format)
|
||||
│ ├── jobs.js # In-memory job store + log helper
|
||||
│ ├── metadata.js # iTunes lookup, artwork download, lyrics fetch, filename helpers
|
||||
│ ├── ytdlp.js # yt-dlp download cascade + ffmpeg metadata embedding
|
||||
│ ├── squid.js # Squid/Qobuz download backend
|
||||
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks entry point
|
||||
│ └── romaji.js # Japanese → romaji index for search
|
||||
├── public/ # Static assets (copied as-is to dist/)
|
||||
│ ├── config.js # Env var → config object
|
||||
│ ├── jobs.js # In-memory download job store
|
||||
│ ├── deezer.js # Deezer metadata: track/album/artist lookups
|
||||
│ ├── metadata.js # Artwork, filename helpers, duplicate detection
|
||||
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
|
||||
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
|
||||
│ └── romaji.js # Japanese → romaji search index
|
||||
├── scrobble/ # Isolated listening-stats service (own container + DB)
|
||||
│ └── scrobble-server.js # node:sqlite play-log: ingest / history / stats / health
|
||||
├── android/ # Native Android app (Jetpack Compose + Media3)
|
||||
│ └── app/src/main/kotlin/com/novasonic/app/
|
||||
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient (Deezer-backed)
|
||||
│ ├── data/ # PrefsStore (DataStore), MusicRepository, StarState
|
||||
│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads)
|
||||
│ ├── player/ # PlayerController, MusicService (Media3)
|
||||
│ ├── ui/
|
||||
│ │ ├── screen/ # HomeScreen, NowPlayingScreen, AbsScreen, SettingsScreen, …
|
||||
│ │ ├── component/ # MiniPlayer, SongRow, SongContextSheet, …
|
||||
│ │ └── theme/ # Color, Type, Theme
|
||||
│ └── viewmodel/ # ViewModel per screen
|
||||
├── public/
|
||||
│ ├── sw.js # Service worker
|
||||
│ ├── manifest.json # PWA manifest
|
||||
│ ├── icon.svg # App icon
|
||||
│ └── config.js # Runtime config placeholder (generated by Docker)
|
||||
├── android/ # Android WebView APK wrapper
|
||||
├── index.html # App shell + Vite entry point
|
||||
├── styles.css # All styles
|
||||
│ └── config.js # Runtime config (generated by Docker entrypoint)
|
||||
├── index.html
|
||||
├── styles.css
|
||||
├── vite.config.js
|
||||
├── Dockerfile # Multi-stage: Node build → nginx serve
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml # Standard: zieht die fertigen Images aus der Registry
|
||||
├── docker-compose.dev.yml # Variante: baut lokal aus den Dockerfiles
|
||||
├── nginx.conf
|
||||
└── docker-entrypoint.sh # Injects NS_SERVER_URL / NS_BACKEND_URL at startup
|
||||
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL / SCROBBLE_URL at runtime
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Setup
|
||||
## Setup
|
||||
|
||||
### Frontend only
|
||||
### 1. Clone and configure
|
||||
|
||||
```bash
|
||||
git clone <repo>
|
||||
cd NovaSonic
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
```env
|
||||
NAVIDROME_URL=https://music.yourdomain.com
|
||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
||||
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # listening-stats service
|
||||
|
||||
MUSIC_PATH=/srv/navidrome/music
|
||||
|
||||
AUDIO_FMT=mp3 # mp3 | flac | opus | best
|
||||
AUDIO_QUALITY=0 # 0 = best (yt-dlp)
|
||||
MAX_PARALLEL=2
|
||||
|
||||
TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
|
||||
```
|
||||
|
||||
### 2. Start
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Environment variables (set in `docker-compose.yml` or via `-e`):
|
||||
Zieht die drei fertigen Images aus der Registry — nichts wird auf dem Server gebaut. Dafür in der
|
||||
`.env` `REGISTRY` (und optional `TAG`) setzen; die Images heißen
|
||||
`$REGISTRY/novasonic-frontend|-backend|-scrobble`. Neue Stände lädt man von der Dev-Maschine mit
|
||||
`npm run push-images` hoch (bzw. `docker build -t $REGISTRY/<name>:latest <context> && docker push …`
|
||||
— Kontexte: `.` (frontend), `backend/`, `scrobble/`).
|
||||
|
||||
**Selbst bauen statt ziehen** (keine Registry nötig):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
Baut lokal aus den Dockerfiles; die Deps stecken in den Images, also sind Restarts sofort da und
|
||||
Rebuilds nach einem `git pull` dank Layer-Cache schnell.
|
||||
|
||||
Frontend on port 8080, backend on port 3700, scrobble service on port 3710. Route each through your reverse proxy. **Important:** the scrobble subdomain must *not* sit behind an SSO / forward-auth gateway — it authenticates itself via Navidrome (`ping.view`), and a gateway in front redirects the app's API calls to a login page.
|
||||
|
||||
### 3. First launch
|
||||
|
||||
1. Enter your Navidrome server URL, username, and password
|
||||
2. Optionally add a Last.fm API key in Settings for bios and recommendations
|
||||
3. Optionally connect Audiobookshelf in Settings
|
||||
4. Optionally add the NovaSonic backend URL in Settings to enable downloads
|
||||
5. Optionally add the NovaSonic scrobble URL in Settings to record listening stats
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Frontend container
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `NS_SERVER_URL` | Pre-fills the Navidrome server URL on the login screen |
|
||||
| `NS_BACKEND_URL` | Pre-fills the download backend URL |
|
||||
| `NAVIDROME_URL` | Pre-fills the Navidrome server URL on the login screen |
|
||||
| `BACKEND_URL` | Pre-fills the download backend URL on the login screen |
|
||||
| `SCROBBLE_URL` | Pre-fills the listening-stats (scrobble) service URL on the login screen |
|
||||
|
||||
### With download backend
|
||||
|
||||
The backend requires `yt-dlp` and `ffmpeg`. A separate `docker-compose.yml` lives in `backend/`.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Environment variables for the backend:
|
||||
### Backend container
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `3700` | Port the backend listens on |
|
||||
| `MUSIC_PATH` | `/music` | Path to your Navidrome music folder (mount as volume) |
|
||||
| `AUDIO_FMT` | `mp3` | Output format (`mp3`, `flac`, `opus`, …) |
|
||||
| `MUSIC_PATH` | `/music` | Absolute path to your Navidrome music folder |
|
||||
| `AUDIO_FMT` | `mp3` | Download format (`mp3`, `flac`, `opus`, `best`) |
|
||||
| `AUDIO_QUALITY` | `0` | yt-dlp audio quality (0 = best) |
|
||||
| `MAX_PARALLEL` | `2` | Maximum concurrent downloads |
|
||||
| `MAX_PARALLEL` | `2` | Max concurrent download jobs |
|
||||
| `TICKETMASTER_API_KEY` | — | Free key from [developer.ticketmaster.com](https://developer.ticketmaster.com) |
|
||||
|
||||
### Scrobble container
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `3710` | Port the scrobble service listens on |
|
||||
| `DATA_PATH` | `/data` | Where the play-log SQLite DB is stored (own volume) |
|
||||
| `NAVIDROME_URL` | — | Used to verify user credentials via `ping.view` (multi-user auth) |
|
||||
|
||||
---
|
||||
|
||||
@@ -161,13 +194,3 @@ npm run dev # Vite dev server with HMR
|
||||
npm run build # Production build → dist/
|
||||
npm run preview # Preview the production build locally
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## First Launch
|
||||
|
||||
1. Open the app in a browser
|
||||
2. Enter your Navidrome server URL, username and password
|
||||
3. Optionally add a Last.fm API key (Settings) for recommendations and similar artists
|
||||
4. Optionally add the backend URL (Settings) to enable yt-dlp downloads
|
||||
5. Optionally connect Audiobookshelf (Settings) to stream audiobooks
|
||||
@@ -1,22 +1,52 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.novasonic.app"
|
||||
compileSdk = 34
|
||||
namespace = "com.novasonic.app"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.novasonic.app"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
buildConfigField("String", "NOVASONIC_URL", "\"https://music.asianxpolish.duckdns.org\"")
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 30
|
||||
versionName = "1.27"
|
||||
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
|
||||
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
|
||||
// Platzhalter stehen und der App-Link greift schlicht nicht.
|
||||
val host1 = (project.findProperty("novasonicHost") as String?) ?: "music.example.com"
|
||||
// Zweiter Host fuer einen Domainwechsel: solange beide Adressen erreichbar sind, oeffnen
|
||||
// Links von der alten UND der neuen in der App. Ohne Angabe wird host1 wiederholt — ein
|
||||
// doppelter Eintrag ist harmlos, ein Platzhalter-Host waere es NICHT: auf Android 11 und
|
||||
// aelter verifiziert das System nur, wenn ALLE angegebenen Hosts eine gueltige
|
||||
// assetlinks.json ausliefern, ein toter Host wuerde also alle blockieren.
|
||||
val host2 = (project.findProperty("novasonicHost2") as String?) ?: host1
|
||||
manifestPlaceholders["shareHost"] = host1
|
||||
manifestPlaceholders["shareHost2"] = host2
|
||||
// Derselbe Host auch fuer AUSGEHENDE Links. Er steht ohnehin schon fest im Manifest, eine
|
||||
// zusaetzliche Einstellung in der App waere also nur eine weitere Adresse zum Pflegen —
|
||||
// und eine, die vom Manifest abweichen koennte, ohne dass es jemand merkt.
|
||||
buildConfigField("String", "SHARE_HOST", "\"$host1\"")
|
||||
|
||||
// Vorgabe-Adressen fuer Backend und Scrobble-Dienst. Sie stehen ohnehin in der
|
||||
// docker-compose.yml — sie zusaetzlich auf jedem Geraet einzutippen ist Arbeit ohne Nutzen.
|
||||
// Leer gelassen verhaelt sich die App wie bisher (Felder sichtbar, nichts vorbelegt).
|
||||
// Zugangsdaten bleiben bewusst aussen vor: die gehoeren pro Person, nicht ins APK.
|
||||
val backendUrl = (project.findProperty("novasonicBackend") as String?) ?: ""
|
||||
val scrobbleUrl = (project.findProperty("novasonicScrobble") as String?) ?: ""
|
||||
val navidromeUrl= (project.findProperty("novasonicNavidrome") as String?) ?: ""
|
||||
buildConfigField("String", "DEFAULT_BACKEND_URL", "\"$backendUrl\"")
|
||||
buildConfigField("String", "DEFAULT_SCROBBLE_URL", "\"$scrobbleUrl\"")
|
||||
buildConfigField("String", "DEFAULT_ND_URL", "\"$navidromeUrl\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
@@ -28,22 +58,77 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
jvmTarget = "11"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.splashscreen)
|
||||
|
||||
// Compose
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.icons)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.navigation.compose)
|
||||
|
||||
// Lifecycle / ViewModel
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
|
||||
// Media3
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
implementation(libs.media3.common)
|
||||
implementation(libs.media3.datasource)
|
||||
implementation(libs.media3.ui)
|
||||
|
||||
// Ktor
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.android)
|
||||
implementation(libs.ktor.client.content.neg)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
implementation(libs.ktor.client.logging)
|
||||
|
||||
// Kotlinx
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlin.reflect)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// WorkManager
|
||||
implementation(libs.work.runtime.ktx)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore.preferences)
|
||||
|
||||
// Coil
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Palette (dominant cover color extraction in NowPlaying)
|
||||
implementation("androidx.palette:palette-ktx:1.0.0")
|
||||
|
||||
// MediaStyle for ABS notification (already transitive via media3-session, made explicit)
|
||||
implementation("androidx.media:media:1.7.0")
|
||||
|
||||
// Google Fonts (Outfit)
|
||||
implementation(libs.compose.ui.google.fonts)
|
||||
}
|
||||
Vendored
+6
@@ -6,3 +6,9 @@
|
||||
# Keep Media3 session / exoplayer classes used by reflection / service binding.
|
||||
-keep class androidx.media3.** { *; }
|
||||
-keep class com.google.android.exoplayer2.** { *; }
|
||||
|
||||
# Ktors Logging referenziert ueber slf4j den Binder aus slf4j 1.x. Auf Android existiert der nicht
|
||||
# (slf4j 2.x sucht Implementierungen per ServiceLoader, und wir binden ohnehin keine), deshalb bricht
|
||||
# R8 den Release-Build mit "Missing class org.slf4j.impl.StaticLoggerBinder" ab. Die Referenz ist zur
|
||||
# Laufzeit unerreichbar — slf4j faengt das Fehlen selbst ab und schaltet auf einen No-op-Logger.
|
||||
-dontwarn org.slf4j.**
|
||||
@@ -5,10 +5,14 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<!-- In-app updater: install a downloaded APK (user still confirms the install) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
|
||||
<application
|
||||
android:name=".NovaSonicApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -20,7 +24,7 @@
|
||||
android:largeHeap="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
@@ -29,10 +33,23 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Geteilte Links (https://<host>/?song=…) direkt in der App oeffnen.
|
||||
autoVerify greift erst, wenn unter https://<host>/.well-known/assetlinks.json der
|
||||
SHA-256 des RELEASE-Signaturschluessels hinterlegt ist. Ohne das bleibt der Filter
|
||||
wirkungslos, bis der Nutzer die App in den Systemeinstellungen unter
|
||||
"Standardmaessig oeffnen" fuer diese Domain zulaesst. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https" android:host="${shareHost}"/>
|
||||
<data android:scheme="https" android:host="${shareHost2}"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".MusicService"
|
||||
android:name=".player.MusicService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@@ -41,7 +58,19 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Android Auto support -->
|
||||
|
||||
<!-- FileProvider — hands the downloaded update APK to the system installer -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
|
||||
<!-- Android Auto -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
data class Track(
|
||||
val id: String,
|
||||
val url: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val album: String?,
|
||||
val artworkUrl: String?,
|
||||
val durationMs: Long
|
||||
) {
|
||||
fun toMediaItem(): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setAlbumTitle(album)
|
||||
.apply {
|
||||
if (!artworkUrl.isNullOrEmpty()) setArtworkUri(Uri.parse(artworkUrl))
|
||||
if (durationMs > 0) setDurationMs(durationMs)
|
||||
}
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setUri(url)
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(obj: JSONObject) = Track(
|
||||
id = obj.optString("id", ""),
|
||||
url = obj.optString("url", ""),
|
||||
title = obj.optString("title", ""),
|
||||
artist = obj.optString("artist", ""),
|
||||
album = obj.optString("album").takeIf { it.isNotEmpty() },
|
||||
artworkUrl = obj.optString("artworkUrl").takeIf { it.isNotEmpty() },
|
||||
durationMs = obj.optLong("durationMs", 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTrackList(json: String): List<Track> = try {
|
||||
val arr = JSONArray(json)
|
||||
List(arr.length()) { Track.fromJson(arr.getJSONObject(it)) }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import org.json.JSONObject
|
||||
|
||||
class MainActivity : AppCompatActivity(), MusicService.StateListener {
|
||||
|
||||
private lateinit var webView: WebView
|
||||
private lateinit var webBridge: WebBridge
|
||||
private var controllerFuture: ListenableFuture<MediaController>? = null
|
||||
private var mediaController: MediaController? = null
|
||||
private var pendingOpenNowPlaying = false
|
||||
// Debounce onReady: WebView can fire onPageFinished multiple times (hash changes,
|
||||
// certain JS navigations). Only deliver onReady once per 3-second window.
|
||||
private var lastOnReadyMs = 0L
|
||||
|
||||
// ─── Back-press handling ──────────────────────────────────────────────────
|
||||
|
||||
// Always enabled — the callback itself decides whether to handle the event.
|
||||
private val backCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (webBridge.consumeBackPress()) {
|
||||
// An overlay is open in JS — ask it to close the topmost layer.
|
||||
webView.evaluateJavascript(
|
||||
"if(window.NSPlayer&&window.NSPlayer.handleBack)window.NSPlayer.handleBack()",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack()
|
||||
return
|
||||
}
|
||||
// Nothing left to pop — send the task to background.
|
||||
// Using moveTaskToBack instead of finish() so the Activity stays alive
|
||||
// and the MusicService binding is preserved; resuming is instant.
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
webView = findViewById(R.id.web_view)
|
||||
|
||||
webBridge = WebBridge { mediaController }
|
||||
configureWebView()
|
||||
requestNotificationPermission()
|
||||
onBackPressedDispatcher.addCallback(this, backCallback)
|
||||
// Explicitly start the service before binding so Android 14 lets it
|
||||
// promote to foreground (bindService alone doesn't satisfy the
|
||||
// "started from foreground" requirement for foreground services).
|
||||
startService(Intent(this, MusicService::class.java))
|
||||
connectToService()
|
||||
|
||||
val url = BuildConfig.NOVASONIC_URL.ifEmpty { "https://demo.navidrome.org" }
|
||||
webView.loadUrl(url)
|
||||
|
||||
// Notification tap while app was not running — defer until page is loaded.
|
||||
if (intent?.getBooleanExtra("open_now_playing", false) == true) {
|
||||
pendingOpenNowPlaying = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Notification tap while app is already running — WebView is ready immediately.
|
||||
if (intent.getBooleanExtra("open_now_playing", false)) {
|
||||
// Refresh snapshot so getState() is never stale, then emit onTrackChanged
|
||||
// + openNowPlaying in a single JS tick to avoid the race where the bottom
|
||||
// bar and NP overlay show the previous song.
|
||||
updateStateSnapshot()
|
||||
val ctrl = mediaController
|
||||
if (ctrl != null) {
|
||||
val idx = ctrl.currentMediaItemIndex
|
||||
val trackId = ctrl.currentMediaItem?.mediaId ?: ""
|
||||
val json = JSONObject().apply {
|
||||
put("index", idx)
|
||||
put("trackId", trackId)
|
||||
put("reason", "notification")
|
||||
}.toString()
|
||||
val escaped = escapeJs(json)
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"(function(){" +
|
||||
"if(window.NSPlayer&&window.NSPlayer.onTrackChanged)window.NSPlayer.onTrackChanged(\"$escaped\");" +
|
||||
"if(window.openNowPlaying)window.openNowPlaying();" +
|
||||
"})()",
|
||||
null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
webView.post { webView.evaluateJavascript("if(window.openNowPlaying)window.openNowPlaying()", null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() { super.onResume(); webView.onResume() }
|
||||
override fun onPause() { webView.onPause(); super.onPause() }
|
||||
|
||||
override fun onDestroy() {
|
||||
MusicService.stateListener = null
|
||||
controllerFuture?.let { MediaController.releaseFuture(it) }
|
||||
webView.destroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ─── Service connection ───────────────────────────────────────────────────
|
||||
|
||||
private fun connectToService() {
|
||||
MusicService.stateListener = this
|
||||
val token = SessionToken(this, ComponentName(this, MusicService::class.java))
|
||||
val future = MediaController.Builder(this, token).buildAsync()
|
||||
controllerFuture = future
|
||||
future.addListener({
|
||||
mediaController = try { future.get() } catch (_: Exception) { null }
|
||||
if (mediaController != null) {
|
||||
lastOnReadyMs = System.currentTimeMillis()
|
||||
updateStateSnapshot() // ensure getState() is fresh when JS calls it in onReady
|
||||
callJs("onReady")
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
// ─── WebView configuration ────────────────────────────────────────────────
|
||||
|
||||
private fun configureWebView() {
|
||||
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
|
||||
|
||||
webView.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
allowFileAccess = true // needed for bridge-test.html asset
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
userAgentString = userAgentString.replace("wv", "") + " NovaSonicApp/1.0"
|
||||
}
|
||||
|
||||
// New full bridge — JS calls window.NSBridge.*
|
||||
webView.addJavascriptInterface(webBridge, "NSBridge")
|
||||
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false
|
||||
|
||||
// Re-announce ready + current state after a WebView reload so JS can re-sync.
|
||||
// Debounced: WebView can fire onPageFinished for hash/state changes mid-session.
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (mediaController != null) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastOnReadyMs > 3_000) {
|
||||
lastOnReadyMs = now
|
||||
updateStateSnapshot()
|
||||
callJs("onReady")
|
||||
}
|
||||
}
|
||||
if (pendingOpenNowPlaying) {
|
||||
pendingOpenNowPlaying = false
|
||||
webView.post { webView.evaluateJavascript("if(window.openNowPlaying)window.openNowPlaying()", null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
webView.webChromeClient = WebChromeClient()
|
||||
}
|
||||
|
||||
// ─── MusicService.StateListener → push state to JS ───────────────────────
|
||||
|
||||
override fun onReady() = callJs("onReady")
|
||||
|
||||
override fun onPlaybackStateChanged(isPlaying: Boolean, isBuffering: Boolean, isLoading: Boolean) {
|
||||
val json = """{"isPlaying":$isPlaying,"isBuffering":$isBuffering,"isLoading":$isLoading}"""
|
||||
updateStateSnapshot()
|
||||
callJs("onPlaybackStateChanged", json)
|
||||
}
|
||||
|
||||
override fun onTrackChanged(index: Int, trackId: String, reason: String) {
|
||||
val json = JSONObject().apply {
|
||||
put("index", index)
|
||||
put("trackId", trackId)
|
||||
put("reason", reason)
|
||||
}.toString()
|
||||
updateStateSnapshot()
|
||||
callJs("onTrackChanged", json)
|
||||
}
|
||||
|
||||
override fun onPositionUpdate(positionMs: Long, durationMs: Long) =
|
||||
callJs("onPositionUpdate", positionMs, durationMs)
|
||||
|
||||
override fun onQueueEnded() = callJs("onQueueEnded")
|
||||
|
||||
override fun onError(message: String) = callJs("onError", message)
|
||||
|
||||
override fun onSkipToNext() = callJs("onNotificationNext")
|
||||
override fun onSkipToPrevious() = callJs("onNotificationPrev")
|
||||
|
||||
// ─── JS bridge helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calls window.NSPlayer.<method>(<args>) safely on the UI thread.
|
||||
* Strings are JSON-escaped and quoted; numbers/booleans are passed as-is.
|
||||
*/
|
||||
fun callJs(method: String, vararg args: Any?) {
|
||||
val argsJs = args.joinToString(",") { arg ->
|
||||
when (arg) {
|
||||
null -> "null"
|
||||
is Boolean -> arg.toString()
|
||||
is Number -> arg.toString()
|
||||
is String -> "\"${escapeJs(arg)}\""
|
||||
else -> "\"${escapeJs(arg.toString())}\""
|
||||
}
|
||||
}
|
||||
val js = "if(window.NSPlayer&&window.NSPlayer.$method)window.NSPlayer.$method($argsJs)"
|
||||
webView.post { webView.evaluateJavascript(js, null) }
|
||||
}
|
||||
|
||||
private fun escapeJs(s: String) = s
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "")
|
||||
|
||||
private fun updateStateSnapshot() {
|
||||
val ctrl = mediaController ?: return
|
||||
val json = JSONObject().apply {
|
||||
put("isPlaying", ctrl.isPlaying)
|
||||
put("isBuffering", ctrl.playbackState == Player.STATE_BUFFERING)
|
||||
put("isEnded", ctrl.playbackState == Player.STATE_ENDED)
|
||||
put("currentIndex", ctrl.currentMediaItemIndex)
|
||||
put("currentId", ctrl.currentMediaItem?.mediaId ?: "")
|
||||
put("positionMs", ctrl.currentPosition)
|
||||
put("queueLength", ctrl.mediaItemCount)
|
||||
}.toString()
|
||||
webBridge.updateSnapshot(json)
|
||||
}
|
||||
|
||||
// ─── Permissions ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
when {
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED -> return
|
||||
|
||||
// System won't show the dialog again after one denial — send user to settings once.
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
this, Manifest.permission.POST_NOTIFICATIONS
|
||||
) && _notifPermissionAskedBefore() && !_notifSettingsShownBefore() -> {
|
||||
markNotifSettingsShown()
|
||||
openNotificationSettings()
|
||||
}
|
||||
|
||||
else -> {
|
||||
markNotifPermissionAsked()
|
||||
ActivityCompat.requestPermissions(
|
||||
this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 42
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == 42 &&
|
||||
grantResults.firstOrNull() == PackageManager.PERMISSION_DENIED &&
|
||||
!_notifSettingsShownBefore()
|
||||
) {
|
||||
markNotifSettingsShown()
|
||||
openNotificationSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openNotificationSettings() {
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun _notifPermissionAskedBefore() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).getBoolean("notif_asked", false)
|
||||
|
||||
private fun markNotifPermissionAsked() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).edit()
|
||||
.putBoolean("notif_asked", true).apply()
|
||||
|
||||
private fun _notifSettingsShownBefore() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).getBoolean("notif_settings_shown", false)
|
||||
|
||||
private fun markNotifSettingsShown() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).edit()
|
||||
.putBoolean("notif_settings_shown", true).apply()
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
// Replaced by MusicService.kt (Media3 MediaSessionService + ExoPlayer).
|
||||
// Kept as an empty file so git history is preserved.
|
||||
@@ -1,271 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.CacheBitmapLoader
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MusicService : MediaSessionService() {
|
||||
|
||||
// ─── State callback interface (consumed by MainActivity) ─────────────────
|
||||
|
||||
interface StateListener {
|
||||
fun onReady()
|
||||
fun onPlaybackStateChanged(isPlaying: Boolean, isBuffering: Boolean, isLoading: Boolean)
|
||||
fun onTrackChanged(index: Int, trackId: String, reason: String)
|
||||
fun onPositionUpdate(positionMs: Long, durationMs: Long)
|
||||
fun onQueueEnded()
|
||||
fun onError(message: String)
|
||||
fun onSkipToNext()
|
||||
fun onSkipToPrevious()
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Set by MainActivity before connecting; cleared on destroy.
|
||||
@Volatile var stateListener: StateListener? = null
|
||||
// Service instance — lets WebBridge trigger artwork pre-warming directly.
|
||||
@Volatile var instance: MusicService? = null
|
||||
}
|
||||
|
||||
// ─── Fields ───────────────────────────────────────────────────────────────
|
||||
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var mediaSession: MediaSession
|
||||
private lateinit var bitmapLoader: CacheBitmapLoader
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// 250 ms position ticker — only runs while actually playing
|
||||
private val positionTicker = object : Runnable {
|
||||
override fun run() {
|
||||
if (player.isPlaying) {
|
||||
val dur = player.duration.let { if (it == C.TIME_UNSET) 0L else it }
|
||||
stateListener?.onPositionUpdate(player.currentPosition, dur)
|
||||
handler.postDelayed(this, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
val httpFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent("NovaSonic-Android/1.0")
|
||||
.setConnectTimeoutMs(15_000)
|
||||
.setReadTimeoutMs(15_000)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
|
||||
val dataSourceFactory = DefaultDataSource.Factory(this, httpFactory)
|
||||
|
||||
// CacheBitmapLoader caches fetched bitmaps in-memory so that
|
||||
// the notification never has to re-fetch artwork on auto-advance.
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
bitmapLoader = CacheBitmapLoader(
|
||||
DataSourceBitmapLoader(
|
||||
MoreExecutors.listeningDecorator(executor),
|
||||
dataSourceFactory
|
||||
)
|
||||
)
|
||||
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
|
||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||
.setHandleAudioBecomingNoisy(true) // pause on headphone unplug
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze
|
||||
.build()
|
||||
|
||||
// ForwardingPlayer that always reports next/prev as available commands.
|
||||
// ExoPlayer drops those commands when its queue has only one item (single-track model),
|
||||
// which makes Media3 hide the skip buttons in the notification. JS still owns queue
|
||||
// logic — the MediaSession callback intercepts the commands and delegates to JS.
|
||||
val sessionPlayer = object : ForwardingPlayer(player) {
|
||||
override fun getAvailableCommands(): Player.Commands =
|
||||
super.getAvailableCommands().buildUpon()
|
||||
.add(Player.COMMAND_SEEK_TO_NEXT)
|
||||
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
.add(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.build()
|
||||
|
||||
override fun isCommandAvailable(command: Int): Boolean = when (command) {
|
||||
Player.COMMAND_SEEK_TO_NEXT,
|
||||
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> true
|
||||
else -> super.isCommandAvailable(command)
|
||||
}
|
||||
}
|
||||
|
||||
// Tapping the notification opens the app directly to the Now Playing screen.
|
||||
val notifTapIntent = Intent(this, MainActivity::class.java).apply {
|
||||
putExtra("open_now_playing", true)
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val notifTapPending = PendingIntent.getActivity(
|
||||
this, 0, notifTapIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, sessionPlayer)
|
||||
.setBitmapLoader(bitmapLoader)
|
||||
.setSessionActivity(notifTapPending)
|
||||
.setCallback(object : MediaSession.Callback {
|
||||
// Intercept notification next/prev so JS (not ExoPlayer) handles queue logic.
|
||||
// With single-track model ExoPlayer has no queue to advance — block the command
|
||||
// and delegate to JS instead.
|
||||
override fun onPlayerCommandRequest(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
playerCommand: Int
|
||||
): Int = when (playerCommand) {
|
||||
Player.COMMAND_SEEK_TO_NEXT,
|
||||
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> {
|
||||
stateListener?.onSkipToNext()
|
||||
androidx.media3.session.SessionResult.RESULT_ERROR_NOT_SUPPORTED
|
||||
}
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> {
|
||||
stateListener?.onSkipToPrevious()
|
||||
androidx.media3.session.SessionResult.RESULT_ERROR_NOT_SUPPORTED
|
||||
}
|
||||
else -> androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
player.addListener(PlayerListener())
|
||||
|
||||
// Pre-warm: load the last played track immediately so ExoPlayer starts
|
||||
// buffering while the WebView is still loading — reduces startup latency.
|
||||
try {
|
||||
val prefs = getSharedPreferences("ns_prefs", android.content.Context.MODE_PRIVATE)
|
||||
val trackJson = prefs.getString("last_played_track", null)
|
||||
if (!trackJson.isNullOrBlank()) {
|
||||
val track = Track.fromJson(org.json.JSONObject(trackJson))
|
||||
player.setMediaItem(track.toMediaItem())
|
||||
player.prepare()
|
||||
// No play() — wait for JS to decide whether to auto-play
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
player.stop()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
instance = null
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
mediaSession.release()
|
||||
player.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ─── Artwork pre-warming ──────────────────────────────────────────────────
|
||||
|
||||
// Loads the next `count` tracks' artwork into the CacheBitmapLoader so that
|
||||
// onMediaItemTransition never stalls waiting for a fresh HTTP fetch.
|
||||
// fromIndex: explicit start index; -1 means use player.currentMediaItemIndex.
|
||||
private fun warmArtworkCache(count: Int = 3, fromIndex: Int = -1) {
|
||||
val startIdx = if (fromIndex >= 0) fromIndex else player.currentMediaItemIndex
|
||||
val total = player.mediaItemCount
|
||||
for (offset in 0 until count) {
|
||||
val idx = startIdx + offset
|
||||
if (idx >= total) break
|
||||
val item = player.getMediaItemAt(idx)
|
||||
if (item.mediaMetadata.artworkUri != null) {
|
||||
bitmapLoader.loadBitmapFromMetadata(item.mediaMetadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called by WebBridge right after setMediaItems so artwork fetch starts
|
||||
// before prepare()/play() — avoids the race where the notification renders
|
||||
// before the bitmap is in the cache.
|
||||
fun publicWarmArtwork(startIndex: Int) = warmArtworkCache(count = 5, fromIndex = startIndex)
|
||||
|
||||
// ─── Player.Listener ─────────────────────────────────────────────────────
|
||||
|
||||
private inner class PlayerListener : Player.Listener {
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
val state = player.playbackState
|
||||
stateListener?.onPlaybackStateChanged(
|
||||
isPlaying,
|
||||
isBuffering = state == Player.STATE_BUFFERING,
|
||||
isLoading = state == Player.STATE_IDLE || state == Player.STATE_BUFFERING
|
||||
)
|
||||
if (isPlaying) handler.post(positionTicker)
|
||||
else handler.removeCallbacks(positionTicker)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
val isPlaying = player.isPlaying
|
||||
stateListener?.onPlaybackStateChanged(
|
||||
isPlaying,
|
||||
isBuffering = playbackState == Player.STATE_BUFFERING,
|
||||
isLoading = playbackState == Player.STATE_IDLE
|
||||
)
|
||||
// STATE_ENDED fires when the last item finishes with no repeat
|
||||
if (playbackState == Player.STATE_ENDED) stateListener?.onQueueEnded()
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
val reasonStr = when (reason) {
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto"
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek"
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "queue"
|
||||
else -> "seek"
|
||||
}
|
||||
// Fire immediately — snapshot must always be fresh so the JS-side
|
||||
// getState() reconciliation in onTrackChanged sees current native state.
|
||||
stateListener?.onTrackChanged(
|
||||
player.currentMediaItemIndex,
|
||||
mediaItem?.mediaId ?: "",
|
||||
reasonStr
|
||||
)
|
||||
// Pre-warm artwork for current + next tracks so notification
|
||||
// never shows a blank cover on auto-advance.
|
||||
warmArtworkCache()
|
||||
}
|
||||
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
// Queue was replaced (setQueue / addToQueue from JS) — warm cache from new start.
|
||||
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
|
||||
warmArtworkCache()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
stateListener?.onError(error.message ?: "Playback error")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
// Replaced by Media3-managed notifications (MusicService.kt).
|
||||
// Notification actions are now handled natively by Media3 MediaSession.
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.app.Application
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import com.novasonic.app.offline.OfflineRepository
|
||||
import com.novasonic.app.player.AbsPlayerController
|
||||
import com.novasonic.app.player.PlayerController
|
||||
import com.novasonic.app.player.PreviewController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NovaSonicApp : Application() {
|
||||
|
||||
lateinit var repository: MusicRepository
|
||||
private set
|
||||
lateinit var playerController: PlayerController
|
||||
private set
|
||||
lateinit var absPlayerController: AbsPlayerController
|
||||
private set
|
||||
lateinit var offlineRepository: OfflineRepository
|
||||
private set
|
||||
// 30s preview player — app-level so its control bar survives navigation (see AppNavigation).
|
||||
val previewController: PreviewController = PreviewController()
|
||||
|
||||
/**
|
||||
* Raeumt den Cache nach einem App-Update einmalig auf.
|
||||
*
|
||||
* Im cacheDir liegen Coils Bildspeicher (Cover) und heruntergeladene APK-Updates — beides
|
||||
* jederzeit neu beschaffbar. Nach einem Update konnten dort Reste liegen, die zu alten Anzeigen
|
||||
* fuehrten; die einzige Abhilfe war bisher "Cache leeren" von Hand in den Systemeinstellungen,
|
||||
* was man einem normalen Nutzer nicht erklaeren moechte. Das passiert jetzt automatisch und
|
||||
* ausschliesslich beim Versionswechsel, nicht im laufenden Betrieb — sonst waeren die Cover bei
|
||||
* jedem Start neu zu laden.
|
||||
*
|
||||
* Offline-Downloads sind NICHT betroffen: die liegen unter filesDir/offline, nicht im cacheDir.
|
||||
*/
|
||||
private fun cacheNachUpdateLeeren() {
|
||||
val prefs = getSharedPreferences("app_state", MODE_PRIVATE)
|
||||
val zuletzt = prefs.getInt("last_version_code", -1)
|
||||
val jetzt = BuildConfig.VERSION_CODE
|
||||
if (zuletzt == jetzt) return
|
||||
if (zuletzt != -1) {
|
||||
runCatching { cacheDir.deleteRecursively() }
|
||||
.onSuccess { android.util.Log.i("NsCache", "Cache nach Update $zuletzt -> $jetzt geleert") }
|
||||
.onFailure { android.util.Log.w("NsCache", "Cache-Leeren fehlgeschlagen", it) }
|
||||
}
|
||||
prefs.edit().putInt("last_version_code", jetzt).apply()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
cacheNachUpdateLeeren()
|
||||
offlineRepository = OfflineRepository(applicationContext) // must be first
|
||||
repository = MusicRepository(applicationContext)
|
||||
playerController = PlayerController(applicationContext, repository)
|
||||
playerController.connect()
|
||||
absPlayerController = AbsPlayerController(applicationContext)
|
||||
// Angefangene Downloads weiterlaufen lassen (grosse Playlists brauchen viele Laeufe).
|
||||
offlineRepository.setzeUnvollstaendigeFort()
|
||||
// Flush any plays queued while offline to the scrobble service.
|
||||
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
|
||||
runCatching { repository.flushPlayLog() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience extension to access app-level singletons from any Activity/Composable context.
|
||||
val android.content.Context.app: NovaSonicApp
|
||||
get() = applicationContext as NovaSonicApp
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
/**
|
||||
* Legacy singleton — no longer used. Kept to avoid breaking any stale git references.
|
||||
* State is now pushed directly from MusicService.StateListener via MainActivity.callJs().
|
||||
*/
|
||||
object NovaSonicBridge {
|
||||
@Volatile var jsRunner: ((String) -> Unit)? = null
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
// Replaced by WebBridge.kt (window.NSBridge).
|
||||
// Kept as an empty file so git history is preserved.
|
||||
@@ -1,168 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Exposed to the WebView as `window.NSBridge`.
|
||||
* All @JavascriptInterface methods are called on a WebView background thread — every
|
||||
* MediaController call is posted to the main looper before execution.
|
||||
*/
|
||||
class WebBridge(private val controllerProvider: () -> MediaController?) {
|
||||
|
||||
// Snapshot of current playback state; read from getState() on any thread.
|
||||
@Volatile private var stateSnapshot = "{}"
|
||||
|
||||
fun updateSnapshot(snapshot: String) { stateSnapshot = snapshot }
|
||||
|
||||
// ─── Queue management ─────────────────────────────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun setQueue(queueJson: String, startIndex: Int, autoPlay: Boolean) {
|
||||
val items = parseTrackList(queueJson).map { it.toMediaItem() }
|
||||
if (items.isEmpty()) return
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
val idx = startIndex.coerceAtLeast(0)
|
||||
ctrl.setMediaItems(items, idx, 0L)
|
||||
// Pre-warm artwork before prepare()/play() so the notification bitmap
|
||||
// cache is populated before Media3 renders the first notification.
|
||||
MusicService.instance?.publicWarmArtwork(idx)
|
||||
ctrl.prepare()
|
||||
if (autoPlay) ctrl.play()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun addToQueue(trackJson: String, position: Int) {
|
||||
val track = try { Track.fromJson(JSONObject(trackJson)) } catch (_: Exception) { return }
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
val item = track.toMediaItem()
|
||||
if (position < 0 || position >= ctrl.mediaItemCount) ctrl.addMediaItem(item)
|
||||
else ctrl.addMediaItem(position, item)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun removeFromQueue(index: Int) {
|
||||
runOnMain { controllerProvider()?.removeMediaItem(index) }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun clearQueue() {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
ctrl.stop()
|
||||
ctrl.clearMediaItems()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Transport ────────────────────────────────────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun play() { runOnMain { controllerProvider()?.play() } }
|
||||
|
||||
@JavascriptInterface
|
||||
fun pause() { runOnMain { controllerProvider()?.pause() } }
|
||||
|
||||
@JavascriptInterface
|
||||
fun togglePlay() {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun seekTo(positionMs: Long) {
|
||||
runOnMain { controllerProvider()?.seekTo(positionMs) }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun seekToIndex(index: Int, positionMs: Long) {
|
||||
runOnMain { controllerProvider()?.seekTo(index, positionMs) }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun next() { runOnMain { controllerProvider()?.seekToNextMediaItem() } }
|
||||
|
||||
@JavascriptInterface
|
||||
fun prev() {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
if (ctrl.currentPosition > 3000) ctrl.seekTo(0)
|
||||
else ctrl.seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Playback settings ────────────────────────────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun setRepeatMode(mode: String) {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
ctrl.repeatMode = when (mode) {
|
||||
"one" -> Player.REPEAT_MODE_ONE
|
||||
"all" -> Player.REPEAT_MODE_ALL
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setShuffleMode(enabled: Boolean) {
|
||||
runOnMain { controllerProvider()?.shuffleModeEnabled = enabled }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setVolume(volume: Float) {
|
||||
runOnMain { controllerProvider()?.volume = volume }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setPlaybackSpeed(speed: Float) {
|
||||
runOnMain { controllerProvider()?.setPlaybackSpeed(speed) }
|
||||
}
|
||||
|
||||
// ─── State snapshot (synchronous read from JS) ────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun getState(): String = stateSnapshot
|
||||
|
||||
// ─── Last played (persisted for startup preload) ──────────────────────────
|
||||
|
||||
// Called by JS whenever the current song changes — persists to SharedPreferences
|
||||
// so MusicService can start buffering immediately on next launch before JS loads.
|
||||
@JavascriptInterface
|
||||
fun saveLastPlayed(trackJson: String) {
|
||||
runOnMain {
|
||||
try {
|
||||
val ctx = MusicService.instance ?: return@runOnMain
|
||||
ctx.getSharedPreferences("ns_prefs", android.content.Context.MODE_PRIVATE)
|
||||
.edit().putString("last_played_track", trackJson).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Overlay state (for back-button routing) ──────────────────────────────
|
||||
|
||||
// Written by WebView thread; read by UI thread — must be @Volatile.
|
||||
@Volatile private var hasOpenOverlay = false
|
||||
|
||||
@JavascriptInterface
|
||||
fun setOverlayState(active: Boolean) { hasOpenOverlay = active }
|
||||
|
||||
// Called by MainActivity on back press; returns true if JS owns the event.
|
||||
fun consumeBackPress(): Boolean = hasOpenOverlay
|
||||
|
||||
// ─── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun runOnMain(block: () -> Unit) {
|
||||
Handler(Looper.getMainLooper()).post(block)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class AbsLibrary(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val mediaType: String = "book",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsLibrariesResponse(val libraries: List<AbsLibrary> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class AbsMediaMetadata(
|
||||
val title: String? = null,
|
||||
val authorName: String? = null,
|
||||
val description: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsMedia(
|
||||
val metadata: AbsMediaMetadata = AbsMediaMetadata(),
|
||||
val duration: Double = 0.0,
|
||||
val numFiles: Int = 0,
|
||||
val chapters: List<AbsChapter> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsLibraryItem(
|
||||
val id: String = "",
|
||||
val media: AbsMedia = AbsMedia(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsItemsResponse(
|
||||
val results: List<AbsLibraryItem> = emptyList(),
|
||||
val total: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsSearchBookResult(
|
||||
val libraryItem: AbsLibraryItem = AbsLibraryItem(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsSearchResponse(
|
||||
val book: List<AbsSearchBookResult> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsChapter(
|
||||
val id: Int = 0,
|
||||
val start: Double = 0.0,
|
||||
val end: Double = 0.0,
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsAudioTrack(
|
||||
val index: Int = 0,
|
||||
val startOffset: Double = 0.0,
|
||||
val duration: Double = 0.0,
|
||||
val contentUrl: String = "",
|
||||
val mimeType: String = "audio/mpeg",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsPlaySession(
|
||||
val id: String = "",
|
||||
val audioTracks: List<AbsAudioTrack> = emptyList(),
|
||||
val chapters: List<AbsChapter> = emptyList(),
|
||||
val currentTime: Double = 0.0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsProgress(
|
||||
val currentTime: Double = 0.0,
|
||||
val duration: Double = 0.0,
|
||||
val progress: Double = 0.0,
|
||||
val isFinished: Boolean = false,
|
||||
)
|
||||
|
||||
class AbsClient(
|
||||
val absUrl: String,
|
||||
val apiKey: String,
|
||||
private val backendUrl: String = "",
|
||||
) {
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; isLenient = true }) }
|
||||
install(HttpTimeout) { requestTimeoutMillis = 30_000; connectTimeoutMillis = 8_000 }
|
||||
}
|
||||
|
||||
private suspend fun apiGet(path: String): HttpResponse =
|
||||
if (backendUrl.isNotBlank()) {
|
||||
http.get("$backendUrl/api/abs-proxy$path") {
|
||||
header("X-ABS-URL", absUrl)
|
||||
header("X-ABS-Token", apiKey)
|
||||
}
|
||||
} else {
|
||||
http.get("$absUrl$path") {
|
||||
header("Authorization", "Bearer $apiKey")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun apiPost(path: String, jsonBody: String): HttpResponse =
|
||||
if (backendUrl.isNotBlank()) {
|
||||
http.post("$backendUrl/api/abs-proxy$path") {
|
||||
header("X-ABS-URL", absUrl)
|
||||
header("X-ABS-Token", apiKey)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(jsonBody)
|
||||
}
|
||||
} else {
|
||||
http.post("$absUrl$path") {
|
||||
header("Authorization", "Bearer $apiKey")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(jsonBody)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun apiPatch(path: String, jsonBody: String): HttpResponse =
|
||||
if (backendUrl.isNotBlank()) {
|
||||
http.patch("$backendUrl/api/abs-proxy$path") {
|
||||
header("X-ABS-URL", absUrl)
|
||||
header("X-ABS-Token", apiKey)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(jsonBody)
|
||||
}
|
||||
} else {
|
||||
http.patch("$absUrl$path") {
|
||||
header("Authorization", "Bearer $apiKey")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(jsonBody)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ping(): Boolean = runCatching { apiGet("/api/me").status == HttpStatusCode.OK }.getOrDefault(false)
|
||||
|
||||
suspend fun getLibraries(): List<AbsLibrary> = runCatching {
|
||||
apiGet("/api/libraries").body<AbsLibrariesResponse>().libraries
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
suspend fun getLibraryItems(libId: String, page: Int = 0): AbsItemsResponse = runCatching {
|
||||
apiGet("/api/libraries/$libId/items?expanded=1&limit=50&page=$page&sort=media.metadata.title&asc=1")
|
||||
.body<AbsItemsResponse>()
|
||||
}.getOrDefault(AbsItemsResponse())
|
||||
|
||||
suspend fun searchLibraryItems(libId: String, query: String): List<AbsLibraryItem> = runCatching {
|
||||
val q = java.net.URLEncoder.encode(query.trim(), "UTF-8")
|
||||
apiGet("/api/libraries/$libId/search?q=$q&limit=40")
|
||||
.body<AbsSearchResponse>().book.map { it.libraryItem }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
suspend fun getItemChapters(itemId: String): List<AbsChapter> = runCatching {
|
||||
apiGet("/api/items/$itemId?expanded=1").body<AbsLibraryItem>().media.chapters
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
suspend fun startSession(itemId: String): AbsPlaySession = runCatching {
|
||||
apiPost(
|
||||
"/api/items/$itemId/play",
|
||||
"""{"deviceInfo":{"clientName":"NovaSonic","clientVersion":"1.0"},"supportedMimeTypes":["audio/mpeg","audio/mp4","audio/aac","audio/ogg","audio/flac","audio/m4b"],"mediaPlayer":"html5","forceDirectPlay":true,"forceTranscode":false}"""
|
||||
).body<AbsPlaySession>()
|
||||
}.getOrThrow()
|
||||
|
||||
suspend fun closeSession(sessionId: String) {
|
||||
runCatching { apiPost("/api/session/$sessionId/close", "{}") }
|
||||
}
|
||||
|
||||
suspend fun getProgress(itemId: String): AbsProgress? = runCatching {
|
||||
apiGet("/api/me/progress/$itemId").body<AbsProgress>()
|
||||
}.getOrNull()
|
||||
|
||||
suspend fun saveProgress(itemId: String, currentTime: Double, duration: Double) {
|
||||
val progress = if (duration > 0) (currentTime / duration).coerceAtMost(1.0) else 0.0
|
||||
val finished = progress >= 0.99
|
||||
runCatching {
|
||||
apiPatch(
|
||||
"/api/me/progress/$itemId",
|
||||
"""{"currentTime":$currentTime,"duration":$duration,"progress":$progress,"isFinished":$finished,"lastUpdate":${System.currentTimeMillis()}}"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun coverUrl(itemId: String): String =
|
||||
"$absUrl/api/items/$itemId/cover?token=${java.net.URLEncoder.encode(apiKey, "UTF-8")}"
|
||||
|
||||
fun audioTrackUrl(contentUrl: String): String {
|
||||
val sep = if (contentUrl.contains('?')) "&" else "?"
|
||||
return "$absUrl$contentUrl${sep}token=${java.net.URLEncoder.encode(apiKey, "UTF-8")}"
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.ArtistOverride
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Reads/writes the shared artist-pin overrides on the download backend
|
||||
* (GET/POST /api/artist-overrides, DELETE /api/artist-overrides/:id).
|
||||
* The pins fix same-name artist collisions; see [ArtistOverride].
|
||||
*/
|
||||
class ArtistOverrideClient(private val backendUrl: String) {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; coerceInputValues = true }) }
|
||||
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
|
||||
}
|
||||
|
||||
private fun base() = backendUrl.trimEnd('/')
|
||||
|
||||
@Serializable
|
||||
private data class SetReq(
|
||||
val ndArtistId: String,
|
||||
val deezerArtistId: String,
|
||||
val name: String,
|
||||
val image: String,
|
||||
val fanCount: Long?,
|
||||
)
|
||||
|
||||
/** Full map { ndArtistId -> override }. */
|
||||
suspend fun loadAll(): Map<String, ArtistOverride> =
|
||||
http.get("${base()}/api/artist-overrides").body()
|
||||
|
||||
/** Upsert a pin. Returns true on 2xx. */
|
||||
suspend fun set(ndArtistId: String, ov: ArtistOverride): Boolean {
|
||||
val resp: HttpResponse = http.post("${base()}/api/artist-overrides") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(SetReq(ndArtistId, ov.deezerArtistId, ov.name, ov.image, ov.fanCount))
|
||||
}
|
||||
return resp.status.isSuccess()
|
||||
}
|
||||
|
||||
/** Remove a pin. Returns true on 2xx. */
|
||||
suspend fun remove(ndArtistId: String): Boolean {
|
||||
val resp: HttpResponse = http.delete("${base()}/api/artist-overrides/${ndArtistId.encodeURLPath()}")
|
||||
return resp.status.isSuccess()
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.ConcertEvent
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object EventsClient {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
// Surface non-2xx as a real exception (e.g. an SSO redirect / HTML login page) instead of
|
||||
// letting .body() fail obscurely — the ViewModel turns it into a visible "load failed".
|
||||
expectSuccess = true
|
||||
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 10_000 }
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchEvents(backendUrl: String, artistName: String): List<ConcertEvent> {
|
||||
if (backendUrl.isBlank()) return emptyList()
|
||||
val base = backendUrl.trimEnd('/')
|
||||
val encoded = java.net.URLEncoder.encode(artistName, "UTF-8")
|
||||
return http.get("$base/api/events/$encoded").body()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Talks to the NovaSonic download/importer backend (Express server, default :3700). */
|
||||
class ImporterClient(private val backendUrl: String) {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 40_000
|
||||
connectTimeoutMillis = 12_000
|
||||
socketTimeoutMillis = 40_000
|
||||
}
|
||||
}
|
||||
|
||||
private fun base() = backendUrl.trimEnd('/')
|
||||
|
||||
companion object {
|
||||
/** Detect which importer endpoint to hit from a pasted URL. */
|
||||
fun platformFor(url: String): String? = when {
|
||||
url.contains("spotify.com") -> "spotify"
|
||||
url.contains("soundcloud.com") -> "soundcloud"
|
||||
url.contains("youtube.com") ||
|
||||
url.contains("youtu.be") -> "youtube"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resolve(platform: String, url: String): ImportResolveResponse =
|
||||
http.post("${base()}/api/$platform/resolve") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ResolveRequest(url))
|
||||
}.body()
|
||||
|
||||
suspend fun download(request: DownloadRequest) {
|
||||
http.post("${base()}/api/download") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun status(jobId: String): DownloadStatus =
|
||||
http.get("${base()}/api/status/$jobId").body()
|
||||
|
||||
/**
|
||||
* Loescht einen Song endgueltig von der Platte — derselbe Endpunkt, den der Web-Client nutzt.
|
||||
* Das Backend braucht die Navidrome-Zugangsdaten, weil es den Dateipfad ueber getSong aufloest.
|
||||
* Wirft bei HTTP != 2xx (Ktor `expectSuccess` ist hier aus, deshalb der explizite Check).
|
||||
*/
|
||||
suspend fun deleteSong(ndUrl: String, user: String, pass: String, songId: String) {
|
||||
val res = http.post("${base()}/api/delete-song") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(DeleteSongRequest(ndUrl.trimEnd('/'), user, pass, songId))
|
||||
}
|
||||
if (!res.status.isSuccess()) error("HTTP ${res.status.value}")
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Metadata/search client. Backed by **Deezer** (api.deezer.com) — Apple's iTunes /search
|
||||
* returns 403 from datacenter IPs, and Deezer keeps the phone + web PWA on the *same* source
|
||||
* (identical naming + explicit versions). Deezer responses are mapped to the legacy ItunesItem
|
||||
* shape (see DeezerModels.kt) so the screens/view models did not change. The public method names
|
||||
* are kept for the same reason.
|
||||
*/
|
||||
object ItunesClient {
|
||||
|
||||
private const val BASE = "https://api.deezer.com"
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined search. Deezer splits by type, so `entity` "album" hits /search/album; anything else
|
||||
* runs tracks + albums + artists in parallel and merges them (the view model filters by wrapperType).
|
||||
*/
|
||||
suspend fun search(
|
||||
query: String,
|
||||
entity: String = "musicTrack,album,musicArtist",
|
||||
limit: Int = 30,
|
||||
country: String = "us",
|
||||
): ItunesSearchResponse {
|
||||
if (entity == "album") {
|
||||
val albums: DzList<DzAlbum> = http.get("$BASE/search/album") {
|
||||
parameter("q", query); parameter("limit", limit)
|
||||
}.body()
|
||||
return ItunesSearchResponse(results = albums.data.map { it.toItunesItem() })
|
||||
}
|
||||
return coroutineScope {
|
||||
val tracksD = async { runCatching { http.get("$BASE/search") { parameter("q", query); parameter("limit", limit) }.body<DzList<DzTrack>>().data }.getOrDefault(emptyList()) }
|
||||
val albumsD = async { runCatching { http.get("$BASE/search/album") { parameter("q", query); parameter("limit", 12) }.body<DzList<DzAlbum>>().data }.getOrDefault(emptyList()) }
|
||||
val artistsD = async { runCatching { http.get("$BASE/search/artist") { parameter("q", query); parameter("limit", 8) }.body<DzList<DzArtist>>().data }.getOrDefault(emptyList()) }
|
||||
// Deezer doesn't rank artists by popularity — sort by nb_fan so a name collision
|
||||
// (5× "Joji") resolves to the artist the user means (real Joji has 559k fans, not 62).
|
||||
val results = artistsD.await().sortedByDescending { it.nb_fan ?: 0 }.map { it.toItunesItem() } +
|
||||
albumsD.await().map { it.toItunesItem() } +
|
||||
tracksD.await().map { it.toItunesItem() }
|
||||
ItunesSearchResponse(results = results)
|
||||
}
|
||||
}
|
||||
|
||||
/** All tracks of a Deezer album, in the ItunesItem song shape. */
|
||||
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
|
||||
val album: DzAlbum = http.get("$BASE/album/$collectionId").body()
|
||||
return album.tracks?.data.orEmpty().mapIndexed { i, t -> t.toAlbumTrackItem(album, i) }
|
||||
}
|
||||
|
||||
/** A Deezer artist's full discography — albums, EPs AND singles, matching what Deezer's own
|
||||
* artist page shows. Ordered albums → EPs → singles (Deezer returns newest-first within each). */
|
||||
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
|
||||
val albums: DzList<DzAlbum> = http.get("$BASE/artist/$artistId/albums") {
|
||||
parameter("limit", 200)
|
||||
}.body()
|
||||
val rank = { rt: String? -> when (rt) { "album" -> 0; "ep" -> 1; "single" -> 2; else -> 3 } }
|
||||
return albums.data.sortedBy { rank(it.record_type) }.map { it.toItunesItem() }
|
||||
}
|
||||
|
||||
/**
|
||||
* A single Deezer artist by id (name / picture / fan count), as an ItunesItem. The reliable
|
||||
* route when /search/artist can't surface a low-profile same-name artist — used by the
|
||||
* artist-pin "paste Deezer link/id" flow. Null on a bad id / error.
|
||||
*/
|
||||
suspend fun lookupArtist(artistId: Long): ItunesItem? {
|
||||
val a = runCatching { http.get("$BASE/artist/$artistId").body<DzArtist>() }.getOrNull()
|
||||
return if (a?.id != null) a.toItunesItem() else null
|
||||
}
|
||||
|
||||
/** Top tracks for a Deezer artist. */
|
||||
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
|
||||
val tracks: DzList<DzTrack> = http.get("$BASE/artist/$artistId/top") {
|
||||
parameter("limit", limit)
|
||||
}.body()
|
||||
return tracks.data.map { it.toItunesItem() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Related Deezer artists (real pictures + fan counts). Used for the "Similar Artists" row when
|
||||
* a pin is active — Last.fm's similar list is keyed to the (possibly merged/wrong) Navidrome
|
||||
* artist, so it goes "weird"; Deezer's related are correct for the pinned artist.
|
||||
*/
|
||||
suspend fun lookupRelatedArtists(artistId: Long, limit: Int = 12): List<ItunesItem> {
|
||||
val artists: DzList<DzArtist> = http.get("$BASE/artist/$artistId/related") {
|
||||
parameter("limit", limit)
|
||||
}.body()
|
||||
return artists.data.map { it.toItunesItem() }
|
||||
}
|
||||
|
||||
/** "Neu & Beliebt" — Deezer's global album chart, mapped to the RSS-entry shape. */
|
||||
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
|
||||
val albums: DzList<DzAlbum> = http.get("$BASE/chart/0/albums") {
|
||||
parameter("limit", limit)
|
||||
}.body()
|
||||
return albums.data.map { it.toRssEntry() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class LrcLibResponse(
|
||||
val id: Long = 0,
|
||||
val trackName: String = "",
|
||||
val artistName: String = "",
|
||||
val albumName: String = "",
|
||||
val duration: Double = 0.0,
|
||||
val instrumental: Boolean = false,
|
||||
val plainLyrics: String? = null,
|
||||
val syncedLyrics: String? = null,
|
||||
)
|
||||
|
||||
|
||||
data class LrcLine(val timeMs: Long, val text: String)
|
||||
|
||||
fun parseLrc(lrc: String): List<LrcLine> {
|
||||
val regex = Regex("""\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)""")
|
||||
return lrc.lines()
|
||||
.mapNotNull { line ->
|
||||
regex.find(line.trim())?.let { m ->
|
||||
val mm = m.groupValues[1].toLong()
|
||||
val ss = m.groupValues[2].toLong()
|
||||
val frac = m.groupValues[3]
|
||||
val ms = mm * 60_000 + ss * 1_000 +
|
||||
if (frac.length == 2) frac.toLong() * 10 else frac.toLong()
|
||||
val text = m.groupValues[4].trim()
|
||||
if (text.isNotEmpty()) LrcLine(ms, text) else null
|
||||
}
|
||||
}
|
||||
.sortedBy { it.timeMs }
|
||||
}
|
||||
|
||||
object LrcLibClient {
|
||||
private val client = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; isLenient = true })
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 8_000
|
||||
connectTimeoutMillis = 5_000
|
||||
socketTimeoutMillis = 8_000
|
||||
}
|
||||
// lrclib asks clients to identify themselves; a proper UA also avoids being throttled.
|
||||
install(UserAgent) { agent = "NovaSonic/1.0 (+https://gitea.honeyy.duckdns.org/Docker/NovaSonic)" }
|
||||
}
|
||||
|
||||
private val cache = HashMap<String, LrcLibResponse?>()
|
||||
// Per-song lock so a prefetch + an on-open load don't both fire the whole cascade (→ throttling).
|
||||
private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
suspend fun getLyrics(
|
||||
artist: String,
|
||||
title: String,
|
||||
album: String?,
|
||||
durationSecs: Int?,
|
||||
): LrcLibResponse? {
|
||||
val cacheKey = "$artist|$title"
|
||||
if (cache.containsKey(cacheKey)) return cache[cacheKey]
|
||||
val mutex = locks.getOrPut(cacheKey) { Mutex() }
|
||||
return mutex.withLock { getLyricsInner(artist, title, album, durationSecs, cacheKey) }
|
||||
}
|
||||
|
||||
private suspend fun getLyricsInner(
|
||||
artist: String,
|
||||
title: String,
|
||||
album: String?,
|
||||
durationSecs: Int?,
|
||||
cacheKey: String,
|
||||
): LrcLibResponse? {
|
||||
if (cache.containsKey(cacheKey)) return cache[cacheKey] // populated while we waited on the lock
|
||||
|
||||
// 1a. lrclib exact match with duration
|
||||
val exact = runCatching {
|
||||
val resp = client.get("https://lrclib.net/api/get") {
|
||||
parameter("artist_name", artist)
|
||||
parameter("track_name", title)
|
||||
if (!album.isNullOrBlank()) parameter("album_name", album)
|
||||
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
|
||||
}
|
||||
if (resp.status == HttpStatusCode.OK) resp.body<LrcLibResponse>() else null
|
||||
}.getOrNull()
|
||||
if (exact.hasContent()) return exact.also { cache[cacheKey] = it }
|
||||
|
||||
// 1b. lrclib exact match without duration — handles version mismatches (e.g. 5:58 file vs 4:24 in lrclib)
|
||||
val exactNoDur = if (durationSecs != null && !album.isNullOrBlank()) runCatching {
|
||||
val resp = client.get("https://lrclib.net/api/get") {
|
||||
parameter("artist_name", artist)
|
||||
parameter("track_name", title)
|
||||
parameter("album_name", album)
|
||||
}
|
||||
if (resp.status == HttpStatusCode.OK) resp.body<LrcLibResponse>() else null
|
||||
}.getOrNull() else null
|
||||
if (exactNoDur.hasContent()) return exactNoDur.also { cache[cacheKey] = it }
|
||||
|
||||
// 2+3. lrclib search — artist+title and title-only in parallel (2 requests, not 3)
|
||||
val lrclibResult = coroutineScope {
|
||||
val artistDef = async {
|
||||
runCatching {
|
||||
val resp = client.get("https://lrclib.net/api/search") {
|
||||
parameter("track_name", title)
|
||||
if (artist.isNotBlank()) parameter("artist_name", artist)
|
||||
}
|
||||
if (resp.status == HttpStatusCode.OK) resp.body<List<LrcLibResponse>>() else null
|
||||
}.getOrNull()
|
||||
}
|
||||
val titleDef = async {
|
||||
runCatching {
|
||||
val resp = client.get("https://lrclib.net/api/search") {
|
||||
parameter("track_name", title)
|
||||
}
|
||||
if (resp.status == HttpStatusCode.OK) resp.body<List<LrcLibResponse>>() else null
|
||||
}.getOrNull()
|
||||
}
|
||||
bestMatch(artistDef.await(), durationSecs)
|
||||
?: bestMatch(titleDef.await(), durationSecs)
|
||||
}
|
||||
|
||||
cache[cacheKey] = lrclibResult
|
||||
return lrclibResult
|
||||
}
|
||||
|
||||
private fun LrcLibResponse?.hasContent() =
|
||||
this != null && (instrumental || !syncedLyrics.isNullOrBlank() || !plainLyrics.isNullOrBlank())
|
||||
|
||||
private fun bestMatch(candidates: List<LrcLibResponse>?, durationSecs: Int?): LrcLibResponse? {
|
||||
if (candidates.isNullOrEmpty()) return null
|
||||
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
|
||||
val pool = synced.ifEmpty { candidates }
|
||||
return if (durationSecs != null && durationSecs > 0) {
|
||||
val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
|
||||
?: return synced.firstOrNull() ?: candidates.firstOrNull()
|
||||
if (kotlin.math.abs(best.duration - durationSecs) <= 5.0) best
|
||||
else synced.firstOrNull() ?: candidates.firstOrNull()
|
||||
} else {
|
||||
pool.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistReleasesRequest
|
||||
import com.novasonic.app.api.model.ArtistKonzerteRequest
|
||||
import com.novasonic.app.api.model.ArtistKonzerteResponse
|
||||
import com.novasonic.app.api.model.ArtistReleasesResponse
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Neue Veröffentlichungen der gefolgten Künstler. Die Deezer-Auflösung macht das Backend — dort
|
||||
* liegen auch die Künstler-Pins, und der Cache gilt dann für alle Geräte gemeinsam statt pro App.
|
||||
*/
|
||||
object ReleasesClient {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
expectSuccess = true
|
||||
// Der erste Aufruf kann dauern: das Backend holt pro Künstler die Deezer-Diskografie.
|
||||
// Danach liegt es dort im Cache.
|
||||
install(HttpTimeout) { requestTimeoutMillis = 60_000; connectTimeoutMillis = 10_000 }
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetch(backendUrl: String, artists: List<ArtistRef>, days: Int): ArtistReleasesResponse {
|
||||
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistReleasesResponse()
|
||||
return http.post("${backendUrl.trimEnd('/')}/api/artist-releases") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ArtistReleasesRequest(artists, days))
|
||||
}.body()
|
||||
}
|
||||
|
||||
/** Anstehende Konzerte derselben Kuenstler — gleicher Weg, anderer Endpunkt. */
|
||||
suspend fun konzerte(backendUrl: String, artists: List<ArtistRef>): ArtistKonzerteResponse {
|
||||
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistKonzerteResponse()
|
||||
// Ohne `days`: das Backend liefert alles Angekuendigte.
|
||||
return http.post("${backendUrl.trimEnd('/')}/api/artist-concerts") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ArtistKonzerteRequest(artists))
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.security.MessageDigest
|
||||
import kotlin.random.Random
|
||||
|
||||
class SubsonicClient(
|
||||
private val serverUrl: String,
|
||||
private val username: String,
|
||||
private val password: String,
|
||||
private val clientName: String = "NovaSonic",
|
||||
private val apiVersion: String = "1.16.1",
|
||||
) {
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
})
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 20_000
|
||||
connectTimeoutMillis = 12_000
|
||||
socketTimeoutMillis = 20_000
|
||||
}
|
||||
install(Logging) {
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun authParams(): Map<String, String> {
|
||||
val salt = Random.nextInt(100_000, 999_999).toString()
|
||||
val token = md5(password + salt)
|
||||
return mapOf("u" to username, "t" to token, "s" to salt,
|
||||
"v" to apiVersion, "c" to clientName, "f" to "json")
|
||||
}
|
||||
|
||||
private fun md5(input: String): String =
|
||||
MessageDigest.getInstance("MD5")
|
||||
.digest(input.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun url(path: String) = "${serverUrl.trimEnd('/')}/rest/$path"
|
||||
|
||||
// ── Core request ──────────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun get(path: String, params: Map<String, Any?> = emptyMap()): SubsonicResponse {
|
||||
val combined = authParams() + params.filterValues { it != null }
|
||||
.mapValues { it.value.toString() }
|
||||
val resp: SubsonicWrapper = http.get(url(path)) {
|
||||
combined.forEach { (k, v) -> parameter(k, v) }
|
||||
}.body()
|
||||
if (resp.response.status != "ok") {
|
||||
val err = resp.response.error
|
||||
throw SubsonicException(err?.code ?: -1, err?.message ?: "Unknown error")
|
||||
}
|
||||
return resp.response
|
||||
}
|
||||
|
||||
// ── Cover art / stream URLs (no auth roundtrip needed for URL building) ───
|
||||
|
||||
fun coverArtUrl(id: String, size: Int = 300): String {
|
||||
val salt = "novasonic"
|
||||
val token = md5(password + salt)
|
||||
val base = url("getCoverArt.view")
|
||||
return "$base?id=$id&size=$size&u=$username&t=$token&s=$salt&v=$apiVersion&c=$clientName&f=json"
|
||||
}
|
||||
|
||||
fun streamUrl(id: String, maxBitRate: Int? = null): String {
|
||||
val salt = "novasonic"
|
||||
val token = md5(password + salt)
|
||||
val base = url("stream.view")
|
||||
val br = if (maxBitRate != null) "&maxBitRate=$maxBitRate" else ""
|
||||
return "$base?id=$id$br&u=$username&t=$token&s=$salt&v=$apiVersion&c=$clientName&f=json"
|
||||
}
|
||||
|
||||
// ── API methods ───────────────────────────────────────────────────────────
|
||||
|
||||
suspend fun ping() = get("ping.view")
|
||||
|
||||
suspend fun getAlbumList(
|
||||
type: String = "newest",
|
||||
size: Int = 30,
|
||||
offset: Int = 0,
|
||||
): List<Album> = get("getAlbumList2.view",
|
||||
mapOf("type" to type, "size" to size, "offset" to offset))
|
||||
.albumList2?.album ?: emptyList()
|
||||
|
||||
suspend fun getAlbum(id: String): Album =
|
||||
get("getAlbum.view", mapOf("id" to id)).album
|
||||
?: throw SubsonicException(-1, "Album not found")
|
||||
|
||||
suspend fun getArtists(): List<ArtistIndex> =
|
||||
get("getArtists.view").artists?.index ?: emptyList()
|
||||
|
||||
suspend fun getArtist(id: String): Artist =
|
||||
get("getArtist.view", mapOf("id" to id)).artist
|
||||
?: throw SubsonicException(-1, "Artist not found")
|
||||
|
||||
suspend fun getSong(id: String): Song =
|
||||
get("getSong.view", mapOf("id" to id)).song
|
||||
?: throw SubsonicException(-1, "Song not found")
|
||||
|
||||
suspend fun search(query: String, albumCount: Int = 10, artistCount: Int = 5, songCount: Int = 20): SearchResult3 =
|
||||
get("search3.view", mapOf(
|
||||
"query" to query,
|
||||
"albumCount" to albumCount,
|
||||
"artistCount" to artistCount,
|
||||
"songCount" to songCount,
|
||||
)).searchResult3 ?: SearchResult3()
|
||||
|
||||
suspend fun getPlaylists(): List<Playlist> =
|
||||
get("getPlaylists.view").playlists?.playlist ?: emptyList()
|
||||
|
||||
suspend fun getPlaylist(id: String): PlaylistWithSongs =
|
||||
get("getPlaylist.view", mapOf("id" to id)).playlist
|
||||
?: throw SubsonicException(-1, "Playlist not found")
|
||||
|
||||
suspend fun getStarred(): Starred2 =
|
||||
get("getStarred2.view").starred2 ?: Starred2()
|
||||
|
||||
suspend fun getRandomSongs(size: Int = 50): List<Song> =
|
||||
get("getRandomSongs.view", mapOf("size" to size)).randomSongs?.song ?: emptyList()
|
||||
|
||||
suspend fun getSimilarSongs(songId: String, count: Int = 20): List<Song> =
|
||||
get("getSimilarSongs2.view", mapOf("id" to songId, "count" to count)).similarSongs2?.song ?: emptyList()
|
||||
|
||||
suspend fun star(id: String) = get("star.view", mapOf("id" to id))
|
||||
suspend fun unstar(id: String) = get("unstar.view", mapOf("id" to id))
|
||||
|
||||
suspend fun scrobble(id: String, submission: Boolean = true) =
|
||||
get("scrobble.view", mapOf("id" to id, "submission" to submission))
|
||||
|
||||
suspend fun getArtistInfo(id: String, count: Int = 10): com.novasonic.app.api.model.ArtistInfo =
|
||||
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
|
||||
?: com.novasonic.app.api.model.ArtistInfo()
|
||||
|
||||
/**
|
||||
* id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
|
||||
* Namen zurueck, wenn es sie nicht kennt; aeltere Versionen ignorieren id. Der Namensweg sucht
|
||||
* mit LIKE und Max:1 — bei gleichnamigen Kuenstlern gewann bisher der erste Treffer.
|
||||
*/
|
||||
suspend fun getTopSongs(artist: String, count: Int = 10, artistId: String? = null): List<com.novasonic.app.api.model.Song> =
|
||||
get("getTopSongs.view", buildMap {
|
||||
put("artist", artist); put("count", count)
|
||||
artistId?.takeIf { it.isNotBlank() }?.let { put("id", it) }
|
||||
}).topSongs?.song ?: emptyList()
|
||||
|
||||
suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
|
||||
get("getGenres.view").genres?.genre ?: emptyList()
|
||||
|
||||
suspend fun getSongsByGenre(genre: String, count: Int = 50): List<com.novasonic.app.api.model.Song> =
|
||||
get("getSongsByGenre.view", mapOf("genre" to genre, "count" to count)).songsByGenre?.song
|
||||
?: emptyList()
|
||||
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
|
||||
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
|
||||
|
||||
/** Stoesst Navidromes Bibliotheks-Scan an — nach einem Import, damit frische Dateien auffindbar sind. */
|
||||
suspend fun startScan() = get("startScan.view")
|
||||
|
||||
suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs {
|
||||
val params = mutableListOf("name" to name)
|
||||
songIds.forEach { params += "songId" to it }
|
||||
return getList("createPlaylist.view", params).playlist
|
||||
?: throw SubsonicException(-1, "Failed to create playlist")
|
||||
}
|
||||
|
||||
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) =
|
||||
getList("updatePlaylist.view", listOfNotNull(
|
||||
"playlistId" to playlistId,
|
||||
"name" to name,
|
||||
comment?.let { "comment" to it },
|
||||
))
|
||||
|
||||
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) =
|
||||
getList("updatePlaylist.view",
|
||||
listOf("playlistId" to playlistId) + indices.map { "songIndexToRemove" to it.toString() })
|
||||
|
||||
suspend fun deletePlaylist(playlistId: String) =
|
||||
getList("deletePlaylist.view", listOf("id" to playlistId))
|
||||
|
||||
// ── Navidrome REST API (cover art) ────────────────────────────────────────
|
||||
|
||||
@Serializable private data class NdLoginReq(val username: String, val password: String)
|
||||
@Serializable private data class NdLoginResp(val token: String = "")
|
||||
|
||||
private var _ndToken: String? = null
|
||||
|
||||
private suspend fun ndToken(): String {
|
||||
_ndToken?.let { return it }
|
||||
val resp = http.post("$serverUrl/auth/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(NdLoginReq(username, password))
|
||||
}.body<NdLoginResp>()
|
||||
val token = resp.token.ifBlank { throw Exception("Navidrome login fehlgeschlagen") }
|
||||
_ndToken = token
|
||||
return token
|
||||
}
|
||||
|
||||
suspend fun uploadPlaylistCover(playlistId: String, imageBytes: ByteArray, mimeType: String) {
|
||||
val token = ndToken()
|
||||
http.post("$serverUrl/api/playlist/$playlistId/image") {
|
||||
header("X-Nd-Authorization", "Bearer $token")
|
||||
setBody(MultiPartFormDataContent(formData {
|
||||
append("image", imageBytes, Headers.build {
|
||||
append(HttpHeaders.ContentType, mimeType)
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"cover\"")
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removePlaylistCover(playlistId: String) {
|
||||
val token = ndToken()
|
||||
http.delete("$serverUrl/api/playlist/$playlistId/image") {
|
||||
header("X-Nd-Authorization", "Bearer $token")
|
||||
}
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
|
||||
private suspend fun getList(path: String, params: List<Pair<String, String>>): SubsonicResponse {
|
||||
val allParams = authParams().entries.map { (k, v) -> k to v } + params
|
||||
val resp: SubsonicWrapper = http.get(url(path)) {
|
||||
allParams.forEach { (k, v) -> parameter(k, v) }
|
||||
}.body()
|
||||
if (resp.response.status != "ok") {
|
||||
val err = resp.response.error
|
||||
throw SubsonicException(err?.code ?: -1, err?.message ?: "Unknown error")
|
||||
}
|
||||
return resp.response
|
||||
}
|
||||
}
|
||||
|
||||
class SubsonicException(val code: Int, message: String) : Exception(message)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A manual "pin" mapping a local Navidrome artist → a specific Deezer artist, fixing same-name
|
||||
* collisions where NovaSonic would otherwise guess the wrong one (fans / top songs / albums / image).
|
||||
* Stored server-side (shared across users) on the download backend at /api/artist-overrides, keyed
|
||||
* by the Navidrome artist id. Mirrors the web PWA's override shape exactly.
|
||||
*/
|
||||
@Serializable
|
||||
data class ArtistOverride(
|
||||
val deezerArtistId: String = "",
|
||||
val name: String = "",
|
||||
val image: String = "",
|
||||
val fanCount: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// ─── DEEZER API MODELS ────────────────────────────────────────────────────────
|
||||
// Apple's iTunes /search returns 403 from server IPs, so the Explore/search source is
|
||||
// Deezer (free, no key, returns explicit flags + artist images + durations). Deezer JSON is
|
||||
// mapped to the existing ItunesItem/ItunesRssEntry shapes so the screens + view models are
|
||||
// unchanged. See ItunesClient for the endpoint calls.
|
||||
|
||||
@Serializable
|
||||
data class DzList<T>(val data: List<T> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class DzArtist(
|
||||
val id: Long? = null,
|
||||
val name: String? = null,
|
||||
val picture_medium: String? = null,
|
||||
val picture_big: String? = null,
|
||||
val picture_xl: String? = null,
|
||||
val nb_fan: Long? = null, // popularity — disambiguates same-name artists (5× "Joji")
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DzGenre(val name: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class DzGenres(val data: List<DzGenre> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class DzAlbum(
|
||||
val id: Long? = null,
|
||||
val title: String? = null,
|
||||
val cover_medium: String? = null,
|
||||
val cover_big: String? = null,
|
||||
val cover_xl: String? = null,
|
||||
val release_date: String? = null,
|
||||
val nb_tracks: Int? = null,
|
||||
val record_type: String? = null,
|
||||
val genres: DzGenres? = null,
|
||||
val artist: DzArtist? = null,
|
||||
val tracks: DzList<DzTrack>? = null,
|
||||
val position: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DzTrack(
|
||||
val id: Long? = null,
|
||||
val title: String? = null,
|
||||
val duration: Long? = null,
|
||||
val track_position: Int? = null,
|
||||
val disk_number: Int? = null,
|
||||
val explicit_lyrics: Boolean = false,
|
||||
val preview: String? = null,
|
||||
val artist: DzArtist? = null,
|
||||
val album: DzAlbum? = null,
|
||||
)
|
||||
|
||||
// ─── MAPPERS to the legacy ItunesItem shape ───────────────────────────────────
|
||||
|
||||
private fun DzAlbum.coverXl(): String? = cover_xl ?: cover_big ?: cover_medium
|
||||
private fun DzArtist.pictureXl(): String? = picture_xl ?: picture_big ?: picture_medium
|
||||
|
||||
/** A Deezer search-result track → ItunesItem (album cover for artwork). */
|
||||
fun DzTrack.toItunesItem(): ItunesItem = ItunesItem(
|
||||
wrapperType = "track",
|
||||
kind = "song",
|
||||
trackId = id,
|
||||
artistId = artist?.id,
|
||||
collectionId = album?.id,
|
||||
artistName = artist?.name,
|
||||
collectionName = album?.title,
|
||||
trackName = title,
|
||||
artworkUrl100 = album?.coverXl() ?: artist?.pictureXl(),
|
||||
previewUrl = preview,
|
||||
trackTimeMillis = duration?.let { it * 1000 },
|
||||
trackNumber = track_position,
|
||||
)
|
||||
|
||||
/** A Deezer album → ItunesItem collection. */
|
||||
fun DzAlbum.toItunesItem(): ItunesItem = ItunesItem(
|
||||
wrapperType = "collection",
|
||||
kind = "album",
|
||||
collectionId = id,
|
||||
artistId = artist?.id,
|
||||
artistName = artist?.name,
|
||||
collectionName = title,
|
||||
artworkUrl100 = coverXl(),
|
||||
trackCount = nb_tracks,
|
||||
releaseDate = release_date,
|
||||
primaryGenreName = genres?.data?.firstOrNull()?.name,
|
||||
)
|
||||
|
||||
/** A Deezer artist → ItunesItem artist (carries a real picture, unlike iTunes). */
|
||||
fun DzArtist.toItunesItem(): ItunesItem = ItunesItem(
|
||||
wrapperType = "artist",
|
||||
artistId = id,
|
||||
artistName = name,
|
||||
artworkUrl100 = pictureXl(),
|
||||
fanCount = nb_fan,
|
||||
)
|
||||
|
||||
/**
|
||||
* A Deezer album track (from /album/{id}) → ItunesItem song. The embedded tracks carry no
|
||||
* track_position, so the album ordering supplies the number; genre/release come from the album.
|
||||
*/
|
||||
fun DzTrack.toAlbumTrackItem(album: DzAlbum, index: Int): ItunesItem = ItunesItem(
|
||||
wrapperType = "track",
|
||||
kind = "song",
|
||||
trackId = id,
|
||||
artistId = artist?.id ?: album.artist?.id,
|
||||
collectionId = album.id,
|
||||
artistName = artist?.name ?: album.artist?.name,
|
||||
collectionName = album.title,
|
||||
trackName = title,
|
||||
artworkUrl100 = album.coverXl(),
|
||||
previewUrl = preview,
|
||||
trackTimeMillis = duration?.let { it * 1000 },
|
||||
trackNumber = track_position ?: (index + 1),
|
||||
trackCount = album.nb_tracks,
|
||||
releaseDate = album.release_date,
|
||||
primaryGenreName = album.genres?.data?.firstOrNull()?.name,
|
||||
)
|
||||
|
||||
/** A Deezer chart album → ItunesRssEntry (keeps the charts UI unchanged). */
|
||||
fun DzAlbum.toRssEntry(): ItunesRssEntry = ItunesRssEntry(
|
||||
name = ItunesLabel(title),
|
||||
artist = ItunesLabel(artist?.name),
|
||||
image = listOfNotNull(coverXl()?.let { ItunesLabel(it) }),
|
||||
id = ItunesId(attributes = ItunesIdAttributes(imId = id?.toString())),
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ConcertEvent(
|
||||
val datetime: String = "",
|
||||
val venue: ConcertVenue? = null,
|
||||
val offers: List<ConcertOffer> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConcertVenue(
|
||||
val name: String? = null,
|
||||
val city: String? = null,
|
||||
val country: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConcertOffer(
|
||||
val url: String? = null,
|
||||
val status: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Response from POST {backend}/api/{platform}/resolve */
|
||||
@Serializable
|
||||
data class ImportResolveResponse(
|
||||
val type: String? = null, // "track" | "album" | "playlist"
|
||||
val name: String? = null,
|
||||
val artist: String? = null,
|
||||
val artworkUrl: String? = null,
|
||||
val tracks: List<ImportTrack> = emptyList(),
|
||||
val skippedLocal: Int = 0,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImportTrack(
|
||||
val trackName: String? = null,
|
||||
val artistName: String? = null,
|
||||
val collectionName: String? = null,
|
||||
val artworkUrl100: String? = null,
|
||||
val previewUrl: String? = null,
|
||||
val durationMs: Long? = null,
|
||||
val trackNumber: Int? = null,
|
||||
)
|
||||
|
||||
/** Body for POST {backend}/api/download */
|
||||
@Serializable
|
||||
data class DownloadRequest(
|
||||
val jobId: String,
|
||||
val type: String, // "track" | "album" | "artist"
|
||||
val title: String,
|
||||
val artist: String? = null,
|
||||
val album: String? = null,
|
||||
val artworkUrl: String? = null,
|
||||
val itunesId: Long? = null,
|
||||
val collectionId: Long? = null,
|
||||
val tracks: List<ImportTrack> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResolveRequest(val url: String)
|
||||
|
||||
/** Body fuer POST {backend}/api/delete-song — loescht die Datei von der Platte (wie im Web). */
|
||||
@Serializable
|
||||
data class DeleteSongRequest(
|
||||
val ndUrl: String,
|
||||
val user: String,
|
||||
val pass: String,
|
||||
val songId: String,
|
||||
)
|
||||
|
||||
/** Response from GET {backend}/api/status/{jobId} */
|
||||
@Serializable
|
||||
data class DownloadStatus(
|
||||
val status: String? = null, // "pending" | "downloading" | "done" | "error"
|
||||
val progress: Int? = null,
|
||||
val message: String? = null,
|
||||
val completed: Int? = null,
|
||||
val total: Int? = null,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ItunesSearchResponse(
|
||||
val resultCount: Int = 0,
|
||||
val results: List<ItunesItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesItem(
|
||||
val wrapperType: String? = null, // "track" | "collection" | "artist"
|
||||
val kind: String? = null, // "song" | "album" etc
|
||||
val artistId: Long? = null,
|
||||
val collectionId: Long? = null,
|
||||
val trackId: Long? = null,
|
||||
val artistName: String? = null,
|
||||
val collectionName: String? = null,
|
||||
val trackName: String? = null,
|
||||
val artworkUrl100: String? = null,
|
||||
val previewUrl: String? = null,
|
||||
val trackTimeMillis: Long? = null,
|
||||
val releaseDate: String? = null,
|
||||
val primaryGenreName: String? = null,
|
||||
val trackCount: Int? = null,
|
||||
val trackNumber: Int? = null,
|
||||
val collectionPrice: Double? = null,
|
||||
val fanCount: Long? = null, // Deezer nb_fan — disambiguates same-name artists + shown in UI
|
||||
)
|
||||
|
||||
// German fan-count format: 559542 → "559.542", 1200000 → "1,2 Mio." (matches the web PWA).
|
||||
fun fmtFanCount(n: Long): String {
|
||||
if (n >= 1_000_000) {
|
||||
val s = String.format(java.util.Locale.GERMAN, "%.1f", n / 1_000_000.0).removeSuffix(",0")
|
||||
return "$s Mio."
|
||||
}
|
||||
return String.format(java.util.Locale.GERMAN, "%,d", n)
|
||||
}
|
||||
|
||||
// iTunes RSS feed response
|
||||
@Serializable
|
||||
data class ItunesRssResponse(
|
||||
val feed: ItunesFeed? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesFeed(
|
||||
val entry: List<ItunesRssEntry> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesRssEntry(
|
||||
@SerialName("im:name") val name: ItunesLabel? = null,
|
||||
@SerialName("im:artist") val artist: ItunesLabel? = null,
|
||||
@SerialName("im:image") val image: List<ItunesLabel> = emptyList(),
|
||||
@SerialName("im:itemCount") val itemCount: ItunesLabel? = null,
|
||||
val id: ItunesId? = null,
|
||||
val category: ItunesCategory? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesLabel(
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesId(
|
||||
val label: String? = null,
|
||||
val attributes: ItunesIdAttributes? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesIdAttributes(
|
||||
@SerialName("im:id") val imId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesCategory(
|
||||
val attributes: ItunesCategoryAttributes? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesCategoryAttributes(
|
||||
val term: String? = null,
|
||||
)
|
||||
|
||||
// Helper to get high-res artwork (replace 100x100 with 600x600)
|
||||
fun ItunesItem.artworkHd(): String? =
|
||||
artworkUrl100?.replace("100x100bb", "600x600bb")?.replace("/100x100/", "/600x600/")
|
||||
|
||||
fun ItunesRssEntry.artworkHd(): String? =
|
||||
image.lastOrNull()?.label // last image is always the largest in the RSS
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// ── Subsonic JSON envelope ────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class SubsonicWrapper(
|
||||
@SerialName("subsonic-response") val response: SubsonicResponse,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubsonicResponse(
|
||||
val status: String,
|
||||
val version: String = "1.16.1",
|
||||
val error: SubsonicError? = null,
|
||||
|
||||
val musicFolders: MusicFoldersResult? = null,
|
||||
val albumList2: AlbumListResult? = null,
|
||||
val album: Album? = null,
|
||||
val artists: ArtistsResult? = null,
|
||||
val artist: Artist? = null,
|
||||
val searchResult3: SearchResult3? = null,
|
||||
val playlists: PlaylistsResult? = null,
|
||||
val playlist: PlaylistWithSongs? = null,
|
||||
val song: Song? = null,
|
||||
val starred2: Starred2? = null,
|
||||
val randomSongs: SongListResult? = null,
|
||||
val similarSongs2: SongListResult? = null,
|
||||
val scanStatus: ScanStatus? = null,
|
||||
val artistInfo2: ArtistInfo? = null,
|
||||
val topSongs: SongListResult? = null,
|
||||
val genres: GenresResult? = null,
|
||||
val songsByGenre: SongListResult? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubsonicError(val code: Int, val message: String)
|
||||
|
||||
// ── Domain models ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class SongArtist(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Song(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val album: String? = null,
|
||||
val albumId: String? = null,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val artists: List<SongArtist> = emptyList(),
|
||||
val track: Int? = null,
|
||||
val year: Int? = null,
|
||||
val genre: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val size: Long? = null,
|
||||
val contentType: String? = null,
|
||||
val suffix: String? = null,
|
||||
val duration: Int? = null,
|
||||
val bitRate: Int? = null,
|
||||
val path: String? = null,
|
||||
val playCount: Long? = null,
|
||||
val starred: String? = null, // ISO date string when starred
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Album(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val songCount: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val year: Int? = null,
|
||||
val genre: String? = null,
|
||||
val starred: String? = null,
|
||||
val song: List<Song> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Artist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val coverArt: String? = null,
|
||||
val albumCount: Int = 0,
|
||||
val starred: String? = null,
|
||||
val album: List<Album> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistIndex(
|
||||
val name: String,
|
||||
val artist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Playlist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val comment: String? = null,
|
||||
val owner: String? = null,
|
||||
val songCount: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val coverArt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaylistWithSongs(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val comment: String? = null,
|
||||
val owner: String? = null,
|
||||
val songCount: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val coverArt: String? = null,
|
||||
val entry: List<Song> = emptyList(),
|
||||
)
|
||||
|
||||
// ── Result wrappers ───────────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class MusicFoldersResult(val musicFolder: List<MusicFolder> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class MusicFolder(val id: String, val name: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class AlbumListResult(val album: List<Album> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class ArtistsResult(val index: List<ArtistIndex> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class PlaylistsResult(val playlist: List<Playlist> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class SongListResult(val song: List<Song> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class Starred2(
|
||||
val song: List<Song> = emptyList(),
|
||||
val album: List<Album> = emptyList(),
|
||||
val artist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchResult3(
|
||||
val song: List<Song> = emptyList(),
|
||||
val album: List<Album> = emptyList(),
|
||||
val artist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ScanStatus(val scanning: Boolean, val count: Long? = null)
|
||||
|
||||
// ── Artist info (Last.fm bio + similar artists) ───────────────────────────────
|
||||
@Serializable
|
||||
data class ArtistInfo(
|
||||
val biography: String? = null,
|
||||
val musicBrainzId: String? = null,
|
||||
val lastFmUrl: String? = null,
|
||||
val smallImageUrl: String? = null,
|
||||
val mediumImageUrl: String? = null,
|
||||
val largeImageUrl: String? = null,
|
||||
val similarArtist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
// ── Genres ────────────────────────────────────────────────────────────────────
|
||||
@Serializable
|
||||
data class Genre(
|
||||
val value: String, // genre name is the text value
|
||||
val songCount: Int = 0,
|
||||
val albumCount: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GenresResult(val genre: List<Genre> = emptyList())
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Eine Veröffentlichung eines gefolgten Künstlers, wie sie das Backend liefert. */
|
||||
@Serializable
|
||||
data class ArtistRelease(
|
||||
val artistId: String = "",
|
||||
val artistName: String = "",
|
||||
val deezerArtistId: String? = null,
|
||||
val albumId: String = "",
|
||||
val title: String = "",
|
||||
val cover: String = "",
|
||||
val releaseDate: String = "",
|
||||
val recordType: String = "album",
|
||||
val trackCount: Int? = null,
|
||||
val explicit: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistReleasesResponse(
|
||||
val releases: List<ArtistRelease> = emptyList(),
|
||||
/** Künstler, zu denen kein Deezer-Profil gefunden wurde — dort hilft nur ein Pin. */
|
||||
val ohneZuordnung: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistRef(val id: String, val name: String)
|
||||
|
||||
@Serializable
|
||||
data class ArtistReleasesRequest(val artists: List<ArtistRef>, val days: Int)
|
||||
|
||||
/** Ein anstehendes Konzert eines gefolgten Künstlers. */
|
||||
@Serializable
|
||||
data class ArtistKonzert(
|
||||
val artistId: String = "",
|
||||
val artistName: String = "",
|
||||
val eventId: String = "",
|
||||
val titel: String = "",
|
||||
/** Ortszeit ohne Zone, z. B. "2026-11-04T20:00:00" — bewusst nicht UTC. */
|
||||
val datum: String = "",
|
||||
val ort: String = "",
|
||||
val stadt: String = "",
|
||||
val land: String = "",
|
||||
val url: String = "",
|
||||
val abgesagt: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistKonzerteResponse(
|
||||
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||
/** Künstler ohne anstehende Termine — nur zur Erklärung einer leeren Liste. */
|
||||
val ohneTermine: List<String> = emptyList(),
|
||||
/** Im Backend ist kein Ticketmaster-Schlüssel hinterlegt. */
|
||||
val keinSchluessel: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistKonzerteRequest(val artists: List<ArtistRef>)
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Mirrors the JSON from the scrobble service GET /scrobble/stats.
|
||||
@Serializable
|
||||
data class ScrobbleStats(
|
||||
val firstPlayedAt: Long? = null,
|
||||
val totalPlays: Int = 0,
|
||||
val totalSeconds: Long = 0,
|
||||
val topArtists: List<StatArtist> = emptyList(),
|
||||
val topAlbums: List<StatAlbum> = emptyList(),
|
||||
val topTracks: List<StatTrack> = emptyList(),
|
||||
val topGenres: List<StatGenre> = emptyList(),
|
||||
val series: List<StatBucket> = emptyList(),
|
||||
val byHour: List<StatHour> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatGenre(
|
||||
val name: String? = null,
|
||||
val plays: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatArtist(
|
||||
val name: String? = null,
|
||||
val artistId: String? = null,
|
||||
val plays: Int = 0,
|
||||
val seconds: Long = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatAlbum(
|
||||
val name: String? = null,
|
||||
val albumId: String? = null,
|
||||
val artist: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val plays: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatTrack(
|
||||
val title: String? = null,
|
||||
val artist: String? = null,
|
||||
val album: String? = null,
|
||||
val albumId: String? = null,
|
||||
val artistId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val songId: String? = null,
|
||||
val plays: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatBucket(
|
||||
val bucket: String = "",
|
||||
val plays: Int = 0,
|
||||
val seconds: Long = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatHour(
|
||||
val hour: Int = 0,
|
||||
val plays: Int = 0,
|
||||
)
|
||||
|
||||
// ─── Head-to-Head "Musik-Match" ─────────────────────────────────────────────
|
||||
// GET /scrobble/users — distinct users in the log, for the partner picker.
|
||||
@Serializable
|
||||
data class ScrobbleUsersResponse(
|
||||
val me: String = "",
|
||||
val users: List<ScrobbleUser> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ScrobbleUser(
|
||||
val user: String = "",
|
||||
val plays: Int = 0,
|
||||
val lastPlayedAt: Long? = null,
|
||||
)
|
||||
|
||||
// GET /scrobble/compare?with= — taste comparison of the authed user (A) vs another (B).
|
||||
@Serializable
|
||||
data class CompareResult(
|
||||
val userA: String = "",
|
||||
val userB: String = "",
|
||||
val matchScore: Int = 0,
|
||||
val battle: Battle? = null,
|
||||
val totalsA: CompareTotals = CompareTotals(),
|
||||
val totalsB: CompareTotals = CompareTotals(),
|
||||
val sharedArtists: List<SharedArtist> = emptyList(),
|
||||
val sharedTracks: List<SharedTrack> = emptyList(),
|
||||
val onlyA: List<UniqueArtist> = emptyList(),
|
||||
val onlyB: List<UniqueArtist> = emptyList(),
|
||||
)
|
||||
|
||||
/** Anzeigetafel: je Kategorie ein Sieger ("A"/"B"/null bei Gleichstand) plus Gesamtstand. */
|
||||
@Serializable
|
||||
data class Battle(
|
||||
val kategorien: List<BattleKategorie> = emptyList(),
|
||||
val punkteA: Int = 0,
|
||||
val punkteB: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BattleKategorie(
|
||||
val key: String = "",
|
||||
val label: String = "",
|
||||
val textA: String = "",
|
||||
val textB: String = "",
|
||||
val sieger: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CompareTotals(
|
||||
val plays: Int = 0,
|
||||
val seconds: Long = 0,
|
||||
val artists: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedArtist(
|
||||
val name: String? = null,
|
||||
val artistId: String? = null,
|
||||
val playsA: Int = 0,
|
||||
val playsB: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SharedTrack(
|
||||
val songId: String? = null,
|
||||
val title: String? = null,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val albumId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val playsA: Int = 0,
|
||||
val playsB: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UniqueArtist(
|
||||
val name: String? = null,
|
||||
val artistId: String? = null,
|
||||
val plays: Int = 0,
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.novasonic.app.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* In-app updater for the sideloaded APK. Talks ONLY to the backend the app already knows:
|
||||
* GET {backend}/api/apk/info → is there a newer versionCode than this build?
|
||||
* GET {backend}/api/apk → download it (the backend proxies the fixed Gitea release)
|
||||
* The user still confirms the system install (Android requires that for sideloaded APKs).
|
||||
*
|
||||
* No APK hoarding: the download goes to a single file under cacheDir/updates that is wiped before
|
||||
* each download. An update (same package + signature) is an in-place install, so app data / offline
|
||||
* downloads survive — provided the signing key stays stable across builds.
|
||||
*/
|
||||
object AppUpdater {
|
||||
|
||||
@Serializable
|
||||
data class ApkInfo(
|
||||
val available: Boolean = false,
|
||||
val version: String? = null,
|
||||
val versionCode: Int? = null,
|
||||
val size: Long? = null,
|
||||
)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _available = MutableStateFlow<ApkInfo?>(null)
|
||||
val available: StateFlow<ApkInfo?> = _available
|
||||
|
||||
private val _downloading = MutableStateFlow(false)
|
||||
val downloading: StateFlow<Boolean> = _downloading
|
||||
|
||||
// Versions the user waved away this session — don't nag again until app restart.
|
||||
@Volatile private var dismissedCode: Int = -1
|
||||
|
||||
suspend fun check(backendUrl: String) {
|
||||
if (backendUrl.isBlank()) return
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val c = (URL("${backendUrl.trimEnd('/')}/api/apk/info").openConnection() as HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
c.inputStream.bufferedReader().use { it.readText() }
|
||||
.let { json.decodeFromString<ApkInfo>(it) }
|
||||
}.getOrNull()
|
||||
} ?: return
|
||||
val vc = info.versionCode ?: return // no reliable integer → don't prompt
|
||||
if (info.available && vc > BuildConfig.VERSION_CODE && vc != dismissedCode) {
|
||||
_available.value = info
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
dismissedCode = _available.value?.versionCode ?: dismissedCode
|
||||
_available.value = null
|
||||
}
|
||||
|
||||
suspend fun downloadAndInstall(context: Context, backendUrl: String, onError: (String) -> Unit) {
|
||||
if (_downloading.value || backendUrl.isBlank()) return
|
||||
_downloading.value = true
|
||||
try {
|
||||
val apk = withContext(Dispatchers.IO) {
|
||||
val dir = File(context.cacheDir, "updates").apply { mkdirs(); listFiles()?.forEach { it.delete() } }
|
||||
val out = File(dir, "NovaSonic-update.apk")
|
||||
val c = (URL("${backendUrl.trimEnd('/')}/api/apk").openConnection() as HttpURLConnection)
|
||||
.apply { connectTimeout = 15000; readTimeout = 30000; instanceFollowRedirects = true }
|
||||
if (c.responseCode !in 200..299) throw RuntimeException("HTTP ${c.responseCode}")
|
||||
c.inputStream.use { input -> out.outputStream().use { input.copyTo(it) } }
|
||||
out
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
_available.value = null // installer takes over
|
||||
} catch (e: Exception) {
|
||||
onError(e.message ?: "Download fehlgeschlagen")
|
||||
} finally {
|
||||
_downloading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Uebergabepunkt fuer geteilte Links (https://<host>/?song=… bzw. ?album=…).
|
||||
*
|
||||
* Die Activity kann nicht selbst navigieren — der NavController lebt in der Compose-Hierarchie.
|
||||
* Deshalb legt MainActivity den eingehenden Link hier ab, und AppNavigation holt ihn sich, sobald
|
||||
* es bereit ist. Ein einzelner Slot genuegt: der letzte geoeffnete Link gewinnt.
|
||||
*/
|
||||
object DeepLinkState {
|
||||
val pending = MutableStateFlow<Uri?>(null)
|
||||
|
||||
fun offer(uri: Uri?) {
|
||||
// Nur Links mit einer fuer uns verwertbaren Kennung annehmen — ein blosser Start ueber das
|
||||
// App-Symbol darf keine Navigation ausloesen.
|
||||
if (uri == null) return
|
||||
if (uri.getQueryParameter("song") == null && uri.getQueryParameter("album") == null) return
|
||||
pending.value = uri
|
||||
}
|
||||
|
||||
/** Holt den Link und leert den Slot, damit er bei Drehung o. ae. nicht erneut greift. */
|
||||
fun consume(): Uri? = pending.value.also { pending.value = null }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
/**
|
||||
* Einmalige Absicht beim Öffnen der Bibliothek. Das LibraryViewModel hängt am Navigationseintrag,
|
||||
* überlebt also keinen Wechsel des Ziels — ein Zustand, den ein anderer Screen setzen will, muss
|
||||
* deshalb hier durch. Wird beim Verarbeiten sofort zurückgesetzt, damit er nicht nachwirkt.
|
||||
*/
|
||||
object LibraryIntent {
|
||||
/** Direkt in den Künstler-Reiter mit aktivem "Gefolgt"-Filter springen. */
|
||||
var oeffneGefolgte: Boolean = false
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.novasonic.app.NovaSonicApp
|
||||
import com.novasonic.app.api.SubsonicClient
|
||||
import com.novasonic.app.api.model.*
|
||||
import com.novasonic.app.offline.OfflineRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.File
|
||||
|
||||
object ClientHolder {
|
||||
@Volatile private var client: SubsonicClient? = null
|
||||
@Volatile private var lastCreds: PrefsStore.Credentials? = null
|
||||
|
||||
fun get(creds: PrefsStore.Credentials): SubsonicClient {
|
||||
if (creds != lastCreds) {
|
||||
client?.close()
|
||||
client = SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||
lastCreds = creds
|
||||
}
|
||||
return client!!
|
||||
}
|
||||
|
||||
fun invalidate() {
|
||||
client?.close()
|
||||
client = null
|
||||
lastCreds = null
|
||||
}
|
||||
}
|
||||
|
||||
class MusicRepository(private val context: Context) {
|
||||
|
||||
private val prefs = PrefsStore(context)
|
||||
private val playLog = PlayLogClient(context)
|
||||
|
||||
private suspend fun api(): SubsonicClient =
|
||||
ClientHolder.get(prefs.credentials.first())
|
||||
|
||||
suspend fun ping() = api().ping()
|
||||
suspend fun getRecentAlbums(size: Int = 20) = api().getAlbumList("newest", size)
|
||||
suspend fun getFrequentAlbums(size: Int = 20) = api().getAlbumList("frequent", size)
|
||||
suspend fun getRecentlyPlayedAlbums(size: Int = 20) = api().getAlbumList("recent", size)
|
||||
suspend fun getRandomAlbums(size: Int = 20) = api().getAlbumList("random", size)
|
||||
suspend fun getAlphaAlbums(size: Int = 40, offset: Int = 0) =
|
||||
api().getAlbumList("alphabeticalByName", size, offset)
|
||||
suspend fun getAlbum(id: String) = api().getAlbum(id)
|
||||
suspend fun getArtists() = api().getArtists()
|
||||
suspend fun getArtist(id: String) = api().getArtist(id)
|
||||
suspend fun getSong(id: String) = api().getSong(id)
|
||||
suspend fun search(query: String) = api().search(query)
|
||||
suspend fun getPlaylists() = api().getPlaylists()
|
||||
suspend fun getPlaylist(id: String) = api().getPlaylist(id)
|
||||
suspend fun getStarred() = api().getStarred()
|
||||
suspend fun getRandomSongs() = api().getRandomSongs()
|
||||
suspend fun getSimilarSongs(songId: String, count: Int = 20) = api().getSimilarSongs(songId, count)
|
||||
suspend fun star(id: String) = api().star(id)
|
||||
suspend fun unstar(id: String) = api().unstar(id)
|
||||
suspend fun scrobble(id: String) = api().scrobble(id)
|
||||
// Our own timestamped play-log (stats + Wrapped), in addition to the Navidrome scrobble above.
|
||||
suspend fun logPlay(song: Song) = playLog.logPlay(song)
|
||||
suspend fun flushPlayLog() = playLog.flush()
|
||||
suspend fun fetchScrobbleStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int) =
|
||||
playLog.fetchStats(from, to, tz, bucket, limit)
|
||||
suspend fun fetchScrobbleUsers() = playLog.fetchUsers()
|
||||
suspend fun fetchScrobbleCompare(with: String) = playLog.fetchCompare(with)
|
||||
// ── Artist pins (shared, server-side same-name disambiguation) ───────────────
|
||||
@Volatile private var _artistOverrides: Map<String, ArtistOverride>? = null
|
||||
|
||||
/** Full pin map, cached after first load. Empty if no backend / unreachable. */
|
||||
suspend fun artistOverrides(): Map<String, ArtistOverride> {
|
||||
_artistOverrides?.let { return it }
|
||||
val url = getBackendUrl()
|
||||
if (url.isBlank()) return emptyMap<String, ArtistOverride>().also { _artistOverrides = it }
|
||||
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||
val m = runCatching { c.loadAll() }.getOrDefault(emptyMap())
|
||||
c.close()
|
||||
return m.also { _artistOverrides = it }
|
||||
}
|
||||
|
||||
suspend fun getArtistOverride(ndArtistId: String): ArtistOverride? = artistOverrides()[ndArtistId]
|
||||
|
||||
suspend fun setArtistOverride(ndArtistId: String, ov: ArtistOverride): Boolean {
|
||||
val url = getBackendUrl()
|
||||
if (url.isBlank()) return false
|
||||
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||
val ok = runCatching { c.set(ndArtistId, ov) }.getOrDefault(false)
|
||||
c.close()
|
||||
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) + (ndArtistId to ov)
|
||||
return ok
|
||||
}
|
||||
|
||||
suspend fun removeArtistOverride(ndArtistId: String): Boolean {
|
||||
val url = getBackendUrl()
|
||||
if (url.isBlank()) return false
|
||||
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||
val ok = runCatching { c.remove(ndArtistId) }.getOrDefault(false)
|
||||
c.close()
|
||||
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) - ndArtistId
|
||||
return ok
|
||||
}
|
||||
|
||||
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
|
||||
/** artistId ist die NAVIDROME-Kuenstler-ID (nicht Deezers) — ohne sie sucht Navidrome
|
||||
* ueber den Namen und nimmt bei Gleichnamigen den ersten Treffer. */
|
||||
suspend fun getTopSongs(artistName: String, artistId: String? = null) =
|
||||
api().getTopSongs(artistName, artistId = artistId)
|
||||
suspend fun getGenres() = api().getGenres()
|
||||
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
|
||||
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
|
||||
suspend fun startScan() = api().startScan()
|
||||
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment)
|
||||
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices)
|
||||
suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId)
|
||||
suspend fun uploadPlaylistCover(playlistId: String, imageBytes: ByteArray, mimeType: String) = api().uploadPlaylistCover(playlistId, imageBytes, mimeType)
|
||||
suspend fun removePlaylistCover(playlistId: String) = api().removePlaylistCover(playlistId)
|
||||
|
||||
fun coverArtUrl(id: String?, size: Int = 300): String? {
|
||||
if (id != null) {
|
||||
offlineRepo()?.localCoverPath(id)?.let { path ->
|
||||
val f = File(path)
|
||||
if (f.exists()) return Uri.fromFile(f).toString()
|
||||
}
|
||||
}
|
||||
val creds = lastCredentials ?: return null
|
||||
// Reuse the cached client — building a fresh SubsonicClient here spins up a whole Ktor
|
||||
// HttpClient per cover (and never closes it), which is heavy GC/alloc churn while scrolling.
|
||||
return ClientHolder.get(creds).coverArtUrl(id ?: return null, size)
|
||||
}
|
||||
|
||||
fun streamUrl(id: String): String? {
|
||||
offlineRepo()?.localAudioPath(id)?.let { path ->
|
||||
val f = File(path)
|
||||
if (f.exists()) return Uri.fromFile(f).toString()
|
||||
}
|
||||
val creds = lastCredentials ?: return null
|
||||
return ClientHolder.get(creds).streamUrl(id) // reuse cached client (no per-call HttpClient)
|
||||
}
|
||||
|
||||
private fun offlineRepo(): OfflineRepository? = runCatching {
|
||||
(context.applicationContext as NovaSonicApp).offlineRepository
|
||||
}.getOrNull()
|
||||
|
||||
var lastCredentials: PrefsStore.Credentials? = null
|
||||
|
||||
fun credentialsFlow() = prefs.credentials
|
||||
fun appSettingsFlow() = prefs.appSettings
|
||||
suspend fun getBackendUrl(): String = prefs.appSettings.first().backendUrl
|
||||
|
||||
/** Finds Navidrome albums for a given artist name (for iTunes cross-reference). */
|
||||
suspend fun getNdAlbumsForArtist(artistName: String): List<com.novasonic.app.api.model.Album> {
|
||||
// Try exact artist match first, then fall back to album search
|
||||
val artistResult = api().search(artistName, albumCount = 0, artistCount = 10, songCount = 0)
|
||||
val ndArtist = artistResult.artist.find { a ->
|
||||
a.name.equals(artistName, ignoreCase = true) ||
|
||||
a.name.contains(artistName, ignoreCase = true) ||
|
||||
artistName.contains(a.name, ignoreCase = true)
|
||||
}
|
||||
return if (ndArtist != null) {
|
||||
runCatching { api().getArtist(ndArtist.id).album }.getOrDefault(emptyList())
|
||||
} else {
|
||||
api().search(artistName, albumCount = 50, artistCount = 0, songCount = 0).album
|
||||
.filter { it.artist?.contains(artistName, ignoreCase = true) == true }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveCredentials(serverUrl: String, username: String, password: String) {
|
||||
ClientHolder.invalidate()
|
||||
prefs.save(serverUrl, username, password)
|
||||
}
|
||||
|
||||
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
|
||||
|
||||
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) =
|
||||
prefs.saveQueueState(songs, index)
|
||||
suspend fun readQueueState() = prefs.readQueueState()
|
||||
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
|
||||
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
|
||||
|
||||
fun lastPlayedSongFlow() = prefs.lastPlayedSong
|
||||
|
||||
suspend fun absClient(): com.novasonic.app.api.AbsClient? {
|
||||
val s = prefs.appSettings.first()
|
||||
if (s.absUrl.isBlank() || s.absApiKey.isBlank()) return null
|
||||
return com.novasonic.app.api.AbsClient(s.absUrl.trimEnd('/'), s.absApiKey, s.backendUrl.trimEnd('/'))
|
||||
}
|
||||
|
||||
suspend fun saveAppSettings(settings: PrefsStore.AppSettings) {
|
||||
val urlChanged = settings.serverUrl != lastCredentials?.serverUrl ||
|
||||
settings.username != lastCredentials?.username ||
|
||||
settings.password != lastCredentials?.password
|
||||
if (urlChanged) ClientHolder.invalidate()
|
||||
prefs.saveAll(settings)
|
||||
}
|
||||
|
||||
suspend fun getScrobbleDelayMs(durationMs: Long): Long {
|
||||
val mode = prefs.appSettings.first().scrobbleMode
|
||||
return when (mode) {
|
||||
"15s" -> 15_000L
|
||||
"30s" -> 30_000L
|
||||
"third" -> (durationMs / 3).coerceAtLeast(15_000L)
|
||||
"two_thirds" -> (durationMs * 2 / 3).coerceAtLeast(15_000L)
|
||||
"end" -> (durationMs - 5_000L).coerceAtLeast(15_000L)
|
||||
else -> 30_000L
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.model.DownloadStatus
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class AppNotification(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val artist: String = "",
|
||||
val status: String, // "done" | "error" | "pending"
|
||||
val message: String = "",
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val read: Boolean = false,
|
||||
// Navigation — mirrors web notify.js fields
|
||||
val navType: String = "", // "track" | "album" | "playlist"
|
||||
val navAlbum: String = "",
|
||||
val navArtist: String = "",
|
||||
val navId: String = "", // playlist ID if known
|
||||
)
|
||||
|
||||
object NotificationStore {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val _items = MutableStateFlow<List<AppNotification>>(emptyList())
|
||||
val items: StateFlow<List<AppNotification>> = _items.asStateFlow()
|
||||
|
||||
fun add(n: AppNotification) = _items.update { listOf(n) + it }
|
||||
|
||||
fun markAllRead() = _items.update { list -> list.map { it.copy(read = true) } }
|
||||
|
||||
fun remove(id: String) = _items.update { it.filter { n -> n.id != id } }
|
||||
|
||||
fun clear() { _items.value = emptyList() }
|
||||
|
||||
/**
|
||||
* Submit a download job to the backend and poll until done/error,
|
||||
* then add a notification. Safe to call from any ViewModel — uses
|
||||
* an app-lifetime coroutine scope so the ViewModel can be cleared
|
||||
* while polling continues.
|
||||
*/
|
||||
fun startPoll(
|
||||
backendUrl: String,
|
||||
jobId: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
navType: String = "",
|
||||
navAlbum: String = "",
|
||||
navArtist: String = "",
|
||||
navId: String = "",
|
||||
) {
|
||||
add(AppNotification(id = jobId, title = title, artist = artist, status = "pending"))
|
||||
scope.launch {
|
||||
val client = ImporterClient(backendUrl)
|
||||
try {
|
||||
repeat(400) { // up to ~10 minutes at 1.5 s interval
|
||||
delay(1500)
|
||||
val st: DownloadStatus = runCatching { client.status(jobId) }.getOrNull()
|
||||
?: return@repeat
|
||||
when (st.status) {
|
||||
"done" -> {
|
||||
replace(AppNotification(
|
||||
id = jobId, title = title, artist = artist,
|
||||
status = "done", message = st.message ?: "Fertig!",
|
||||
navType = navType, navAlbum = navAlbum,
|
||||
navArtist = navArtist, navId = navId,
|
||||
))
|
||||
return@launch
|
||||
}
|
||||
"error" -> {
|
||||
replace(AppNotification(
|
||||
id = jobId, title = title, artist = artist,
|
||||
status = "error", message = st.message ?: "Fehler",
|
||||
navType = navType, navAlbum = navAlbum,
|
||||
navArtist = navArtist, navId = navId,
|
||||
))
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun replace(n: AppNotification) =
|
||||
_items.update { list -> listOf(n) + list.filter { it.id != n.id } }
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import com.novasonic.app.api.model.ScrobbleStats
|
||||
import com.novasonic.app.api.model.Song
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.android.Android
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.encodeURLParameter
|
||||
import io.ktor.http.isSuccess
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Records timestamped plays into NovaSonic's own scrobble service (separate origin,
|
||||
* settings.scrobbleUrl) — the basis for day/month/year stats + Wrapped.
|
||||
*
|
||||
* Runs IN ADDITION to the existing Navidrome scrobble (playCount + Last.fm); never
|
||||
* replaces it. Called at the same "counted play" moment as the Navidrome scrobble
|
||||
* (see PlayerController.scheduleScrobble).
|
||||
*
|
||||
* Offline-safe: entries persist to a JSON file and flush as an idempotent batch
|
||||
* (the service dedups by uuid), so a play made offline is delivered later exactly once.
|
||||
*
|
||||
* One instance lives on MusicRepository → a single reused HttpClient (never per-call).
|
||||
*/
|
||||
class PlayLogClient(context: Context) {
|
||||
|
||||
private val prefs = PrefsStore(context)
|
||||
private val queueFile = File(context.filesDir, "play-queue.json")
|
||||
private val mutex = Mutex()
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
private val http = HttpClient(Android) {
|
||||
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PlayEntry(
|
||||
val uuid: String,
|
||||
val songId: String,
|
||||
val title: String? = null,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val album: String? = null,
|
||||
val albumId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val genre: String? = null,
|
||||
val duration: Int? = null,
|
||||
val playedAt: Long,
|
||||
val source: String = "android",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class Batch(val plays: List<PlayEntry>)
|
||||
|
||||
/** Queue one counted play and try to deliver it. */
|
||||
suspend fun logPlay(song: Song) {
|
||||
val s = prefs.appSettings.first()
|
||||
if (s.scrobbleUrl.isBlank()) return
|
||||
val entry = PlayEntry(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
songId = song.id,
|
||||
title = song.title,
|
||||
artist = song.artist,
|
||||
artistId = song.artistId,
|
||||
album = song.album,
|
||||
albumId = song.albumId,
|
||||
coverArt = song.coverArt,
|
||||
genre = song.genre,
|
||||
duration = song.duration,
|
||||
playedAt = System.currentTimeMillis() / 1000,
|
||||
)
|
||||
mutex.withLock {
|
||||
val q = readQueue().toMutableList()
|
||||
q.add(entry)
|
||||
writeQueue(q.takeLast(MAX_QUEUE))
|
||||
}
|
||||
flush(s)
|
||||
}
|
||||
|
||||
/** Deliver everything queued as one idempotent batch. Safe to call repeatedly. */
|
||||
suspend fun flush(settings: PrefsStore.AppSettings? = null) {
|
||||
val s = settings ?: prefs.appSettings.first()
|
||||
val base = s.scrobbleUrl.trimEnd('/')
|
||||
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return
|
||||
|
||||
val batch = mutex.withLock { readQueue() }
|
||||
if (batch.isEmpty()) return
|
||||
|
||||
val auth = authParams(s.username, s.password)
|
||||
val ok = runCatching {
|
||||
val resp: HttpResponse = http.post("$base/scrobble/plays?$auth") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(json.encodeToString(Batch(batch)))
|
||||
}
|
||||
resp.status.isSuccess()
|
||||
}.getOrDefault(false)
|
||||
|
||||
if (ok) {
|
||||
// Remove exactly what we sent (accepted + duplicates are durable), preserving
|
||||
// any plays queued while the request was in flight.
|
||||
val sent = batch.map { it.uuid }.toHashSet()
|
||||
mutex.withLock { writeQueue(readQueue().filterNot { it.uuid in sent }) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Read aggregated stats for the current user. Returns null on any failure (unconfigured,
|
||||
* unauthorized, network). Decodes with the tolerant json instance (ignoreUnknownKeys). */
|
||||
suspend fun fetchStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int): ScrobbleStats? {
|
||||
val s = prefs.appSettings.first()
|
||||
val base = s.scrobbleUrl.trimEnd('/')
|
||||
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
|
||||
val auth = authParams(s.username, s.password)
|
||||
val url = "$base/scrobble/stats?$auth&from=$from&to=$to&tz=$tz&bucket=$bucket&limit=$limit"
|
||||
return runCatching {
|
||||
val resp = http.get(url)
|
||||
if (!resp.status.isSuccess()) return null
|
||||
json.decodeFromString<ScrobbleStats>(resp.bodyAsText())
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Head-to-Head: list the users in the log (for the partner picker). Null on failure. */
|
||||
suspend fun fetchUsers(): com.novasonic.app.api.model.ScrobbleUsersResponse? {
|
||||
val s = prefs.appSettings.first()
|
||||
val base = s.scrobbleUrl.trimEnd('/')
|
||||
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
|
||||
val auth = authParams(s.username, s.password)
|
||||
return runCatching {
|
||||
val resp = http.get("$base/scrobble/users?$auth")
|
||||
if (!resp.status.isSuccess()) return null
|
||||
json.decodeFromString<com.novasonic.app.api.model.ScrobbleUsersResponse>(resp.bodyAsText())
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Head-to-Head: compare the authed user against [with]. Null on failure. */
|
||||
suspend fun fetchCompare(with: String): com.novasonic.app.api.model.CompareResult? {
|
||||
val s = prefs.appSettings.first()
|
||||
val base = s.scrobbleUrl.trimEnd('/')
|
||||
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
|
||||
val auth = authParams(s.username, s.password)
|
||||
val url = "$base/scrobble/compare?$auth&with=${with.encodeURLParameter()}"
|
||||
return runCatching {
|
||||
val resp = http.get(url)
|
||||
if (!resp.status.isSuccess()) return null
|
||||
json.decodeFromString<com.novasonic.app.api.model.CompareResult>(resp.bodyAsText())
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readQueue(): List<PlayEntry> =
|
||||
runCatching {
|
||||
if (!queueFile.exists()) emptyList()
|
||||
else json.decodeFromString<List<PlayEntry>>(queueFile.readText())
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun writeQueue(q: List<PlayEntry>) {
|
||||
runCatching { queueFile.writeText(json.encodeToString(q)) }
|
||||
}
|
||||
|
||||
private fun authParams(user: String, pass: String): String {
|
||||
val salt = Random.nextInt(100_000, 999_999).toString()
|
||||
val token = md5(pass + salt)
|
||||
return "u=${user.encodeURLParameter()}&t=$token&s=$salt"
|
||||
}
|
||||
|
||||
private fun md5(input: String): String =
|
||||
MessageDigest.getInstance("MD5").digest(input.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
companion object { private const val MAX_QUEUE = 500 }
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore by preferencesDataStore("novasonic_prefs")
|
||||
|
||||
class PrefsStore(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
val KEY_SERVER = stringPreferencesKey("server_url")
|
||||
val KEY_USERNAME = stringPreferencesKey("username")
|
||||
val KEY_PASSWORD = stringPreferencesKey("password")
|
||||
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
|
||||
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
|
||||
val KEY_SCROBBLE_URL = stringPreferencesKey("scrobble_url")
|
||||
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
|
||||
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
||||
val KEY_THEME = stringPreferencesKey("theme")
|
||||
val KEY_ABS_URL = stringPreferencesKey("abs_url")
|
||||
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
|
||||
val KEY_ABS_CHAPTER_TRACK = booleanPreferencesKey("abs_chapter_track")
|
||||
val KEY_DL_WIFI_ONLY = booleanPreferencesKey("download_wifi_only")
|
||||
val KEY_WISCH_TITEL = booleanPreferencesKey("swipe_changes_track")
|
||||
val KEY_LYRICS_FOLGEN = booleanPreferencesKey("lyrics_autoscroll")
|
||||
val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id")
|
||||
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
|
||||
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
|
||||
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
|
||||
// Warteschlange fortsetzen. Gespeichert werden die VOLLSTAENDIGEN Songs als JSON, nicht nur
|
||||
// Ids: mit Ids musste beim Start jeder Titel einzeln ueber das Netz nachgeladen werden, was
|
||||
// Sekunden dauerte und ohne Verbindung gar nicht ging. So ist es ein lokaler Lesevorgang.
|
||||
// Ohne Sekunde im Titel — der zuletzt gehoerte Titel beginnt von vorn.
|
||||
val KEY_QUEUE_JSON = stringPreferencesKey("queue_json")
|
||||
val KEY_QUEUE_INDEX = intPreferencesKey("queue_index")
|
||||
}
|
||||
|
||||
data class Credentials(val serverUrl: String, val username: String, val password: String) {
|
||||
val isValid get() = serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
|
||||
}
|
||||
|
||||
data class AppSettings(
|
||||
val serverUrl: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val lastFmKey: String = "",
|
||||
val backendUrl: String = "",
|
||||
val scrobbleUrl: String = "",
|
||||
val scrobbleMode: String = "30s",
|
||||
val showPlayCount: Boolean = false,
|
||||
val theme: String = "dark-ocean",
|
||||
val absUrl: String = "",
|
||||
val absApiKey: String = "",
|
||||
val absChapterTrackMode: Boolean = false,
|
||||
/** Offline-Downloads nur ueber WLAN. Standard an: eine grosse Playlist sind schnell
|
||||
* mehrere Gigabyte, und die Wiederholkette laeuft ohne Zutun weiter. */
|
||||
val downloadWifiOnly: Boolean = true,
|
||||
/** Waagerechtes Wischen im Now-Playing wechselt den Titel statt der Ansicht.
|
||||
* Standard an — Titelwechsel ist die Geste, die man dort staendig braucht. */
|
||||
val wischenWechseltTitel: Boolean = true,
|
||||
/** Im Lyrics-Tab automatisch zur laufenden Zeile scrollen. Standard AUS: wer selbst
|
||||
* blaettert, will nicht, dass der Text unter dem Finger wegspringt. Umschalten direkt
|
||||
* im Lyrics-Tab, wirkt nur bei synchronen Texten. */
|
||||
val lyricsFolgenAutomatisch: Boolean = false,
|
||||
)
|
||||
|
||||
data class LastPlayedSong(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val coverArtId: String?,
|
||||
)
|
||||
|
||||
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
|
||||
Credentials(
|
||||
// Adresse vorbelegt, Zugangsdaten bewusst nicht — die gehoeren pro Person.
|
||||
serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
|
||||
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
|
||||
username = prefs[KEY_USERNAME] ?: "",
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
|
||||
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
|
||||
username = prefs[KEY_USERNAME] ?: "",
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
|
||||
backendUrl = prefs[KEY_BACKEND_URL]?.ifBlank { null }
|
||||
?: com.novasonic.app.BuildConfig.DEFAULT_BACKEND_URL,
|
||||
// Leerer Speicherwert => eingebaute Vorgabe. So muss auf einem frischen Geraet nichts
|
||||
// eingetippt werden, ein eigener Eintrag hat aber weiterhin Vorrang.
|
||||
scrobbleUrl = prefs[KEY_SCROBBLE_URL]?.ifBlank { null }
|
||||
?: com.novasonic.app.BuildConfig.DEFAULT_SCROBBLE_URL,
|
||||
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
||||
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
||||
theme = prefs[KEY_THEME] ?: "dark-ocean",
|
||||
absUrl = prefs[KEY_ABS_URL] ?: "",
|
||||
absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
|
||||
absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false,
|
||||
downloadWifiOnly = prefs[KEY_DL_WIFI_ONLY] ?: true,
|
||||
wischenWechseltTitel = prefs[KEY_WISCH_TITEL] ?: true,
|
||||
lyricsFolgenAutomatisch = prefs[KEY_LYRICS_FOLGEN] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
val lastPlayedSong: Flow<LastPlayedSong?> = context.dataStore.data.map { prefs ->
|
||||
val id = prefs[KEY_LAST_SONG_ID]?.takeIf { it.isNotBlank() } ?: return@map null
|
||||
LastPlayedSong(
|
||||
id = id,
|
||||
title = prefs[KEY_LAST_SONG_TITLE] ?: "",
|
||||
artist = prefs[KEY_LAST_SONG_ARTIST] ?: "",
|
||||
coverArtId = prefs[KEY_LAST_SONG_COVER]?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun save(serverUrl: String, username: String, password: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_SERVER] = serverUrl.trimEnd('/')
|
||||
prefs[KEY_USERNAME] = username
|
||||
prefs[KEY_PASSWORD] = password
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveAll(settings: AppSettings) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_SERVER] = settings.serverUrl.trimEnd('/')
|
||||
prefs[KEY_USERNAME] = settings.username
|
||||
prefs[KEY_PASSWORD] = settings.password
|
||||
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
|
||||
prefs[KEY_BACKEND_URL] = settings.backendUrl
|
||||
prefs[KEY_SCROBBLE_URL] = settings.scrobbleUrl
|
||||
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
|
||||
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
||||
prefs[KEY_THEME] = settings.theme
|
||||
prefs[KEY_ABS_URL] = settings.absUrl
|
||||
prefs[KEY_ABS_API_KEY] = settings.absApiKey
|
||||
prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode
|
||||
prefs[KEY_DL_WIFI_ONLY] = settings.downloadWifiOnly
|
||||
prefs[KEY_WISCH_TITEL] = settings.wischenWechseltTitel
|
||||
// KEY_LYRICS_FOLGEN bewusst NICHT: der Schalter sitzt im Lyrics-Tab und wird mit
|
||||
// saveLyricsFolgen() einzeln geschrieben. Wuerde er hier mitgeschrieben, setzte jedes
|
||||
// Speichern in den Einstellungen ihn auf die Vorgabe zurueck — der Bildschirm fuehrt
|
||||
// dieses Feld gar nicht mit.
|
||||
}
|
||||
}
|
||||
|
||||
/** Einzeln speicherbar: der Schalter sitzt im Lyrics-Tab, nicht in den Einstellungen, und
|
||||
* darf dort nicht den ganzen Einstellungssatz ueberschreiben. */
|
||||
suspend fun saveLyricsFolgen(an: Boolean) {
|
||||
context.dataStore.edit { prefs -> prefs[KEY_LYRICS_FOLGEN] = an }
|
||||
}
|
||||
|
||||
suspend fun saveTheme(themeId: String) {
|
||||
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
|
||||
}
|
||||
|
||||
suspend fun saveLastSong(id: String, title: String, artist: String, coverArt: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_LAST_SONG_ID] = id
|
||||
prefs[KEY_LAST_SONG_TITLE] = title
|
||||
prefs[KEY_LAST_SONG_ARTIST] = artist
|
||||
prefs[KEY_LAST_SONG_COVER] = coverArt ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
private val queueJson = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Sichert die Warteschlange als VOLLSTAENDIGE Songs, nicht nur als Ids. Mit Ids musste beim
|
||||
* Start jeder Titel einzeln ueber das Netz nachgeladen werden — das dauerte Sekunden und ging
|
||||
* ohne Verbindung gar nicht. So ist das Wiederherstellen ein rein lokaler Lesevorgang.
|
||||
* Der Preis: zwischenzeitlich geaenderte Titel oder Cover zeigen bis zum naechsten Abspielen
|
||||
* den alten Stand. Das ist die deutlich kleinere Unschoenheit.
|
||||
*/
|
||||
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) {
|
||||
val kodiert = runCatching {
|
||||
queueJson.encodeToString(kotlinx.serialization.builtins.ListSerializer(
|
||||
com.novasonic.app.api.model.Song.serializer()), songs)
|
||||
}.getOrNull() ?: return
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_QUEUE_JSON] = kodiert
|
||||
prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
|
||||
data class QueueState(val songs: List<com.novasonic.app.api.model.Song>, val index: Int)
|
||||
|
||||
suspend fun readQueueState(): QueueState? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val roh = prefs[KEY_QUEUE_JSON]?.takeIf { it.isNotBlank() } ?: return null
|
||||
val songs = runCatching {
|
||||
queueJson.decodeFromString(kotlinx.serialization.builtins.ListSerializer(
|
||||
com.novasonic.app.api.model.Song.serializer()), roh)
|
||||
}.getOrNull().orEmpty()
|
||||
if (songs.isEmpty()) return null
|
||||
return QueueState(songs, (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, songs.lastIndex))
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import com.novasonic.app.api.model.Song
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Global, reactive star/favorite overrides. Tapping a star flips the state here *immediately*
|
||||
* (optimistic), so every screen showing that song updates at once — regardless of when each
|
||||
* screen last fetched `song.starred`. The API call runs in the background and reverts on failure.
|
||||
*/
|
||||
object StarState {
|
||||
private val _overrides = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
||||
val overrides: StateFlow<Map<String, Boolean>> = _overrides.asStateFlow()
|
||||
|
||||
/** Current starred state for a song: the live override if present, else the song's own flag. */
|
||||
fun isStarred(song: Song): Boolean = _overrides.value[song.id] ?: (song.starred != null)
|
||||
fun isStarred(songId: String, fallback: Boolean): Boolean = _overrides.value[songId] ?: fallback
|
||||
|
||||
/** Optimistically toggle, then hit the API; revert the override if the call fails. */
|
||||
fun toggle(repo: MusicRepository, scope: CoroutineScope, songId: String, fallbackStarred: Boolean) {
|
||||
val current = _overrides.value[songId] ?: fallbackStarred
|
||||
val next = !current
|
||||
_overrides.value = _overrides.value + (songId to next)
|
||||
scope.launch {
|
||||
val ok = runCatching { if (next) repo.star(songId) else repo.unstar(songId) }.isSuccess
|
||||
if (!ok) _overrides.value = _overrides.value + (songId to current) // revert on failure
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.novasonic.app.offline
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.api.SubsonicClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class DownloadWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_GROUP_ID = "group_id"
|
||||
private const val TAG = "DownloadWorker"
|
||||
/** Obergrenze fuer selbst angeforderte Wiederholungen, damit dauerhaft kaputte Titel
|
||||
* (auf dem Server geloescht, immer 404) die Kette nicht endlos weiterlaufen lassen. */
|
||||
private const val MAX_VERSUCHE = 20
|
||||
}
|
||||
|
||||
private val offlineRepo = applicationContext.app.offlineRepository
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val groupId = inputData.getString(EXTRA_GROUP_ID) ?: return Result.failure()
|
||||
// Nur die Existenz zaehlt — der Fortschritt wird unten neu gezaehlt, nicht fortgeschrieben.
|
||||
offlineRepo.getGroup(groupId) ?: return Result.failure()
|
||||
val songIds = offlineRepo.songIdsForGroup(groupId)
|
||||
if (songIds.isEmpty()) return Result.success()
|
||||
|
||||
val creds = PrefsStore(applicationContext).credentials.first()
|
||||
if (!creds.isValid) return Result.failure()
|
||||
|
||||
val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||
// Bei 0 anfangen und JEDEN fertigen Titel zaehlen — auch die aus frueheren Laeufen, die
|
||||
// gleich uebersprungen werden. Frueher stand hier group.downloadedTracks, und die Schleife
|
||||
// zaehlte dieselben Titel gleich nochmal: nach einem Fortsetzen mit 412 fertigen Titeln
|
||||
// meldete die Gruppe 824. Ueberschreitet der Wert totalTracks, gilt die Gruppe als
|
||||
// "Vollstaendig" und der Herunterladen-Knopf wird unantastbar, obwohl Titel fehlen.
|
||||
var done = 0
|
||||
val dao = OfflineDatabase.get(applicationContext).offlineDao()
|
||||
|
||||
return try {
|
||||
var gestoppt = false
|
||||
for (songId in songIds) {
|
||||
// WorkManager raeumt Hintergrundarbeit rund 10 Minuten ein und stoppt sie dann.
|
||||
// Ohne diese Pruefung laeuft die Schleife blind weiter, bis sie hart abgebrochen
|
||||
// wird — der angefangene Titel geht verloren und niemand startet neu. So steigen
|
||||
// wir sauber aus und fordern den naechsten Lauf an; Fertiges wird uebersprungen.
|
||||
if (isStopped) { gestoppt = true; break }
|
||||
if (offlineRepo.getTrackState(songId) == DownloadState.DONE) {
|
||||
// Kein Schreibvorgang beim Ueberspringen: der Fortschritt aendert sich dabei
|
||||
// nicht, und bei 1900 Titeln waeren das 1900 unnoetige Schreibzugriffe je Lauf.
|
||||
done++
|
||||
continue
|
||||
}
|
||||
|
||||
val track = offlineRepo.getTrack(songId) ?: continue
|
||||
dao.updateState(songId, DownloadState.DOWNLOADING, 0f)
|
||||
|
||||
val suffix = track.suffix?.takeIf { it.isNotBlank() } ?: "mp3"
|
||||
val audioFile = offlineRepo.audioFile(songId, suffix)
|
||||
|
||||
try {
|
||||
streamToFile(subsonicClient.streamUrl(songId), audioFile)
|
||||
|
||||
val coverPath = track.coverArt?.let { coverArtId ->
|
||||
val existing = offlineRepo.localCoverPath(coverArtId)
|
||||
if (existing != null && File(existing).exists()) {
|
||||
existing
|
||||
} else {
|
||||
val coverFile = offlineRepo.coverFile(coverArtId)
|
||||
runCatching { streamToFile(subsonicClient.coverArtUrl(coverArtId, 512), coverFile) }
|
||||
if (coverFile.exists()) coverFile.absolutePath else null
|
||||
}
|
||||
}
|
||||
|
||||
offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
|
||||
done++
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Titel $songId fehlgeschlagen", e)
|
||||
audioFile.delete()
|
||||
offlineRepo.markFailed(songId)
|
||||
}
|
||||
|
||||
dao.updateGroupProgress(groupId, done)
|
||||
}
|
||||
// Beim Abbruch ist der letzte Stand sonst nicht gesichert.
|
||||
if (gestoppt) dao.updateGroupProgress(groupId, done)
|
||||
|
||||
val offen = dao.offeneTitel(groupId)
|
||||
when {
|
||||
gestoppt -> {
|
||||
Log.i(TAG, "Gestoppt bei $done/${songIds.size} — fordere naechsten Lauf an")
|
||||
Result.retry()
|
||||
}
|
||||
offen == 0 -> {
|
||||
Log.i(TAG, "Gruppe $groupId vollstaendig (${songIds.size} Titel)")
|
||||
Result.success()
|
||||
}
|
||||
runAttemptCount < MAX_VERSUCHE -> {
|
||||
Log.i(TAG, "$offen Titel offen (Versuch $runAttemptCount) — noch ein Lauf")
|
||||
Result.retry()
|
||||
}
|
||||
else -> {
|
||||
// Aufgeben, aber nicht stillschweigend: die Gruppenzeile zeigt weiterhin
|
||||
// "x/y Titel", und ein Tipp auf Herunterladen startet die Kette neu.
|
||||
Log.w(TAG, "$offen Titel bleiben offen — nach $runAttemptCount Versuchen aufgegeben")
|
||||
Result.success()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Lauf fuer Gruppe $groupId abgebrochen", e)
|
||||
// Frueher Result.failure(): ein einzelner Netzfehler beendete die ganze Kette endgueltig.
|
||||
if (runAttemptCount < MAX_VERSUCHE) Result.retry() else Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun streamToFile(url: String, dest: File) = withContext(Dispatchers.IO) {
|
||||
val tmp = File("${dest.absolutePath}.tmp")
|
||||
try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 120_000
|
||||
conn.connect()
|
||||
if (conn.responseCode !in 200..299) {
|
||||
throw Exception("HTTP ${conn.responseCode} for ${dest.name}")
|
||||
}
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(tmp).use { out ->
|
||||
val buf = ByteArray(32_768)
|
||||
var n: Int
|
||||
while (input.read(buf).also { n = it } >= 0) {
|
||||
out.write(buf, 0, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tmp.renameTo(dest)) {
|
||||
tmp.copyTo(dest, overwrite = true)
|
||||
tmp.delete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
tmp.delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.novasonic.app.offline
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface OfflineDao {
|
||||
|
||||
// ── Tracks ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertTrack(track: DownloadedTrackEntity)
|
||||
|
||||
@Query("UPDATE downloaded_tracks SET state = :state, progress = :progress WHERE songId = :songId")
|
||||
suspend fun updateState(songId: String, state: DownloadState, progress: Float)
|
||||
|
||||
@Query("UPDATE downloaded_tracks SET state = 'DONE', progress = 1, localPath = :localPath, coverPath = :coverPath, downloadedAt = :at WHERE songId = :songId")
|
||||
suspend fun markDone(songId: String, localPath: String, coverPath: String?, at: Long)
|
||||
|
||||
/** Emits whenever any downloaded track changes — drives the hot in-memory maps. */
|
||||
@Query("SELECT songId, localPath, coverArt, coverPath FROM downloaded_tracks WHERE state = 'DONE'")
|
||||
fun doneTracksFlow(): Flow<List<DoneTrackInfo>>
|
||||
|
||||
@Query("SELECT * FROM downloaded_tracks WHERE songId = :songId")
|
||||
fun trackFlow(songId: String): Flow<DownloadedTrackEntity?>
|
||||
|
||||
@Query("SELECT * FROM downloaded_tracks WHERE songId = :songId")
|
||||
suspend fun getTrackById(songId: String): DownloadedTrackEntity?
|
||||
|
||||
@Query("SELECT state FROM downloaded_tracks WHERE songId = :songId")
|
||||
suspend fun getTrackStateRaw(songId: String): String?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertTrackIfAbsent(track: DownloadedTrackEntity)
|
||||
|
||||
@Query("SELECT * FROM downloaded_tracks ORDER BY downloadedAt DESC")
|
||||
fun allTracksFlow(): Flow<List<DownloadedTrackEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM group_track_cross_refs WHERE songId = :songId")
|
||||
suspend fun groupCountForSong(songId: String): Int
|
||||
|
||||
@Query("DELETE FROM downloaded_tracks WHERE songId = :songId")
|
||||
suspend fun deleteTrackById(songId: String)
|
||||
|
||||
// ── Groups ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertGroup(group: DownloadGroupEntity)
|
||||
|
||||
@Query("SELECT * FROM download_groups ORDER BY createdAt DESC")
|
||||
fun allGroupsFlow(): Flow<List<DownloadGroupEntity>>
|
||||
|
||||
@Query("SELECT * FROM download_groups WHERE groupId = :groupId")
|
||||
suspend fun getGroup(groupId: String): DownloadGroupEntity?
|
||||
|
||||
/**
|
||||
* Rechnet den gespeicherten Fortschritt ALLER Gruppen aus dem tatsaechlichen Zustand der Titel
|
||||
* neu. Noetig, weil der Worker bis 1.10 doppelt zaehlte: der Wert konnte totalTracks
|
||||
* uebersteigen, die Gruppe galt als "Vollstaendig", der Herunterladen-Knopf wurde unantastbar
|
||||
* und das Fortsetzen uebersprang sie — der falsche Wert konnte sich also nie mehr korrigieren.
|
||||
* Eine Anweisung fuer alle Gruppen, laeuft beim App-Start.
|
||||
*/
|
||||
@Query("""
|
||||
UPDATE download_groups SET downloadedTracks = (
|
||||
SELECT COUNT(*) FROM group_track_cross_refs r
|
||||
INNER JOIN downloaded_tracks t ON t.songId = r.songId
|
||||
WHERE r.groupId = download_groups.groupId AND t.state = 'DONE'
|
||||
)
|
||||
""")
|
||||
suspend fun fortschrittNeuBerechnen()
|
||||
|
||||
/** Gruppen, bei denen noch Titel fehlen — Grundlage fuers Fortsetzen beim App-Start. */
|
||||
@Query("SELECT * FROM download_groups WHERE totalTracks > 0 AND downloadedTracks < totalTracks")
|
||||
suspend fun incompleteGroups(): List<DownloadGroupEntity>
|
||||
|
||||
@Query("DELETE FROM download_groups WHERE groupId = :groupId")
|
||||
suspend fun deleteGroup(groupId: String)
|
||||
|
||||
@Query("UPDATE download_groups SET downloadedTracks = :count WHERE groupId = :groupId")
|
||||
suspend fun updateGroupProgress(groupId: String, count: Int)
|
||||
|
||||
@Query("UPDATE download_groups SET totalTracks = :total WHERE groupId = :groupId")
|
||||
suspend fun updateGroupTotal(groupId: String, total: Int)
|
||||
|
||||
// ── Cross-refs ────────────────────────────────────────────────────────────
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertCrossRef(ref: GroupTrackCrossRef)
|
||||
|
||||
@Query("SELECT songId FROM group_track_cross_refs WHERE groupId = :groupId")
|
||||
suspend fun songIdsForGroup(groupId: String): List<String>
|
||||
|
||||
@Query("""
|
||||
SELECT t.* FROM downloaded_tracks t
|
||||
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
|
||||
WHERE r.groupId = :groupId
|
||||
""")
|
||||
suspend fun tracksForGroup(groupId: String): List<DownloadedTrackEntity>
|
||||
|
||||
@Query("""
|
||||
SELECT t.* FROM downloaded_tracks t
|
||||
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
|
||||
WHERE r.groupId = :groupId
|
||||
""")
|
||||
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>>
|
||||
|
||||
/** Wie viele Titel der Gruppe noch nicht fertig sind — eine Abfrage statt einer pro Titel. */
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM downloaded_tracks t
|
||||
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
|
||||
WHERE r.groupId = :groupId AND t.state != 'DONE'
|
||||
""")
|
||||
suspend fun offeneTitel(groupId: String): Int
|
||||
|
||||
@Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId")
|
||||
suspend fun removeCrossRef(groupId: String, songId: String)
|
||||
|
||||
/** Wie viele Titel dieses Cover noch benutzen — Cover werden geteilt gespeichert. */
|
||||
@Query("SELECT COUNT(*) FROM downloaded_tracks WHERE coverArt = :coverArtId")
|
||||
suspend fun titelMitCover(coverArtId: String): Int
|
||||
|
||||
/** Find a locally-stored cover path by Navidrome coverArt ID (deduped storage). */
|
||||
@Query("SELECT coverPath FROM downloaded_tracks WHERE coverArt = :coverArtId AND state = 'DONE' AND coverPath IS NOT NULL LIMIT 1")
|
||||
suspend fun localCoverPathByCoverArt(coverArtId: String): String?
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.novasonic.app.offline
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.*
|
||||
|
||||
@TypeConverters(OfflineConverters::class)
|
||||
@Database(
|
||||
entities = [
|
||||
DownloadedTrackEntity::class,
|
||||
DownloadGroupEntity::class,
|
||||
GroupTrackCrossRef::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class OfflineDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun offlineDao(): OfflineDao
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: OfflineDatabase? = null
|
||||
|
||||
fun get(context: Context): OfflineDatabase = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
OfflineDatabase::class.java,
|
||||
"novasonic_offline.db",
|
||||
).build().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OfflineConverters {
|
||||
@TypeConverter fun fromState(v: DownloadState): String = v.name
|
||||
@TypeConverter fun toState(v: String): DownloadState = DownloadState.valueOf(v)
|
||||
@TypeConverter fun fromType(v: GroupType): String = v.name
|
||||
@TypeConverter fun toType(v: String): GroupType = GroupType.valueOf(v)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.novasonic.app.offline
|
||||
|
||||
import androidx.room.*
|
||||
import com.novasonic.app.api.model.Song
|
||||
|
||||
enum class DownloadState { QUEUED, DOWNLOADING, DONE, FAILED }
|
||||
enum class GroupType { SONG, ALBUM, PLAYLIST, FAVORITES }
|
||||
|
||||
/** Maps a stored offline track back to a domain [Song] for playback / list display. */
|
||||
fun DownloadedTrackEntity.toSong() = Song(
|
||||
id = songId,
|
||||
title = title,
|
||||
album = album,
|
||||
albumId = albumId,
|
||||
artist = artist,
|
||||
artistId = artistId,
|
||||
track = track,
|
||||
year = year,
|
||||
genre = genre,
|
||||
coverArt = coverArt,
|
||||
duration = duration,
|
||||
suffix = suffix,
|
||||
contentType = contentType,
|
||||
bitRate = bitRate,
|
||||
starred = starred,
|
||||
)
|
||||
|
||||
/** Lightweight projection used to keep the hot in-memory maps up to date. */
|
||||
data class DoneTrackInfo(
|
||||
val songId: String,
|
||||
val localPath: String,
|
||||
val coverArt: String?,
|
||||
val coverPath: String?,
|
||||
)
|
||||
|
||||
@Entity(tableName = "downloaded_tracks")
|
||||
data class DownloadedTrackEntity(
|
||||
@PrimaryKey val songId: String,
|
||||
val title: String,
|
||||
val artist: String?,
|
||||
val artistId: String?,
|
||||
val album: String?,
|
||||
val albumId: String?,
|
||||
val coverArt: String?,
|
||||
val track: Int?,
|
||||
val year: Int?,
|
||||
val genre: String?,
|
||||
val duration: Int?,
|
||||
val suffix: String?,
|
||||
val contentType: String?,
|
||||
val bitRate: Int?,
|
||||
val starred: String?,
|
||||
val localPath: String,
|
||||
val coverPath: String?,
|
||||
val state: DownloadState,
|
||||
val progress: Float = 0f,
|
||||
val downloadedAt: Long = 0L,
|
||||
)
|
||||
|
||||
@Entity(tableName = "download_groups")
|
||||
data class DownloadGroupEntity(
|
||||
@PrimaryKey val groupId: String, // e.g. "album:abc123", "playlist:xyz", "favorites"
|
||||
val type: GroupType,
|
||||
val sourceId: String,
|
||||
val name: String,
|
||||
val coverArt: String?,
|
||||
val totalTracks: Int = 0,
|
||||
val downloadedTracks: Int = 0,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "group_track_cross_refs",
|
||||
primaryKeys = ["groupId", "songId"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = DownloadGroupEntity::class,
|
||||
parentColumns = ["groupId"],
|
||||
childColumns = ["groupId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = DownloadedTrackEntity::class,
|
||||
parentColumns = ["songId"],
|
||||
childColumns = ["songId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("songId")],
|
||||
)
|
||||
data class GroupTrackCrossRef(val groupId: String, val songId: String)
|
||||
@@ -0,0 +1,287 @@
|
||||
package com.novasonic.app.offline
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.novasonic.app.api.model.Song
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import java.io.File
|
||||
|
||||
class OfflineRepository(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val dao = OfflineDatabase.get(appContext).offlineDao()
|
||||
private val scope = CoroutineScope(
|
||||
SupervisorJob() + Dispatchers.IO +
|
||||
CoroutineExceptionHandler { _, t -> Log.e("OfflineRepository", "Unhandled exception", t) }
|
||||
)
|
||||
|
||||
// ── Hot in-memory maps (no suspend needed for lookups) ────────────────────
|
||||
// songId → absolute local audio path
|
||||
private val _audioMap = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
// coverArtId (Navidrome) → absolute local cover path
|
||||
private val _coverMap = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
|
||||
/** Reactive set of all downloaded song IDs — collect in UI for per-row indicators. */
|
||||
val downloadedIds: StateFlow<Set<String>> = _audioMap
|
||||
.map { it.keys.toSet() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
dao.doneTracksFlow().collect { tracks ->
|
||||
_audioMap.value = tracks.associate { it.songId to it.localPath }
|
||||
_coverMap.value = tracks
|
||||
.filter { it.coverArt != null && it.coverPath != null }
|
||||
.associate { it.coverArt!! to it.coverPath!! }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Synchronous lookups (called from MusicRepository — no coroutine) ──────
|
||||
|
||||
fun isDownloaded(songId: String): Boolean = songId in _audioMap.value
|
||||
fun localAudioPath(songId: String): String? = _audioMap.value[songId]
|
||||
fun localCoverPath(coverArtId: String): String? = _coverMap.value[coverArtId]
|
||||
|
||||
// ── Storage layout ────────────────────────────────────────────────────────
|
||||
|
||||
fun audioDir(): File = File(appContext.filesDir, "offline/audio").also { it.mkdirs() }
|
||||
fun coversDir(): File = File(appContext.filesDir, "offline/covers").also { it.mkdirs() }
|
||||
|
||||
fun audioFile(songId: String, suffix: String): File = File(audioDir(), "$songId.$suffix")
|
||||
fun coverFile(coverArtId: String): File = File(coversDir(), "$coverArtId.jpg")
|
||||
|
||||
// ── Reactive flows for UI ─────────────────────────────────────────────────
|
||||
|
||||
val allGroupsFlow: Flow<List<DownloadGroupEntity>> = dao.allGroupsFlow()
|
||||
val allTracksFlow: Flow<List<DownloadedTrackEntity>> = dao.allTracksFlow()
|
||||
|
||||
fun trackFlow(songId: String): Flow<DownloadedTrackEntity?> = dao.trackFlow(songId)
|
||||
|
||||
// ── Write operations (used by DownloadWorker — Phase 2) ──────────────────
|
||||
|
||||
suspend fun insertTrack(entity: DownloadedTrackEntity) = dao.insertTrack(entity)
|
||||
|
||||
suspend fun updateState(songId: String, state: DownloadState, progress: Float) =
|
||||
dao.updateState(songId, state, progress)
|
||||
|
||||
suspend fun markDone(songId: String, localPath: String, coverPath: String?) =
|
||||
dao.markDone(songId, localPath, coverPath, System.currentTimeMillis())
|
||||
|
||||
suspend fun markFailed(songId: String) =
|
||||
dao.updateState(songId, DownloadState.FAILED, 0f)
|
||||
|
||||
suspend fun insertGroup(group: DownloadGroupEntity) = dao.insertGroup(group)
|
||||
suspend fun getGroup(groupId: String) = dao.getGroup(groupId)
|
||||
suspend fun updateGroupProgress(groupId: String, count: Int) =
|
||||
dao.updateGroupProgress(groupId, count)
|
||||
suspend fun updateGroupTotal(groupId: String, total: Int) =
|
||||
dao.updateGroupTotal(groupId, total)
|
||||
|
||||
suspend fun insertCrossRef(groupId: String, songId: String) =
|
||||
dao.insertCrossRef(GroupTrackCrossRef(groupId, songId))
|
||||
|
||||
suspend fun songIdsForGroup(groupId: String): List<String> = dao.songIdsForGroup(groupId)
|
||||
|
||||
suspend fun getTrack(songId: String): DownloadedTrackEntity? = dao.getTrackById(songId)
|
||||
|
||||
suspend fun tracksForGroup(groupId: String): List<DownloadedTrackEntity> = dao.tracksForGroup(groupId)
|
||||
|
||||
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>> = dao.tracksForGroupFlow(groupId)
|
||||
|
||||
suspend fun getTrackState(songId: String): DownloadState? =
|
||||
dao.getTrackStateRaw(songId)?.let { DownloadState.valueOf(it) }
|
||||
|
||||
/**
|
||||
* Inserts all songs as QUEUED into Room, creates a download group, and enqueues a WorkManager
|
||||
* job that downloads the files in the background. Already-DONE tracks are counted but not
|
||||
* re-downloaded. Safe to call for an existing groupId — WorkManager KEEP policy is used.
|
||||
*/
|
||||
suspend fun enqueueGroup(
|
||||
type: GroupType,
|
||||
sourceId: String,
|
||||
name: String,
|
||||
coverArt: String?,
|
||||
songs: List<Song>,
|
||||
) {
|
||||
val groupId = "${type.name.lowercase()}:$sourceId"
|
||||
// Preserve creation time + progress when re-enqueuing (resume / re-download missing songs).
|
||||
val existing = dao.getGroup(groupId)
|
||||
dao.insertGroup(
|
||||
DownloadGroupEntity(
|
||||
groupId = groupId,
|
||||
type = type,
|
||||
sourceId = sourceId,
|
||||
name = name,
|
||||
coverArt = coverArt,
|
||||
totalTracks = songs.size,
|
||||
downloadedTracks = existing?.downloadedTracks ?: 0,
|
||||
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
var alreadyDone = 0
|
||||
for (song in songs) {
|
||||
if (isDownloaded(song.id)) {
|
||||
dao.insertCrossRef(GroupTrackCrossRef(groupId, song.id))
|
||||
alreadyDone++
|
||||
} else {
|
||||
dao.insertTrackIfAbsent(
|
||||
DownloadedTrackEntity(
|
||||
songId = song.id,
|
||||
title = song.title,
|
||||
artist = song.artist,
|
||||
artistId = song.artistId,
|
||||
album = song.album,
|
||||
albumId = song.albumId,
|
||||
coverArt = song.coverArt,
|
||||
track = song.track,
|
||||
year = song.year,
|
||||
genre = song.genre,
|
||||
duration = song.duration,
|
||||
suffix = song.suffix,
|
||||
contentType = song.contentType,
|
||||
bitRate = song.bitRate,
|
||||
starred = song.starred,
|
||||
localPath = "",
|
||||
coverPath = null,
|
||||
state = DownloadState.QUEUED,
|
||||
)
|
||||
)
|
||||
dao.insertCrossRef(GroupTrackCrossRef(groupId, song.id))
|
||||
}
|
||||
}
|
||||
dao.updateGroupProgress(groupId, alreadyDone)
|
||||
|
||||
starteWorker(groupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet (oder setzt fort) die Downloadarbeit einer Gruppe. KEEP heisst: laeuft schon etwas
|
||||
* fuer diese Gruppe, passiert nichts — sonst beginnt ein Lauf, der alles Fertige ueberspringt.
|
||||
*/
|
||||
private suspend fun starteWorker(groupId: String, ersetzen: Boolean = false) {
|
||||
// UNMETERED = WLAN (bzw. jede nicht getaktete Verbindung). WorkManager pausiert den Download
|
||||
// dann von selbst, sobald das WLAN weg ist, und setzt fort, sobald es zurueck ist. Mit
|
||||
// CONNECTED liefe die Wiederholkette auch ueber Mobilfunk durch — bei einer grossen Playlist
|
||||
// sind das schnell mehrere Gigabyte ohne Zutun des Nutzers.
|
||||
val nurWlan = runCatching {
|
||||
com.novasonic.app.data.PrefsStore(appContext).appSettings.first().downloadWifiOnly
|
||||
}.getOrDefault(true)
|
||||
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(if (nurWlan) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build()
|
||||
)
|
||||
// Ohne Backoff wuerde ein Result.retry() sofort erneut anlaufen; 30 s linear geben dem
|
||||
// Geraet Luft und halten die Kette trotzdem in Gang.
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
||||
groupId,
|
||||
if (ersetzen) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einer Aenderung an "nur ueber WLAN" die laufende Arbeit neu einreihen. WorkManager
|
||||
* aendert die Bedingungen einer bereits eingereihten Arbeit nicht mehr — ohne das bliebe der
|
||||
* Schalter bis zum naechsten App-Start wirkungslos. REPLACE ist unbedenklich, weil der Worker
|
||||
* ohnehin fortsetzt statt von vorn zu laden.
|
||||
*/
|
||||
fun netzbedingungAktualisieren() {
|
||||
scope.launch {
|
||||
runCatching { dao.incompleteGroups() }.getOrDefault(emptyList())
|
||||
.forEach { starteWorker(it.groupId, ersetzen = true) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Beim App-Start unvollstaendige Gruppen weiterlaufen lassen. WorkManager stoppt einen
|
||||
* Hintergrund-Worker nach rund 10 Minuten; eine Playlist mit 1900 Titeln braucht daher viele
|
||||
* Laeufe. Der Worker fordert selbst einen neuen an (Result.retry()), aber wenn die Kette doch
|
||||
* einmal reisst — Neustart des Geraets, aufgebrauchte Versuche — faengt das hier es auf, statt
|
||||
* darauf zu warten, dass jemand von Hand erneut auf Herunterladen tippt.
|
||||
*/
|
||||
fun setzeUnvollstaendigeFort() {
|
||||
scope.launch {
|
||||
// Erst den gespeicherten Fortschritt begradigen: Gruppen mit einem zu hohen Wert aus
|
||||
// der Zeit vor 1.10 sehen sonst vollstaendig aus und werden hier gar nicht gefunden.
|
||||
runCatching { dao.fortschrittNeuBerechnen() }
|
||||
.onFailure { Log.w("OfflineRepository", "Fortschritt neu berechnen fehlgeschlagen", it) }
|
||||
val offen = runCatching { dao.incompleteGroups() }.getOrDefault(emptyList())
|
||||
offen.forEach { g ->
|
||||
Log.i("OfflineRepository",
|
||||
"Setze fort: ${g.groupId} (${g.downloadedTracks}/${g.totalTracks})")
|
||||
starteWorker(g.groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt alle Dateien eines Titels und dann die Datenbankzeile.
|
||||
*
|
||||
* Frueher lief das ueber _audioMap — die kennt aber nur FERTIGE Titel. Ein abgebrochener oder
|
||||
* fehlgeschlagener Download hinterliess seine Datei also unberuehrt. Jetzt kommt der Pfad aus
|
||||
* der Zeile selbst, dazu die angefangene .tmp und das Cover, sofern es kein anderer Titel mehr
|
||||
* benutzt (Cover werden geteilt gespeichert).
|
||||
*/
|
||||
private suspend fun loescheTitelVollstaendig(songId: String) {
|
||||
val track = dao.getTrackById(songId)
|
||||
val pfade = listOfNotNull(track?.localPath, _audioMap.value[songId])
|
||||
pfade.distinct().forEach { p ->
|
||||
runCatching { File(p).delete(); File("$p.tmp").delete() }
|
||||
}
|
||||
track?.coverArt?.let { coverArtId ->
|
||||
// Diese Zeile zaehlt noch mit, deshalb ist 1 der letzte Benutzer.
|
||||
if (dao.titelMitCover(coverArtId) <= 1) {
|
||||
runCatching { File(coversDir(), "$coverArtId.jpg").delete() }
|
||||
track.coverPath?.let { runCatching { File(it).delete() } }
|
||||
}
|
||||
}
|
||||
dao.deleteTrackById(songId)
|
||||
}
|
||||
|
||||
// ── Delete with reference-count file cleanup ──────────────────────────────
|
||||
|
||||
/** Removes a group and any tracks that are no longer referenced by another group. */
|
||||
suspend fun deleteGroup(groupId: String) {
|
||||
val songIds = dao.songIdsForGroup(groupId)
|
||||
// Laufende Downloadarbeit stoppen, sonst laedt sie noch Minuten weiter ins Leere.
|
||||
WorkManager.getInstance(appContext).cancelUniqueWork(groupId)
|
||||
dao.deleteGroup(groupId) // cascades GroupTrackCrossRef rows
|
||||
for (songId in songIds) {
|
||||
if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a single track from a group, deleting the file if no other group still needs it. */
|
||||
suspend fun removeSongFromGroup(groupId: String, songId: String) {
|
||||
dao.removeCrossRef(groupId, songId)
|
||||
if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
|
||||
recountGroup(groupId)
|
||||
}
|
||||
|
||||
/** Recomputes a group's total + downloaded counts from its cross-refs; deletes it if empty. */
|
||||
private suspend fun recountGroup(groupId: String) {
|
||||
val tracks = dao.tracksForGroup(groupId)
|
||||
if (tracks.isEmpty()) {
|
||||
dao.deleteGroup(groupId)
|
||||
return
|
||||
}
|
||||
dao.updateGroupTotal(groupId, tracks.size)
|
||||
dao.updateGroupProgress(groupId, tracks.count { it.state == DownloadState.DONE })
|
||||
}
|
||||
|
||||
// ── Storage accounting ────────────────────────────────────────────────────
|
||||
|
||||
fun storageUsedBytes(): Long =
|
||||
audioDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } +
|
||||
coversDir().walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.novasonic.app.player
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.api.AbsChapter
|
||||
import com.novasonic.app.api.AbsClient
|
||||
import com.novasonic.app.api.AbsLibraryItem
|
||||
import com.novasonic.app.api.AbsAudioTrack
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
data class AbsPlayState(
|
||||
val isLoading: Boolean = false,
|
||||
val isPlaying: Boolean = false,
|
||||
val item: AbsLibraryItem? = null,
|
||||
val chapters: List<AbsChapter> = emptyList(),
|
||||
val duration: Double = 0.0,
|
||||
val currentTime: Double = 0.0,
|
||||
val chapterIdx: Int = 0,
|
||||
val speed: Float = 1f,
|
||||
val sleepRemainingSecs: Int? = null,
|
||||
val chapterTrackMode: Boolean = false,
|
||||
val sessionId: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class AbsPlayerController(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
var scope = CoroutineScope(
|
||||
Dispatchers.Main + SupervisorJob() +
|
||||
CoroutineExceptionHandler { _, t -> android.util.Log.e("AbsPlayerController", "Uncaught coroutine exception", t) }
|
||||
)
|
||||
private set
|
||||
|
||||
private val _state = MutableStateFlow(AbsPlayState())
|
||||
val state: StateFlow<AbsPlayState> = _state.asStateFlow()
|
||||
|
||||
val isActive: Boolean get() = _state.value.item != null
|
||||
|
||||
private var absClient: AbsClient? = null
|
||||
private var audioTracks: List<AbsAudioTrack> = emptyList()
|
||||
private var currentItemId: String? = null
|
||||
private var isClosed = false
|
||||
|
||||
private var positionObserverJob: Job? = null
|
||||
private var syncJob: Job? = null
|
||||
private var sleepJob: Job? = null
|
||||
private var sleepTickJob: Job? = null
|
||||
|
||||
fun currentCoverUrl(): String? {
|
||||
val item = _state.value.item ?: return null
|
||||
val base = absClient?.coverUrl(item.id) ?: return null
|
||||
return "$base&width=512"
|
||||
}
|
||||
|
||||
fun setChapterTrackMode(enabled: Boolean) {
|
||||
_state.value = _state.value.copy(chapterTrackMode = enabled)
|
||||
}
|
||||
|
||||
suspend fun playItem(item: AbsLibraryItem, client: AbsClient) {
|
||||
if (isClosed) {
|
||||
isClosed = false
|
||||
scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
}
|
||||
absClient = client
|
||||
currentItemId = item.id
|
||||
|
||||
val prevSpeed = _state.value.speed
|
||||
val chTrackMode = _state.value.chapterTrackMode
|
||||
_state.value = AbsPlayState(isLoading = true, item = item, speed = prevSpeed, chapterTrackMode = chTrackMode)
|
||||
|
||||
// Cancel in-progress jobs from a previous session
|
||||
positionObserverJob?.cancel()
|
||||
stopSync()
|
||||
cancelSleepInternal()
|
||||
|
||||
try {
|
||||
val session = client.startSession(item.id)
|
||||
val tracks = session.audioTracks
|
||||
val chapters = session.chapters.ifEmpty { client.getItemChapters(item.id) }
|
||||
val duration = tracks.sumOf { it.duration }.let { if (it > 0.0) it else item.media.duration }
|
||||
|
||||
val saved = client.getProgress(item.id)
|
||||
val startTime = when {
|
||||
(saved?.currentTime ?: 0.0) > 10.0 -> saved!!.currentTime
|
||||
session.currentTime > 10.0 -> session.currentTime
|
||||
else -> 0.0
|
||||
}
|
||||
|
||||
audioTracks = tracks
|
||||
currentItemId = item.id
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = false,
|
||||
sessionId = session.id,
|
||||
chapters = chapters,
|
||||
duration = duration,
|
||||
currentTime = startTime,
|
||||
chapterIdx = findChapterIdx(chapters, startTime),
|
||||
)
|
||||
|
||||
if (tracks.isEmpty()) {
|
||||
_state.value = _state.value.copy(error = "Keine Audiotracks gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
val meta = item.media.metadata
|
||||
val artworkUri = client.coverUrl(item.id)?.let { Uri.parse(it) }
|
||||
|
||||
// Each ABS track gets mediaId "abs:<contentUrl>" so PlayerController can detect
|
||||
// ABS items and skip Navidrome scrobbling / last-song tracking.
|
||||
val mediaItems = tracks.map { t ->
|
||||
MediaItem.Builder()
|
||||
.setMediaId("abs:${t.contentUrl}")
|
||||
.setUri(client.audioTrackUrl(t.contentUrl))
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(meta?.title ?: "Hörbuch")
|
||||
.setArtist(meta?.authorName ?: "")
|
||||
.setArtworkUri(artworkUri)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
val (trackIdx, localSecs) = findTrack(tracks, startTime)
|
||||
// Route audio through MusicService — same notification, same mini player
|
||||
appContext.app.playerController.playAbsItems(
|
||||
items = mediaItems,
|
||||
startIndex = trackIdx,
|
||||
startPositionMs = (localSecs * 1000).toLong(),
|
||||
speed = prevSpeed,
|
||||
)
|
||||
|
||||
startPositionObserver()
|
||||
startSync()
|
||||
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = e.message ?: "Fehler beim Laden")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Position observer — keeps AbsPlayState in sync with MusicService player ──
|
||||
|
||||
private fun startPositionObserver() {
|
||||
positionObserverJob?.cancel()
|
||||
positionObserverJob = scope.launch {
|
||||
appContext.app.playerController.state.collect { playerState ->
|
||||
val s = _state.value
|
||||
if (s.item == null || audioTracks.isEmpty()) return@collect
|
||||
|
||||
val trackIdx = playerState.currentIndex.coerceAtLeast(0)
|
||||
val track = audioTracks.getOrNull(trackIdx) ?: return@collect
|
||||
val localSecs = playerState.positionMs / 1000.0
|
||||
val globalTime = track.startOffset + localSecs
|
||||
val chapterIdx = findChapterIdx(s.chapters, globalTime)
|
||||
|
||||
_state.value = s.copy(
|
||||
isPlaying = playerState.isPlaying,
|
||||
currentTime = globalTime,
|
||||
chapterIdx = chapterIdx,
|
||||
)
|
||||
|
||||
if (playerState.isPlaying) startSync() else { stopSync(); doSync() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback controls — delegate to PlayerController ─────────────────────
|
||||
|
||||
fun togglePlay() = appContext.app.playerController.togglePlay()
|
||||
fun pause() = appContext.app.playerController.pause()
|
||||
fun resume() = appContext.app.playerController.play()
|
||||
|
||||
fun seekToGlobal(globalSecs: Double) {
|
||||
val clamped = globalSecs.coerceIn(0.0, _state.value.duration)
|
||||
val (trackIdx, localSecs) = findTrack(audioTracks, clamped)
|
||||
appContext.app.playerController.seekToAbs(trackIdx, (localSecs * 1000).toLong())
|
||||
_state.value = _state.value.copy(
|
||||
currentTime = clamped,
|
||||
chapterIdx = findChapterIdx(_state.value.chapters, clamped),
|
||||
)
|
||||
}
|
||||
|
||||
fun nextChapter() {
|
||||
val s = _state.value
|
||||
val next = s.chapters.getOrNull(s.chapterIdx + 1) ?: return
|
||||
seekToGlobal(next.start)
|
||||
}
|
||||
|
||||
fun prevChapter() {
|
||||
val s = _state.value
|
||||
val cur = s.chapters.getOrNull(s.chapterIdx)
|
||||
if (cur != null && s.currentTime - cur.start > 5) { seekToGlobal(cur.start); return }
|
||||
val prev = s.chapters.getOrNull(s.chapterIdx - 1) ?: return
|
||||
seekToGlobal(prev.start)
|
||||
}
|
||||
|
||||
fun seekToChapter(idx: Int) {
|
||||
val ch = _state.value.chapters.getOrNull(idx) ?: return
|
||||
seekToGlobal(ch.start)
|
||||
}
|
||||
|
||||
fun skipSecs(secs: Double) = seekToGlobal(_state.value.currentTime + secs)
|
||||
|
||||
fun setSpeed(speed: Float) {
|
||||
_state.value = _state.value.copy(speed = speed)
|
||||
appContext.app.playerController.setSpeed(speed)
|
||||
}
|
||||
|
||||
// ── Sleep timer ───────────────────────────────────────────────────────────
|
||||
|
||||
fun setSleepTimer(minutes: Int) {
|
||||
cancelSleepInternal()
|
||||
if (minutes <= 0) { _state.value = _state.value.copy(sleepRemainingSecs = null); return }
|
||||
val totalSecs = minutes * 60
|
||||
_state.value = _state.value.copy(sleepRemainingSecs = totalSecs)
|
||||
sleepJob = scope.launch {
|
||||
delay(totalSecs * 1000L)
|
||||
appContext.app.playerController.pause()
|
||||
_state.value = _state.value.copy(sleepRemainingSecs = null)
|
||||
}
|
||||
sleepTickJob = scope.launch {
|
||||
var rem = totalSecs
|
||||
while (rem > 0 && isActive) {
|
||||
delay(1000)
|
||||
rem--
|
||||
_state.value = _state.value.copy(sleepRemainingSecs = rem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelSleep() {
|
||||
cancelSleepInternal()
|
||||
_state.value = _state.value.copy(sleepRemainingSecs = null)
|
||||
}
|
||||
|
||||
private fun cancelSleepInternal() {
|
||||
sleepJob?.cancel(); sleepJob = null
|
||||
sleepTickJob?.cancel(); sleepTickJob = null
|
||||
}
|
||||
|
||||
// ── Progress sync to ABS server ───────────────────────────────────────────
|
||||
|
||||
private fun startSync() {
|
||||
if (syncJob?.isActive == true) return
|
||||
syncJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(30_000)
|
||||
doSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSync() { syncJob?.cancel(); syncJob = null }
|
||||
|
||||
private fun doSync() {
|
||||
val itemId = currentItemId ?: return
|
||||
val client = absClient ?: return
|
||||
val s = _state.value
|
||||
scope.launch { runCatching { client.saveProgress(itemId, s.currentTime, s.duration) } }
|
||||
}
|
||||
|
||||
// ── Close ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fun close() {
|
||||
if (isClosed) return
|
||||
isClosed = true
|
||||
|
||||
// Save progress and close ABS session on a detached scope so it survives
|
||||
// cancellation of the main scope below.
|
||||
val sessionId = _state.value.sessionId
|
||||
val client = absClient
|
||||
val itemId = currentItemId
|
||||
val ct = _state.value.currentTime
|
||||
val dur = _state.value.duration
|
||||
if (sessionId != null && client != null && itemId != null) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
runCatching { client.saveProgress(itemId, ct, dur) }
|
||||
runCatching { client.closeSession(sessionId) }
|
||||
}
|
||||
}
|
||||
|
||||
positionObserverJob?.cancel()
|
||||
stopSync()
|
||||
cancelSleepInternal()
|
||||
appContext.app.playerController.stop()
|
||||
_state.value = AbsPlayState()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun findChapterIdx(chapters: List<AbsChapter>, time: Double): Int {
|
||||
if (chapters.isEmpty()) return 0
|
||||
var idx = 0
|
||||
for (i in chapters.indices) { if (chapters[i].start <= time) idx = i }
|
||||
return idx
|
||||
}
|
||||
|
||||
private fun findTrack(tracks: List<AbsAudioTrack>, globalTime: Double): Pair<Int, Double> {
|
||||
if (tracks.isEmpty()) return Pair(0, globalTime)
|
||||
var idx = 0
|
||||
for (i in tracks.indices) { if (tracks[i].startOffset <= globalTime) idx = i }
|
||||
val local = (globalTime - tracks[idx].startOffset).coerceAtLeast(0.0)
|
||||
return Pair(idx, local)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
package com.novasonic.app.player
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.media3.common.*
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.*
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import com.novasonic.app.NovaSonicApp
|
||||
import com.novasonic.app.R
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import com.novasonic.app.ui.MainActivity
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MusicService : MediaLibraryService() {
|
||||
|
||||
companion object {
|
||||
// Android Auto root IDs
|
||||
const val ROOT_ID = "ROOT"
|
||||
const val RECENT_ID = "RECENT"
|
||||
const val PLAYLISTS_ID = "PLAYLISTS"
|
||||
const val FAVORITES_ID = "FAVORITES"
|
||||
const val ITEM_PREFIX_ALBUM = "album:"
|
||||
const val ITEM_PREFIX_PL = "playlist:"
|
||||
// Pseudo-items for play-all / shuffle at top of each list
|
||||
const val PLAY_ALL_PREFIX = "playall:"
|
||||
const val SHUFFLE_PREFIX = "shuffle:"
|
||||
// Encodes a song's browse context so a tap plays the whole list from that song:
|
||||
// "songctx:<contextId>|<songId>" (contextId may itself be "album:…"/"playlist:…").
|
||||
const val SONG_CTX_PREFIX = "songctx:"
|
||||
// ABS session commands (handled via onCustomCommand)
|
||||
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
|
||||
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
|
||||
const val ACTION_ABS_SKIP_FWD = "novasonic.abs.SKIP_FORWARD"
|
||||
const val ACTION_ABS_NEXT_CH = "novasonic.abs.NEXT_CHAPTER"
|
||||
const val ACTION_ABS_TOGGLE = "novasonic.abs.TOGGLE_PLAY"
|
||||
}
|
||||
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var session: MediaLibrarySession
|
||||
private lateinit var repo: MusicRepository
|
||||
private val scope = CoroutineScope(
|
||||
Dispatchers.Main + SupervisorJob() +
|
||||
CoroutineExceptionHandler { _, t -> Log.e("MusicService", "Uncaught coroutine exception", t) }
|
||||
)
|
||||
private var lastAbsChapterIdx = -1
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
repo = MusicRepository(applicationContext)
|
||||
|
||||
val httpFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent("NovaSonic-Android/1.0")
|
||||
.setConnectTimeoutMs(15_000)
|
||||
.setReadTimeoutMs(15_000)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
|
||||
val dataFactory = DefaultDataSource.Factory(this, httpFactory)
|
||||
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val bitmapLoader = CacheBitmapLoader(
|
||||
DataSourceBitmapLoader(MoreExecutors.listeningDecorator(executor), dataFactory)
|
||||
)
|
||||
|
||||
val audioAttr = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataFactory))
|
||||
.setAudioAttributes(audioAttr, true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.build()
|
||||
|
||||
val wrappedPlayer = AbsAwareForwardingPlayer(player)
|
||||
|
||||
val activityIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
session = MediaLibrarySession.Builder(this, wrappedPlayer, LibraryCallback())
|
||||
.setBitmapLoader(bitmapLoader)
|
||||
.setSessionActivity(activityIntent)
|
||||
.build()
|
||||
|
||||
scope.launch {
|
||||
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
|
||||
}
|
||||
scope.launch {
|
||||
(applicationContext as NovaSonicApp).absPlayerController.state.collect { s ->
|
||||
session.setCustomLayout(if (s.item != null) buildAbsCustomLayout() else ImmutableList.of())
|
||||
|
||||
val newIdx = if (s.item != null) s.chapterIdx else -1
|
||||
if (newIdx != lastAbsChapterIdx) {
|
||||
lastAbsChapterIdx = newIdx
|
||||
if (s.item != null) {
|
||||
val chapter = s.chapters.getOrNull(s.chapterIdx)
|
||||
val bookTitle = s.item.media.metadata?.title ?: "Hörbuch"
|
||||
val idx = player.currentMediaItemIndex.takeIf { it >= 0 } ?: return@collect
|
||||
val cur = player.getMediaItemAt(idx)
|
||||
player.replaceMediaItem(idx, cur.buildUpon()
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(chapter?.title?.takeIf { it.isNotBlank() } ?: bookTitle)
|
||||
.setArtist(if (!chapter?.title.isNullOrBlank()) bookTitle else cur.mediaMetadata.artist)
|
||||
.setArtworkUri(cur.mediaMetadata.artworkUri)
|
||||
.build()
|
||||
).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setMediaNotificationProvider(AbsAwareDnmp())
|
||||
}
|
||||
|
||||
private fun buildAbsButtons(isPlaying: Boolean): ImmutableList<CommandButton> {
|
||||
val ppIcon = if (isPlaying) R.drawable.ic_abs_pause else R.drawable.ic_abs_play
|
||||
val ppLabel = if (isPlaying) "Pause" else "Abspielen"
|
||||
return ImmutableList.of(
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_skip_prev)
|
||||
.setDisplayName("Kapitel zurück")
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_rewind)
|
||||
.setDisplayName("30s zurück")
|
||||
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 0) })
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_TOGGLE, Bundle.EMPTY))
|
||||
.setIconResId(ppIcon)
|
||||
.setDisplayName(ppLabel)
|
||||
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 1) })
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_forward)
|
||||
.setDisplayName("30s vor")
|
||||
.setExtras(Bundle().apply { putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 2) })
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_skip_next)
|
||||
.setDisplayName("Kapitel vor")
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
// 4-button layout for the Samsung media widget — play/pause is the standard player control.
|
||||
// prevOrCustom → Kapitel zurück, custom0 → 30s zurück, custom1 → 30s vor, nextOrCustom → Kapitel vor.
|
||||
// Samsung One UI slot mapping (empirically determined):
|
||||
// prevOrCustom → custom[2], custom0 → custom[0], custom1 → custom[1], nextOrCustom → custom[3]
|
||||
// Desired display: Kapitel zurück | 30s zurück | play | 30s vor | Kapitel vor
|
||||
// Required layout order: [30s zurück, 30s vor, Kapitel zurück, Kapitel vor]
|
||||
private fun buildAbsCustomLayout(): ImmutableList<CommandButton> = ImmutableList.of(
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_rewind)
|
||||
.setDisplayName("30s zurück")
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_forward)
|
||||
.setDisplayName("30s vor")
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_skip_prev)
|
||||
.setDisplayName("Kapitel zurück")
|
||||
.build(),
|
||||
CommandButton.Builder()
|
||||
.setSessionCommand(SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY))
|
||||
.setIconResId(R.drawable.ic_abs_skip_next)
|
||||
.setDisplayName("Kapitel vor")
|
||||
.build(),
|
||||
)
|
||||
|
||||
// Removes SEEK_TO_PREV/NEXT from available commands when ABS is active so Samsung One UI
|
||||
// replaces its standard prev/next slots with our first/last custom layout buttons.
|
||||
private inner class AbsAwareForwardingPlayer(player: ExoPlayer) : ForwardingPlayer(player) {
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
val base = super.getAvailableCommands()
|
||||
val isAbs = currentMediaItem?.mediaId?.startsWith("abs:") == true ||
|
||||
(applicationContext as NovaSonicApp).absPlayerController.state.value.item != null
|
||||
return if (isAbs) {
|
||||
base.buildUpon()
|
||||
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.remove(Player.COMMAND_SEEK_TO_NEXT)
|
||||
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
.build()
|
||||
} else base
|
||||
}
|
||||
}
|
||||
|
||||
// Subclasses DNMP so we stay inside the class hierarchy Media3 expects.
|
||||
// createNotification() stores the session and always passes ImmutableList.of() to super
|
||||
// so DNMP never appends setCustomLayout() buttons after getMediaButtons().
|
||||
// getMediaButtons() detects ABS via currentMediaItem.mediaId — reliable, no state race.
|
||||
// getMediaButtons() receives the full MediaSession — detect ABS via currentMediaItem.mediaId.
|
||||
// No createNotification() override needed. setCustomLayout() is always called with empty list
|
||||
// (in the onCreate observer) so DNMP appends zero extra buttons after getMediaButtons().
|
||||
private inner class AbsAwareDnmp : DefaultMediaNotificationProvider(this@MusicService) {
|
||||
override fun getMediaButtons(
|
||||
mediaSession: MediaSession,
|
||||
playerCommands: Player.Commands,
|
||||
customLayout: ImmutableList<CommandButton>,
|
||||
showPauseButton: Boolean,
|
||||
): ImmutableList<CommandButton> {
|
||||
val mediaId = mediaSession.player.currentMediaItem?.mediaId
|
||||
val absItem = (applicationContext as NovaSonicApp).absPlayerController.state.value.item
|
||||
android.util.Log.e("ABS_NOTIF", "getMediaButtons: mediaId=$mediaId absItem=${absItem?.id} customLayout.size=${customLayout.size}")
|
||||
val isAbs = mediaId?.startsWith("abs:") == true || absItem != null
|
||||
if (!isAbs) return super.getMediaButtons(mediaSession, playerCommands, ImmutableList.of(), showPauseButton)
|
||||
return buildAbsButtons(mediaSession.player.isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetSession(info: MediaSession.ControllerInfo) = session
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
// Wischt jemand die App aus den letzten Apps, wird der Prozess gleich beendet — hier ist die
|
||||
// letzte Gelegenheit, die Warteschlange festzuhalten. Ohne das ginge sie genau in dem Fall
|
||||
// verloren, in dem man sie beim naechsten Oeffnen am ehesten erwartet.
|
||||
runCatching { (application as? com.novasonic.app.NovaSonicApp)?.playerController?.saveQueueNow() }
|
||||
player.stop()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
session.release()
|
||||
player.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ── Android Auto / MediaBrowser library callbacks ─────────────────────────
|
||||
|
||||
private inner class LibraryCallback : MediaLibrarySession.Callback {
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
): MediaSession.ConnectionResult {
|
||||
val absCommands = listOf(
|
||||
SessionCommand(ACTION_ABS_PREV_CH, Bundle.EMPTY),
|
||||
SessionCommand(ACTION_ABS_SKIP_BACK, Bundle.EMPTY),
|
||||
SessionCommand(ACTION_ABS_SKIP_FWD, Bundle.EMPTY),
|
||||
SessionCommand(ACTION_ABS_NEXT_CH, Bundle.EMPTY),
|
||||
SessionCommand(ACTION_ABS_TOGGLE, Bundle.EMPTY),
|
||||
)
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(
|
||||
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
|
||||
.addSessionCommands(absCommands).build()
|
||||
).build()
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle,
|
||||
): ListenableFuture<SessionResult> {
|
||||
val abs = (applicationContext as NovaSonicApp).absPlayerController
|
||||
when (customCommand.customAction) {
|
||||
ACTION_ABS_PREV_CH -> abs.prevChapter()
|
||||
ACTION_ABS_SKIP_BACK -> abs.skipSecs(-30.0)
|
||||
ACTION_ABS_SKIP_FWD -> abs.skipSecs(30.0)
|
||||
ACTION_ABS_NEXT_CH -> abs.nextChapter()
|
||||
ACTION_ABS_TOGGLE -> abs.togglePlay()
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
override fun onGetLibraryRoot(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
params: LibraryParams?,
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val root = MediaItem.Builder()
|
||||
.setMediaId(ROOT_ID)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle("NovaSonic")
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
|
||||
.build()
|
||||
).build()
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(root, null))
|
||||
}
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: LibraryParams?,
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
val future = scope.async { buildChildren(parentId) }
|
||||
val settable = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
|
||||
scope.launch {
|
||||
try {
|
||||
settable.set(LibraryResult.ofItemList(future.await(), null))
|
||||
} catch (e: Exception) {
|
||||
settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
mediaId: String,
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val settable = SettableFuture.create<LibraryResult<MediaItem>>()
|
||||
scope.launch {
|
||||
try {
|
||||
val item = when {
|
||||
mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(mediaId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
album.toMediaItem(repo)
|
||||
}
|
||||
mediaId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(mediaId.removePrefix(ITEM_PREFIX_PL))
|
||||
val artUri = pl.coverArt?.let { repo.coverArtUrl(it, 300)?.let { url -> android.net.Uri.parse(url) } }
|
||||
MediaItem.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(pl.name)
|
||||
.setArtworkUri(artUri)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
mediaId.startsWith(SONG_CTX_PREFIX) -> {
|
||||
val songId = mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')
|
||||
repo.getSong(songId).toMediaItem(repo).buildUpon().setMediaId(mediaId).build()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
||||
else settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
|
||||
} catch (e: Exception) {
|
||||
settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
// Android Auto "add to queue" path — resolve browsable items to their songs.
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
): ListenableFuture<MutableList<MediaItem>> {
|
||||
val settable = SettableFuture.create<MutableList<MediaItem>>()
|
||||
scope.launch {
|
||||
try { settable.set(expandForPlayback(mediaItems).first.toMutableList()) }
|
||||
catch (e: Exception) { settable.set(mediaItems) }
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
// Android Auto "tap to play" path — return the resolved list AND the index to start at, so
|
||||
// tapping a song plays its whole list from that song (not just the one song).
|
||||
override fun onSetMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
startIndex: Int,
|
||||
startPositionMs: Long,
|
||||
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
||||
val settable = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
|
||||
scope.launch {
|
||||
try {
|
||||
val (list, idx) = expandForPlayback(mediaItems)
|
||||
// A single browse tap expands into a full list → start at the tapped index.
|
||||
// Otherwise honour the caller's startIndex.
|
||||
val expanded = list.size != mediaItems.size
|
||||
val start = if (expanded) idx else startIndex.coerceIn(0, (list.size - 1).coerceAtLeast(0))
|
||||
settable.set(MediaSession.MediaItemsWithStartPosition(list, start, startPositionMs))
|
||||
} catch (e: Exception) {
|
||||
settable.set(MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
// Resolve browse items (play-all / shuffle / album / playlist / context-song / plain song)
|
||||
// into a flat, playable song list plus the index to start at (the tapped song, else 0).
|
||||
private suspend fun expandForPlayback(items: List<MediaItem>): Pair<List<MediaItem>, Int> {
|
||||
if (items.size == 1 && items[0].mediaId.startsWith(SONG_CTX_PREFIX)) {
|
||||
val rest = items[0].mediaId.removePrefix(SONG_CTX_PREFIX)
|
||||
val ctx = rest.substringBefore('|')
|
||||
val songId = rest.substringAfter('|')
|
||||
val full = resolveSongs(ctx, shuffle = false)
|
||||
if (full.isNotEmpty()) {
|
||||
val idx = full.indexOfFirst { it.mediaId == songId }.coerceAtLeast(0)
|
||||
return full to idx
|
||||
}
|
||||
return listOf(reattachUri(items[0], songId)) to 0
|
||||
}
|
||||
val resolved = items.flatMap { item ->
|
||||
when {
|
||||
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), false)
|
||||
item.mediaId.startsWith(SHUFFLE_PREFIX) -> resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), true)
|
||||
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM)).song.map { it.toMediaItem(repo) }
|
||||
item.mediaId.startsWith(ITEM_PREFIX_PL) -> repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL)).entry.map { it.toMediaItem(repo) }
|
||||
item.mediaId.startsWith(SONG_CTX_PREFIX) -> listOf(reattachUri(item, item.mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')))
|
||||
else -> listOf(reattachUri(item))
|
||||
}
|
||||
}
|
||||
return resolved to 0
|
||||
}
|
||||
|
||||
// Android Auto strips the URI when a browse item crosses the process boundary — re-resolve it.
|
||||
private fun reattachUri(item: MediaItem, songId: String = item.mediaId): MediaItem {
|
||||
val uri = repo.streamUrl(songId)?.let { android.net.Uri.parse(it) }
|
||||
return item.buildUpon().setMediaId(songId).setUri(uri).build()
|
||||
}
|
||||
|
||||
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
|
||||
val songs: List<com.novasonic.app.api.model.Song> = when {
|
||||
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
|
||||
repo.getAlbum(contextId.removePrefix(ITEM_PREFIX_ALBUM)).song
|
||||
contextId.startsWith(ITEM_PREFIX_PL) ->
|
||||
repo.getPlaylist(contextId.removePrefix(ITEM_PREFIX_PL)).entry
|
||||
contextId == FAVORITES_ID ->
|
||||
repo.getStarred().song
|
||||
contextId == RECENT_ID -> {
|
||||
repo.getRecentlyPlayedAlbums(20).flatMap { album ->
|
||||
runCatching { repo.getAlbum(album.id).song }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
val ordered = if (shuffle) songs.shuffled() else songs
|
||||
return ordered.map { it.toMediaItem(repo) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build Android Auto browsable tree ─────────────────────────────────────
|
||||
|
||||
private suspend fun buildChildren(parentId: String): ImmutableList<MediaItem> {
|
||||
val items = when (parentId) {
|
||||
ROOT_ID -> listOf(
|
||||
browseFolder(RECENT_ID, "Zuletzt gespielt", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
||||
browseFolder(FAVORITES_ID, "Favoriten", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED),
|
||||
browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
|
||||
)
|
||||
RECENT_ID -> {
|
||||
val albums = repo.getRecentlyPlayedAlbums(20)
|
||||
listOf(playAllItem(RECENT_ID), shuffleItem(RECENT_ID)) + albums.map { it.toMediaItem(repo) }
|
||||
}
|
||||
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
|
||||
FAVORITES_ID -> {
|
||||
val songs = repo.getStarred().song
|
||||
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { browseSong(it, FAVORITES_ID) }
|
||||
}
|
||||
else -> when {
|
||||
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { browseSong(it, parentId) }
|
||||
}
|
||||
parentId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { browseSong(it, parentId) }
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
return ImmutableList.copyOf(items)
|
||||
}
|
||||
|
||||
// A song shown in a browse list, with its context encoded in the mediaId so a tap can play the
|
||||
// whole list from that song (Android Auto strips the URI, so it's re-resolved on playback).
|
||||
private fun browseSong(song: com.novasonic.app.api.model.Song, contextId: String): MediaItem =
|
||||
song.toMediaItem(repo).buildUpon()
|
||||
.setMediaId("$SONG_CTX_PREFIX$contextId|${song.id}")
|
||||
.build()
|
||||
|
||||
private fun browseFolder(id: String, title: String, mediaType: Int) =
|
||||
MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(mediaType)
|
||||
.build()
|
||||
).build()
|
||||
|
||||
private fun playAllItem(contextId: String) = MediaItem.Builder()
|
||||
.setMediaId("$PLAY_ALL_PREFIX$contextId")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle("▶ Alle abspielen")
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(false)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
.build()
|
||||
).build()
|
||||
|
||||
private fun shuffleItem(contextId: String) = MediaItem.Builder()
|
||||
.setMediaId("$SHUFFLE_PREFIX$contextId")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle("⇀ Zufällig abspielen")
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(false)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
.build()
|
||||
).build()
|
||||
|
||||
private fun isAndroidAuto(info: MediaSession.ControllerInfo): Boolean =
|
||||
info.packageName.contains("android.auto", ignoreCase = true) ||
|
||||
info.packageName.contains("goog.android.projected", ignoreCase = true)
|
||||
}
|
||||
|
||||
// ── Extension: model → MediaItem ─────────────────────────────────────────────
|
||||
|
||||
fun com.novasonic.app.api.model.Song.toMediaItem(repo: MusicRepository): MediaItem {
|
||||
val artUri = coverArt?.let { repo.coverArtUrl(it, 512)?.let { url ->
|
||||
android.net.Uri.parse(url) } }
|
||||
val streamUri = repo.streamUrl(id)?.let { android.net.Uri.parse(it) }
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setUri(streamUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setAlbumTitle(album)
|
||||
.setArtworkUri(artUri)
|
||||
.setTrackNumber(track)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(false)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
|
||||
fun com.novasonic.app.api.model.Album.toMediaItem(repo: MusicRepository): MediaItem {
|
||||
val artUri = coverArt?.let { repo.coverArtUrl(it, 300)?.let { url ->
|
||||
android.net.Uri.parse(url) } }
|
||||
return MediaItem.Builder()
|
||||
.setMediaId("${MusicService.ITEM_PREFIX_ALBUM}$id")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name)
|
||||
.setArtist(artist)
|
||||
.setArtworkUri(artUri)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
|
||||
|
||||
fun com.novasonic.app.api.model.Playlist.toMediaItem(repo: MusicRepository): MediaItem {
|
||||
val artUri = coverArt?.let { repo.coverArtUrl(it, 300)?.let { url -> android.net.Uri.parse(url) } }
|
||||
return MediaItem.Builder()
|
||||
.setMediaId("${MusicService.ITEM_PREFIX_PL}$id")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name)
|
||||
.setArtworkUri(artUri)
|
||||
.setIsPlayable(false) // browse-only: Auto opens the song list instead of playing immediately
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package com.novasonic.app.player
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.novasonic.app.NovaSonicApp
|
||||
import com.novasonic.app.api.LrcLibClient
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class PlayerState(
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val currentSong: Song? = null,
|
||||
val currentIndex: Int = -1,
|
||||
val positionMs: Long = 0L,
|
||||
val durationMs: Long = 0L,
|
||||
val queue: List<Song> = emptyList(),
|
||||
val repeatMode: Int = Player.REPEAT_MODE_OFF,
|
||||
val shuffled: Boolean = false,
|
||||
val volume: Float = 1f,
|
||||
val artworkUri: String? = null,
|
||||
val radioMode: Boolean = false,
|
||||
)
|
||||
|
||||
class PlayerController(private val context: Context, private val repo: MusicRepository) {
|
||||
|
||||
private val _state = MutableStateFlow(PlayerState())
|
||||
val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||
|
||||
private val TAG_QUEUE = "NsQueue"
|
||||
|
||||
private val scope = CoroutineScope(
|
||||
Dispatchers.Main + SupervisorJob() +
|
||||
CoroutineExceptionHandler { _, t -> android.util.Log.e("PlayerController", "Uncaught coroutine exception", t) }
|
||||
)
|
||||
private var controller: MediaController? = null
|
||||
private var queue: List<Song> = emptyList()
|
||||
private var originalQueue: List<Song> = emptyList()
|
||||
private var isManuallyShuffled = false
|
||||
private var tickerJob: Job? = null
|
||||
private var scrobbleJob: Job? = null
|
||||
|
||||
fun connect() {
|
||||
if (controller != null) return
|
||||
val token = SessionToken(context, ComponentName(context, MusicService::class.java))
|
||||
MediaController.Builder(context, token).buildAsync().also { future ->
|
||||
future.addListener({
|
||||
controller = runCatching { future.get() }.getOrNull()
|
||||
controller?.addListener(PlayerListener())
|
||||
syncState()
|
||||
restoreQueue()
|
||||
}, context.mainExecutor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haelt Warteschlange und Listenposition fest — ohne die Sekunde im Titel: der zuletzt gehoerte
|
||||
* Titel beginnt von vorn. ABS-Hoerbuecher bleiben aussen vor, die haben ihren eigenen
|
||||
* Fortschritt auf dem Server.
|
||||
*/
|
||||
private fun persistQueueState() {
|
||||
val ctrl = controller ?: return
|
||||
val songs = queue
|
||||
if (songs.isEmpty() || songs.any { it.id.startsWith("abs:") }) return
|
||||
val index = ctrl.currentMediaItemIndex
|
||||
scope.launch {
|
||||
runCatching { repo.saveQueueState(songs, index) }
|
||||
.onSuccess { android.util.Log.i(TAG_QUEUE, "gesichert: ${songs.size} Titel, Index $index") }
|
||||
.onFailure { android.util.Log.w(TAG_QUEUE, "Sichern fehlgeschlagen", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Von aussen aufrufbar (Dienst-Ende beim Wegwischen der App). */
|
||||
fun saveQueueNow() = persistQueueState()
|
||||
|
||||
/**
|
||||
* Stellt die zuletzt gehoerte Warteschlange wieder her — vorbereitet, aber NICHT abgespielt.
|
||||
* Wer die App oeffnet, will nicht ueberfallen werden; ein Druck auf Play macht dort weiter.
|
||||
* Laeuft schon etwas (etwa ueber Android Auto gestartet), passiert nichts.
|
||||
*
|
||||
* Die Aufloesung der Song-Ids braucht die Navidrome-Zugangsdaten. connect() laeuft aber schon in
|
||||
* Application.onCreate(), wo der DataStore unter Umstaenden noch nicht gelesen ist — dann
|
||||
* scheitern alle Abfragen und die Warteschlange bliebe still leer. Deshalb wird es mehrfach mit
|
||||
* wachsendem Abstand versucht, statt beim ersten Fehlschlag aufzugeben.
|
||||
*/
|
||||
fun restoreQueue() {
|
||||
scope.launch {
|
||||
val zustand = runCatching { repo.readQueueState() }.getOrNull()
|
||||
if (zustand == null) {
|
||||
android.util.Log.i(TAG_QUEUE, "nichts gesichert — nichts wiederherzustellen")
|
||||
return@launch
|
||||
}
|
||||
val ctrl = controller
|
||||
if (ctrl == null) {
|
||||
android.util.Log.w(TAG_QUEUE, "kein Controller — Wiederherstellen verworfen")
|
||||
return@launch
|
||||
}
|
||||
if (ctrl.mediaItemCount > 0) {
|
||||
android.util.Log.i(TAG_QUEUE, "Player hat bereits Titel — nicht angefasst")
|
||||
return@launch
|
||||
}
|
||||
val songs = zustand.songs
|
||||
val index = zustand.index.coerceIn(0, songs.lastIndex)
|
||||
queue = songs
|
||||
originalQueue = songs
|
||||
runCatching {
|
||||
ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, 0L)
|
||||
ctrl.prepare()
|
||||
}.onFailure { android.util.Log.w(TAG_QUEUE, "setMediaItems fehlgeschlagen", it) }
|
||||
syncState()
|
||||
android.util.Log.i(TAG_QUEUE, "wiederhergestellt: ${songs.size} Titel, Index $index")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback commands ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Startet eine Liste. Ein `startIndex` KLEINER 0 bedeutet "gemischt starten".
|
||||
*
|
||||
* Warum das Mischen hier passiert und nicht beim Aufrufer: vorher mischte jeder
|
||||
* Shuffle-Knopf selbst (`liste.shuffled()`) und uebergab die fertige Reihenfolge. Damit war
|
||||
* `originalQueue` schon die gemischte Liste, `isManuallyShuffled` blieb false — das
|
||||
* Shuffle-Symbol leuchtete nicht, obwohl gemischt gespielt wurde, und ein Druck auf
|
||||
* "Shuffle aus" stellte die Albumreihenfolge nie wieder her, weil es keine mehr gab.
|
||||
*/
|
||||
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
|
||||
// If ABS is active, close it — user explicitly switched to music
|
||||
(context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs ->
|
||||
if (abs.isActive) abs.close()
|
||||
}
|
||||
val ctrl = controller ?: return
|
||||
val gemischt = startIndex < 0
|
||||
val reihenfolge = if (gemischt) songs.shuffled() else songs
|
||||
queue = reihenfolge
|
||||
originalQueue = songs // immer die UNGEMISCHTE Liste — Rueckweg fuer "Shuffle aus"
|
||||
isManuallyShuffled = gemischt
|
||||
ctrl.shuffleModeEnabled = false
|
||||
val items = reihenfolge.map { it.toMediaItem(repo) }
|
||||
ctrl.setMediaItems(items, if (gemischt) 0 else startIndex.coerceIn(0, maxOf(0, songs.lastIndex)), 0L)
|
||||
ctrl.prepare()
|
||||
ctrl.play()
|
||||
syncState()
|
||||
}
|
||||
|
||||
/** Called by AbsPlayerController to route ABS audio through this service. */
|
||||
fun playAbsItems(
|
||||
items: List<MediaItem>,
|
||||
startIndex: Int,
|
||||
startPositionMs: Long,
|
||||
speed: Float = 1f,
|
||||
) {
|
||||
val ctrl = controller ?: return
|
||||
queue = emptyList()
|
||||
originalQueue = emptyList()
|
||||
isManuallyShuffled = false
|
||||
ctrl.setMediaItems(items, startIndex, startPositionMs)
|
||||
ctrl.prepare()
|
||||
ctrl.setPlaybackSpeed(speed)
|
||||
ctrl.play()
|
||||
syncState()
|
||||
}
|
||||
|
||||
/** Stop playback and clear the queue (used when ABS session ends). */
|
||||
fun stop() {
|
||||
val ctrl = controller ?: return
|
||||
ctrl.setPlaybackSpeed(1f)
|
||||
ctrl.pause()
|
||||
ctrl.clearMediaItems()
|
||||
queue = emptyList()
|
||||
originalQueue = emptyList()
|
||||
syncState()
|
||||
}
|
||||
|
||||
fun play() = controller?.play()
|
||||
fun pause() = controller?.pause()
|
||||
fun togglePlay() = controller?.let { if (it.isPlaying) it.pause() else it.play() }
|
||||
fun seekTo(ms: Long) = controller?.seekTo(ms)
|
||||
fun skipNext() = controller?.seekToNextMediaItem()
|
||||
fun skipPrev() = controller?.seekToPreviousMediaItem()
|
||||
fun seekToItem(index: Int) = controller?.seekToDefaultPosition(index)
|
||||
|
||||
/** Seek to a specific track index and position within that track (for ABS chapter navigation). */
|
||||
fun seekToAbs(windowIndex: Int, positionMs: Long) = controller?.seekTo(windowIndex, positionMs)
|
||||
|
||||
fun setSpeed(speed: Float) = controller?.setPlaybackSpeed(speed)
|
||||
|
||||
fun setVolume(v: Float) {
|
||||
val clamped = v.coerceIn(0f, 1f)
|
||||
controller?.volume = clamped
|
||||
_state.update { it.copy(volume = clamped) }
|
||||
}
|
||||
|
||||
fun insertNext(song: Song) {
|
||||
val ctrl = controller ?: return
|
||||
val idx = (ctrl.currentMediaItemIndex + 1).coerceAtMost(queue.size)
|
||||
queue = queue.toMutableList().apply { add(idx, song) }
|
||||
originalQueue = originalQueue + song
|
||||
ctrl.addMediaItem(idx, song.toMediaItem(repo))
|
||||
}
|
||||
|
||||
fun addToQueueEnd(song: Song) {
|
||||
val ctrl = controller ?: return
|
||||
queue = queue + song
|
||||
originalQueue = originalQueue + song
|
||||
ctrl.addMediaItem(song.toMediaItem(repo))
|
||||
}
|
||||
|
||||
// ── Warteschlange bearbeiten ────────────────────────────────────────────
|
||||
// queue (lokal) und die MediaItems des Controllers muessen im Gleichschritt bleiben:
|
||||
// syncState() liest den laufenden Index vom Controller, den Titel dazu aber aus queue.
|
||||
// Liefen die beiden auseinander, zeigte die Oberflaeche den falschen Song an.
|
||||
|
||||
/** Entfernt Position [index]. Traf es den laufenden Titel, rueckt Media3 selbst weiter. */
|
||||
fun entferneAusQueue(index: Int) {
|
||||
val ctrl = controller ?: return
|
||||
if (index !in queue.indices) return
|
||||
val raus = queue[index]
|
||||
queue = queue.toMutableList().apply { removeAt(index) }
|
||||
// originalQueue haelt die Reihenfolge fuer das Zuruecknehmen des Mischens — dort denselben
|
||||
// Titel EINMAL entfernen, sonst taucht er beim Entmischen wieder auf.
|
||||
originalQueue = originalQueue.toMutableList().apply {
|
||||
val i = indexOfFirst { it.id == raus.id }
|
||||
if (i >= 0) removeAt(i)
|
||||
}
|
||||
ctrl.removeMediaItem(index)
|
||||
if (queue.isEmpty()) {
|
||||
ctrl.stop()
|
||||
ctrl.clearMediaItems()
|
||||
}
|
||||
syncState()
|
||||
}
|
||||
|
||||
/** Verschiebt Position [von] auf [nach]. */
|
||||
fun verschiebeInQueue(von: Int, nach: Int) {
|
||||
val ctrl = controller ?: return
|
||||
if (von == nach || von !in queue.indices || nach !in queue.indices) return
|
||||
queue = queue.toMutableList().apply { add(nach, removeAt(von)) }
|
||||
ctrl.moveMediaItem(von, nach)
|
||||
syncState()
|
||||
}
|
||||
|
||||
fun toggleRepeat() {
|
||||
val ctrl = controller ?: return
|
||||
ctrl.repeatMode = when (ctrl.repeatMode) {
|
||||
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
|
||||
Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleShuffle() {
|
||||
val ctrl = controller ?: return
|
||||
val currentIdx = ctrl.currentMediaItemIndex
|
||||
|
||||
// Der Rest hinter dem laufenden Titel wird in EINEM Zug ersetzt. Vorher lief das als
|
||||
// Schleife aus removeMediaItem + addMediaItem, und dazwischen hatte der Controller
|
||||
// voruebergehend weniger Titel als unsere Liste. Genau darauf reagiert onTimelineChanged
|
||||
// mit "die Titel kamen von aussen" — es baute die Warteschlange neu auf und setzte dabei
|
||||
// isManuallyShuffled ZURUECK. Das Mischen lief, aber das Symbol leuchtete nie.
|
||||
val neuerRest = if (!isManuallyShuffled) {
|
||||
isManuallyShuffled = true
|
||||
queue.drop(currentIdx + 1).shuffled()
|
||||
} else {
|
||||
isManuallyShuffled = false
|
||||
val currentSong = queue.getOrNull(currentIdx)
|
||||
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
|
||||
originalQueue.drop(origIdx + 1)
|
||||
}
|
||||
queue = queue.take(currentIdx + 1) + neuerRest
|
||||
ctrl.replaceMediaItems(currentIdx + 1, ctrl.mediaItemCount, neuerRest.map { it.toMediaItem(repo) })
|
||||
syncState()
|
||||
}
|
||||
|
||||
fun addToQueue(song: Song) = addToQueueEnd(song)
|
||||
|
||||
// ── Radio mode ────────────────────────────────────────────────────────────
|
||||
|
||||
private var _radioMode = false
|
||||
|
||||
fun toggleRadio() {
|
||||
_radioMode = !_radioMode
|
||||
_state.update { it.copy(radioMode = _radioMode) }
|
||||
if (_radioMode) scope.launch { fillRadioQueue() }
|
||||
}
|
||||
|
||||
private fun checkRadioRefill() {
|
||||
if (!_radioMode) return
|
||||
val remaining = queue.size - (_state.value.currentIndex + 1)
|
||||
if (remaining < 5) scope.launch { fillRadioQueue() }
|
||||
}
|
||||
|
||||
private suspend fun fillRadioQueue() {
|
||||
val currentSong = _state.value.currentSong ?: return
|
||||
val recentIds = queue.takeLast(50).map { it.id }.toHashSet()
|
||||
|
||||
var candidates = runCatching {
|
||||
repo.getSimilarSongs(currentSong.id, 20)
|
||||
}.getOrDefault(emptyList()).filter { it.id !in recentIds }
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
candidates = runCatching {
|
||||
repo.getRandomSongs()
|
||||
}.getOrDefault(emptyList()).filter { it.id !in recentIds }
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
// Letzte Rueckfallebene: Wiederholungen sind erlaubt, damit Musik weiterlaeuft — aber
|
||||
// nicht der gerade laufende Titel. Der haengte sich sonst direkt hinter sich selbst.
|
||||
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
|
||||
.filter { it.id != currentSong.id }
|
||||
}
|
||||
|
||||
val ctrl = controller ?: return
|
||||
candidates.forEach { song ->
|
||||
queue = queue + song
|
||||
originalQueue = originalQueue + song
|
||||
ctrl.addMediaItem(song.toMediaItem(repo))
|
||||
}
|
||||
syncState()
|
||||
}
|
||||
|
||||
// ── Ticker ────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun startTicker() {
|
||||
tickerJob?.cancel()
|
||||
tickerJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(500)
|
||||
val ctrl = controller ?: break
|
||||
// Refresh durationMs too — after a SEAMLESS track transition (skipping through
|
||||
// already-buffered tracks) the player stays READY, so onPlaybackStateChanged never
|
||||
// fires and syncState() isn't re-called; ctrl.duration was TIME_UNSET(→0) at the
|
||||
// transition, leaving durationMs stuck at 0. Seek then computes fraction*0 = 0 and
|
||||
// snaps to the start every time. Re-reading here lets it self-heal within 500ms.
|
||||
_state.update { it.copy(positionMs = ctrl.currentPosition, durationMs = resolveDurationMs(ctrl)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Player duration, falling back to the current song's known duration while ctrl.duration is
|
||||
* still TIME_UNSET (right after a media transition). Prevents a 0-duration seek → snap-to-0. */
|
||||
private fun resolveDurationMs(ctrl: MediaController): Long {
|
||||
val d = ctrl.duration
|
||||
if (d > 0) return d
|
||||
val songDurSecs = queue.getOrNull(ctrl.currentMediaItemIndex)?.duration?.toLong() ?: 0L
|
||||
return songDurSecs * 1000L
|
||||
}
|
||||
|
||||
private fun stopTicker() {
|
||||
tickerJob?.cancel()
|
||||
tickerJob = null
|
||||
}
|
||||
|
||||
// ── Scrobbling ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun scheduleScrobble(song: Song, durationMs: Long) {
|
||||
scrobbleJob?.cancel()
|
||||
scrobbleJob = scope.launch {
|
||||
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
|
||||
delay(delayMs)
|
||||
runCatching { repo.scrobble(song.id) }
|
||||
runCatching { repo.logPlay(song) } // our own play-log, same counted moment
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
private fun songFromMediaItem(item: MediaItem): Song {
|
||||
val meta = item.mediaMetadata
|
||||
return Song(
|
||||
id = item.mediaId,
|
||||
title = meta.title?.toString() ?: "",
|
||||
artist = meta.artist?.toString(),
|
||||
album = meta.albumTitle?.toString(),
|
||||
track = meta.trackNumber,
|
||||
)
|
||||
}
|
||||
|
||||
private fun syncState() {
|
||||
val ctrl = controller ?: return
|
||||
val idx = ctrl.currentMediaItemIndex
|
||||
_state.value = PlayerState(
|
||||
isPlaying = ctrl.isPlaying,
|
||||
isBuffering = ctrl.playbackState == Player.STATE_BUFFERING,
|
||||
currentSong = queue.getOrNull(idx),
|
||||
currentIndex = idx,
|
||||
positionMs = ctrl.currentPosition,
|
||||
durationMs = resolveDurationMs(ctrl),
|
||||
queue = queue,
|
||||
repeatMode = ctrl.repeatMode,
|
||||
shuffled = isManuallyShuffled,
|
||||
volume = ctrl.volume,
|
||||
artworkUri = ctrl.currentMediaItem?.mediaMetadata?.artworkUri?.toString(),
|
||||
radioMode = _radioMode,
|
||||
)
|
||||
}
|
||||
|
||||
private var _lyricsPrefetchJob: Job? = null
|
||||
/** Warm the LrcLib cache shortly after a track settles. Debounced so rapid skips don't
|
||||
* hammer lrclib (which throttles the IP and then no lyrics load at all). */
|
||||
private fun prefetchLyrics(song: Song) {
|
||||
_lyricsPrefetchJob?.cancel()
|
||||
_lyricsPrefetchJob = scope.launch {
|
||||
delay(1500) // skip quickly without firing a request per track
|
||||
runCatching {
|
||||
LrcLibClient.getLyrics(
|
||||
artist = song.artist ?: "",
|
||||
title = song.title,
|
||||
album = song.album,
|
||||
durationSecs = song.duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class PlayerListener : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) startTicker() else stopTicker()
|
||||
syncState()
|
||||
// Beim Pausieren die Position festhalten: von da soll es beim naechsten Start weitergehen.
|
||||
if (!isPlaying) persistQueueState()
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(state: Int) = syncState()
|
||||
|
||||
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
|
||||
syncState()
|
||||
checkRadioRefill()
|
||||
val ctrl = controller ?: return
|
||||
// Skip scrobble and last-song tracking for ABS tracks (mediaId starts with "abs:")
|
||||
val mediaId = ctrl.currentMediaItem?.mediaId ?: return
|
||||
if (mediaId.startsWith("abs:")) return
|
||||
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
|
||||
// Use the song's KNOWN duration — at a media transition ctrl.duration is still
|
||||
// TIME_UNSET (→0), which collapsed the 1/3 / 2/3 modes to the 15s floor and scrobbled
|
||||
// far too early. Fall back to the player only if the song has no duration.
|
||||
val songDurMs = (song.duration?.toLong() ?: 0L) * 1000L
|
||||
val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0)
|
||||
scheduleScrobble(song, dur)
|
||||
scope.launch { runCatching { repo.saveLastSong(song) } }
|
||||
persistQueueState()
|
||||
prefetchLyrics(song)
|
||||
}
|
||||
|
||||
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
|
||||
override fun onRepeatModeChanged(mode: Int) = syncState()
|
||||
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
val ctrl = controller ?: return
|
||||
val ctrlCount = ctrl.mediaItemCount
|
||||
// Rebuild queue from controller when items were set externally (e.g. Android Auto or ABS)
|
||||
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED && ctrlCount != queue.size) {
|
||||
queue = (0 until ctrlCount).map { i -> songFromMediaItem(ctrl.getMediaItemAt(i)) }
|
||||
originalQueue = queue
|
||||
isManuallyShuffled = false
|
||||
}
|
||||
syncState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.novasonic.app.player
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* App-level 30s preview player (iTunes/Deezer preview clips). Kept on the Application — NOT in a
|
||||
* screen ViewModel — so a preview keeps a **global control bar** (see AppNavigation) even after the
|
||||
* user leaves the album/artist/Explore screen it started on. Previously the preview lived in
|
||||
* ExploreViewModel and became uncontrollable once you navigated away.
|
||||
*/
|
||||
data class PreviewState(
|
||||
val trackId: Long? = null,
|
||||
val title: String = "",
|
||||
val artist: String = "",
|
||||
val artworkUrl: String? = null,
|
||||
val isPlaying: Boolean = false, // true = actively playing, false = paused
|
||||
)
|
||||
|
||||
class PreviewController {
|
||||
|
||||
private val _state = MutableStateFlow(PreviewState())
|
||||
val state: StateFlow<PreviewState> = _state
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var mp: MediaPlayer? = null
|
||||
|
||||
/** Tap on a track: same track → toggle play/pause; different track → start it. */
|
||||
fun toggle(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) {
|
||||
val cur = _state.value
|
||||
when {
|
||||
cur.trackId == trackId && cur.isPlaying -> pause()
|
||||
cur.trackId == trackId && mp != null -> resume()
|
||||
else -> start(trackId, url, title, artist, artworkUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) {
|
||||
stop()
|
||||
_state.value = PreviewState(trackId, title, artist, artworkUrl, isPlaying = true)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val player = MediaPlayer().apply {
|
||||
setDataSource(url)
|
||||
prepare()
|
||||
start()
|
||||
setOnCompletionListener { stop() }
|
||||
}
|
||||
mp = player
|
||||
} catch (e: Exception) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
mp?.runCatching { pause() }
|
||||
_state.update { it.copy(isPlaying = false) }
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
val player = mp
|
||||
if (player == null) return
|
||||
player.runCatching { start() }
|
||||
_state.update { it.copy(isPlaying = true) }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
mp?.runCatching { stop(); release() }
|
||||
mp = null
|
||||
_state.value = PreviewState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
package com.novasonic.app.ui
|
||||
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.*
|
||||
import androidx.navigation.compose.*
|
||||
import com.novasonic.app.api.AbsClient
|
||||
import com.novasonic.app.api.AbsLibraryItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.AbsPlayerController
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.AppNotification
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.ui.component.MiniPlayer
|
||||
import com.novasonic.app.ui.component.PreviewBar
|
||||
import com.novasonic.app.ui.component.NotificationSheet
|
||||
import com.novasonic.app.ui.component.PlaylistPickerSheet
|
||||
import com.novasonic.app.ui.component.SongContextSheet
|
||||
import com.novasonic.app.ui.component.SongInfoSheet
|
||||
import com.novasonic.app.ui.screen.*
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class TopLevel(val route: String, val icon: ImageVector, val label: String) {
|
||||
HOME("home", Icons.Default.Home, "Home"),
|
||||
LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
|
||||
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
|
||||
STARRED("starred", Icons.Default.Favorite, "Favoriten"),
|
||||
ABS("abs", Icons.Default.Book, "Bücher"),
|
||||
SETTINGS("settings", Icons.Default.Settings, "Einst."),
|
||||
}
|
||||
|
||||
private val NavBg = Color(0xF7080814)
|
||||
|
||||
// Position of a route among the top-level tabs (in bottom-bar order), or -1 if it isn't one.
|
||||
// Used to slide tab switches toward the side the target tab sits on, instead of a push/pop feel.
|
||||
private fun topLevelIndex(route: String?): Int = TopLevel.entries.indexOfFirst { it.route == route }
|
||||
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val context = LocalContext.current
|
||||
val app = context.app
|
||||
val repo = app.repository
|
||||
val player = app.playerController
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
repo.appSettingsFlow().collect { settings -> applyNsTheme(settings.theme) }
|
||||
}
|
||||
|
||||
val playerState by player.state.collectAsStateWithLifecycle()
|
||||
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
|
||||
val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null)
|
||||
|
||||
// ── In-app updater — offer the newer APK the backend advertises ─────────────
|
||||
val updateInfo by com.novasonic.app.data.AppUpdater.available.collectAsStateWithLifecycle()
|
||||
val updating by com.novasonic.app.data.AppUpdater.downloading.collectAsStateWithLifecycle()
|
||||
val updateScope = rememberCoroutineScope()
|
||||
LaunchedEffect(appSettings.backendUrl) { com.novasonic.app.data.AppUpdater.check(appSettings.backendUrl) }
|
||||
updateInfo?.let { info ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { com.novasonic.app.data.AppUpdater.dismiss() },
|
||||
title = { Text("Update verfügbar") },
|
||||
text = { Text("NovaSonic ${info.version ?: ""} ist verfügbar. Jetzt laden und installieren? Deine Downloads und Einstellungen bleiben erhalten.") },
|
||||
confirmButton = {
|
||||
TextButton(enabled = !updating, onClick = {
|
||||
updateScope.launch {
|
||||
com.novasonic.app.data.AppUpdater.downloadAndInstall(context, appSettings.backendUrl) { msg ->
|
||||
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}) { Text(if (updating) "Lädt…" else "Laden & installieren") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { com.novasonic.app.data.AppUpdater.dismiss() }) { Text("Später") } },
|
||||
)
|
||||
}
|
||||
|
||||
// 30s preview (app-level, so its control bar survives navigation). Starting real playback
|
||||
// cancels any active preview so the two don't overlap.
|
||||
val previewState by app.previewController.state.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(playerState.isPlaying) { if (playerState.isPlaying) app.previewController.stop() }
|
||||
|
||||
val absConfigured = appSettings.absUrl.isNotBlank() && appSettings.absApiKey.isNotBlank()
|
||||
val absClient: AbsClient? = remember(appSettings.absUrl, appSettings.absApiKey, appSettings.backendUrl) {
|
||||
if (absConfigured) AbsClient(appSettings.absUrl.trimEnd('/'), appSettings.absApiKey, appSettings.backendUrl.trimEnd('/'))
|
||||
else null
|
||||
}
|
||||
|
||||
val absPlayerController = context.app.absPlayerController
|
||||
val absState by absPlayerController.state.collectAsStateWithLifecycle()
|
||||
DisposableEffect(Unit) { onDispose { absPlayerController.close() } }
|
||||
|
||||
LaunchedEffect(appSettings.absChapterTrackMode) {
|
||||
absPlayerController.setChapterTrackMode(appSettings.absChapterTrackMode)
|
||||
}
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Geteilten Link aufloesen, sobald die Navigation steht: Song abspielen und sein Album oeffnen,
|
||||
// bzw. direkt zum Album. Laeuft ueber denselben Weg wie im Web, damit sich beide gleich
|
||||
// verhalten. Fehlschlaege (geloeschter Song, fremde Instanz) enden in einer Meldung statt still.
|
||||
LaunchedEffect(Unit) {
|
||||
com.novasonic.app.data.DeepLinkState.pending.collect { uri ->
|
||||
if (uri == null) return@collect
|
||||
com.novasonic.app.data.DeepLinkState.consume()
|
||||
val songId = uri.getQueryParameter("song")
|
||||
val albumId = uri.getQueryParameter("album")
|
||||
runCatching {
|
||||
when {
|
||||
songId != null -> {
|
||||
val song = repo.getSong(songId)
|
||||
song.albumId?.let { navController.navigate("album/$it") }
|
||||
player.playSongs(listOf(song), 0)
|
||||
}
|
||||
albumId != null -> navController.navigate("album/$albumId")
|
||||
}
|
||||
}.onFailure {
|
||||
Toast.makeText(context, "Link konnte nicht geöffnet werden: ${it.message}",
|
||||
Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
var showNowPlaying by remember { mutableStateOf(false) }
|
||||
|
||||
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
|
||||
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
|
||||
var songToDelete by remember { mutableStateOf<Song?>(null) }
|
||||
var showNotifications by remember { mutableStateOf(false) }
|
||||
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
|
||||
var showAbsPlayer by remember { mutableStateOf(false) }
|
||||
// Close action for the Erkunden (Apple) in-screen overlay (iTunes artist / album). Handled at the
|
||||
// app root because a BackHandler inside the NavHost "erkunden" destination loses back priority to
|
||||
// the NavHost, which pops Erkunden → Home instead of dismissing the overlay.
|
||||
var exploreOverlayBack by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
|
||||
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
val currentBackEntry by navController.currentBackStackEntryAsState()
|
||||
// Returning from the background re-registers the NavController's OWN back callback on top of the
|
||||
// dispatcher, which stole back-priority from the NowPlaying/ABS overlays: pressing back then popped
|
||||
// the NavHost (invisibly, all the way to Home) before dismissing the overlay. Bump this tick on
|
||||
// ON_RESUME (and re-key on navigation) so the overlay callbacks below re-add above it again.
|
||||
var resumeTick by remember { mutableIntStateOf(0) }
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { resumeTick++ }
|
||||
|
||||
DisposableEffect(showNowPlaying, backDispatcher, currentBackEntry, resumeTick) {
|
||||
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() { showNowPlaying = false }
|
||||
}
|
||||
backDispatcher.addCallback(cb)
|
||||
onDispose { cb.remove() }
|
||||
}
|
||||
DisposableEffect(showAbsPlayer, backDispatcher, currentBackEntry, resumeTick) {
|
||||
if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() { showAbsPlayer = false }
|
||||
}
|
||||
backDispatcher.addCallback(cb)
|
||||
onDispose { cb.remove() }
|
||||
}
|
||||
DisposableEffect(selectedAbsItem, backDispatcher, currentBackEntry, resumeTick) {
|
||||
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() { selectedAbsItem = null }
|
||||
}
|
||||
backDispatcher.addCallback(cb)
|
||||
onDispose { cb.remove() }
|
||||
}
|
||||
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
|
||||
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
|
||||
|
||||
fun starToggle(songId: String, currentlyStarred: Boolean) {
|
||||
// Optimistic + reactive: flips StarState instantly so every star icon updates, then hits the API.
|
||||
com.novasonic.app.data.StarState.toggle(repo, scope, songId, currentlyStarred)
|
||||
}
|
||||
|
||||
fun showContextMenu(song: Song) { contextMenuSong = song }
|
||||
|
||||
var homeRefreshKey by remember { mutableIntStateOf(0) }
|
||||
|
||||
val currentRoute = currentBackEntry?.destination?.route
|
||||
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
|
||||
|
||||
// Root-level back for the Erkunden (Apple) overlay. Keyed on currentBackEntry as well, so the
|
||||
// callback is re-added (bumped to the top of the OnBackPressedDispatcher) after every navigation
|
||||
// — otherwise, once the NavHost re-registers its own callback during an album drill-down or tab
|
||||
// switch, it could regain priority and pop Erkunden → Home on the next back ("sometimes" bug).
|
||||
DisposableEffect(exploreOverlayBack, backDispatcher, currentBackEntry, resumeTick) {
|
||||
val action = exploreOverlayBack
|
||||
if (action == null || backDispatcher == null) return@DisposableEffect onDispose {}
|
||||
val cb = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() { action() }
|
||||
}
|
||||
backDispatcher.addCallback(cb)
|
||||
onDispose { cb.remove() }
|
||||
}
|
||||
|
||||
val bgGlow = Brush.radialGradient(
|
||||
colors = listOf(Color(0x0AE8A020), Color(0x00000000)),
|
||||
center = Offset.Unspecified, radius = 900f,
|
||||
)
|
||||
|
||||
// Nav items — ABS tab only shown when configured; Settings moved to HomeScreen header
|
||||
val navItems = remember(absConfigured) {
|
||||
TopLevel.entries.filter { it != TopLevel.ABS || absConfigured }
|
||||
.filter { it != TopLevel.SETTINGS }
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
containerColor = NsBackground,
|
||||
modifier = Modifier.background(NsBackground).background(bgGlow),
|
||||
bottomBar = {
|
||||
// MiniPlayer shows on EVERY screen (incl. album/artist/playlist detail) so playback
|
||||
// is always reachable; the tab bar stays top-level-only. On detail screens there is no
|
||||
// NavigationBar to absorb the system-navigation inset, so pad the bar itself — otherwise
|
||||
// the MiniPlayer sits behind the phone's gesture/nav bar.
|
||||
Column(modifier = if (isTopLevel) Modifier else Modifier.navigationBarsPadding()) {
|
||||
// 30s preview control bar — above the MiniPlayer, on every screen, so a preview
|
||||
// started on an album/artist/Explore screen can be paused/stopped from anywhere.
|
||||
AnimatedVisibility(
|
||||
visible = previewState.trackId != null,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||
) {
|
||||
PreviewBar(
|
||||
state = previewState,
|
||||
onToggle = { if (previewState.isPlaying) app.previewController.pause() else app.previewController.resume() },
|
||||
onClose = { app.previewController.stop() },
|
||||
)
|
||||
}
|
||||
// ABS audio routes through MusicService — one notification, one mini player.
|
||||
// When an ABS book is loaded, tapping the mini player opens the ABS overlay
|
||||
// instead of the music NowPlaying screen.
|
||||
val hasAbsItem = absState.item != null
|
||||
val showMiniPlayer = playerState.isPlaying ||
|
||||
playerState.currentSong != null ||
|
||||
(lastPlayedSong != null && !showNowPlaying)
|
||||
AnimatedVisibility(
|
||||
visible = showMiniPlayer,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||
) {
|
||||
MiniPlayer(
|
||||
state = playerState,
|
||||
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
|
||||
onTap = { if (hasAbsItem) showAbsPlayer = true else showNowPlaying = true },
|
||||
onTogglePlay = { if (hasAbsItem) absPlayerController.togglePlay() else player.togglePlay() },
|
||||
onSkipPrev = { if (hasAbsItem) absPlayerController.prevChapter() else player.skipPrev() },
|
||||
onSkipNext = { if (hasAbsItem) absPlayerController.nextChapter() else player.skipNext() },
|
||||
lastSong = if (playerState.currentSong == null) lastPlayedSong else null,
|
||||
lastSongCoverUrl = lastPlayedSong?.coverArtId?.let { coverUrl(it) },
|
||||
onPlayLast = {
|
||||
lastPlayedSong?.let { ls ->
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val song = repo.getSong(ls.id)
|
||||
player.playSongs(listOf(song), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (isTopLevel) {
|
||||
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
|
||||
navItems.forEach { dest ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == dest.route,
|
||||
onClick = {
|
||||
if (dest == TopLevel.HOME && currentRoute == TopLevel.HOME.route) {
|
||||
homeRefreshKey++
|
||||
} else {
|
||||
navController.navigate(dest.route) {
|
||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true; restoreState = true
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = { Icon(dest.icon, contentDescription = dest.label) },
|
||||
label = { Text(dest.label, style = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 9.sp), maxLines = 1) },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = NsAccent,
|
||||
selectedTextColor = NsAccent,
|
||||
indicatorColor = NsAccent.copy(alpha = 0.15f),
|
||||
unselectedIconColor = Color(0xFF8888AA),
|
||||
unselectedTextColor = Color(0xFF8888AA),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = TopLevel.HOME.route,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
// Direction of a tab switch: +1 if the target tab sits to the right of the current
|
||||
// one, -1 if to the left, 0 if this isn't a tab-to-tab move (detail push/pop).
|
||||
enterTransition = {
|
||||
val f = topLevelIndex(initialState.destination.route)
|
||||
val t = topLevelIndex(targetState.destination.route)
|
||||
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
|
||||
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeIn(tween(200))
|
||||
},
|
||||
exitTransition = {
|
||||
val f = topLevelIndex(initialState.destination.route)
|
||||
val t = topLevelIndex(targetState.destination.route)
|
||||
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
|
||||
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeOut(tween(180))
|
||||
},
|
||||
popEnterTransition = {
|
||||
val f = topLevelIndex(initialState.destination.route)
|
||||
val t = topLevelIndex(targetState.destination.route)
|
||||
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
|
||||
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeIn(tween(200))
|
||||
},
|
||||
popExitTransition = {
|
||||
val f = topLevelIndex(initialState.destination.route)
|
||||
val t = topLevelIndex(targetState.destination.route)
|
||||
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
|
||||
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
|
||||
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeOut(tween(180))
|
||||
},
|
||||
) {
|
||||
composable(TopLevel.HOME.route) {
|
||||
HomeScreen(
|
||||
coverUrl = { album -> coverUrl(album.coverArt) },
|
||||
onBellClick = { showNotifications = true },
|
||||
onSettingsClick = { navController.navigate(TopLevel.SETTINGS.route) {
|
||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true; restoreState = true
|
||||
} },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onAlbumPlay = { album ->
|
||||
scope.launch { runCatching {
|
||||
val full = repo.getAlbum(album.id)
|
||||
if (full.song.isNotEmpty()) player.playSongs(full.song, 0)
|
||||
}}
|
||||
},
|
||||
onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") },
|
||||
onGenrePlay = { genre ->
|
||||
scope.launch { runCatching {
|
||||
val songs = repo.getSongsByGenre(genre.value)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
|
||||
}}
|
||||
},
|
||||
onShowAllGenres = { navController.navigate("genres") },
|
||||
refreshKey = homeRefreshKey,
|
||||
)
|
||||
}
|
||||
composable(TopLevel.LIBRARY.route) {
|
||||
LibraryScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
||||
onPlaylistClick = { pl -> navController.navigate("playlist/${pl.id}") },
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onOpenOffline = { group -> navController.navigate("offline/${encodeRoute(group.groupId)}") },
|
||||
)
|
||||
}
|
||||
composable(TopLevel.ERKUNDEN.route) {
|
||||
ExploreScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onSongPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onOverlayBackChange = { exploreOverlayBack = it },
|
||||
onOpenImporter = { navController.navigate("importer") },
|
||||
)
|
||||
}
|
||||
composable("importer") {
|
||||
ImporterScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(TopLevel.STARRED.route) {
|
||||
StarredScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||
onOpenNews = { navController.navigate("artist-news") },
|
||||
onOpenFollowed = {
|
||||
com.novasonic.app.data.LibraryIntent.oeffneGefolgte = true
|
||||
navController.navigate(TopLevel.LIBRARY.route)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable("artist-news") {
|
||||
ArtistNewsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
)
|
||||
}
|
||||
composable(TopLevel.ABS.route) {
|
||||
val client = absClient
|
||||
if (client != null) {
|
||||
if (selectedAbsItem != null) {
|
||||
AbsBookDetailScreen(
|
||||
absClient = client,
|
||||
libraryItem = selectedAbsItem!!,
|
||||
absPlayerController = absPlayerController,
|
||||
onPlay = { showAbsPlayer = true },
|
||||
onBack = { selectedAbsItem = null },
|
||||
)
|
||||
} else {
|
||||
AbsBooksScreen(
|
||||
absClient = client,
|
||||
onItemClick = { selectedAbsItem = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(TopLevel.SETTINGS.route) {
|
||||
SettingsScreen(
|
||||
onOpenStats = { navController.navigate("stats") },
|
||||
onOpenCompare = { navController.navigate("compare") },
|
||||
)
|
||||
}
|
||||
composable("stats") {
|
||||
StatsScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onBack = { navController.popBackStack() },
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||
)
|
||||
}
|
||||
composable("compare") {
|
||||
CompareScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onBack = { navController.popBackStack() },
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"album/{albumId}",
|
||||
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
||||
) {
|
||||
AlbumScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"artist/{artistId}",
|
||||
arguments = listOf(navArgument("artistId") { type = NavType.StringType }),
|
||||
) {
|
||||
ArtistDetailScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"playlist/{playlistId}",
|
||||
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
|
||||
) {
|
||||
PlaylistDetailScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onDeleted = { navController.popBackStack() },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"offline/{groupId}",
|
||||
arguments = listOf(navArgument("groupId") { type = NavType.StringType }),
|
||||
) {
|
||||
OfflineDetailScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"genre/{genreName}",
|
||||
arguments = listOf(navArgument("genreName") { type = NavType.StringType }),
|
||||
) { backEntry ->
|
||||
val genreName = backEntry.arguments?.getString("genreName") ?: ""
|
||||
GenreSongsScreen(
|
||||
genreName = genreName,
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable("genres") {
|
||||
GenreListScreen(
|
||||
onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") },
|
||||
onGenrePlay = { genre ->
|
||||
scope.launch { runCatching {
|
||||
val songs = repo.getSongsByGenre(genre.value)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
|
||||
}}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NowPlaying overlay
|
||||
AnimatedVisibility(
|
||||
visible = showNowPlaying,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||
) {
|
||||
NowPlayingScreen(
|
||||
state = playerState,
|
||||
coverUrl = heroCoverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
|
||||
queueCoverUrl = { id -> coverUrl(id) },
|
||||
controller = player,
|
||||
onDismiss = { showNowPlaying = false },
|
||||
onStarToggle = { playerState.currentSong?.let { s -> starToggle(s.id, s.starred != null) } },
|
||||
onShowOptions = { playerState.currentSong?.let { s -> contextMenuSong = s } },
|
||||
onArtistClick = { artistId ->
|
||||
showNowPlaying = false
|
||||
if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
|
||||
},
|
||||
onToggleRadio = { player.toggleRadio() },
|
||||
)
|
||||
}
|
||||
|
||||
// ABS player overlay
|
||||
AnimatedVisibility(
|
||||
visible = showAbsPlayer,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||
) {
|
||||
val client = absClient
|
||||
if (client != null) {
|
||||
AbsNowPlayingScreen(
|
||||
absPlayerController = absPlayerController,
|
||||
absClient = client,
|
||||
onDismiss = { showAbsPlayer = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
contextMenuSong?.let { song ->
|
||||
SongContextSheet(
|
||||
song = song,
|
||||
isStarred = com.novasonic.app.data.StarState.isStarred(song),
|
||||
onDismiss = { contextMenuSong = null },
|
||||
onPlay = { player.playSongs(listOf(song), 0) },
|
||||
onInsertNext = { player.insertNext(song) },
|
||||
onAddToQueue = { player.addToQueueEnd(song) },
|
||||
onStarToggle = { starToggle(song.id, com.novasonic.app.data.StarState.isStarred(song)) },
|
||||
onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
|
||||
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
|
||||
onShowInfo = { infoSheetSong = song; contextMenuSong = null },
|
||||
onDownload = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
app.offlineRepository.enqueueGroup(
|
||||
type = com.novasonic.app.offline.GroupType.SONG,
|
||||
sourceId = song.id,
|
||||
name = song.title,
|
||||
coverArt = song.coverArt,
|
||||
songs = listOf(song),
|
||||
)
|
||||
}.onFailure { android.util.Log.e("AppNavigation", "enqueueGroup failed", it) }
|
||||
}
|
||||
Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
contextMenuSong = null
|
||||
},
|
||||
// Link auf die eigene Web-App, den der Empfaenger mit SEINEN Zugangsdaten oeffnet —
|
||||
// uebertragen wird nur die Song-Id, kein Geheimnis. Der Host kommt aus BuildConfig
|
||||
// (dieselbe Angabe wie fuer die App-Links im Manifest), damit es keine vierte
|
||||
// Adresse zum Pflegen gibt und beide Richtungen nie auseinanderlaufen koennen.
|
||||
onShare = {
|
||||
val link = "https://${com.novasonic.app.BuildConfig.SHARE_HOST}/?song=${song.id}"
|
||||
val text = listOfNotNull(song.title, song.artist).joinToString(" — ")
|
||||
context.startActivity(android.content.Intent.createChooser(
|
||||
android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(android.content.Intent.EXTRA_SUBJECT, text)
|
||||
putExtra(android.content.Intent.EXTRA_TEXT, "$text\n$link")
|
||||
}, "Song teilen"))
|
||||
},
|
||||
// Nur mit Backend: das Loeschen laeuft ueber /api/delete-song, Navidrome allein
|
||||
// kann keine Dateien entfernen.
|
||||
onDelete = if (appSettings.backendUrl.isNotBlank()) {
|
||||
{ songToDelete = song; contextMenuSong = null }
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
|
||||
// Sicherheitsabfrage vor dem endgueltigen Loeschen — Wortlaut wie im Web.
|
||||
songToDelete?.let { song ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { songToDelete = null },
|
||||
title = { Text("Song löschen") },
|
||||
text = { Text("\"${song.title}\" wird unwiderruflich von der Festplatte gelöscht. " +
|
||||
"Leere Album- und Künstlerordner werden ebenfalls entfernt.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
songToDelete = null
|
||||
scope.launch {
|
||||
val creds = repo.lastCredentials
|
||||
val client = com.novasonic.app.api.ImporterClient(appSettings.backendUrl)
|
||||
val res = runCatching {
|
||||
client.deleteSong(creds?.serverUrl ?: "", creds?.username ?: "",
|
||||
creds?.password ?: "", song.id)
|
||||
}
|
||||
client.close()
|
||||
Toast.makeText(
|
||||
context,
|
||||
res.fold({ "🗑 \"${song.title}\" gelöscht" },
|
||||
{ "Löschen fehlgeschlagen: ${it.message}" }),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
// Navidrome braucht einen Moment zum Nachscannen, dann die Ansicht neu laden.
|
||||
if (res.isSuccess) { kotlinx.coroutines.delay(3000); homeRefreshKey++ }
|
||||
}
|
||||
}) { Text("Löschen", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { songToDelete = null }) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
|
||||
playlistPickerSong?.let { song ->
|
||||
PlaylistPickerSheet(
|
||||
song = song,
|
||||
loadPlaylists = { repo.getPlaylists() },
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onAdd = { pl ->
|
||||
playlistPickerSong = null
|
||||
scope.launch {
|
||||
runCatching { repo.addSongToPlaylist(pl.id, song.id) }
|
||||
.onSuccess { Toast.makeText(context, "✓ ${song.title} → ${pl.name}", Toast.LENGTH_SHORT).show() }
|
||||
.onFailure { Toast.makeText(context, "Fehler: ${it.message}", Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
},
|
||||
onCreate = { name ->
|
||||
playlistPickerSong = null
|
||||
scope.launch {
|
||||
runCatching { repo.createPlaylist(name, listOf(song.id)) }
|
||||
.onSuccess { Toast.makeText(context, "✓ Playlist \"$name\" erstellt", Toast.LENGTH_SHORT).show() }
|
||||
.onFailure { Toast.makeText(context, "Fehler: ${it.message}", Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
},
|
||||
onDismiss = { playlistPickerSong = null },
|
||||
)
|
||||
}
|
||||
|
||||
infoSheetSong?.let { song ->
|
||||
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null })
|
||||
}
|
||||
|
||||
if (showNotifications) {
|
||||
NotificationSheet(
|
||||
onDismiss = { showNotifications = false },
|
||||
onNavigate = { n ->
|
||||
showNotifications = false
|
||||
scope.launch {
|
||||
runCatching {
|
||||
when {
|
||||
n.navType == "playlist" && n.navId.isNotBlank() -> {
|
||||
navController.navigate("playlist/${n.navId}")
|
||||
}
|
||||
n.navAlbum.isNotBlank() -> {
|
||||
val res = repo.search(n.navAlbum)
|
||||
val albums = res.album
|
||||
val norm = { s: String -> s.lowercase().trim() }
|
||||
val na = norm(n.navArtist)
|
||||
// REQUIRE the artist match when we know it — several artists share an
|
||||
// album title ("I LIKE IT!"); the old name-only / firstOrNull fallback
|
||||
// opened a completely different artist's album (wrong song).
|
||||
val match = albums.firstOrNull {
|
||||
val aa = norm(it.artist ?: "")
|
||||
norm(it.name) == norm(n.navAlbum) && na.isNotBlank() &&
|
||||
(aa == na || aa.contains(na) || na.contains(aa))
|
||||
} ?: if (na.isBlank()) albums.firstOrNull { norm(it.name) == norm(n.navAlbum) } else null
|
||||
if (match != null) {
|
||||
navController.navigate("album/${match.id}")
|
||||
} else {
|
||||
Toast.makeText(context, "Album noch nicht gefunden — evtl. wird die Library gerade gescannt", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun encodeRoute(s: String) = java.net.URLEncoder.encode(s, "UTF-8")
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.novasonic.app.ui
|
||||
|
||||
/**
|
||||
* Gesamtdauer einer Playlist oder eines Albums, in Sekunden hereingereicht.
|
||||
*
|
||||
* Ab einer Stunde werden Stunden ausgewiesen: "134 Min." muss man sonst selbst umrechnen, und bei
|
||||
* einer Playlist mit 2000 Titeln staende dort eine vierstellige Minutenzahl. Gleiche Regel und
|
||||
* gleiche Schreibweise wie `formatGesamtdauer` im Web (src/utils.js).
|
||||
*/
|
||||
fun formatGesamtdauer(sekunden: Int): String {
|
||||
val min = Math.round((sekunden.coerceAtLeast(0)) / 60.0).toInt()
|
||||
if (min < 60) return "$min Min."
|
||||
val std = min / 60
|
||||
val rest = min % 60
|
||||
return if (rest > 0) "$std Std. $rest Min." else "$std Std."
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.novasonic.app.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.ui.screen.LoginScreen
|
||||
import com.novasonic.app.ui.theme.NovaSonicTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Geteilter Link, mit dem die App gestartet wurde.
|
||||
com.novasonic.app.data.DeepLinkState.offer(intent?.data)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
NovaSonicTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
NovaSonicRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// launchMode=singleTask: laeuft die App bereits, kommt ein weiterer Link hier an statt in
|
||||
// onCreate. Ohne das passierte beim zweiten geteilten Link schlicht nichts.
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
com.novasonic.app.data.DeepLinkState.offer(intent.data)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NovaSonicRoot() {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { PrefsStore(context) }
|
||||
val creds by prefs.credentials.collectAsStateWithLifecycle(
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
var forceLogin by remember { mutableStateOf(false) }
|
||||
|
||||
// null = still loading from DataStore; don't show anything yet to avoid login screen flash
|
||||
if (creds == null) return
|
||||
|
||||
if (!creds!!.isValid || forceLogin) {
|
||||
LoginScreen(onLoggedIn = { forceLogin = false })
|
||||
} else {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.AbsClient
|
||||
import com.novasonic.app.player.AbsPlayState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun AbsMiniPlayer(
|
||||
state: AbsPlayState,
|
||||
absClient: AbsClient,
|
||||
onTap: () -> Unit,
|
||||
onTogglePlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val item = state.item ?: return
|
||||
val meta = item.media.metadata
|
||||
val chapterName = state.chapters.getOrNull(state.chapterIdx)?.title
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 6.dp, bottom = 4.dp)
|
||||
.shadow(12.dp, RoundedCornerShape(18.dp))
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(NsSurface),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onTap)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = absClient.coverUrl(item.id),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = meta?.title ?: "Hörbuch",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = chapterName ?: meta?.authorName ?: "",
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent)
|
||||
.clickable(onClick = onTogglePlay),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.duration > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (state.currentTime / state.duration).toFloat().coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().height(3.dp),
|
||||
color = NsAccent,
|
||||
trackColor = NsAccent.copy(alpha = 0.15f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DownloadForOffline
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
private val cardBorder = Color(0x12FFFFFF) // rgba(255,255,255,0.07)
|
||||
|
||||
@Composable
|
||||
fun AlbumCard(
|
||||
album: Album,
|
||||
coverUrl: String?,
|
||||
width: Dp = 145.dp,
|
||||
downloaded: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.clip(shape)
|
||||
.background(NsSurface)
|
||||
.border(1.dp, cardBorder, shape)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
if (downloaded) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(6.dp)
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.55f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.DownloadForOffline,
|
||||
contentDescription = "Heruntergeladen",
|
||||
tint = NsAccent,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = album.name,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!album.artist.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = album.artist,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 11.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.api.model.ConcertEvent
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConcertsSheet(
|
||||
artistName: String,
|
||||
events: List<ConcertEvent>,
|
||||
onDismiss: () -> Unit,
|
||||
eventsError: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
"Konzerte · $artistName",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 16.sp,
|
||||
color = NsOnSurface,
|
||||
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
|
||||
)
|
||||
|
||||
if (events.isEmpty()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 40.dp),
|
||||
) {
|
||||
Text(
|
||||
if (eventsError != null) "Konzerte konnten nicht geladen werden" else "Keine Konzerte angekündigt",
|
||||
fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
if (eventsError != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
eventsError,
|
||||
fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(events) { event ->
|
||||
EventRow(event = event, onTicketClick = { url ->
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
|
||||
val w = formatEventWhen(event.datetime)
|
||||
val venue = event.venue?.name.orEmpty()
|
||||
val city = listOfNotNull(event.venue?.city, event.venue?.country).joinToString(", ")
|
||||
val offer = event.offers.firstOrNull { !it.url.isNullOrBlank() }
|
||||
val soldOut = offer?.status == "unavailable"
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
// Date block — fixed width, calendar style (day big, month/year + time small) so every
|
||||
// row is the same height and the divider/venue line up.
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(56.dp),
|
||||
) {
|
||||
Text(
|
||||
w.day,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp, color = NsAccent, maxLines = 1,
|
||||
)
|
||||
if (w.monthYear.isNotBlank()) {
|
||||
Text(
|
||||
w.monthYear,
|
||||
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (w.time.isNotBlank()) {
|
||||
Text(
|
||||
w.time,
|
||||
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Box(Modifier.width(1.dp).height(40.dp).background(NsBorder))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
|
||||
// Venue
|
||||
Column(Modifier.weight(1f)) {
|
||||
if (venue.isNotBlank()) {
|
||||
Text(
|
||||
venue, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (city.isNotBlank()) {
|
||||
Text(
|
||||
city, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Ticket button
|
||||
if (offer?.url != null) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.then(
|
||||
if (soldOut) Modifier.border(1.dp, NsOnSurface2.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
|
||||
else Modifier.background(NsAccent)
|
||||
)
|
||||
.clickable(enabled = !soldOut) { onTicketClick(offer.url) }
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
if (soldOut) "Ausverkauft" else "Tickets →",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 11.sp,
|
||||
color = if (soldOut) NsOnSurface2 else Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class EventWhen(val day: String, val monthYear: String, val time: String)
|
||||
|
||||
private val DAY_FMT = DateTimeFormatter.ofPattern("dd", Locale.GERMAN)
|
||||
private val MONTH_FMT = DateTimeFormatter.ofPattern("MMM yyyy", Locale.GERMAN)
|
||||
private val TIME_FMT = DateTimeFormatter.ofPattern("HH:mm")
|
||||
|
||||
private fun formatEventWhen(datetime: String): EventWhen {
|
||||
return try {
|
||||
// Backend now sends a naive venue-local datetime ("2026-09-16T20:00:00"); older/zoned
|
||||
// values ("…Z") still parse via the ZonedDateTime fallback.
|
||||
val ldt = runCatching { LocalDateTime.parse(datetime) }
|
||||
.getOrElse { ZonedDateTime.parse(datetime).toLocalDateTime() }
|
||||
val time = if (ldt.hour == 0 && ldt.minute == 0) "" else ldt.format(TIME_FMT)
|
||||
EventWhen(ldt.format(DAY_FMT), ldt.format(MONTH_FMT), time)
|
||||
} catch (_: Exception) {
|
||||
EventWhen(datetime.take(10), "", "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ItunesDownloadSheet(
|
||||
item: ItunesItem,
|
||||
onDownload: (type: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val hasAlbum = !item.collectionName.isNullOrBlank()
|
||||
val hasArtist = !item.artistName.isNullOrBlank()
|
||||
|
||||
var selected by remember {
|
||||
mutableStateOf(if (item.trackName != null) "track" else if (hasAlbum) "album" else "artist")
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
// Open fully so the "Herunterladen" button is visible right away (it sits above the system
|
||||
// nav bar via navigationBarsPadding below) — no drag-up needed.
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2.copy(0.3f)) },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 24.dp),
|
||||
) {
|
||||
// ── Header ────────────────────────────────────────────────────────
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val art = item.artworkUrl100?.replace("100x100bb", "80x80bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.trackName ?: item.collectionName ?: item.artistName ?: "—",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp, color = NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (hasArtist) {
|
||||
Text(
|
||||
item.artistName!!,
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (hasAlbum && item.trackName != null) {
|
||||
Text(
|
||||
item.collectionName!!,
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2.copy(0.6f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Was herunterladen?",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// ── Options ───────────────────────────────────────────────────────
|
||||
if (item.trackName != null) {
|
||||
DownloadTypeRow(
|
||||
icon = Icons.Default.MusicNote,
|
||||
title = "Nur dieser Titel",
|
||||
subtitle = item.trackName!!,
|
||||
selected = selected == "track",
|
||||
onClick = { selected = "track" },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if (hasAlbum) {
|
||||
DownloadTypeRow(
|
||||
icon = Icons.Default.Album,
|
||||
title = "Ganzes Album",
|
||||
subtitle = "\"${item.collectionName}\" herunterladen",
|
||||
selected = selected == "album",
|
||||
onClick = { selected = "album" },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if (hasArtist) {
|
||||
DownloadTypeRow(
|
||||
icon = Icons.Default.Person,
|
||||
title = "Ganzer Künstler",
|
||||
subtitle = "Alle Songs von ${item.artistName}",
|
||||
selected = selected == "artist",
|
||||
onClick = { selected = "artist" },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
Button(
|
||||
onClick = { onDownload(selected) },
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
) {
|
||||
Icon(Icons.Default.Download, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadTypeRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val borderColor = if (selected) NsAccent else NsBorder
|
||||
val bgColor = if (selected) NsAccent.copy(alpha = 0.08f) else NsSurface2
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(bgColor)
|
||||
.border(1.dp, borderColor, RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) NsAccent else NsOnSurface2.copy(0.12f)),
|
||||
) {
|
||||
Icon(icon, null, modifier = Modifier.size(18.dp),
|
||||
tint = if (selected) Color.White else NsOnSurface2)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = if (selected) NsAccent else NsOnSurface)
|
||||
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (selected) {
|
||||
Icon(Icons.Default.CheckCircle, null,
|
||||
tint = NsAccent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun MiniPlayer(
|
||||
state: PlayerState,
|
||||
coverUrl: String?,
|
||||
onTap: () -> Unit,
|
||||
onTogglePlay: () -> Unit,
|
||||
onSkipPrev: () -> Unit,
|
||||
onSkipNext: () -> Unit,
|
||||
lastSong: PrefsStore.LastPlayedSong? = null,
|
||||
lastSongCoverUrl: String? = null,
|
||||
onPlayLast: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val song = state.currentSong
|
||||
|
||||
// If no active song but last song exists, show it in "resume" mode
|
||||
if (song == null) {
|
||||
val ls = lastSong ?: return
|
||||
LastSongMiniPlayer(
|
||||
lastSong = ls,
|
||||
coverUrl = lastSongCoverUrl,
|
||||
onTap = onTap,
|
||||
onPlay = onPlayLast ?: {},
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 6.dp, bottom = 4.dp)
|
||||
.shadow(12.dp, RoundedCornerShape(18.dp))
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(NsSurface),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onTap)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = song.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!song.artist.isNullOrBlank()) {
|
||||
Text(
|
||||
text = song.artist!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSkipPrev, modifier = Modifier.size(40.dp)) {
|
||||
Icon(Icons.Default.SkipPrevious, contentDescription = null, tint = NsOnSurface2, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(CircleShape).background(NsAccent).clickable(onClick = onTogglePlay),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
IconButton(onClick = onSkipNext, modifier = Modifier.size(40.dp)) {
|
||||
Icon(Icons.Default.SkipNext, contentDescription = null, tint = NsOnSurface2, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
if (state.durationMs > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().height(3.dp),
|
||||
color = NsAccent, trackColor = NsAccent.copy(alpha = 0.15f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LastSongMiniPlayer(
|
||||
lastSong: PrefsStore.LastPlayedSong,
|
||||
coverUrl: String?,
|
||||
onTap: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 6.dp, bottom = 4.dp)
|
||||
.shadow(12.dp, RoundedCornerShape(18.dp))
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onTap)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = lastSong.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (lastSong.artist.isNotBlank()) {
|
||||
Text(
|
||||
text = lastSong.artist, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(CircleShape).background(NsAccent.copy(alpha = 0.6f)).clickable(onClick = onPlay),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.novasonic.app.data.AppNotification
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.novasonic.app.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NotificationSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onNavigate: (AppNotification) -> Unit = {},
|
||||
) {
|
||||
val items by NotificationStore.items.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) { NotificationStore.markAllRead() }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
dragHandle = null,
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 8.dp, top = 18.dp, bottom = 12.dp),
|
||||
) {
|
||||
Icon(painterResource(R.drawable.ic_honigtopf_brief), null,
|
||||
tint = NsAccent, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Benachrichtigungen",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (items.isNotEmpty()) {
|
||||
TextButton(onClick = { NotificationStore.clear() }) {
|
||||
Text("Alle löschen", color = NsOnSurface2, fontFamily = Outfit, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
if (items.isEmpty()) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth().height(140.dp),
|
||||
) {
|
||||
Text("Keine Benachrichtigungen", color = NsOnSurface2, fontFamily = Outfit)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(top = 4.dp, bottom = 24.dp),
|
||||
modifier = Modifier.navigationBarsPadding(),
|
||||
) {
|
||||
items(items, key = { it.id }) { n ->
|
||||
NotifCard(
|
||||
n = n,
|
||||
onDelete = { NotificationStore.remove(n.id) },
|
||||
onNavigate = { onNavigate(n); onDismiss() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotifCard(n: AppNotification, onDelete: () -> Unit, onNavigate: () -> Unit) {
|
||||
val hasNav = n.status == "done" && n.navType.isNotBlank()
|
||||
val navLabel = when (n.navType) {
|
||||
"playlist" -> "Öffnen →"
|
||||
else -> "Zum Album →"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (!n.read) NsAccent.copy(alpha = 0.05f) else Color.Transparent),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = if (hasNav) 6.dp else 12.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.background(
|
||||
color = if (n.status == "done") Color(0xFF22C55E).copy(0.15f)
|
||||
else if (n.status == "error") Color(0xFFEF4444).copy(0.15f)
|
||||
else NsAccent.copy(0.15f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = when (n.status) {
|
||||
"done" -> "✓"
|
||||
"error" -> "✕"
|
||||
else -> "…"
|
||||
},
|
||||
fontSize = 14.sp,
|
||||
color = when (n.status) {
|
||||
"done" -> Color(0xFF22C55E)
|
||||
"error" -> Color(0xFFEF4444)
|
||||
else -> NsAccent
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
n.title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface,
|
||||
)
|
||||
if (n.artist.isNotBlank()) {
|
||||
Text(n.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
if (n.message.isNotBlank() && n.status != "pending") {
|
||||
Text(
|
||||
n.message,
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
formatTs(n.timestamp),
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2.copy(alpha = 0.55f),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
if (n.status != "pending") {
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Close, null, tint = NsOnSurface2, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNav) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(start = 64.dp, end = 16.dp, bottom = 12.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsAccent.copy(alpha = 0.12f))
|
||||
.clickable(onClick = onNavigate)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
navLabel,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = NsAccent,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowForward, null,
|
||||
tint = NsAccent, modifier = Modifier.size(13.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
color = NsBorder.copy(alpha = 0.4f),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val _fmt = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN)
|
||||
private fun formatTs(ts: Long): String = _fmt.format(Date(ts))
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlaylistPickerSheet(
|
||||
song: Song,
|
||||
loadPlaylists: suspend () -> List<Playlist>,
|
||||
coverUrl: (String?) -> String?,
|
||||
onAdd: (Playlist) -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var playlists by remember { mutableStateOf<List<Playlist>?>(null) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var loadError by remember { mutableStateOf<String?>(null) }
|
||||
var newName by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
isLoading = true
|
||||
try { playlists = loadPlaylists() }
|
||||
catch (e: Exception) { loadError = e.message ?: e.toString() }
|
||||
finally { isLoading = false }
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp)) {
|
||||
Text(
|
||||
"\"${song.title}\" zu Playlist",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface, maxLines = 1,
|
||||
)
|
||||
if (!song.artist.isNullOrBlank()) {
|
||||
Text(song.artist!!, fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
// Create new playlist row
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
placeholder = { Text("Neue Playlist…", color = NsOnSurface2, fontSize = 14.sp) },
|
||||
leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, null, tint = NsOnSurface2) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
focusedContainerColor = NsSurface2,
|
||||
unfocusedContainerColor = NsSurface2,
|
||||
),
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = Outfit, fontSize = 14.sp,
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
val name = newName.trim()
|
||||
if (name.isNotBlank()) { onCreate(name) }
|
||||
},
|
||||
enabled = newName.isNotBlank(),
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Erstellen", tint = NsAccent)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
when {
|
||||
isLoading -> Box(
|
||||
Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(24.dp)) }
|
||||
|
||||
loadError != null -> Box(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(loadError!!, color = MaterialTheme.colorScheme.error, fontSize = 13.sp) }
|
||||
|
||||
playlists.isNullOrEmpty() -> Box(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("Noch keine Playlists vorhanden", color = NsOnSurface2, fontSize = 13.sp) }
|
||||
|
||||
else -> LazyColumn(Modifier.fillMaxWidth()) {
|
||||
items(playlists!!, key = { it.id }) { pl ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onAdd(pl) }
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val cv = coverUrl(pl.coverArt)
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (cv != null) {
|
||||
AsyncImage(
|
||||
model = cv, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.AutoMirrored.Filled.QueueMusic, null,
|
||||
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Text(
|
||||
pl.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp, color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"${pl.songCount} Titel",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.player.PreviewState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
/**
|
||||
* Global 30s-preview control bar. Rendered above the MiniPlayer on every screen while a preview
|
||||
* clip is active, so the user can pause/stop it after navigating away from the album/artist/Explore
|
||||
* screen it was started on.
|
||||
*/
|
||||
@Composable
|
||||
fun PreviewBar(
|
||||
state: PreviewState,
|
||||
onToggle: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 6.dp, bottom = 2.dp)
|
||||
.shadow(10.dp, RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = state.artworkUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(38.dp).clip(RoundedCornerShape(7.dp)).background(NsSurface),
|
||||
)
|
||||
Spacer(Modifier.width(11.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "30s Preview", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 9.sp, color = NsAccent, letterSpacing = 0.5.sp,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = state.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (state.artist.isNotBlank()) {
|
||||
Text(
|
||||
text = state.artist, fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.size(36.dp).clip(CircleShape).background(NsAccent).clickable(onClick = onToggle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = if (state.isPlaying) "Pause" else "Abspielen",
|
||||
tint = Color.White, modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(2.dp))
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Preview beenden", tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Rollbalken zum Ziehen fuer eine [LazyListState] — das Gegenstueck zum verbreiterten Balken im Web.
|
||||
*
|
||||
* Compose bringt keinen mit: eine LazyColumn zeigt gar nichts an, egal wie lang sie ist. Bei einer
|
||||
* Playlist mit 2000 Titeln bleibt so nur Wischen, bis der Daumen glueht.
|
||||
*
|
||||
* Gerechnet wird in ITEMS, nicht in Pixeln: die tatsaechliche Gesamthoehe einer LazyColumn ist
|
||||
* unbekannt, solange nicht alles gebaut wurde, und bei unterschiedlich hohen Zeilen laesst sie sich
|
||||
* auch nicht ausrechnen. Ueber den Anteil sichtbarer an allen Eintraegen stimmt der Balken bei
|
||||
* gleich hohen Zeilen — und genau die haben Song- und Albumlisten.
|
||||
*
|
||||
* Drei Fallen steckten hier drin, alle beim Ziehen:
|
||||
*
|
||||
* 1. **Die Greifflaeche muss breiter sein als der sichtbare Balken.** Sichtbar 6 dp, anfassbar
|
||||
* [griffBreite]. 6 dp am Bildschirmrand trifft mit dem Finger niemand.
|
||||
* 2. **Der erste Fingerkontakt muss verbraucht werden**, sonst startet die LazyColumn ihre eigene
|
||||
* Scrollgeste, sobald die Wischschwelle faellt, und der Balken sieht die Bewegung nie.
|
||||
* 3. **`pointerInput` darf NICHT auf die Geometrie geschluesselt sein.** Genau das war der Grund,
|
||||
* warum das Ziehen unbrauchbar blieb: die Daumenposition steht in jedem Bild anders da, ein
|
||||
* Schluessel darauf setzt die Gestenerkennung also mitten in der Geste neu auf — der Finger
|
||||
* liegt noch auf dem Schirm, die Erkennung faengt aber wieder bei null an. Der Schluessel ist
|
||||
* deshalb `Unit`; die wechselnden Werte liest die Geste ueber [rememberUpdatedState] zur
|
||||
* Laufzeit nach.
|
||||
*
|
||||
* Gescrollt wird nicht im Zeigerereignis, sondern in einem eigenen Effekt, der [zielPos] verfolgt.
|
||||
* Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte konkurrierende Scroll-Aufrufe.
|
||||
*
|
||||
* Und eine vierte, die erst nach dem Ausliefern auffiel: **nur der Daumen nimmt Beruehrungen an,
|
||||
* nicht die ganze Spur.** Compose reicht eine Beruehrung nur an das oberste getroffene Element
|
||||
* weiter — ein Streifen ueber die volle Hoehe verschluckte damit das ⋮ jeder Songzeile, obwohl er
|
||||
* die Beruehrung gar nicht wollte. Der aeussere Rahmen hat deshalb KEIN `pointerInput` und ist
|
||||
* dadurch durchlaessig; nur der Daumen selbst hoert zu, mit etwas Luft nach oben und unten.
|
||||
*/
|
||||
@Composable
|
||||
fun Rollbalken(
|
||||
state: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
breite: Dp = 6.dp,
|
||||
griffBreite: Dp = 24.dp,
|
||||
minHoehe: Dp = 44.dp,
|
||||
farbe: Color = Color.White,
|
||||
) {
|
||||
val dichte = LocalDensity.current
|
||||
var spurPx by remember { mutableFloatStateOf(0f) }
|
||||
var ziehen by remember { mutableStateOf(false) }
|
||||
// Beim Ziehen fuehrt diese Kommazahl. Wuerde jeder Zeigerschritt aus firstVisibleItemIndex neu
|
||||
// rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal verloren — der Balken
|
||||
// bliebe bei langsamem Ziehen stehen.
|
||||
var zielPos by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val info = state.layoutInfo
|
||||
val gesamt = info.totalItemsCount
|
||||
val zuSehen = info.visibleItemsInfo.size
|
||||
if (gesamt == 0 || zuSehen == 0 || gesamt <= zuSehen) return
|
||||
|
||||
val aktiv = state.isScrollInProgress || ziehen
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (aktiv) 0.9f else 0.3f,
|
||||
animationSpec = tween(if (aktiv) 120 else 450),
|
||||
label = "rollbalken-alpha",
|
||||
)
|
||||
|
||||
val maxIndex = (gesamt - zuSehen).coerceAtLeast(1)
|
||||
val minHoehePx = with(dichte) { minHoehe.toPx() }
|
||||
val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx)
|
||||
val frei = (spurPx - daumenPx).coerceAtLeast(0f)
|
||||
val anteil = if (ziehen) zielPos / maxIndex
|
||||
else state.firstVisibleItemIndex.toFloat() / maxIndex
|
||||
val daumenY = frei * anteil.coerceIn(0f, 1f)
|
||||
val spielPx = with(dichte) { 10.dp.toPx() } // Luft ober- und unterhalb des Daumens
|
||||
|
||||
// Was die Geste zur Laufzeit braucht — nicht als pointerInput-Schluessel, siehe oben.
|
||||
val geo = rememberUpdatedState(Geometrie(maxIndex.toFloat(), frei, daumenY, daumenPx, spielPx))
|
||||
|
||||
// Die Liste folgt zielPos in EINER Koroutine statt in einer je Fingerbewegung.
|
||||
LaunchedEffect(ziehen) {
|
||||
if (!ziehen) return@LaunchedEffect
|
||||
snapshotFlow { zielPos.roundToInt() }.collect { state.scrollToItem(it) }
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.width(griffBreite)
|
||||
.fillMaxHeight()
|
||||
.onSizeChanged { spurPx = it.height.toFloat() }
|
||||
) {
|
||||
val luftDp = with(dichte) { spielPx.toDp() }
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
// Der Greifbereich beginnt eine Spielbreite ueber dem Daumen und ist entsprechend
|
||||
// hoeher; der sichtbare Balken sitzt durch das Innenmass wieder genau auf daumenY.
|
||||
.offset { IntOffset(0, (daumenY - spielPx).roundToInt()) }
|
||||
.width(griffBreite)
|
||||
.height(with(dichte) { (daumenPx + 2 * spielPx).toDp() })
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val start = awaitFirstDown(requireUnconsumed = false)
|
||||
if (geo.value.frei <= 0f) return@awaitEachGesture
|
||||
start.consume()
|
||||
zielPos = state.firstVisibleItemIndex.toFloat()
|
||||
ziehen = true
|
||||
try {
|
||||
while (true) {
|
||||
val ereignis = awaitPointerEvent()
|
||||
val punkt = ereignis.changes.firstOrNull { it.id == start.id } ?: break
|
||||
if (!punkt.pressed) break
|
||||
val delta = punkt.positionChange().y
|
||||
punkt.consume()
|
||||
if (delta != 0f) {
|
||||
val jetzt = geo.value
|
||||
if (jetzt.frei > 0f) {
|
||||
zielPos = (zielPos + delta * jetzt.maxIndex / jetzt.frei)
|
||||
.coerceIn(0f, jetzt.maxIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ziehen = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(vertical = luftDp, horizontal = 0.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(end = 2.dp)
|
||||
.width(breite)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(farbe.copy(alpha = alpha))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Nur ein Buendel der Werte, die die Geste zur Laufzeit nachliest. */
|
||||
private data class Geometrie(
|
||||
val maxIndex: Float,
|
||||
val frei: Float,
|
||||
val daumenY: Float,
|
||||
val daumenPx: Float,
|
||||
val spiel: Float,
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SongContextSheet(
|
||||
song: Song,
|
||||
isStarred: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
onInsertNext: () -> Unit,
|
||||
onAddToQueue: () -> Unit,
|
||||
onStarToggle: () -> Unit,
|
||||
onAddToPlaylist: (() -> Unit)? = null,
|
||||
onGoToAlbum: (() -> Unit)? = null,
|
||||
onShowInfo: (() -> Unit)? = null,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
onShare: (() -> Unit)? = null,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
// Open fully expanded so all options (incl. "Herunterladen" at the bottom) are reachable
|
||||
// right away — no extra drag-up needed.
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
||||
) {
|
||||
// Header: song title + artist
|
||||
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp)) {
|
||||
Text(
|
||||
song.title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface, maxLines = 1,
|
||||
)
|
||||
if (!song.artist.isNullOrBlank()) {
|
||||
Text(song.artist!!, fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
CtxItem(Icons.Default.PlayArrow, "Abspielen", onClick = { onDismiss(); onPlay() })
|
||||
CtxItem(Icons.AutoMirrored.Filled.QueueMusic, "Als Nächstes einfügen", onClick = { onDismiss(); onInsertNext() })
|
||||
CtxItem(Icons.Default.AddToQueue, "An Queue anfügen", onClick = { onDismiss(); onAddToQueue() })
|
||||
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
if (onAddToPlaylist != null) {
|
||||
CtxItem(Icons.AutoMirrored.Filled.PlaylistAdd, "Zu Playlist hinzufügen",
|
||||
onClick = { onDismiss(); onAddToPlaylist() })
|
||||
}
|
||||
|
||||
CtxItem(
|
||||
icon = if (isStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
label = if (isStarred) "Aus Favoriten entfernen" else "Zu Favoriten",
|
||||
tint = if (isStarred) NsAccent else NsOnSurface,
|
||||
onClick = { onDismiss(); onStarToggle() },
|
||||
)
|
||||
|
||||
if (onGoToAlbum != null || onShowInfo != null || onDownload != null) {
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
|
||||
onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) }
|
||||
onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", onClick = { onDismiss(); it() }) }
|
||||
onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) }
|
||||
onShare?.let { CtxItem(Icons.Default.Share, "Teilen", onClick = { onDismiss(); it() }) }
|
||||
}
|
||||
|
||||
// Loeschen steht bewusst unten, abgesetzt und rot: es entfernt die Datei unwiderruflich von
|
||||
// der Platte. Die Sicherheitsabfrage sitzt beim Aufrufer, wie im Web auch.
|
||||
onDelete?.let {
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
|
||||
CtxItem(Icons.Default.DeleteForever, "Von Festplatte löschen",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = { onDismiss(); it() })
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CtxItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
tint: androidx.compose.ui.graphics.Color = NsOnSurface,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(label, fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp, color = tint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SongInfoSheet(song: Song, onDismiss: () -> Unit) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) {
|
||||
Text(
|
||||
"Song-Info",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(bottom = 12.dp))
|
||||
|
||||
InfoRow("Titel", song.title)
|
||||
song.artist?.takeIf { it.isNotBlank() }?.let { InfoRow("Künstler", it) }
|
||||
song.album?.takeIf { it.isNotBlank() }?.let { InfoRow("Album", it) }
|
||||
song.year?.let { InfoRow("Jahr", it.toString()) }
|
||||
song.genre?.takeIf { it.isNotBlank() }?.let { InfoRow("Genre", it) }
|
||||
song.track?.let { InfoRow("Track", it.toString()) }
|
||||
song.duration?.let { InfoRow("Dauer", formatDur(it)) }
|
||||
song.bitRate?.let { InfoRow("Bitrate", "${it} kbps") }
|
||||
song.suffix?.let { InfoRow("Format", it.uppercase()) }
|
||||
song.size?.let { InfoRow("Größe", formatSize(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 5.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.width(70.dp),
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDur(s: Int): String {
|
||||
val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%d:%02d".format(m, sec)
|
||||
}
|
||||
|
||||
private fun formatSize(bytes: Long): String = when {
|
||||
bytes < 1024L -> "$bytes B"
|
||||
bytes < 1024L * 1024 -> "${bytes / 1024} KB"
|
||||
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun SongRow(
|
||||
song: Song,
|
||||
coverUrl: String?,
|
||||
isPlaying: Boolean = false,
|
||||
trackNumber: Int? = null,
|
||||
starred: Boolean = false,
|
||||
onPlay: () -> Unit,
|
||||
onStarToggle: (() -> Unit)? = null,
|
||||
onMore: (() -> Unit)? = null,
|
||||
) {
|
||||
// Reactive star: reflects an optimistic StarState override the instant it's toggled anywhere.
|
||||
val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState()
|
||||
val effectiveStarred = starOverrides[song.id] ?: starred
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPlay)
|
||||
.background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent)
|
||||
// Rechts 24 dp frei halten: genau dort liegt der Rollbalken, und ein ⋮ darunter
|
||||
// waere nicht mehr zu treffen — Compose gibt eine Beruehrung nur an das oberste
|
||||
// getroffene Element. Gilt ueberall, damit die Zeilen gleich aussehen.
|
||||
.padding(start = if (isPlaying) 0.dp else 4.dp, end = 24.dp),
|
||||
) {
|
||||
// Accent left bar when playing
|
||||
if (isPlaying) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(3.dp)
|
||||
.height(52.dp)
|
||||
.background(NsAccent, RoundedCornerShape(topEnd = 3.dp, bottomEnd = 3.dp))
|
||||
)
|
||||
Spacer(Modifier.width(9.dp))
|
||||
}
|
||||
|
||||
// Track number OR album art
|
||||
if (trackNumber != null) {
|
||||
Text(
|
||||
text = trackNumber.toString(),
|
||||
fontFamily = JetBrainsMono,
|
||||
fontSize = 11.sp,
|
||||
color = if (isPlaying) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.width(34.dp).padding(start = if (isPlaying) 0.dp else 12.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.width(if (isPlaying) 0.dp else 12.dp))
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(46.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 10.dp)
|
||||
.padding(end = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = song.title,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
color = if (isPlaying) NsAccent else NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val sub = if (trackNumber != null) {
|
||||
// in album context: show artist only (album name redundant)
|
||||
song.artist?.takeIf { it.isNotBlank() } ?: ""
|
||||
} else {
|
||||
listOfNotNull(song.artist, song.album).joinToString(" · ")
|
||||
}
|
||||
if (sub.isNotBlank()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = sub,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onStarToggle != null) {
|
||||
IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) {
|
||||
Icon(
|
||||
if (effectiveStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
contentDescription = null,
|
||||
tint = if (effectiveStarred) NsAccent else NsOnSurface2.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onMore != null) {
|
||||
IconButton(onClick = onMore, modifier = Modifier.size(38.dp)) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert, contentDescription = null,
|
||||
tint = NsOnSurface2.copy(alpha = 0.6f), modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,951 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.AbsChapter
|
||||
import com.novasonic.app.api.AbsClient
|
||||
import com.novasonic.app.api.AbsLibrary
|
||||
import com.novasonic.app.api.AbsLibraryItem
|
||||
import com.novasonic.app.player.AbsPlayState
|
||||
import com.novasonic.app.player.AbsPlayerController
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private fun String.stripHtml(): String =
|
||||
replace(Regex("<[^>]+>"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
// ── Library list ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun AbsScreen(
|
||||
absClient: AbsClient,
|
||||
onLibraryClick: (AbsLibrary) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var libraries by remember { mutableStateOf<List<AbsLibrary>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
isLoading = true
|
||||
val result = runCatching { absClient.getLibraries() }
|
||||
if (result.isSuccess) {
|
||||
libraries = result.getOrDefault(emptyList())
|
||||
} else {
|
||||
error = result.exceptionOrNull()?.message ?: "Fehler"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp)) {
|
||||
Text(
|
||||
"Audiobookshelf",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
when {
|
||||
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
libraries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Bibliotheken gefunden", fontFamily = Outfit, color = NsOnSurface2)
|
||||
}
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(libraries) { lib ->
|
||||
LibraryRow(library = lib, onClick = { onLibraryClick(lib) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Combined books screen (skips library list, auto-loads first library) ─────
|
||||
|
||||
@Composable
|
||||
fun AbsBooksScreen(
|
||||
absClient: AbsClient,
|
||||
onItemClick: (AbsLibraryItem) -> Unit,
|
||||
) {
|
||||
var libraries by remember { mutableStateOf<List<AbsLibrary>>(emptyList()) }
|
||||
var selectedLibIdx by remember { mutableIntStateOf(0) }
|
||||
var items by remember { mutableStateOf<List<AbsLibraryItem>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
// Initial load: fetch libraries + first library's items
|
||||
LaunchedEffect(Unit) {
|
||||
isLoading = true
|
||||
val libs = runCatching { absClient.getLibraries() }.getOrDefault(emptyList())
|
||||
libraries = libs
|
||||
val first = libs.firstOrNull() ?: run { isLoading = false; return@LaunchedEffect }
|
||||
val r = runCatching { absClient.getLibraryItems(first.id).results }
|
||||
items = r.getOrDefault(emptyList())
|
||||
error = r.exceptionOrNull()?.message
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// Reload when user switches library tab
|
||||
LaunchedEffect(selectedLibIdx) {
|
||||
val lib = libraries.getOrNull(selectedLibIdx) ?: return@LaunchedEffect
|
||||
query = ""
|
||||
isLoading = true; error = null
|
||||
items = runCatching { absClient.getLibraryItems(lib.id).results }.getOrDefault(emptyList())
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
// Debounced search — 400ms after last keystroke
|
||||
LaunchedEffect(query) {
|
||||
val lib = libraries.getOrNull(selectedLibIdx) ?: return@LaunchedEffect
|
||||
if (query.isBlank()) {
|
||||
isLoading = true; error = null
|
||||
items = runCatching { absClient.getLibraryItems(lib.id).results }.getOrDefault(emptyList())
|
||||
isLoading = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(400)
|
||||
isLoading = true; error = null
|
||||
items = runCatching { absClient.searchLibraryItems(lib.id, query) }.getOrDefault(emptyList())
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp)) {
|
||||
Text(
|
||||
"Hörbücher",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Suchen…", fontFamily = Outfit, color = NsOnSurface3) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = NsOnSurface2) },
|
||||
trailingIcon = if (query.isNotEmpty()) {{
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Löschen", tint = NsOnSurface2)
|
||||
}
|
||||
}} else null,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsBorder,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
cursorColor = NsAccent,
|
||||
focusedContainerColor = NsSurface,
|
||||
unfocusedContainerColor = NsSurface,
|
||||
),
|
||||
)
|
||||
|
||||
// Library switcher — only shown when more than one library exists
|
||||
if (libraries.size > 1) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
libraries.forEachIndexed { idx, lib ->
|
||||
val sel = idx == selectedLibIdx
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(if (sel) NsAccent else NsSurface)
|
||||
.clickable { selectedLibIdx = idx }
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
lib.name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = if (sel) NsBackground else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
when {
|
||||
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
if (query.isNotBlank()) "Keine Ergebnisse für \"$query\"" else "Keine Bücher gefunden",
|
||||
fontFamily = Outfit, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(items) { item ->
|
||||
AbsItemRow(item = item, absClient = absClient, onClick = { onItemClick(item) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryRow(library: AbsLibrary, onClick: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(44.dp).clip(RoundedCornerShape(10.dp)).background(NsAccent.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Book, contentDescription = null, tint = NsAccent, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = library.name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = library.mediaType.replaceFirstChar { it.uppercase() },
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Library items list ────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun AbsLibraryScreen(
|
||||
absClient: AbsClient,
|
||||
library: AbsLibrary,
|
||||
onItemClick: (AbsLibraryItem) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var items by remember { mutableStateOf<List<AbsLibraryItem>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(library.id) {
|
||||
isLoading = true
|
||||
val result = runCatching { absClient.getLibraryItems(library.id).results }
|
||||
if (result.isSuccess) {
|
||||
items = result.getOrDefault(emptyList())
|
||||
} else {
|
||||
error = result.exceptionOrNull()?.message ?: "Fehler"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text(
|
||||
library.name, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
when {
|
||||
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Bücher gefunden", fontFamily = Outfit, color = NsOnSurface2)
|
||||
}
|
||||
else -> LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items) { item ->
|
||||
AbsItemRow(item = item, absClient = absClient, onClick = { onItemClick(item) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AbsItemRow(item: AbsLibraryItem, absClient: AbsClient, onClick: () -> Unit) {
|
||||
val meta = item.media.metadata
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(10.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = absClient.coverUrl(item.id),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = meta.title ?: item.id,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!meta.authorName.isNullOrBlank()) {
|
||||
Text(
|
||||
text = meta.authorName, fontFamily = Outfit,
|
||||
fontSize = 12.sp, color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
val durationMin = (item.media.duration / 60).toInt()
|
||||
if (durationMin > 0) {
|
||||
Text(
|
||||
text = "${durationMin / 60}h ${durationMin % 60}m",
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = NsOnSurface2, modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Book detail ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun AbsBookDetailScreen(
|
||||
absClient: AbsClient,
|
||||
libraryItem: AbsLibraryItem,
|
||||
absPlayerController: AbsPlayerController,
|
||||
onPlay: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var chapters by remember { mutableStateOf(libraryItem.media.chapters) }
|
||||
var progress by remember { mutableStateOf<Double?>(null) }
|
||||
var isLoadingDetail by remember { mutableStateOf(libraryItem.media.chapters.isEmpty()) }
|
||||
var isStartingPlay by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(libraryItem.id) {
|
||||
if (chapters.isEmpty()) {
|
||||
isLoadingDetail = true
|
||||
chapters = runCatching { absClient.getItemChapters(libraryItem.id) }.getOrDefault(emptyList())
|
||||
isLoadingDetail = false
|
||||
}
|
||||
progress = runCatching { absClient.getProgress(libraryItem.id)?.currentTime }.getOrNull()
|
||||
}
|
||||
|
||||
val meta = libraryItem.media.metadata
|
||||
val duration = libraryItem.media.duration
|
||||
val progressPct = if (duration > 0 && (progress ?: 0.0) > 0) ((progress!! / duration) * 100).toInt() else 0
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text(
|
||||
meta.title ?: "Hörbuch", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Header
|
||||
item(key = "header") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
AsyncImage(
|
||||
model = absClient.coverUrl(libraryItem.id),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(120.dp).clip(RoundedCornerShape(12.dp)).background(NsSurface2),
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = meta.title ?: "Unbekannt",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface,
|
||||
)
|
||||
if (!meta.authorName.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = meta.authorName, fontFamily = Outfit,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
if (duration > 0) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val h = (duration / 3600).toInt()
|
||||
val m = ((duration % 3600) / 60).toInt()
|
||||
Text(
|
||||
text = if (h > 0) "${h}h ${m}m" else "${m}m",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
if (progressPct in 1..98) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progressPct / 100f },
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)),
|
||||
color = NsAccent, trackColor = NsAccent.copy(alpha = 0.2f),
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "$progressPct% gehört",
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Play button
|
||||
item(key = "play") {
|
||||
Button(
|
||||
onClick = {
|
||||
isStartingPlay = true
|
||||
scope.launch {
|
||||
runCatching { absPlayerController.playItem(libraryItem, absClient) }
|
||||
isStartingPlay = false
|
||||
onPlay()
|
||||
}
|
||||
},
|
||||
enabled = !isStartingPlay,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
|
||||
) {
|
||||
if (isStartingPlay) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
|
||||
} else {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
if (progressPct > 5) "Weiterhören" else "Abspielen",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Description (strip HTML tags from ABS markdown/HTML content)
|
||||
if (!meta.description.isNullOrBlank()) {
|
||||
item(key = "desc") {
|
||||
Text(
|
||||
text = meta.description.stripHtml(),
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
lineHeight = 20.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Chapters
|
||||
if (chapters.isNotEmpty()) {
|
||||
item(key = "chHeader") {
|
||||
Text(
|
||||
"Kapitel (${chapters.size})",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp, color = NsOnSurface,
|
||||
)
|
||||
}
|
||||
itemsIndexed(chapters, key = { idx, _ -> "ch_$idx" }) { idx, ch ->
|
||||
ChapterDetailRow(
|
||||
chapter = ch,
|
||||
index = idx + 1,
|
||||
onTap = {
|
||||
scope.launch {
|
||||
isStartingPlay = true
|
||||
runCatching {
|
||||
absPlayerController.playItem(libraryItem, absClient)
|
||||
absPlayerController.seekToGlobal(ch.start)
|
||||
}
|
||||
isStartingPlay = false
|
||||
onPlay()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} else if (isLoadingDetail) {
|
||||
item(key = "chLoading") {
|
||||
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChapterDetailRow(chapter: AbsChapter, index: Int, onTap: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onTap)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "$index",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp, color = NsAccent,
|
||||
modifier = Modifier.width(28.dp),
|
||||
)
|
||||
Text(
|
||||
text = chapter.title.ifBlank { "Kapitel $index" },
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = fmtSecs(chapter.start),
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ABS Now Playing overlay ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun AbsNowPlayingScreen(
|
||||
absPlayerController: AbsPlayerController,
|
||||
absClient: AbsClient,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val state by absPlayerController.state.collectAsStateWithLifecycle()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(NsBackground)
|
||||
.pointerInput(Unit) {
|
||||
detectVerticalDragGestures { _, delta -> if (delta > 80f) onDismiss() }
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Top bar
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Schließen", tint = NsOnSurface)
|
||||
}
|
||||
Text(
|
||||
"Wird abgespielt", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.weight(1f), textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.width(48.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Cover
|
||||
AsyncImage(
|
||||
model = state.item?.let { absClient.coverUrl(it.id) },
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(220.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Title / Author / Chapter
|
||||
val meta = state.item?.media?.metadata
|
||||
Text(
|
||||
text = meta?.title ?: "Unbekannt",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = NsOnSurface,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
|
||||
)
|
||||
if (!meta?.authorName.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = meta?.authorName ?: "",
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
val currentChapter = state.chapters.getOrNull(state.chapterIdx)
|
||||
if (currentChapter != null && currentChapter.title.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = currentChapter.title,
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsAccent, textAlign = TextAlign.Center,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Progress bar
|
||||
val chWin = chapterWindow(state)
|
||||
val dispCur = if (chWin != null) (state.currentTime - chWin.first).coerceAtLeast(0.0) else state.currentTime
|
||||
val dispDur = if (chWin != null) chWin.second - chWin.first else state.duration
|
||||
val progress = if (dispDur > 0) (dispCur / dispDur).coerceIn(0.0, 1.0).toFloat() else 0f
|
||||
|
||||
Slider(
|
||||
value = progress,
|
||||
onValueChange = { v ->
|
||||
val globalTarget = if (chWin != null) chWin.first + v * (chWin.second - chWin.first)
|
||||
else v * state.duration
|
||||
absPlayerController.seekToGlobal(globalTarget)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = NsAccent, activeTrackColor = NsAccent,
|
||||
inactiveTrackColor = NsAccent.copy(alpha = 0.2f),
|
||||
),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(fmtSecs(dispCur), fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
Text("-${fmtSecs(dispDur - dispCur)}", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Controls
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
IconButton(onClick = { absPlayerController.prevChapter() }) {
|
||||
Icon(Icons.Default.SkipPrevious, contentDescription = "Voriges Kapitel", tint = NsOnSurface, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
IconButton(onClick = { absPlayerController.skipSecs(-30.0) }) {
|
||||
Icon(Icons.Default.Replay30, contentDescription = "-30s", tint = NsOnSurface, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
Box(
|
||||
Modifier.size(64.dp).clip(CircleShape).background(NsAccent)
|
||||
.clickable { absPlayerController.togglePlay() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = null, tint = NsBackground, modifier = Modifier.size(34.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { absPlayerController.skipSecs(30.0) }) {
|
||||
Icon(Icons.Default.Forward30, contentDescription = "+30s", tint = NsOnSurface, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
IconButton(onClick = { absPlayerController.nextChapter() }) {
|
||||
Icon(Icons.Default.SkipNext, contentDescription = "Nächstes Kapitel", tint = NsOnSurface, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Speed + Sleep + Chapter track row
|
||||
AbsBottomControls(state = state, absPlayerController = absPlayerController)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Chapter list
|
||||
if (state.chapters.isNotEmpty()) {
|
||||
AbsChapterList(
|
||||
state = state,
|
||||
absPlayerController = absPlayerController,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading overlay
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize().background(NsBackground.copy(alpha = 0.7f)), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AbsBottomControls(state: AbsPlayState, absPlayerController: AbsPlayerController) {
|
||||
var showSpeedPicker by remember { mutableStateOf(false) }
|
||||
var showSleepPicker by remember { mutableStateOf(false) }
|
||||
var showCustomSleepDialog by remember { mutableStateOf(false) }
|
||||
var customMinutesText by remember { mutableStateOf("") }
|
||||
|
||||
val speedOptions = listOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f)
|
||||
val sleepOptions = listOf(5 to "5 min", 10 to "10 min", 15 to "15 min", 30 to "30 min", 60 to "1 Stunde")
|
||||
|
||||
if (showCustomSleepDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCustomSleepDialog = false },
|
||||
title = { Text("Sleep Timer", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
Text("Minuten eingeben:", fontFamily = Outfit, fontSize = 14.sp, color = NsOnSurface2)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = customMinutesText,
|
||||
onValueChange = { v -> if (v.all { it.isDigit() } && v.length <= 4) customMinutesText = v },
|
||||
label = { Text("Minuten", fontFamily = Outfit) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent, focusedLabelColor = NsAccent, cursorColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3, focusedTextColor = NsOnSurface, unfocusedTextColor = NsOnSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val mins = customMinutesText.toIntOrNull()
|
||||
if (mins != null && mins > 0) absPlayerController.setSleepTimer(mins)
|
||||
showCustomSleepDialog = false
|
||||
}) { Text("Starten", color = NsAccent, fontFamily = Outfit) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showCustomSleepDialog = false }) {
|
||||
Text("Abbrechen", fontFamily = Outfit, color = NsOnSurface2)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
// Speed
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
TextButton(onClick = { showSpeedPicker = !showSpeedPicker; showSleepPicker = false }) {
|
||||
Text(
|
||||
"${state.speed}×",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = if (state.speed != 1f) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSpeedPicker,
|
||||
onDismissRequest = { showSpeedPicker = false },
|
||||
containerColor = NsSurface2,
|
||||
) {
|
||||
speedOptions.forEach { s ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${s}×", fontFamily = Outfit, color = if (s == state.speed) NsAccent else NsOnSurface) },
|
||||
onClick = { absPlayerController.setSpeed(s); showSpeedPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep timer
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
val sleepRem = state.sleepRemainingSecs
|
||||
TextButton(onClick = { showSleepPicker = !showSleepPicker; showSpeedPicker = false }) {
|
||||
Icon(
|
||||
Icons.Default.Bedtime, contentDescription = "Sleep Timer",
|
||||
tint = if (sleepRem != null) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
if (sleepRem != null) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
val m = sleepRem / 60
|
||||
val s = (sleepRem % 60).toString().padStart(2, '0')
|
||||
Text("$m:$s", fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSleepPicker,
|
||||
onDismissRequest = { showSleepPicker = false },
|
||||
containerColor = NsSurface2,
|
||||
) {
|
||||
if (state.sleepRemainingSecs != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Abbrechen", fontFamily = Outfit, color = MaterialTheme.colorScheme.error) },
|
||||
onClick = { absPlayerController.cancelSleep(); showSleepPicker = false },
|
||||
)
|
||||
}
|
||||
sleepOptions.forEach { (mins, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
|
||||
onClick = { absPlayerController.setSleepTimer(mins); showSleepPicker = false },
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Benutzerdefiniert…", fontFamily = Outfit, color = NsOnSurface) },
|
||||
onClick = { showSleepPicker = false; customMinutesText = ""; showCustomSleepDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Chapter track mode toggle
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
TextButton(onClick = { absPlayerController.setChapterTrackMode(!state.chapterTrackMode) }) {
|
||||
Icon(
|
||||
Icons.Default.Bookmark, contentDescription = "Kapitel-Modus",
|
||||
tint = if (state.chapterTrackMode) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
"Kapitel", fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = if (state.chapterTrackMode) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AbsChapterList(
|
||||
state: AbsPlayState,
|
||||
absPlayerController: AbsPlayerController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(state.chapterIdx) {
|
||||
if (state.chapters.isNotEmpty()) {
|
||||
listState.animateScrollToItem(state.chapterIdx.coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
"Kapitel",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
HorizontalDivider(color = NsBorder)
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(state.chapters) { idx, ch ->
|
||||
val isActive = idx == state.chapterIdx
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isActive) NsAccent.copy(alpha = 0.08f) else androidx.compose.ui.graphics.Color.Transparent)
|
||||
.clickable { absPlayerController.seekToChapter(idx) }
|
||||
.padding(horizontal = 4.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
"${idx + 1}",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
|
||||
color = if (isActive) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.width(28.dp),
|
||||
)
|
||||
Text(
|
||||
ch.title.ifBlank { "Kapitel ${idx + 1}" },
|
||||
fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = if (isActive) NsAccent else NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
fmtSecs(ch.start),
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = NsBorder.copy(alpha = 0.4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun chapterWindow(state: AbsPlayState): Pair<Double, Double>? {
|
||||
if (!state.chapterTrackMode || state.chapters.isEmpty()) return null
|
||||
val ch = state.chapters.getOrNull(state.chapterIdx) ?: return null
|
||||
val next = state.chapters.getOrNull(state.chapterIdx + 1)
|
||||
val end = next?.start ?: ch.end.takeIf { it > ch.start } ?: state.duration
|
||||
if (end <= ch.start) return null
|
||||
return Pair(ch.start, end)
|
||||
}
|
||||
|
||||
internal fun fmtSecs(secs: Double): String {
|
||||
val s = secs.toLong().coerceAtLeast(0)
|
||||
val h = s / 3600
|
||||
val m = (s % 3600) / 60
|
||||
val sec = s % 60
|
||||
return if (h > 0) "$h:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}"
|
||||
else "$m:${sec.toString().padStart(2, '0')}"
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import com.novasonic.app.data.StarState
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.AlbumViewModel
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
import com.novasonic.app.ui.formatGesamtdauer
|
||||
|
||||
@Composable
|
||||
fun AlbumScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: ((Song) -> Unit)? = null,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onArtistClick: ((String) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: AlbumViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val downloadGroup by vm.downloadGroup.collectAsStateWithLifecycle()
|
||||
val isDownloading by vm.isDownloading.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val album = state.album ?: run {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
state.error ?: "Album nicht gefunden",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = onBack) { Text("Zurück") }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
model = coverUrl(album.coverArt),
|
||||
contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
// gradient overlay
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||
1.0f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
// back button — frosted circle
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
// album info overlaid at bottom
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
album.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!album.artist.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
album.artist!!, fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
|
||||
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
|
||||
Modifier.clickable { onArtistClick(album.artistId!!) }
|
||||
else Modifier,
|
||||
)
|
||||
}
|
||||
val meta = listOfNotNull(
|
||||
album.year?.toString(),
|
||||
album.genre?.takeIf { it.isNotBlank() },
|
||||
if (album.songCount > 0) "${album.songCount} Songs" else null,
|
||||
if (album.duration > 0) formatGesamtdauer(album.duration) else null,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
meta, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle / Merken / Download ────────────────────────────
|
||||
// Gleiche Leiste wie beim Playlist-Detail: vier gleich breite, reine Symbolknoepfe.
|
||||
// Play und Shuffle trugen hier noch Beschriftungen, waehrend Herz und Download fest
|
||||
// 46 dp breit waren — die Reihe war dadurch ungleich geteilt und die Schrift wurde auf
|
||||
// schmalen Geraeten abgeschnitten. Play bleibt gefuellt als Hauptaktion; die uebrigen
|
||||
// sind umrandet und faerben sich erst accent, wenn sie aktiv sind.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onPlay(album.song, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onPlay(album.song, -1) }, // -1 = gemischt, der Player mischt
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
}
|
||||
// Album merken — derselbe Weg wie beim Song: Navidrome behandelt Song-, Album- und
|
||||
// Kuenstler-IDs beim Markieren gleich.
|
||||
val overrides by StarState.overrides.collectAsStateWithLifecycle()
|
||||
val gemerkt = overrides[album.id] ?: (album.starred != null)
|
||||
OutlinedButton(
|
||||
onClick = { vm.merkenUmschalten(album.id, album.starred != null) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (gemerkt) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (gemerkt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||
if (gemerkt) "Gemerkt" else "Album merken",
|
||||
Modifier.size(20.dp), tint = if (gemerkt) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
val isFullyDownloaded = downloadGroup != null &&
|
||||
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
|
||||
downloadGroup!!.totalTracks > 0
|
||||
OutlinedButton(
|
||||
// Tappable unless fully done — resumes / re-downloads any missing songs.
|
||||
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (isFullyDownloaded) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
when {
|
||||
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", Modifier.size(20.dp), tint = NsAccent)
|
||||
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
|
||||
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(album.song, key = { _, s -> s.id }) { index, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = null,
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
trackNumber = song.track ?: (index + 1),
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(album.song, index) },
|
||||
onStarToggle = onStarToggle?.let { cb -> { cb(song) } },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items as gridItems
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
import com.novasonic.app.data.StarState
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.api.model.fmtFanCount
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.component.ConcertsSheet
|
||||
import com.novasonic.app.ui.component.ItunesDownloadSheet
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ArtistViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ArtistDetailScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onArtistClick: ((String) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: ArtistViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
||||
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
|
||||
|
||||
// Which "all albums" section is expanded (by title), or null. rememberSaveable so it survives
|
||||
// navigating into an album and back — otherwise back would land on the collapsed artist page.
|
||||
var openAllAlbums by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val showAllAlbums = openAllAlbums != null
|
||||
BackHandler(enabled = showAllAlbums && state.partialAlbum == null && state.subAlbumItem == null) {
|
||||
openAllAlbums = null
|
||||
}
|
||||
|
||||
LaunchedEffect(state.pendingShufflePlay) {
|
||||
state.pendingShufflePlay?.let { songs ->
|
||||
onPlay(songs, -1) // -1 = gemischt starten, der Player mischt
|
||||
vm.consumeShufflePlay()
|
||||
}
|
||||
}
|
||||
|
||||
// Concerts sheet
|
||||
if (state.showEventsSheet) {
|
||||
val artist = state.artist
|
||||
if (artist != null) {
|
||||
ConcertsSheet(
|
||||
artistName = artist.name,
|
||||
events = state.events,
|
||||
onDismiss = vm::dismissEventsSheet,
|
||||
eventsError = state.eventsError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Download sheet
|
||||
state.downloadSheetItem?.let { item ->
|
||||
ItunesDownloadSheet(
|
||||
item = item,
|
||||
onDownload = { type ->
|
||||
vm.dismissDownloadSheet()
|
||||
vm.downloadItem(item, type) { _, msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onDismiss = vm::dismissDownloadSheet,
|
||||
)
|
||||
}
|
||||
|
||||
// Artist-pin picker
|
||||
if (state.showPinPicker) {
|
||||
ArtistPinSheet(
|
||||
artistName = state.artist?.name ?: "",
|
||||
pinned = state.pin,
|
||||
searching = state.pinSearching,
|
||||
candidates = state.pinCandidates,
|
||||
onPick = vm::pinCandidate,
|
||||
onPinLink = { link -> vm.pinByLink(link) { ok ->
|
||||
if (!ok) Toast.makeText(context, "Deezer-Künstler nicht gefunden", Toast.LENGTH_SHORT).show()
|
||||
} },
|
||||
onClear = vm::clearPin,
|
||||
onDismiss = vm::dismissPinPicker,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val artist = state.artist ?: run {
|
||||
Box(Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||
Text(state.error ?: "Artist not found", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Partial album detail (incomplete album — downloaded + missing tracks split view)
|
||||
val partialAlbum = state.partialAlbum
|
||||
if (partialAlbum != null) {
|
||||
PartialAlbumScreen(
|
||||
partialAlbum = partialAlbum,
|
||||
playerState = playerState,
|
||||
coverUrl = coverUrl,
|
||||
previewingId = state.previewingId,
|
||||
onPlay = onPlay,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissPartialAlbum,
|
||||
onShowContextMenu = onShowContextMenu,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Sub-album detail (not-downloaded iTunes album)
|
||||
val subAlbumItem = state.subAlbumItem
|
||||
val subAlbumTracks = state.subAlbumTracks
|
||||
if (subAlbumItem != null) {
|
||||
if (subAlbumTracks == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
} else {
|
||||
ItunesAlbumDetail(
|
||||
title = state.subAlbumTitle,
|
||||
albumItem = subAlbumItem,
|
||||
tracks = subAlbumTracks,
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissSubAlbum,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Album categories (computed once iTunes data arrives)
|
||||
val cats = if (state.iAlbums.isNotEmpty()) categorizeAlbums(artist.album, state.iAlbums) else null
|
||||
|
||||
// Rebuild the overlay entries from the (surviving) categorized albums for whichever section is
|
||||
// open. Derived — not stored — so it's correct after returning from an album.
|
||||
val allAlbumEntries: List<AllAlbumEntry> = when (openAllAlbums) {
|
||||
"In Library" -> cats?.downloaded?.map { (nd, _) ->
|
||||
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
|
||||
}.orEmpty()
|
||||
"Unvollständig" -> cats?.partial?.map { (nd, ia, counts) ->
|
||||
AllAlbumEntry(
|
||||
name = nd.name, year = nd.year?.toString(),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
|
||||
badge = "${counts.first}/${counts.second}",
|
||||
onClick = { vm.loadPartialAlbum(nd, ia) }, onDownload = { vm.showDownloadSheet(ia) },
|
||||
)
|
||||
}.orEmpty()
|
||||
"Nicht heruntergeladen" -> cats?.notDownloaded?.map { ia ->
|
||||
AllAlbumEntry(
|
||||
name = ia.collectionName ?: "—", year = ia.releaseDate?.take(4),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"), dimmed = true,
|
||||
onClick = { vm.loadSubAlbum(ia) }, onDownload = { vm.showDownloadSheet(ia) },
|
||||
)
|
||||
}.orEmpty()
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
if (showAllAlbums) {
|
||||
AllAlbumsOverlay(
|
||||
title = openAllAlbums ?: "",
|
||||
entries = allAlbumEntries,
|
||||
onBack = { openAllAlbums = null },
|
||||
gridState = vm.allAlbumsGrid,
|
||||
query = vm.allAlbumsQuery.value,
|
||||
onQueryChange = { vm.allAlbumsQuery.value = it },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(state = vm.pageListState, modifier = Modifier.fillMaxSize()) {
|
||||
// Back link
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onBack)
|
||||
.padding(start = 12.dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = NsOnSurface2, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Alle Künstler", fontFamily = Outfit, fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
// Hero card
|
||||
item {
|
||||
// A pin's Deezer picture wins over the Last.fm image (fixes same-name artists whose
|
||||
// Navidrome/Last.fm image is the wrong person).
|
||||
val imgUrl = state.pinnedImage?.takeIf { it.isNotBlank() }
|
||||
?: state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
||||
?: coverUrl(artist.coverArt)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(320.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(NsSurface2),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imgUrl,
|
||||
contentDescription = artist.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
// Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste darunter: die Leiste
|
||||
// ist "was ich jetzt tue", Folgen ist ein Zustand. Deshalb als Herz ins Banner,
|
||||
// direkt neben das Zahnrad — genau wie im Web.
|
||||
val overrides by StarState.overrides.collectAsStateWithLifecycle()
|
||||
val folgt = overrides[artist.id] ?: (artist.starred != null)
|
||||
Surface(
|
||||
onClick = { vm.folgenUmschalten(artist.id, artist.starred != null) },
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = Color(0x59000000),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(top = 10.dp, end = 52.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (folgt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||
if (folgt) "Du folgst ${artist.name}" else "${artist.name} folgen",
|
||||
tint = if (folgt) NsAccent else Color.White.copy(0.72f),
|
||||
modifier = Modifier.padding(7.dp).size(17.dp),
|
||||
)
|
||||
}
|
||||
// "Wrong artist?" pin — minimal icon-only chip, top-right over the hero.
|
||||
Surface(
|
||||
onClick = { vm.openPinPicker() },
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = Color(0x59000000),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(10.dp),
|
||||
) {
|
||||
Text(
|
||||
"⚙",
|
||||
fontSize = 14.sp,
|
||||
color = if (state.pin != null) Color(0xFF7EB8FF) else Color.White.copy(0.72f),
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0.35f to Color(0x00000000),
|
||||
1f to Color(0xD107070F),
|
||||
)
|
||||
)
|
||||
)
|
||||
Column(modifier = Modifier.align(Alignment.BottomStart).padding(20.dp)) {
|
||||
Text(
|
||||
artist.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp,
|
||||
letterSpacing = (-0.5).sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
state.fanCount?.takeIf { it > 0 }?.let {
|
||||
Text(
|
||||
"${fmtFanCount(it)} Fans",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
color = Color.White.copy(0.58f), modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Action bar
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
) {
|
||||
item {
|
||||
ArtistActionPill(Icons.Default.PlayArrow, "Top Songs") {
|
||||
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs, 0)
|
||||
}
|
||||
}
|
||||
item {
|
||||
ArtistActionPill(
|
||||
icon = Icons.Default.Shuffle,
|
||||
label = if (state.isShuffling) "Laden…" else "Shuffle",
|
||||
onClick = { vm.shuffleAll() },
|
||||
)
|
||||
}
|
||||
item {
|
||||
val eventCount = state.events.size
|
||||
val konzertLabel = if (eventCount > 0) "Konzerte ($eventCount)" else "Konzerte"
|
||||
ArtistActionPill(Icons.Default.Event, konzertLabel) {
|
||||
vm.openEventsSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Similar artists. When pinned, use Deezer "related" for the pinned artist — Last.fm's list
|
||||
// is keyed to the (possibly merged/wrong) Navidrome artist, so it goes "weird".
|
||||
val usePinnedRelated = state.pin != null && state.pinnedRelated.isNotEmpty()
|
||||
val similarArtists = state.info?.similarArtist
|
||||
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
|
||||
?: emptyList()
|
||||
if (usePinnedRelated) {
|
||||
item {
|
||||
SectionLabel("Similar Artists")
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(bottom = 20.dp),
|
||||
) {
|
||||
items(state.pinnedRelated, key = { it.artistId ?: 0L }) { rel ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(70.dp)
|
||||
// Deezer-related has no Navidrome id → resolve by name on tap; navigate
|
||||
// if the artist is in the library, else say so.
|
||||
.clickable {
|
||||
scope.launch {
|
||||
val id = vm.libraryArtistId(rel.artistName)
|
||||
if (id != null && onArtistClick != null) onArtistClick(id)
|
||||
else Toast.makeText(context, "Nicht in deiner Bibliothek", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
) {
|
||||
AsyncImage(
|
||||
model = rel.artworkUrl100,
|
||||
contentDescription = rel.artistName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(60.dp).clip(CircleShape).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
rel.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (similarArtists.isNotEmpty()) {
|
||||
item {
|
||||
SectionLabel("Similar Artists")
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(bottom = 20.dp),
|
||||
) {
|
||||
items(similarArtists) { sim ->
|
||||
val navigable = onArtistClick != null && sim.id.isNotBlank()
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(70.dp)
|
||||
.then(
|
||||
if (navigable) Modifier.clickable { onArtistClick!!(sim.id) }
|
||||
else Modifier
|
||||
),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl(sim.coverArt),
|
||||
contentDescription = sim.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
sim.name, fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = if (navigable) NsOnSurface else NsOnSurface2,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navidrome top songs
|
||||
if (state.topSongs.isNotEmpty()) {
|
||||
item { SectionLabel("Top Songs") }
|
||||
itemsIndexed(state.topSongs, key = { idx, s -> "top_${idx}_${s.id}" }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(state.topSongs, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
|
||||
// ── iTunes: Weltweit populär ──────────────────────────────────────────
|
||||
if (state.iTracks.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Weltweit populär") }
|
||||
itemsIndexed(state.iTracks, key = { idx, t -> "itrack_${idx}_${t.trackId ?: 0L}" }) { _, track ->
|
||||
ItunesArtistTrackRow(
|
||||
track = track,
|
||||
isPreviewing = state.previewingId == track.trackId,
|
||||
onPreview = { vm.togglePreview(track) },
|
||||
onDownload = { vm.showDownloadSheet(track) },
|
||||
)
|
||||
}
|
||||
} else if (state.itunesLoading) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Deezer Daten werden geladen…", fontFamily = Outfit,
|
||||
fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Album sections (categorized once iTunes data is ready) ────────────
|
||||
if (cats != null) {
|
||||
if (cats.downloaded.isNotEmpty()) {
|
||||
item {
|
||||
ArtistSectionHeader("In Library", cats.downloaded.size,
|
||||
onShowAll = if (cats.downloaded.size > 4) ({ openAllAlbums = "In Library" }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(cats.downloaded) { (nd, _) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = coverUrl(nd.coverArt),
|
||||
onClick = { onAlbumClick(nd) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (cats.partial.isNotEmpty()) {
|
||||
item {
|
||||
ArtistSectionHeader("Unvollständig", cats.partial.size,
|
||||
onShowAll = if (cats.partial.size > 4) ({ openAllAlbums = "Unvollständig" }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(cats.partial) { (nd, ia, counts) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
?: coverUrl(nd.coverArt),
|
||||
badge = "${counts.first}/${counts.second}",
|
||||
onClick = { vm.loadPartialAlbum(nd, ia) },
|
||||
onDownload = { vm.showDownloadSheet(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (cats.notDownloaded.isNotEmpty()) {
|
||||
item {
|
||||
ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size,
|
||||
onShowAll = if (cats.notDownloaded.size > 4) ({ openAllAlbums = "Nicht heruntergeladen" }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(cats.notDownloaded, key = { it.collectionId ?: 0L }) { ia ->
|
||||
AlbumThumb(
|
||||
name = ia.collectionName ?: "—",
|
||||
year = ia.releaseDate?.take(4),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
||||
dimmed = true,
|
||||
onClick = { vm.loadSubAlbum(ia) },
|
||||
onDownload = { vm.showDownloadSheet(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
} else if (!state.itunesLoading && artist.album.isNotEmpty()) {
|
||||
// Fallback when no iTunes match found
|
||||
item { SectionLabel("Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(bottom = 20.dp),
|
||||
) {
|
||||
items(artist.album, key = { it.id }) { album ->
|
||||
AlbumCard(
|
||||
album = album,
|
||||
coverUrl = coverUrl(album.coverArt),
|
||||
onClick = { onAlbumClick(album) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bio
|
||||
state.info?.biography?.takeIf { it.isNotBlank() }?.let { bio ->
|
||||
item {
|
||||
SectionLabel("Über")
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 24.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(10.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = bio.replace(Regex("<[^>]+>"), "").trim(),
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
lineHeight = 21.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistActionPill(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(20.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 18.dp, vertical = 9.dp),
|
||||
) {
|
||||
Icon(icon, null, tint = NsOnSurface, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(label, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
internal data class AllAlbumEntry(
|
||||
val name: String,
|
||||
val year: String?,
|
||||
val artUrl: String?,
|
||||
val badge: String? = null,
|
||||
val dimmed: Boolean = false,
|
||||
val onClick: () -> Unit,
|
||||
val onDownload: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun AllAlbumsOverlay(
|
||||
title: String,
|
||||
entries: List<AllAlbumEntry>,
|
||||
onBack: () -> Unit,
|
||||
gridState: androidx.compose.foundation.lazy.grid.LazyGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState(),
|
||||
query: String = "",
|
||||
onQueryChange: (String) -> Unit = {},
|
||||
) {
|
||||
val filtered = remember(query, entries) {
|
||||
if (query.isBlank()) entries
|
||||
else entries.filter { it.name.contains(query, ignoreCase = true) }
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(NsBackground),
|
||||
) {
|
||||
// Top bar
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 12.dp, top = 12.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface2)
|
||||
}
|
||||
Text(
|
||||
title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp, color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"${filtered.size}",
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2.copy(0.6f),
|
||||
)
|
||||
}
|
||||
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Suchen…", color = NsOnSurface2) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||
trailingIcon = if (query.isNotEmpty()) { {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(Icons.Default.Clear, null, tint = NsOnSurface2)
|
||||
}
|
||||
} } else null,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
columns = GridCells.Fixed(2),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(filtered.size, key = { idx -> "${idx}_${filtered[idx].name}${filtered[idx].year ?: ""}" }) { idx ->
|
||||
val entry = filtered[idx]
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
AlbumThumb(
|
||||
name = entry.name,
|
||||
year = entry.year,
|
||||
artUrl = entry.artUrl,
|
||||
badge = entry.badge,
|
||||
dimmed = entry.dimmed,
|
||||
onClick = entry.onClick,
|
||||
onDownload = entry.onDownload,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Artist-pin picker ────────────────────────────────────────────────────────
|
||||
// Pin a local (Navidrome) artist to a specific Deezer artist, fixing same-name collisions.
|
||||
// A pasted Deezer link/id is the reliable route when /search/artist can't surface the artist.
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ArtistPinSheet(
|
||||
artistName: String,
|
||||
pinned: com.novasonic.app.api.model.ArtistOverride?,
|
||||
searching: Boolean,
|
||||
candidates: List<com.novasonic.app.api.model.ItunesItem>,
|
||||
onPick: (com.novasonic.app.api.model.ItunesItem) -> Unit,
|
||||
onPinLink: (String) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var link by remember { mutableStateOf("") }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = NsSurface) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
Text("Richtigen Künstler wählen", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp, color = NsOnSurface)
|
||||
Text("Für „$artistName\"", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 2.dp, bottom = 14.dp))
|
||||
|
||||
if (pinned != null) {
|
||||
OutlinedButton(
|
||||
onClick = onClear,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
) {
|
||||
Text("Pin entfernen (aktuell: ${pinned.name.ifBlank { "gepinnt" }})",
|
||||
color = NsError, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
// Paste a Deezer link/id → pin directly (reliable when search misses the artist).
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = link, onValueChange = { link = it },
|
||||
placeholder = { Text("Deezer-Link oder ID", fontFamily = Outfit, fontSize = 13.sp) },
|
||||
singleLine = true,
|
||||
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = Outfit, fontSize = 14.sp, color = NsOnSurface),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { if (link.isNotBlank()) onPinLink(link) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) { Text("Pinnen", fontFamily = Outfit, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
Text("Nicht in der Liste? Künstler auf deezer.com öffnen und Link einfügen.",
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 6.dp, bottom = 14.dp))
|
||||
|
||||
when {
|
||||
searching -> Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(28.dp))
|
||||
}
|
||||
candidates.isEmpty() -> Text("Keine Deezer-Künstler gefunden.", fontFamily = Outfit,
|
||||
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 12.dp))
|
||||
else -> Column(Modifier.fillMaxWidth()) {
|
||||
candidates.forEach { c ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { onPick(c) }.padding(vertical = 7.dp),
|
||||
) {
|
||||
Box(Modifier.size(48.dp).clip(CircleShape).background(NsSurface2)) {
|
||||
AsyncImage(model = c.artworkUrl100, contentDescription = null,
|
||||
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(c.artistName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(c.fanCount?.let { "${fmtFanCount(it)} Fans" } ?: "Deezer",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ArtistNewsViewModel
|
||||
import com.novasonic.app.viewmodel.NewsReiter
|
||||
import com.novasonic.app.api.model.ArtistKonzert
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
/**
|
||||
* Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. Nach Monat gruppiert,
|
||||
* neueste zuerst. Was schon in der Bibliothek liegt, ist markiert statt anklickbar.
|
||||
*/
|
||||
@Composable
|
||||
fun ArtistNewsScreen(
|
||||
onBack: () -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
val vm: ArtistNewsViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val ctx = LocalContext.current
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.statusBarsPadding().padding(start = 4.dp, top = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
val konzerte = state.reiter == NewsReiter.KONZERTE
|
||||
Text(
|
||||
if (konzerte) "Konzerte deiner Künstler" else "Neu von deinen Künstlern",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind dasselbe
|
||||
// Thema. Bewusst NICHT in eine gemeinsame Liste gemischt — Veroeffentlichungen liegen
|
||||
// hinter, Konzerte vor dem heutigen Tag.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
|
||||
) {
|
||||
listOf(
|
||||
NewsReiter.RELEASES to "Neu erschienen",
|
||||
NewsReiter.KONZERTE to "Konzerte",
|
||||
).forEach { (r, label) ->
|
||||
FilterChip(
|
||||
selected = state.reiter == r,
|
||||
onClick = { vm.zeigeReiter(r) },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Das Zeitfenster gilt nur fuer die Veroeffentlichungen. Konzerte sind ohnehin Zukunft,
|
||||
// dort will man alles Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
|
||||
if (state.reiter == NewsReiter.RELEASES) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 10.dp),
|
||||
) {
|
||||
// 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn.
|
||||
listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) ->
|
||||
FilterChip(
|
||||
selected = state.tage == tage,
|
||||
onClick = { vm.setzeTage(tage) },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
when {
|
||||
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
state.keinBackend -> Hinweis("🔌", "Kein Backend konfiguriert",
|
||||
"Neue Veröffentlichungen kommen über das Backend von Deezer.")
|
||||
state.keineGefolgten -> Hinweis("❤", "Du folgst noch niemandem",
|
||||
"Auf einer Künstlerseite oben rechts aufs Herz tippen.")
|
||||
state.error != null -> Hinweis("⚠", "Laden fehlgeschlagen", state.error!!)
|
||||
|
||||
state.reiter == NewsReiter.KONZERTE && state.keinSchluessel -> Hinweis("🎟",
|
||||
"Kein Ticketmaster-Schlüssel hinterlegt", "Im Backend TICKETMASTER_API_KEY setzen.")
|
||||
state.reiter == NewsReiter.KONZERTE && state.konzerte.isEmpty() -> Hinweis("🎪",
|
||||
"Keine anstehenden Termine",
|
||||
"Gesucht wird weltweit über den Künstlernamen.")
|
||||
state.reiter == NewsReiter.KONZERTE -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||
var letzterMonat = ""
|
||||
state.konzerte.forEach { k ->
|
||||
val monat = monatVon(k.datum)
|
||||
if (monat != letzterMonat) {
|
||||
letzterMonat = monat
|
||||
item(key = "km_$monat") {
|
||||
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
|
||||
}
|
||||
}
|
||||
item(key = "k_${k.eventId}_${k.artistId}") {
|
||||
KonzertZeile(k) { onArtistClick(k.artistId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.releases.isEmpty() -> Hinweis("🌙",
|
||||
"Nichts Neues in den letzten ${state.tage} Tagen", "")
|
||||
else -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||
if (state.ohneZuordnung.isNotEmpty()) {
|
||||
item { ZuordnungsHinweis(state.ohneZuordnung) }
|
||||
}
|
||||
var letzterMonat = ""
|
||||
state.releases.forEach { rel ->
|
||||
val monat = monatVon(rel.releaseDate)
|
||||
if (monat != letzterMonat) {
|
||||
letzterMonat = monat
|
||||
item(key = "m_$monat") {
|
||||
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
|
||||
}
|
||||
}
|
||||
item(key = "${rel.artistId}_${rel.albumId}") {
|
||||
ReleaseZeile(
|
||||
rel = rel,
|
||||
schonDa = vm.hatSchon(rel),
|
||||
onArtist = { onArtistClick(rel.artistId) },
|
||||
onDownload = {
|
||||
vm.herunterladen(rel) { _, msg ->
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Hinweis(symbol: String, titel: String, text: String) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(symbol, fontSize = 34.sp)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(titel, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
if (text.isNotBlank()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(text, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZuordnungsHinweis(namen: List<String>) {
|
||||
Box(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsAccent.copy(alpha = 0.08f))
|
||||
.border(1.dp, NsAccent.copy(alpha = 0.28f), RoundedCornerShape(10.dp))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"⚠ Für ${namen.size} Künstler wurde kein Deezer-Profil gefunden: " +
|
||||
namen.take(4).joinToString(", ") + (if (namen.size > 4) " …" else "") +
|
||||
". Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, lineHeight = 17.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val TYP = mapOf("album" to "Album", "single" to "Single", "ep" to "EP",
|
||||
"compile" to "Compilation")
|
||||
|
||||
@Composable
|
||||
private fun ReleaseZeile(
|
||||
rel: ArtistRelease,
|
||||
schonDa: Boolean,
|
||||
onArtist: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 7.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = rel.cover.takeIf { it.isNotBlank() },
|
||||
contentDescription = rel.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(rel.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(rel.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtist))
|
||||
Text(
|
||||
" · " + (TYP[rel.recordType] ?: rel.recordType) +
|
||||
(rel.trackCount?.let { " · $it Titel" } ?: ""),
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(datumVon(rel.releaseDate), fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface3, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (schonDa) {
|
||||
Icon(Icons.Default.CheckCircle, "In deiner Bibliothek",
|
||||
tint = NsOnSurface3, modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
IconButton(onClick = onDownload) {
|
||||
Icon(Icons.Default.Download, "Herunterladen", tint = NsAccent,
|
||||
modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deezer liefert "JJJJ-MM-TT".
|
||||
private val EIN = SimpleDateFormat("yyyy-MM-dd", Locale.GERMAN)
|
||||
private val MONAT = SimpleDateFormat("LLLL yyyy", Locale.GERMAN)
|
||||
private val DATUM = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN)
|
||||
|
||||
private fun monatVon(s: String) = runCatching { MONAT.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
private fun datumVon(s: String) = runCatching { DATUM.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
|
||||
// Ticketmaster liefert die ORTSZEIT ohne Zone: "2026-11-04T20:00:00".
|
||||
private val EIN_ZEIT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMAN)
|
||||
private val TAG_KURZ = SimpleDateFormat("d", Locale.GERMAN)
|
||||
private val MON_KURZ = SimpleDateFormat("MMM", Locale.GERMAN)
|
||||
private val UHR = SimpleDateFormat("HH:mm", Locale.GERMAN)
|
||||
|
||||
private fun zeitVon(s: String) = runCatching { EIN_ZEIT.parse(s) }.getOrNull()
|
||||
|
||||
/**
|
||||
* Eine Konzertzeile. Statt eines Covers steht links ein Kalenderblatt — ein Konzert hat kein Bild,
|
||||
* aber ein Datum, und danach sucht das Auge in einer Liste, die nach vorn läuft.
|
||||
*/
|
||||
@Composable
|
||||
private fun KonzertZeile(k: ArtistKonzert, onArtist: () -> Unit) {
|
||||
val uri = LocalUriHandler.current
|
||||
val d = zeitVon(k.datum)
|
||||
val ort = listOf(k.ort, k.stadt, k.land).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
val uhr = d?.let { UHR.format(it) }?.takeIf { it != "00:00" }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(10.dp))
|
||||
.alpha(if (k.abgesagt) 0.45f else 1f),
|
||||
) {
|
||||
Text(d?.let { TAG_KURZ.format(it) } ?: "?", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold, fontSize = 17.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(d?.let { MON_KURZ.format(it) }?.uppercase() ?: "", fontFamily = Outfit,
|
||||
fontSize = 10.sp, color = NsOnSurface2)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
k.titel.ifBlank { k.artistName },
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = if (k.abgesagt) NsOnSurface2 else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (k.abgesagt) TextDecoration.LineThrough else null,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(k.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtist))
|
||||
val rest = listOfNotNull(ort.ifBlank { null }, uhr?.let { "$it Uhr" })
|
||||
if (rest.isNotEmpty()) {
|
||||
Text(" · " + rest.joinToString(" · "), fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (k.abgesagt) {
|
||||
Text("abgesagt", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
} else if (k.url.isNotBlank()) {
|
||||
TextButton(onClick = { uri.openUri(k.url) }) {
|
||||
Text("Tickets", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.CompareResult
|
||||
import com.novasonic.app.api.model.SharedArtist
|
||||
import com.novasonic.app.api.model.SharedTrack
|
||||
import com.novasonic.app.api.model.UniqueArtist
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.CompareViewModel
|
||||
|
||||
@Composable
|
||||
fun CompareScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
onBack: () -> Unit,
|
||||
onArtistClick: (String) -> Unit = {},
|
||||
onAlbumClick: (String) -> Unit = {},
|
||||
vm: CompareViewModel = viewModel(),
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// ── Top bar ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text(
|
||||
"Musik-Match",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
|
||||
color = NsOnSurface, modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Show "wechseln" once a partner is picked so you can go back to the list.
|
||||
if (state.partner != null) {
|
||||
Text(
|
||||
"↺ Wechseln",
|
||||
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = NsOnSurface2,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { vm.clearPartner() }
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
|
||||
!state.configured -> CenterMsg(
|
||||
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
|
||||
)
|
||||
state.error != null -> CenterMsg(state.error!!, NsError)
|
||||
state.partner == null -> PartnerPicker(state.me, state.users, vm::choosePartner)
|
||||
state.result != null -> CompareContent(state.result!!, coverUrl, onArtistClick, onAlbumClick)
|
||||
else -> CenterBox { CircularProgressIndicator(color = NsAccent) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PartnerPicker(
|
||||
me: String,
|
||||
users: List<com.novasonic.app.api.model.ScrobbleUser>,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
if (users.isEmpty()) {
|
||||
CenterMsg("Noch niemand sonst hat Wiedergaben aufgezeichnet.\nSobald eine andere Person NovaSonic nutzt, könnt ihr euch hier vergleichen.")
|
||||
return
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
"Mit wem willst du dich vergleichen?",
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(vertical = 6.dp),
|
||||
)
|
||||
users.forEach { u ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(vertical = 5.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable { onPick(u.user) }
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(CircleShape).background(NsAccent),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
(u.user.firstOrNull()?.uppercase() ?: "?"),
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Color.Black,
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(u.user, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("${u.plays} Plays", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Text("›", fontFamily = Outfit, fontSize = 20.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompareContent(
|
||||
d: CompareResult,
|
||||
coverUrl: (String?) -> String?,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||
) {
|
||||
Scoreboard(d)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Totals(d)
|
||||
Spacer(Modifier.height(22.dp))
|
||||
|
||||
SectionHeader("Gemeinsame Künstler")
|
||||
if (d.sharedArtists.isEmpty()) {
|
||||
EmptyLine("Keine gemeinsamen Künstler in diesem Zeitraum.")
|
||||
} else {
|
||||
val max = (d.sharedArtists.maxOfOrNull { maxOf(it.playsA, it.playsB) } ?: 1).coerceAtLeast(1)
|
||||
d.sharedArtists.take(15).forEach { a ->
|
||||
SharedArtistRow(a, max, a.artistId?.let { id -> { onArtistClick(id) } })
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(22.dp))
|
||||
|
||||
if (d.sharedTracks.isNotEmpty()) {
|
||||
SectionHeader("Gemeinsame Songs")
|
||||
d.sharedTracks.take(12).forEach { t ->
|
||||
SharedTrackRow(t, coverUrl(t.coverArt ?: t.albumId ?: t.songId),
|
||||
t.albumId?.let { id -> { onAlbumClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(22.dp))
|
||||
}
|
||||
|
||||
Streitpunkte(d, onArtistClick)
|
||||
|
||||
SectionHeader("Nur bei…")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f))
|
||||
UniqueColumn("Nur ${d.userB}", d.onlyB, onArtistClick, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Streitpunkte(d: CompareResult, onArtistClick: (String) -> Unit) {
|
||||
// Gemeinsame Kuenstler mit dem groessten Ungleichgewicht — das Gegenstueck zu "Gemeinsame
|
||||
// Kuenstler", das nach Summe sortiert. Mindestmasse, damit ein einzelner Play gegen null nicht
|
||||
// als Streit gilt. Identische Schwellen wie im Web, damit beide dasselbe zeigen.
|
||||
val items = d.sharedArtists
|
||||
.map { a ->
|
||||
val summe = a.playsA + a.playsB
|
||||
Triple(a, summe, if (summe > 0) kotlin.math.abs(a.playsA - a.playsB) / summe.toFloat() else 0f)
|
||||
}
|
||||
.filter { (_, summe, unwucht) -> summe >= 4 && unwucht >= 0.5f }
|
||||
.sortedWith(compareByDescending<Triple<SharedArtist, Int, Float>> { it.third }.thenByDescending { it.second })
|
||||
.take(8)
|
||||
|
||||
SectionHeader("Streitpunkte")
|
||||
if (items.isEmpty()) {
|
||||
Text("Ihr hört gemeinsame Künstler erstaunlich ausgewogen.",
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
|
||||
return
|
||||
}
|
||||
items.forEach { (a, summe, _) ->
|
||||
val anteilA = a.playsA / summe.toFloat()
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable { a.artistId?.let(onArtistClick) }
|
||||
.padding(vertical = 6.dp)
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("${a.playsA}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
fontWeight = FontWeight.Bold)
|
||||
Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 8.dp))
|
||||
Text("${a.playsB}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp))) {
|
||||
Box(Modifier.weight(anteilA.coerceAtLeast(0.001f)).fillMaxHeight().background(NsAccent))
|
||||
Box(Modifier.weight((1f - anteilA).coerceAtLeast(0.001f)).fillMaxHeight()
|
||||
.background(NsAccent.copy(alpha = .35f)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Scoreboard(d: CompareResult) {
|
||||
val b = d.battle
|
||||
if (b == null || b.kategorien.isEmpty()) return
|
||||
val urteil = when {
|
||||
b.punkteA == b.punkteB -> "Kopf-an-Kopf — kein Sieger."
|
||||
b.punkteA > b.punkteB -> "${d.userA} gewinnt ${b.punkteA}:${b.punkteB}."
|
||||
else -> "${d.userB} gewinnt ${b.punkteB}:${b.punkteA}."
|
||||
}
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(d.userA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
|
||||
Text(" ${b.punkteA} : ${b.punkteB} ", fontFamily = Outfit, fontWeight = FontWeight.Black,
|
||||
fontSize = 30.sp, color = NsOnSurface)
|
||||
Text(d.userB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
|
||||
}
|
||||
Text(urteil, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp),
|
||||
textAlign = TextAlign.Center)
|
||||
|
||||
// Eine Zeile je Kategorie: Wert · Bezeichnung · Wert. Der Sieger wird farblich
|
||||
// hervorgehoben, der Verlierer zurueckgenommen — identisch zum Web.
|
||||
b.kategorien.forEach { k ->
|
||||
val gewinnA = k.sieger == "A"
|
||||
val gewinnB = k.sieger == "B"
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(k.textA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = if (gewinnA) NsAccent else if (gewinnB) NsOnSurface3 else NsOnSurface,
|
||||
textAlign = TextAlign.End, modifier = Modifier.weight(1f))
|
||||
Text(k.label.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 11.sp, color = NsOnSurface2, textAlign = TextAlign.Center,
|
||||
maxLines = 1, modifier = Modifier.weight(1.3f))
|
||||
Text(k.textB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = if (gewinnB) NsAccent else if (gewinnA) NsOnSurface3 else NsOnSurface,
|
||||
modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun Totals(d: CompareResult) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
TotalCard(d.userA, d.totalsA.plays, d.totalsA.artists, Modifier.weight(1f))
|
||||
TotalCard(d.userB, d.totalsB.plays, d.totalsB.artists, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TotalCard(name: String, plays: Int, artists: Int, modifier: Modifier) {
|
||||
Column(modifier.clip(RoundedCornerShape(12.dp)).background(NsSurface2).padding(14.dp)) {
|
||||
Text(name, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("$plays Plays · $artists Künstler", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedArtistRow(a: SharedArtist, max: Int, onClick: (() -> Unit)?) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(vertical = 7.dp),
|
||||
) {
|
||||
Text("${a.playsA}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||
color = NsOnSurface2, textAlign = TextAlign.End, modifier = Modifier.width(30.dp))
|
||||
// left (A) bar grows toward the centre
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.fillMaxWidth(a.playsA.toFloat() / max).height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp)).background(NsAccent))
|
||||
}
|
||||
Text(
|
||||
a.name ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1.6f).padding(horizontal = 6.dp),
|
||||
)
|
||||
// right (B) bar grows from the centre
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
|
||||
Box(Modifier.fillMaxWidth(a.playsB.toFloat() / max).height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp)).background(NsAccent.copy(alpha = 0.55f)))
|
||||
}
|
||||
Text("${a.playsB}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||
color = NsOnSurface2, textAlign = TextAlign.Start, modifier = Modifier.width(30.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedTrackRow(t: SharedTrack, cover: String?, onClick: (() -> Unit)?) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(vertical = 5.dp),
|
||||
) {
|
||||
Box(Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2)) {
|
||||
AsyncImage(model = cover, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(t.title ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!t.artist.isNullOrBlank()) {
|
||||
Text(t.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Text("${t.playsA} · ${t.playsB}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UniqueColumn(title: String, items: List<UniqueArtist>, onArtistClick: (String) -> Unit, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 6.dp))
|
||||
if (items.isEmpty()) {
|
||||
Text("—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 4.dp))
|
||||
} else {
|
||||
items.take(6).forEach { a ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (a.artistId != null) Modifier.clickable { onArtistClick(a.artistId) } else Modifier)
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
Text("${a.plays}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 12.sp,
|
||||
color = NsOnSurface2, modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(t: String) {
|
||||
Text(t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLine(msg: String) {
|
||||
Text(msg, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterMsg(msg: String, color: Color = NsOnSurface2) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.novasonic.app.api.model.*
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.ItunesDownloadSheet
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ExploreTab
|
||||
import com.novasonic.app.viewmodel.ExploreViewModel
|
||||
|
||||
@Composable
|
||||
fun ExploreScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
||||
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
|
||||
onOverlayBackChange: ((() -> Unit)?) -> Unit = {},
|
||||
onOpenImporter: () -> Unit = {},
|
||||
) {
|
||||
val vm: ExploreViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
// Overlay back is handled at the app root (see AppNavigation.exploreOverlayBack): a BackHandler
|
||||
// inside this NavHost destination loses back priority to the NavHost, which pops Erkunden → Home.
|
||||
// Report the current overlay's close action up; clear it when this screen leaves composition
|
||||
// (e.g. drilling into a real album route) so that screen's own back works normally.
|
||||
val overlayKind = when {
|
||||
state.artistDetail != null && state.partialAlbum != null -> 5 // artist → partial album
|
||||
state.artistDetail != null && state.albumTracks != null -> 4 // artist → iTunes album
|
||||
state.artistDetail != null && state.artistAllAlbums != null -> 3 // artist → all-albums grid
|
||||
state.artistDetail != null -> 2 // iTunes artist
|
||||
state.albumTracks != null -> 1 // album straight from search
|
||||
else -> 0
|
||||
}
|
||||
DisposableEffect(overlayKind) {
|
||||
onOverlayBackChange(
|
||||
when (overlayKind) {
|
||||
5 -> vm::dismissPartialAlbum
|
||||
4 -> vm::dismissAlbum
|
||||
3 -> vm::closeArtistAllAlbums
|
||||
2 -> vm::closeArtistDetail
|
||||
1 -> vm::dismissAlbum
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
onDispose { onOverlayBackChange(null) }
|
||||
}
|
||||
|
||||
// Download sheet
|
||||
state.downloadSheetItem?.let { item ->
|
||||
ItunesDownloadSheet(
|
||||
item = item,
|
||||
onDownload = { type ->
|
||||
vm.dismissDownloadSheet()
|
||||
vm.downloadItem(item, type) { _, msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onDismiss = vm::dismissDownloadSheet,
|
||||
)
|
||||
}
|
||||
|
||||
// ── iTunes artist detail — full-screen overlay above tabs ────────────────
|
||||
state.artistDetail?.let { artistState ->
|
||||
val partialAlbum = state.partialAlbum
|
||||
val albumTracks = state.albumTracks
|
||||
val albumItem = state.albumItem
|
||||
when {
|
||||
partialAlbum != null -> {
|
||||
PartialAlbumScreen(
|
||||
partialAlbum = partialAlbum,
|
||||
playerState = playerState,
|
||||
coverUrl = coverUrl,
|
||||
previewingId = state.previewingId,
|
||||
onPlay = onSongPlay,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissPartialAlbum,
|
||||
onShowContextMenu = onShowContextMenu,
|
||||
)
|
||||
}
|
||||
albumTracks != null && albumItem != null -> {
|
||||
ItunesAlbumDetail(
|
||||
title = state.albumTitle,
|
||||
albumItem = albumItem,
|
||||
tracks = albumTracks,
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onArtistClick = vm::openArtistOfAlbum,
|
||||
onBack = vm::dismissAlbum,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
ItunesArtistScreen(
|
||||
artistState = artistState,
|
||||
playerState = playerState,
|
||||
coverUrl = coverUrl,
|
||||
onBack = vm::closeArtistDetail,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onPartialAlbumOpen = vm::loadPartialAlbum,
|
||||
onSongPlay = onSongPlay,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onItunesAlbumOpen = vm::loadAlbum,
|
||||
onItunesTrackPreview = vm::togglePreview,
|
||||
previewingId = state.previewingId,
|
||||
allAlbumsOpen = state.artistAllAlbums,
|
||||
onOpenAllAlbums = vm::openArtistAllAlbums,
|
||||
onCloseAllAlbums = vm::closeArtistAllAlbums,
|
||||
allAlbumsGrid = vm.allAlbumsGrid,
|
||||
allAlbumsQuery = vm.allAlbumsQuery.value,
|
||||
onAllAlbumsQueryChange = { vm.allAlbumsQuery.value = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Swipe left/right to switch between the Bibliothek and Erkunden sub-tabs, kept in sync with the
|
||||
// TabRow: a chip tap animates the pager; a settled swipe updates the selected tab.
|
||||
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { ExploreTab.values().size }
|
||||
LaunchedEffect(state.tab) {
|
||||
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
|
||||
}
|
||||
LaunchedEffect(pagerState.settledPage) {
|
||||
val t = ExploreTab.values()[pagerState.settledPage]
|
||||
if (t != state.tab) { vm.selectTab(t); vm.stopPreview() }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────
|
||||
// Drive the indicator off the pager's current page so it moves WITH the swipe (at the
|
||||
// half-way point), not only once the swipe settles — that lag read as "not smooth".
|
||||
TabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = NsAccent,
|
||||
) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == ExploreTab.LIBRARY.ordinal,
|
||||
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
|
||||
text = { Text("Bibliothek", maxLines = 1) },
|
||||
selectedContentColor = NsAccent,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Tab(
|
||||
selected = pagerState.currentPage == ExploreTab.GLOBAL.ordinal,
|
||||
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
|
||||
text = { Text("Erkunden", maxLines = 1) },
|
||||
selectedContentColor = NsAccent,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search bar ───────────────────────────────────────────────────────
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
OutlinedTextField(
|
||||
value = state.query,
|
||||
onValueChange = vm::onQueryChange,
|
||||
placeholder = {
|
||||
Text(
|
||||
if (state.tab == ExploreTab.LIBRARY) "Suche in Bibliothek…" else "Global suchen…",
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||
trailingIcon = if (state.query.isNotEmpty()) { {
|
||||
IconButton(onClick = { vm.onQueryChange(""); focusManager.clearFocus() }) {
|
||||
Icon(Icons.Default.Close, "Löschen", tint = NsOnSurface2)
|
||||
}
|
||||
} } else null,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
|
||||
// ── Importer entry (matches PWA "Importer" under Entdecken) ───────────
|
||||
if (state.tab == ExploreTab.GLOBAL) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onOpenImporter)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Download, null, tint = NsAccent, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Importer", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Spotify · YouTube · SoundCloud Link laden",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Icon(Icons.Default.ChevronRight, null, tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Content (swipeable) ────────────────────────────────────────────────
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
// Pre-compose the neighbour page so the swipe doesn't hitch while its (heavy) LazyColumn
|
||||
// builds on first drag.
|
||||
beyondViewportPageCount = 1,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { page ->
|
||||
// fillMaxSize so short content (e.g. the Top-Alben charts row) sits at the top, not
|
||||
// vertically centered in the page.
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
when (page) {
|
||||
ExploreTab.LIBRARY.ordinal -> LibrarySearchContent(
|
||||
query = state.query,
|
||||
coverUrl = coverUrl,
|
||||
playerState = playerState,
|
||||
onSongPlay = onSongPlay,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onShowContextMenu = onShowContextMenu,
|
||||
)
|
||||
else -> GlobalExploreContent(
|
||||
state = state,
|
||||
vm = vm,
|
||||
coverUrl = coverUrl,
|
||||
playerState = playerState,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onSongPlay = onSongPlay,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Library (local Navidrome) search ─────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun LibrarySearchContent(
|
||||
query: String,
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
||||
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
|
||||
) {
|
||||
// Reuse SearchScreen's ViewModel
|
||||
val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(query) { vm.onSearch(query) }
|
||||
|
||||
if (query.isBlank()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.Search, null, tint = NsOnSurface3, modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Bibliothek durchsuchen", color = NsOnSurface2, fontFamily = Outfit)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(state = vm.listState) {
|
||||
val artists = state.searchResult?.artist ?: emptyList()
|
||||
val albums = state.searchResult?.album ?: emptyList()
|
||||
val songs = state.searchResult?.song ?: emptyList()
|
||||
|
||||
if (artists.isEmpty() && albums.isEmpty() && songs.isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Ergebnisse", color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
return@LazyColumn
|
||||
}
|
||||
if (artists.isNotEmpty()) {
|
||||
item { SectionHeader("Künstler") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(artists, key = { it.id }) { a ->
|
||||
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image
|
||||
// is the wrong person). Otherwise: search3 usually omits coverArt → fall back
|
||||
// to the artist id, which Navidrome's getCoverArt resolves to the artist image.
|
||||
val pinImg = state.overrides[a.id]?.image?.takeIf { it.isNotBlank() }
|
||||
ArtistCard(name = a.name, imageUrl = pinImg ?: coverUrl(a.coverArt ?: a.id)) { onArtistClick(a) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (albums.isNotEmpty()) {
|
||||
item { SectionHeader("Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(albums, key = { it.id }) { album ->
|
||||
com.novasonic.app.ui.component.AlbumCard(
|
||||
album = album, coverUrl = coverUrl(album.coverArt),
|
||||
onClick = { onAlbumClick(album) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (songs.isNotEmpty()) {
|
||||
item { SectionHeader("Titel") }
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
// Play only the tapped song; Radio mode (if on) fills the rest.
|
||||
onPlay = { onSongPlay(listOf(song), 0) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global (iTunes) explore ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun GlobalExploreContent(
|
||||
state: com.novasonic.app.viewmodel.ExploreUiState,
|
||||
vm: ExploreViewModel,
|
||||
coverUrl: (String?) -> String? = { null },
|
||||
playerState: PlayerState = com.novasonic.app.player.PlayerState(),
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit = {},
|
||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit = { _, _ -> },
|
||||
onDownload: (ItunesItem) -> Unit = {},
|
||||
) {
|
||||
// Album drill-down view
|
||||
if (state.albumTracks != null) {
|
||||
ItunesAlbumDetail(
|
||||
title = state.albumTitle,
|
||||
albumItem = state.albumItem,
|
||||
tracks = state.albumTracks,
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = onDownload,
|
||||
onArtistClick = vm::openArtistOfAlbum,
|
||||
onBack = vm::dismissAlbum,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isSearching) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (state.query.isBlank()) {
|
||||
// Show iTunes charts
|
||||
if (state.chartsLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
LazyColumn {
|
||||
item { SectionHeader("Top-Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(state.charts) { entry ->
|
||||
ItunesChartCard(entry = entry, onClick = {
|
||||
val id = entry.id?.attributes?.imId?.toLongOrNull()
|
||||
if (id != null) vm.loadAlbum(
|
||||
ItunesItem(
|
||||
wrapperType = "collection",
|
||||
collectionId = id,
|
||||
collectionName = entry.name?.label,
|
||||
artistName = entry.artist?.label,
|
||||
artworkUrl100 = entry.artworkHd(),
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Search results
|
||||
val hasResults = state.artists.isNotEmpty() || state.albums.isNotEmpty() || state.tracks.isNotEmpty()
|
||||
if (!hasResults && state.error == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Ergebnisse", color = NsOnSurface2, fontFamily = Outfit)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(state = vm.searchListState) {
|
||||
if (state.error != null) {
|
||||
item { Text(state.error, color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(16.dp)) }
|
||||
}
|
||||
if (state.artists.isNotEmpty()) {
|
||||
item { SectionHeader("Künstler") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(state.artists) { a ->
|
||||
ArtistCard(
|
||||
name = a.artistName ?: "",
|
||||
imageUrl = a.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
||||
subtitle = a.fanCount?.takeIf { it > 0 }?.let { "${fmtFanCount(it)} Fans" },
|
||||
) { vm.openArtistDetail(a) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (state.albums.isNotEmpty()) {
|
||||
item { SectionHeader("Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(state.albums) { album ->
|
||||
ItunesAlbumCard(album = album, onClick = { vm.loadAlbum(album) })
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (state.tracks.isNotEmpty()) {
|
||||
item { SectionHeader("Titel") }
|
||||
itemsIndexed(state.tracks, key = { _, t -> t.trackId ?: 0 }) { _, track ->
|
||||
ItunesTrackRow(
|
||||
track = track,
|
||||
isPreviewing = state.previewingId == track.trackId,
|
||||
onPreview = { vm.togglePreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ItunesAlbumDetail(
|
||||
title: String,
|
||||
albumItem: ItunesItem?,
|
||||
tracks: List<ItunesItem>,
|
||||
previewingId: Long?,
|
||||
onPreview: (ItunesItem) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit = {},
|
||||
onArtistClick: ((ItunesItem) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
// Anklickbar nur, wenn Deezer eine Künstler-id mitgeliefert hat — /artist/{id}/albums
|
||||
// lässt das artist-Objekt weg, dort bleibt der Name einfach Text.
|
||||
val artistName = albumItem?.artistName
|
||||
val artistTippbar = onArtistClick != null && albumItem?.artistId != null &&
|
||||
!artistName.isNullOrBlank()
|
||||
Column(
|
||||
Modifier.weight(1f).then(
|
||||
if (artistTippbar) Modifier.clickable { onArtistClick!!(albumItem!!) } else Modifier
|
||||
)
|
||||
) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
if (!artistName.isNullOrBlank()) {
|
||||
Text(artistName, fontFamily = Outfit, fontSize = 12.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = if (artistTippbar) NsAccent else NsOnSurface2)
|
||||
}
|
||||
}
|
||||
if (albumItem != null) {
|
||||
IconButton(onClick = { onDownload(albumItem) }) {
|
||||
Icon(Icons.Default.Download, "Album herunterladen",
|
||||
tint = NsAccent, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
LazyColumn {
|
||||
itemsIndexed(tracks, key = { _, t -> t.trackId ?: 0 }) { _, track ->
|
||||
ItunesTrackRow(
|
||||
track = track,
|
||||
isPreviewing = previewingId == track.trackId,
|
||||
onPreview = { onPreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Small shared components ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ItunesChartCard(entry: ItunesRssEntry, onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.width(130.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = entry.artworkHd(), contentDescription = entry.name?.label,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(130.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(entry.name?.label ?: "", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(entry.artist?.label ?: "", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItunesAlbumCard(album: ItunesItem, onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.width(130.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
val art = album.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = album.collectionName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(130.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(album.collectionName ?: "", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(album.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItunesTrackRow(
|
||||
track: ItunesItem,
|
||||
isPreviewing: Boolean,
|
||||
onPreview: () -> Unit,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.clickable(onClick = onPreview)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
val art = track.artworkUrl100?.replace("100x100bb", "60x60bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(4.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.trackName ?: "", style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isPreviewing) NsAccent else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
listOfNotNull(track.artistName, track.collectionName).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// Preview button
|
||||
if (track.previewUrl != null) {
|
||||
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
|
||||
if (isPreviewing) "Stop" else "Preview",
|
||||
tint = if (isPreviewing) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Download button
|
||||
if (onDownload != null) {
|
||||
IconButton(onClick = onDownload, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Download, "Herunterladen",
|
||||
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistCard(name: String, imageUrl: String?, subtitle: String? = null, onClick: () -> Unit) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(NsSurface2),
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.Person, contentDescription = null,
|
||||
tint = NsOnSurface2, modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
name, fontFamily = Outfit, fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
subtitle, fontFamily = Outfit, fontSize = 10.sp, fontWeight = FontWeight.Medium,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 12.dp, bottom = 10.dp))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.api.model.Genre
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.GenreViewModel
|
||||
|
||||
@Composable
|
||||
fun GenreListScreen(
|
||||
onGenreClick: (Genre) -> Unit,
|
||||
onGenrePlay: (Genre) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: GenreViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface2)
|
||||
}
|
||||
Text(
|
||||
"Alle Genres",
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
color = NsOnSurface,
|
||||
)
|
||||
}
|
||||
|
||||
when {
|
||||
state.isLoading -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
}
|
||||
state.error != null -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
) {
|
||||
Text(state.error!!, color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = { vm.load() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent)) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
val chunked = state.genres.chunked(2)
|
||||
chunked.forEachIndexed { rowIdx, rowGenres ->
|
||||
item(key = "row_$rowIdx") {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
rowGenres.forEachIndexed { colIdx, genre ->
|
||||
val colorIdx = (rowIdx * 2 + colIdx) % genreColors.size
|
||||
GenreChip(
|
||||
genre = genre,
|
||||
bgColor = genreColors[colorIdx],
|
||||
onClick = { onGenreClick(genre) },
|
||||
onPlay = { onGenrePlay(genre) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (rowGenres.size == 1) Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
|
||||
@Composable
|
||||
fun GenreSongsScreen(
|
||||
genreName: String,
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val repo = context.app.repository
|
||||
|
||||
val decodedName = remember(genreName) {
|
||||
java.net.URLDecoder.decode(genreName, "UTF-8")
|
||||
}
|
||||
|
||||
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(decodedName) {
|
||||
try {
|
||||
songs = repo.getSongsByGenre(decodedName)
|
||||
loading = false
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text(
|
||||
decodedName,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 20.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
when {
|
||||
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
error != null -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(error!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Songs gefunden", color = NsOnSurface2)
|
||||
}
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(songs, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Genre
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.HomeViewModel
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.novasonic.app.R
|
||||
|
||||
private val greetingsWithName = listOf(
|
||||
"Ready for some beats,",
|
||||
"What's on the playlist,",
|
||||
"Good vibes only,",
|
||||
"Let the music play,",
|
||||
"Your soundtrack awaits,",
|
||||
"Turn it up,",
|
||||
"Time to tune in,",
|
||||
"Hit play and chill,",
|
||||
"Music is the answer,",
|
||||
"Welcome back,",
|
||||
)
|
||||
|
||||
private val greetingsNoName = listOf(
|
||||
"Ready for some beats?",
|
||||
"What's on the playlist today?",
|
||||
"Good vibes only",
|
||||
"Let the music play",
|
||||
"Your soundtrack awaits",
|
||||
"Turn it up",
|
||||
"Time to tune in",
|
||||
"Hit play and chill",
|
||||
"Music is the answer",
|
||||
"What are we listening to?",
|
||||
"Time to escape into music",
|
||||
"Pick something great",
|
||||
)
|
||||
|
||||
internal val genreColors = listOf(
|
||||
Color(0xFF1a1a3e), Color(0xFF1e2a1a), Color(0xFF2a1a1a),
|
||||
Color(0xFF1a2a2a), Color(0xFF2a1e1a), Color(0xFF1e1a2a),
|
||||
Color(0xFF2a2a1a), Color(0xFF1a2a1e), Color(0xFF201520),
|
||||
Color(0xFF152020), Color(0xFF201515), Color(0xFF151520),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
coverUrl: (Album) -> String?,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onAlbumPlay: (Album) -> Unit,
|
||||
onGenreClick: (Genre) -> Unit,
|
||||
onGenrePlay: ((Genre) -> Unit)? = null,
|
||||
onShowAllGenres: (() -> Unit)? = null,
|
||||
onBellClick: () -> Unit = {},
|
||||
onSettingsClick: () -> Unit = {},
|
||||
refreshKey: Int = 0,
|
||||
) {
|
||||
val vm: HomeViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val notifs by NotificationStore.items.collectAsStateWithLifecycle()
|
||||
val unreadCount = notifs.count { !it.read }
|
||||
|
||||
LaunchedEffect(refreshKey) { if (refreshKey > 0) vm.load() }
|
||||
|
||||
val greeting = remember { greetingsNoName.random() }
|
||||
val greetingWithName = remember { greetingsWithName.random() }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
val name = state.username.replaceFirstChar { it.uppercaseChar() }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 24.dp, bottom = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (name.isBlank()) greeting else "$greetingWithName $name",
|
||||
fontFamily = Outfit,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (unreadCount > 0) Badge(containerColor = NsAccent) {
|
||||
Text(
|
||||
if (unreadCount > 9) "9+" else "$unreadCount",
|
||||
fontSize = 9.sp, color = NsBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = onBellClick) {
|
||||
Icon(painterResource(R.drawable.ic_honigtopf_brief), "Benachrichtigungen",
|
||||
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(Icons.Default.Settings, "Einstellungen", tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
item {
|
||||
Box(Modifier.fillMaxWidth().height(170.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
}
|
||||
return@LazyColumn
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
item {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
) {
|
||||
Text(state.error!!, color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = { vm.load() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent)) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
return@LazyColumn
|
||||
}
|
||||
|
||||
// Hero card — pool = frequent(5) ∪ recentlyPlayed(5), label from web
|
||||
state.heroAlbum?.let { hero ->
|
||||
item {
|
||||
HeroCard(
|
||||
album = hero,
|
||||
label = state.heroLabel,
|
||||
coverUrl = coverUrl(hero),
|
||||
onPlay = { onAlbumPlay(hero) },
|
||||
onClick = { onAlbumClick(hero) },
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.genres.isNotEmpty()) {
|
||||
item { SectionHeader("Genres") }
|
||||
item {
|
||||
GenreGrid(genres = state.genres.take(6), onGenreClick = onGenreClick, onGenrePlay = onGenrePlay)
|
||||
if (onShowAllGenres != null && state.genres.size > 6) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onShowAllGenres)
|
||||
.padding(vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
"Alle anzeigen · ${state.genres.size}",
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp,
|
||||
color = NsAccent,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.recentAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Zuletzt hinzugefügt") }
|
||||
item {
|
||||
AlbumRow(albums = state.recentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
if (state.recentlyPlayedAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Zuletzt gehört") }
|
||||
item {
|
||||
AlbumRow(albums = state.recentlyPlayedAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
if (state.randomAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Entdecken") }
|
||||
item {
|
||||
AlbumRow(albums = state.randomAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
if (state.frequentAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Meistgespielt") }
|
||||
item {
|
||||
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PWA: .home-hero { height:170px; border-radius:14px }
|
||||
// background = blur(8px) brightness(.45) + horizontal gradient overlay
|
||||
// cover = 100x100, border-radius:9px
|
||||
@Composable
|
||||
private fun HeroCard(album: Album, label: String, coverUrl: String?, onPlay: () -> Unit, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(170.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().blur(16.dp),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(listOf(Color(0xE107070F), Color(0x4D07070F)))
|
||||
)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl, contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 1.sp,
|
||||
color = NsAccent,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = album.name,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
letterSpacing = (-0.3).sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!album.artist.isNullOrBlank()) {
|
||||
Text(
|
||||
text = album.artist!!,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 13.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.clip(RoundedCornerShape(21.dp))
|
||||
.background(NsAccent)
|
||||
.clickable(onClick = onPlay),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, "Play",
|
||||
tint = NsBackground, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GenreGrid(
|
||||
genres: List<Genre>,
|
||||
onGenreClick: (Genre) -> Unit,
|
||||
onGenrePlay: ((Genre) -> Unit)?,
|
||||
) {
|
||||
val rows = genres.chunked(2)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
rows.forEach { rowGenres ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
rowGenres.forEachIndexed { _, genre ->
|
||||
val colorIdx = genres.indexOf(genre) % genreColors.size
|
||||
GenreChip(
|
||||
genre = genre,
|
||||
bgColor = genreColors[colorIdx],
|
||||
onClick = { onGenreClick(genre) },
|
||||
onPlay = onGenrePlay?.let { cb -> { cb(genre) } },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (rowGenres.size == 1) Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GenreChip(
|
||||
genre: Genre,
|
||||
bgColor: Color,
|
||||
onClick: () -> Unit,
|
||||
onPlay: (() -> Unit)?,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.height(52.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bgColor)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 14.dp, end = if (onPlay != null) 8.dp else 14.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.width(4.dp).height(24.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(NsAccent)
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = genre.value,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (onPlay != null) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(NsAccent.copy(alpha = 0.18f))
|
||||
.clickable(onClick = onPlay),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow, "Play",
|
||||
tint = NsAccent,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumRow(albums: List<Album>, coverUrl: (Album) -> String?, onAlbumClick: (Album) -> Unit) {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(albums, key = { it.id }) { album ->
|
||||
AlbumCard(album = album, coverUrl = coverUrl(album), onClick = { onAlbumClick(album) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 1.5.sp,
|
||||
color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.PlayCircle
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ImportTrack
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ImporterViewModel
|
||||
|
||||
// Brand colors for the platform badge
|
||||
private val Spotify = Color(0xFF1DB954)
|
||||
private val SoundCloud = Color(0xFFFF5500)
|
||||
private val YouTube = Color(0xFFFF0000)
|
||||
|
||||
@Composable
|
||||
fun ImporterScreen(onBack: () -> Unit) {
|
||||
val vm: ImporterViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focus = LocalFocusManager.current
|
||||
|
||||
val brand = when (state.platform) {
|
||||
"spotify" -> Spotify
|
||||
"soundcloud" -> SoundCloud
|
||||
"youtube" -> YouTube
|
||||
else -> NsAccent
|
||||
}
|
||||
val brandLabel = when (state.platform) {
|
||||
"spotify" -> "Spotify"
|
||||
"soundcloud" -> "SoundCloud"
|
||||
"youtube" -> "YouTube"
|
||||
else -> "URL"
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text("Importer", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp, color = NsOnSurface)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
"Spotify-, YouTube-, YouTube-Music- oder SoundCloud-Link einfügen, " +
|
||||
"um Tracks zu laden.",
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, lineHeight = 19.sp,
|
||||
)
|
||||
}
|
||||
|
||||
// Link input + badge
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.link,
|
||||
onValueChange = vm::onLink,
|
||||
placeholder = { Text("https://open.spotify.com/…", color = NsOnSurface2) },
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
Box(
|
||||
Modifier.padding(end = 8.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(brand.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(brandLabel, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp, color = brand)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { focus.clearFocus(); vm.resolve() }),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = brand,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = brand,
|
||||
focusedContainerColor = NsSurface,
|
||||
unfocusedContainerColor = NsSurface,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Button(
|
||||
onClick = { focus.clearFocus(); vm.resolve() },
|
||||
enabled = !state.isResolving && state.link.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = brand, contentColor = Color.Black),
|
||||
) {
|
||||
if (state.isResolving) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.Black)
|
||||
} else {
|
||||
Text("Auflösen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let { err ->
|
||||
item {
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Text(err, fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.error, lineHeight = 19.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved preview header
|
||||
state.resolved?.let { r ->
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(14.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = r.artworkUrl?.replace("100x100", "300x300"),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(72.dp).clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text((r.type ?: "").uppercase(), fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold, fontSize = 10.sp,
|
||||
letterSpacing = 1.5.sp, color = brand)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(r.name ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp, color = NsOnSurface,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
listOfNotNull(r.artist, "${r.tracks.size} Tracks").joinToString(" · "),
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist aus dem Import anlegen — wie im Web-Importer. Nur ab zwei Titeln sinnvoll;
|
||||
// ein einzelner Track ergibt keine Playlist.
|
||||
if (r.tracks.size > 1) {
|
||||
item {
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = state.createPlaylist,
|
||||
onCheckedChange = vm::setCreatePlaylist,
|
||||
colors = CheckboxDefaults.colors(checkedColor = brand,
|
||||
checkmarkColor = Color.Black),
|
||||
)
|
||||
Text("Playlist daraus anlegen", fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = NsOnSurface)
|
||||
}
|
||||
if (state.createPlaylist) {
|
||||
OutlinedTextField(
|
||||
value = state.playlistName.ifBlank { r.name ?: "" },
|
||||
onValueChange = vm::setPlaylistName,
|
||||
label = { Text("Playlist-Name", fontFamily = Outfit) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
state.playlistStatus?.let {
|
||||
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download button / job progress
|
||||
item {
|
||||
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
|
||||
JobProgress(
|
||||
status = state.jobStatus,
|
||||
message = state.jobMessage,
|
||||
completed = state.jobCompleted,
|
||||
total = state.jobTotal,
|
||||
brand = brand,
|
||||
)
|
||||
} else {
|
||||
Button(
|
||||
onClick = vm::downloadAll,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = brand, contentColor = Color.Black),
|
||||
) {
|
||||
Icon(Icons.Default.Download, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Alle ${r.tracks.size} Tracks laden",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(r.tracks) { idx, track ->
|
||||
ImportTrackRow(
|
||||
num = idx + 1,
|
||||
track = track,
|
||||
isPreviewing = state.previewingId != null && state.previewingId == vm.previewId(track),
|
||||
onPreview = { vm.togglePreview(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JobProgress(
|
||||
status: String?, message: String?, completed: Int, total: Int, brand: Color,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(12.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
when (status) {
|
||||
"done" -> Icon(Icons.Default.Check, null, tint = brand, modifier = Modifier.size(20.dp))
|
||||
"error" -> Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp))
|
||||
else -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = brand)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
when (status) {
|
||||
"done" -> "Fertig — in Navidrome scannen lassen"
|
||||
"error" -> "Fehler beim Download"
|
||||
"downloading" -> "Lädt… ${if (total > 0) "$completed/$total" else ""}"
|
||||
else -> "In Warteschlange…"
|
||||
},
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = if (status == "error") MaterialTheme.colorScheme.error else NsOnSurface,
|
||||
)
|
||||
}
|
||||
if (total > 0 && status != "done") {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { if (total > 0) completed.toFloat() / total else 0f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = brand,
|
||||
trackColor = NsSurface2,
|
||||
)
|
||||
}
|
||||
message?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportTrackRow(
|
||||
num: Int,
|
||||
track: ImportTrack,
|
||||
isPreviewing: Boolean,
|
||||
onPreview: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = track.artworkUrl100,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp, color = if (isPreviewing) NsAccent else NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!track.artistName.isNullOrBlank()) {
|
||||
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
// 30s preview — only when the source provided a clip
|
||||
if (track.previewUrl != null) {
|
||||
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
|
||||
if (isPreviewing) "Stop" else "Preview",
|
||||
tint = if (isPreviewing) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items as gridItems
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.api.model.fmtFanCount
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ItunesArtistState
|
||||
|
||||
private fun normAlbum(s: String?): String =
|
||||
// Keep Unicode letters/numbers (\p{L}\p{N}) — stripping to a-z0-9 emptied every Japanese/CJK
|
||||
// title, so all of an artist's JP singles collapsed into one and "matched" each other.
|
||||
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}\\s]"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
data class ArtistAlbumCategories(
|
||||
val downloaded: List<Pair<Album, ItunesItem?>>,
|
||||
val partial: List<Triple<Album, ItunesItem, Pair<Int, Int>>>,
|
||||
val notDownloaded: List<ItunesItem>,
|
||||
)
|
||||
|
||||
fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAlbumCategories {
|
||||
// Deduplicate iTunes albums by collectionId (iTunes can return the same album multiple times)
|
||||
val uniqueIAlbums = iAlbums.distinctBy { it.collectionId ?: it.collectionName }
|
||||
val downloaded = mutableListOf<Pair<Album, ItunesItem?>>()
|
||||
val partial = mutableListOf<Triple<Album, ItunesItem, Pair<Int, Int>>>()
|
||||
val notDl = mutableListOf<ItunesItem>()
|
||||
val usedNdIds = mutableSetOf<String>()
|
||||
|
||||
for (ia in uniqueIAlbums) {
|
||||
val iName = normAlbum(ia.collectionName)
|
||||
// Deezer's /artist/{id}/albums omits nb_tracks, so trackCount is usually unknown (0) here.
|
||||
// Only mark "Unvollständig" when we KNOW the album has more tracks than we own — an unknown
|
||||
// total counts as complete, never a false "1 / 0".
|
||||
val iCount = ia.trackCount ?: 0
|
||||
val match = ndAlbums.find { a ->
|
||||
val n = normAlbum(a.name)
|
||||
n == iName || (n.length > 3 && iName.startsWith(n)) || (iName.length > 3 && n.startsWith(iName))
|
||||
}
|
||||
when {
|
||||
match == null -> notDl.add(ia)
|
||||
// Deezer returns the same album as several variants (single/EP/album/regional); distinctBy
|
||||
// above only catches identical ids, so also skip an owned album we've already classified —
|
||||
// otherwise it shows 2-3x ("PIXELATED KISSES").
|
||||
match.id in usedNdIds -> Unit
|
||||
iCount > match.songCount -> {
|
||||
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
|
||||
}
|
||||
else -> {
|
||||
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (na in ndAlbums) {
|
||||
if (na.id !in usedNdIds) downloaded.add(Pair(na, null))
|
||||
}
|
||||
|
||||
return ArtistAlbumCategories(
|
||||
downloaded = downloaded.sortedByDescending { it.first.year ?: 0 },
|
||||
partial = partial.sortedByDescending { it.first.year ?: 0 },
|
||||
notDownloaded = notDl.sortedByDescending { it.releaseDate ?: "" },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItunesArtistScreen(
|
||||
artistState: ItunesArtistState,
|
||||
playerState: PlayerState,
|
||||
coverUrl: (String?) -> String?,
|
||||
onBack: () -> Unit,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onPartialAlbumOpen: (Album, ItunesItem) -> Unit,
|
||||
onSongPlay: (List<Song>, Int) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit,
|
||||
onItunesAlbumOpen: (ItunesItem) -> Unit,
|
||||
onItunesTrackPreview: (ItunesItem) -> Unit,
|
||||
previewingId: Long?,
|
||||
allAlbumsOpen: String?,
|
||||
onOpenAllAlbums: (String) -> Unit,
|
||||
onCloseAllAlbums: () -> Unit,
|
||||
allAlbumsGrid: androidx.compose.foundation.lazy.grid.LazyGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState(),
|
||||
allAlbumsQuery: String = "",
|
||||
onAllAlbumsQueryChange: (String) -> Unit = {},
|
||||
) {
|
||||
val artist = artistState.artist
|
||||
val cats = if (!artistState.isLoading)
|
||||
categorizeAlbums(artistState.ndAlbums, artistState.iAlbums)
|
||||
else null
|
||||
|
||||
// "All albums" state is hoisted (ExploreViewModel.artistAllAlbums) so it survives an album
|
||||
// drill-down and back; entries are re-derived from cats for whichever section is open.
|
||||
val allAlbumEntries: List<AllAlbumEntry> = when (allAlbumsOpen) {
|
||||
"In Library" -> cats?.downloaded?.map { (nd, _) ->
|
||||
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
|
||||
}.orEmpty()
|
||||
"Unvollständig" -> cats?.partial?.map { (nd, ia, counts) ->
|
||||
AllAlbumEntry(
|
||||
name = nd.name, year = nd.year?.toString(),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
|
||||
badge = "${counts.first}/${counts.second}",
|
||||
onClick = { onPartialAlbumOpen(nd, ia) }, onDownload = { onDownload(ia) },
|
||||
)
|
||||
}.orEmpty()
|
||||
"Nicht heruntergeladen" -> cats?.notDownloaded?.map { ia ->
|
||||
AllAlbumEntry(
|
||||
name = ia.collectionName ?: "—", year = ia.releaseDate?.take(4),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"), dimmed = true,
|
||||
onClick = { onItunesAlbumOpen(ia) }, onDownload = { onDownload(ia) },
|
||||
)
|
||||
}.orEmpty()
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
if (allAlbumsOpen != null) {
|
||||
AllAlbumsOverlay(
|
||||
title = allAlbumsOpen,
|
||||
entries = allAlbumEntries,
|
||||
onBack = onCloseAllAlbums,
|
||||
gridState = allAlbumsGrid,
|
||||
query = allAlbumsQuery,
|
||||
onQueryChange = onAllAlbumsQueryChange,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Pick backdrop: first Navidrome album cover, else first iTunes album art
|
||||
val backdropUrl = artistState.ndAlbums.firstOrNull()?.let { coverUrl(it.coverArt) }
|
||||
?: artistState.iAlbums.firstOrNull()?.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
|
||||
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// ── Hero ──────────────────────────────────────────────────────────────
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(200.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = backdropUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().blur(24.dp),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(0f to Color.Black.copy(0.5f), 1f to NsBackground)
|
||||
))
|
||||
// Back button
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
// Artist name
|
||||
Column(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
artist.artistName ?: "Unbekannter Künstler",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 28.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!artist.primaryGenreName.isNullOrBlank()) {
|
||||
Text(artist.primaryGenreName!!, fontFamily = Outfit,
|
||||
fontSize = 13.sp, color = Color.White.copy(0.6f))
|
||||
}
|
||||
artist.fanCount?.takeIf { it > 0 }?.let {
|
||||
Text("${fmtFanCount(it)} Fans", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = Color.White.copy(0.55f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action row ────────────────────────────────────────────────────────
|
||||
if (artistState.topSongs.isNotEmpty()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onSongPlay(artistState.topSongs, 0) },
|
||||
modifier = Modifier.weight(1f).height(42.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onSongPlay(artistState.topSongs, -1) },
|
||||
modifier = Modifier.weight(1f).height(42.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true)
|
||||
.copy(brush = androidx.compose.ui.graphics.SolidColor(NsAccent.copy(0.5f))),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(18.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (artistState.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
// ── Scrollable content ────────────────────────────────────────────────
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
|
||||
// iTunes top tracks
|
||||
if (artistState.iTracks.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Weltweit populär (Deezer)") }
|
||||
itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
|
||||
ItunesArtistTrackRow(
|
||||
track = track,
|
||||
isPreviewing = previewingId == track.trackId,
|
||||
onPreview = { onItunesTrackPreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Downloaded albums ─────────────────────────────────────────────
|
||||
cats?.let { c ->
|
||||
if (c.downloaded.isNotEmpty()) {
|
||||
item {
|
||||
ArtistSectionHeader("In Library", c.downloaded.size,
|
||||
onShowAll = if (c.downloaded.size > 4) ({ onOpenAllAlbums("In Library") }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(c.downloaded) { (nd, _) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = coverUrl(nd.coverArt),
|
||||
onClick = { onAlbumClick(nd) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Partial albums ────────────────────────────────────────────
|
||||
if (c.partial.isNotEmpty()) {
|
||||
item {
|
||||
ArtistSectionHeader("Unvollständig", c.partial.size,
|
||||
onShowAll = if (c.partial.size > 4) ({ onOpenAllAlbums("Unvollständig") }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(c.partial) { (nd, ia, counts) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
|
||||
badge = "${counts.first}/${counts.second}",
|
||||
onClick = { onPartialAlbumOpen(nd, ia) },
|
||||
onDownload = { onDownload(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Not downloaded ────────────────────────────────────────────
|
||||
if (c.notDownloaded.isNotEmpty()) {
|
||||
item {
|
||||
ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size,
|
||||
onShowAll = if (c.notDownloaded.size > 4) ({ onOpenAllAlbums("Nicht heruntergeladen") }) else null
|
||||
)
|
||||
}
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(c.notDownloaded, key = { it.collectionId ?: 0L }) { ia ->
|
||||
AlbumThumb(
|
||||
name = ia.collectionName ?: "—",
|
||||
year = ia.releaseDate?.take(4),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
||||
dimmed = true,
|
||||
onClick = { onItunesAlbumOpen(ia) },
|
||||
onDownload = { onDownload(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AlbumThumb(
|
||||
name: String,
|
||||
year: String?,
|
||||
artUrl: String?,
|
||||
badge: String? = null,
|
||||
dimmed: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(modifier = Modifier.width(130.dp)) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = artUrl, contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(130.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable(onClick = onClick)
|
||||
.then(if (dimmed) Modifier.background(Color.Black.copy(0.35f)) else Modifier),
|
||||
)
|
||||
// Track count badge for partial
|
||||
if (badge != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(6.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color.Black.copy(0.7f))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(badge, fontFamily = JetBrainsMono, fontSize = 10.sp, color = Color.White)
|
||||
}
|
||||
}
|
||||
// Download button (for partial/not-downloaded)
|
||||
if (onDownload != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(6.dp)
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent)
|
||||
.clickable(onClick = onDownload),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Download, null,
|
||||
tint = Color.White, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = if (dimmed) NsOnSurface2 else NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!year.isNullOrBlank()) {
|
||||
Text(year, fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ItunesArtistTrackRow(
|
||||
track: ItunesItem,
|
||||
isPreviewing: Boolean,
|
||||
onPreview: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPreview)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
val art = track.artworkUrl100?.replace("100x100bb", "60x60bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(4.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.trackName ?: "", style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isPreviewing) NsAccent else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
listOfNotNull(track.collectionName).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (track.previewUrl != null) {
|
||||
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
|
||||
null, tint = if (isPreviewing) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDownload, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Download, null, tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ArtistSectionHeader(title: String, count: Int? = null, onShowAll: (() -> Unit)? = null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 10.dp, end = 16.dp),
|
||||
) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.weight(1f))
|
||||
if (count != null) {
|
||||
Text("$count", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2.copy(0.5f),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp))
|
||||
}
|
||||
if (onShowAll != null) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
"Alle anzeigen",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||
modifier = Modifier.clickable(onClick = onShowAll),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Artist
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.GroupType
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.AlbumSort
|
||||
import com.novasonic.app.viewmodel.LibraryTab
|
||||
import com.novasonic.app.viewmodel.LibraryViewModel
|
||||
import com.novasonic.app.viewmodel.OfflineViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.filled.DownloadForOffline
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import com.novasonic.app.api.model.Song
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onOpenOffline: (DownloadGroupEntity) -> Unit,
|
||||
) {
|
||||
val vm: LibraryViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val offlineVm: OfflineViewModel = viewModel()
|
||||
|
||||
// Absicht aus den Favoriten ("Alle anzeigen" bei den gefolgten Künstlern) einmalig einlösen.
|
||||
LaunchedEffect(Unit) {
|
||||
if (com.novasonic.app.data.LibraryIntent.oeffneGefolgte) {
|
||||
com.novasonic.app.data.LibraryIntent.oeffneGefolgte = false
|
||||
vm.selectTab(LibraryTab.ARTISTS)
|
||||
vm.nurGefolgteUmschalten(true)
|
||||
}
|
||||
}
|
||||
val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle()
|
||||
|
||||
// Fully-downloaded source IDs per type — drives the "downloaded" badge on cards.
|
||||
val downloadedAlbumIds = remember(offlineGroups) {
|
||||
offlineGroups.filter { it.type == GroupType.ALBUM && it.totalTracks > 0 && it.downloadedTracks >= it.totalTracks }
|
||||
.map { it.sourceId }.toSet()
|
||||
}
|
||||
val downloadedPlaylistIds = remember(offlineGroups) {
|
||||
offlineGroups.filter { it.type == GroupType.PLAYLIST && it.totalTracks > 0 && it.downloadedTracks >= it.totalTracks }
|
||||
.map { it.sourceId }.toSet()
|
||||
}
|
||||
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
|
||||
|
||||
// Swipe left/right to switch library tabs, kept in sync with the TabRow (like Erkunden): a chip
|
||||
// tap animates the pager; a settled swipe updates the selected tab. Indicator is driven off the
|
||||
// pager's current page so it moves WITH the swipe.
|
||||
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { LibraryTab.entries.size }
|
||||
LaunchedEffect(state.tab) {
|
||||
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
|
||||
}
|
||||
LaunchedEffect(pagerState.settledPage) {
|
||||
val t = LibraryTab.entries[pagerState.settledPage]
|
||||
if (t != state.tab) vm.selectTab(t)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = NsAccent,
|
||||
) {
|
||||
LibraryTab.entries.forEach { tab ->
|
||||
Tab(
|
||||
selected = pagerState.currentPage == tab.ordinal,
|
||||
onClick = { vm.selectTab(tab) },
|
||||
text = {
|
||||
Text(
|
||||
when (tab) {
|
||||
LibraryTab.PLAYLISTS -> "Playlists"
|
||||
LibraryTab.ALBUMS -> "Alben"
|
||||
LibraryTab.ARTISTS -> "Künstler"
|
||||
LibraryTab.OFFLINE -> "Offline"
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
selectedContentColor = NsAccent,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||
val pageTab = LibraryTab.entries[page]
|
||||
// The Offline tab is fed by the local Room DB (offlineGroups) and must stay usable even while
|
||||
// the online library is still loading or unreachable — only gate the network-backed tabs.
|
||||
if (state.isLoading && pageTab != LibraryTab.OFFLINE) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
} else when (pageTab) {
|
||||
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
|
||||
// Sort chips
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
items(AlbumSort.entries) { s ->
|
||||
FilterChip(
|
||||
selected = state.albumSort == s,
|
||||
onClick = { vm.setAlbumSort(s) },
|
||||
label = {
|
||||
Text(when (s) {
|
||||
AlbumSort.NEWEST -> "Neueste"
|
||||
AlbumSort.OLDEST -> "Älteste"
|
||||
AlbumSort.AZ -> "A-Z"
|
||||
}, fontSize = 12.sp)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
selectedLeadingIconColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val sortedAlbums = remember(state.albumsByNewest, state.albumsByName, state.albumSort) {
|
||||
when (state.albumSort) {
|
||||
AlbumSort.NEWEST -> state.albumsByNewest // already ordered by Navidrome add-date
|
||||
AlbumSort.AZ -> state.albumsByName // already ordered alphabetically
|
||||
AlbumSort.OLDEST -> state.albumsByName.sortedWith(
|
||||
compareBy<com.novasonic.app.api.model.Album> { it.year ?: Int.MAX_VALUE }
|
||||
.thenBy { it.name.lowercase() }
|
||||
)
|
||||
}
|
||||
}
|
||||
val albumGridState = vm.albumsGridState // hoisted → survives album drill-in/back
|
||||
// Kein LaunchedEffect(state.albumSort) zum Hochscrollen: der liefe auch beim ersten
|
||||
// Compose nach dem Zurückkommen und würde die gemerkte Position sofort verwerfen.
|
||||
// Das Hochscrollen passiert jetzt in vm.setAlbumSort(), also nur bei echtem Wechsel.
|
||||
LazyVerticalGrid(
|
||||
state = albumGridState,
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(sortedAlbums, key = { it.id }) { album ->
|
||||
AlbumCard(
|
||||
album = album,
|
||||
coverUrl = coverUrl(album.coverArt),
|
||||
width = 150.dp,
|
||||
downloaded = album.id in downloadedAlbumIds,
|
||||
onClick = { onAlbumClick(album) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.ARTISTS -> {
|
||||
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
|
||||
val scope = rememberCoroutineScope()
|
||||
// "Gefolgt" filtert die vorhandene Liste — ein eigener Reiter wäre in einer Leiste,
|
||||
// die schon vier Kacheln trägt, nur Gedränge.
|
||||
val indizes = remember(state.artistIndexes, state.nurGefolgte, state.gefolgteIds) {
|
||||
if (!state.nurGefolgte) state.artistIndexes
|
||||
else state.artistIndexes
|
||||
.map { idx -> idx.copy(artist = idx.artist.filter { it.id in state.gefolgteIds }) }
|
||||
.filter { it.artist.isNotEmpty() }
|
||||
}
|
||||
val letters = remember(indizes) { indizes.map { it.name } }
|
||||
// Map letter → flat LazyColumn index of its header item
|
||||
val letterToIndex = remember(indizes) {
|
||||
buildMap<String, Int> {
|
||||
var flat = 0
|
||||
indizes.forEach { idx ->
|
||||
put(idx.name, flat)
|
||||
flat += 1 + idx.artist.size
|
||||
}
|
||||
}
|
||||
}
|
||||
var activeLetter by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 6.dp, bottom = 6.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !state.nurGefolgte,
|
||||
onClick = { vm.nurGefolgteUmschalten(false) },
|
||||
label = { Text("Alle", fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent),
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.nurGefolgte,
|
||||
onClick = { vm.nurGefolgteUmschalten(true) },
|
||||
label = {
|
||||
val n = state.gefolgteIds.size
|
||||
Text("❤ Gefolgt" + if (state.nurGefolgte && n > 0) " ($n)" else "",
|
||||
fontSize = 12.sp)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent),
|
||||
)
|
||||
}
|
||||
if (state.nurGefolgte && indizes.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("❤", fontSize = 30.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Du folgst noch niemandem", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Auf einer Künstlerseite oben rechts aufs Herz tippen.",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
indizes.forEach { idx ->
|
||||
item(key = "header_${idx.name}") {
|
||||
Text(
|
||||
text = idx.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = NsAccent,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(idx.artist, key = { it.id }) { artist ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(artist.name, fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
},
|
||||
supportingContent = if (artist.albumCount > 0) { {
|
||||
Text("${artist.albumCount} Alben",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
} } else null,
|
||||
leadingContent = {
|
||||
AsyncImage(
|
||||
model = coverUrl(artist.coverArt),
|
||||
contentDescription = artist.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().clickable { onArtistClick(artist) },
|
||||
colors = ListItemDefaults.colors(containerColor = NsBackground),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (letters.isNotEmpty()) {
|
||||
// Bubble showing active letter while dragging
|
||||
activeLetter?.let { letter ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.offset(x = (-44).dp)
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent),
|
||||
) {
|
||||
Text(
|
||||
text = letter,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = NsBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical letter strip
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.width(28.dp)
|
||||
.pointerInput(letters) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val y = event.changes.firstOrNull()?.position?.y ?: continue
|
||||
val idx = ((y / size.height) * letters.size)
|
||||
.toInt().coerceIn(0, letters.lastIndex)
|
||||
val letter = letters[idx]
|
||||
when (event.type) {
|
||||
PointerEventType.Press,
|
||||
PointerEventType.Move -> {
|
||||
event.changes.forEach { it.consume() }
|
||||
if (letter != activeLetter) {
|
||||
activeLetter = letter
|
||||
letterToIndex[letter]?.let { pos ->
|
||||
scope.launch { listState.scrollToItem(pos) }
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> activeLetter = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
letters.forEach { letter ->
|
||||
Text(
|
||||
text = letter,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = if (letter == activeLetter) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (letter == activeLetter) NsAccent else NsOnSurface3,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.PLAYLISTS -> LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 80.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(state.playlists, key = { it.id }) { pl ->
|
||||
// Besitzer nur bei fremden (geteilten) Listen — bei den eigenen waere er Rauschen.
|
||||
val von = pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }
|
||||
PlaylistCard(
|
||||
name = pl.name,
|
||||
subtitle = "${pl.songCount} Titel" + (von?.let { " · von $it" } ?: ""),
|
||||
coverUrl = coverUrl(pl.coverArt),
|
||||
downloaded = pl.id in downloadedPlaylistIds,
|
||||
onClick = { onPlaylistClick(pl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.OFFLINE -> {
|
||||
val groups = offlineGroups
|
||||
val storage by offlineVm.storageBytes.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (groups.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.DownloadDone,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface3,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"Noch nichts heruntergeladen",
|
||||
fontFamily = Outfit,
|
||||
fontSize = 15.sp,
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Öffne ein Album und tippe auf ↓",
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface3,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "%.1f MB gespeichert".format(storage / 1_000_000.0),
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface3,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
items(groups, key = { it.groupId }) { group ->
|
||||
OfflineGroupRow(
|
||||
group = group,
|
||||
coverUrl = coverUrl(group.coverArt),
|
||||
onOpen = { onOpenOffline(group) },
|
||||
onPlay = {
|
||||
scope.launch {
|
||||
val songs = offlineVm.songsForGroup(group.groupId)
|
||||
if (songs.isNotEmpty()) onPlay(songs, 0)
|
||||
}
|
||||
},
|
||||
onDelete = { groupToDelete = group },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(80.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end HorizontalPager
|
||||
} // end Column
|
||||
|
||||
if (pagerState.currentPage == LibraryTab.PLAYLISTS.ordinal) {
|
||||
FloatingActionButton(
|
||||
onClick = { showCreateDialog = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp),
|
||||
containerColor = NsAccent,
|
||||
) {
|
||||
Icon(Icons.Default.Add, "Neue Playlist", tint = NsBackground)
|
||||
}
|
||||
}
|
||||
} // end Box
|
||||
|
||||
// ── Delete offline group dialog ───────────────────────────────────────────
|
||||
groupToDelete?.let { group ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { groupToDelete = null },
|
||||
title = { Text("Löschen?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = { Text("\"${group.name}\" aus dem Offline-Speicher entfernen?", fontFamily = Outfit) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
offlineVm.deleteGroup(group.groupId)
|
||||
groupToDelete = null
|
||||
}) {
|
||||
Text("Löschen", color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { groupToDelete = null }) {
|
||||
Text("Abbrechen", color = NsOnSurface2)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Create playlist dialog ────────────────────────────────────────────────
|
||||
if (showCreateDialog) {
|
||||
var nameInput by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCreateDialog = false },
|
||||
title = { Text("Neue Playlist", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = nameInput,
|
||||
onValueChange = { nameInput = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
vm.createPlaylist(nameInput)
|
||||
showCreateDialog = false
|
||||
},
|
||||
enabled = nameInput.isNotBlank(),
|
||||
) {
|
||||
Text("Erstellen", color = NsAccent, fontFamily = Outfit, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showCreateDialog = false }) {
|
||||
Text("Abbrechen", color = NsOnSurface2)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineGroupRow(
|
||||
group: DownloadGroupEntity,
|
||||
coverUrl: String?,
|
||||
onOpen: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val isFullyDone = group.totalTracks > 0 && group.downloadedTracks >= group.totalTracks
|
||||
val subtitle = when {
|
||||
group.totalTracks == 0 -> "Ausstehend…"
|
||||
isFullyDone -> "${group.totalTracks} Titel · Vollständig"
|
||||
else -> "${group.downloadedTracks}/${group.totalTracks} Titel"
|
||||
}
|
||||
val typeLabel = when (group.type) {
|
||||
GroupType.ALBUM -> "Album"
|
||||
GroupType.PLAYLIST -> "Playlist"
|
||||
GroupType.SONG -> "Song"
|
||||
GroupType.FAVORITES -> "Favoriten"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onOpen)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = group.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = group.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "$typeLabel · $subtitle",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPlay, enabled = isFullyDone) {
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen",
|
||||
tint = if (isFullyDone) NsAccent else NsOnSurface3,
|
||||
modifier = Modifier.size(20.dp))
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Default.Delete, "Löschen", tint = NsOnSurface3, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
if (!isFullyDone && group.totalTracks > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { group.downloadedTracks.toFloat() / group.totalTracks },
|
||||
modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)),
|
||||
color = NsAccent,
|
||||
trackColor = NsSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistCard(
|
||||
name: String,
|
||||
subtitle: String,
|
||||
coverUrl: String?,
|
||||
downloaded: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, shape)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
if (downloaded) DownloadedBadge(Modifier.align(Alignment.BottomEnd).padding(6.dp))
|
||||
}
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 12.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Small "downloaded" pill overlaid on a cover — the down-arrow-in-circle, Spotify-style. */
|
||||
@Composable
|
||||
fun DownloadedBadge(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.55f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.DownloadForOffline,
|
||||
contentDescription = "Heruntergeladen",
|
||||
tint = NsAccent,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.ui.theme.NsAccent
|
||||
import com.novasonic.app.ui.theme.NsOnSurface2
|
||||
import com.novasonic.app.viewmodel.LoginViewModel
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(onLoggedIn: () -> Unit) {
|
||||
val vm: LoginViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
// Serveradresse vorbelegen, falls im Build hinterlegt — dann bleiben nur Benutzer und Passwort.
|
||||
var serverUrl by remember { mutableStateOf(com.novasonic.app.BuildConfig.DEFAULT_ND_URL) }
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPw by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(state.success) {
|
||||
if (state.success) onLoggedIn()
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "NovaSonic",
|
||||
color = NsAccent,
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Connect to your Navidrome server",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("https://music.example.com") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPw = !showPw }) {
|
||||
Icon(
|
||||
imageVector = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
text = state.error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { vm.login(serverUrl.trim(), username.trim(), password) },
|
||||
enabled = !state.isLoading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), color = MaterialTheme.colorScheme.background, strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("Connect", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
focusedLabelColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface2,
|
||||
unfocusedLabelColor = NsOnSurface2,
|
||||
cursorColor = NsAccent,
|
||||
)
|
||||
@@ -0,0 +1,992 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import androidx.palette.graphics.Palette
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.player.PlayerController
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.LyricsViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
|
||||
private enum class NpTab { PLAYER, LYRICS, QUEUE }
|
||||
|
||||
// ── Obergrenzen fuer grosse Anzeigen (Fold aufgeklappt, Tablet) ──────────────────────────────
|
||||
// Bis 1.26 hatte diese Ansicht keine Breitengrenze: das Cover war `fillMaxWidth` mal
|
||||
// `aspectRatio(1f)`. Auf einem Handy sind das knappe 360 dp und passt; auf dem inneren Display
|
||||
// eines Fold sind es ueber 800 dp — ein quadratisches Cover dieser Groesse schob Titel,
|
||||
// Fortschrittsbalken und Knoepfe aus dem Bild, und die ganze Seite wirkte ueberdimensional.
|
||||
// Die Web-Ansicht deckelt dasselbe seit immer bei 480 px (`.np-inner { max-width: 480px }`).
|
||||
// Zusaetzlich wird das Cover gegen die HOEHE gedeckelt: bei einem fast quadratischen Display ist
|
||||
// die Breite kein gutes Mass mehr fuer ein quadratisches Bild.
|
||||
private val NP_SPALTE_MAX = 480.dp // Player-Seite: Cover, Titel, Fortschritt, Knoepfe
|
||||
private val NP_LISTE_MAX = 560.dp // Liedtext und Warteschlange — Listen duerfen etwas breiter
|
||||
private val NP_COVER_MAX = 400.dp
|
||||
// Was Titel, Album, Kuenstler, Fortschrittsbalken, Zeiten, Knopfreihe und die Abstaende
|
||||
// dazwischen zusammen brauchen (gemessen an den gesetzten Groessen, mit etwas Luft). Das Cover
|
||||
// bekommt den Rest — so passt die Seite auch auf kurze Anzeigen, wo bisher die Knopfreihe
|
||||
// unten abgeschnitten wurde.
|
||||
private val NP_RESTBEDARF = 260.dp
|
||||
// Auf kurzen Anzeigen (Flip-Frontdisplay, Querformat) schrumpfen Abstaende und Abspielknopf
|
||||
// zusaetzlich, der Rest braucht dann weniger — und wenn es trotzdem nicht reicht, laesst sich
|
||||
// die Seite rollen.
|
||||
private val NP_RESTBEDARF_KURZ = 200.dp
|
||||
|
||||
@Composable
|
||||
fun NowPlayingScreen(
|
||||
state: PlayerState,
|
||||
coverUrl: String?,
|
||||
queueCoverUrl: ((String?) -> String?)? = null,
|
||||
controller: PlayerController,
|
||||
onDismiss: () -> Unit,
|
||||
onStarToggle: (() -> Unit)? = null,
|
||||
onShowOptions: (() -> Unit)? = null,
|
||||
onArtistClick: ((artistId: String) -> Unit)? = null,
|
||||
onToggleRadio: (() -> Unit)? = null,
|
||||
) {
|
||||
val song = state.currentSong
|
||||
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
||||
|
||||
// Waagerechtes Wischen: standardmaessig wechselt es den TITEL — das ist die Geste, die man hier
|
||||
// staendig braucht. Wer lieber zwischen Player, Warteschlange und Text blaettert, stellt es in
|
||||
// den Einstellungen um; dann uebernimmt wieder der Pager. Die Reiter oben schalten in beiden
|
||||
// Faellen weiter, es geht also nie eine Ansicht verloren.
|
||||
val prefsCtx = LocalContext.current
|
||||
val wischTitel by produceState(initialValue = true, prefsCtx) {
|
||||
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings.collect { value = it.wischenWechseltTitel }
|
||||
}
|
||||
val pagerState = rememberPagerState(initialPage = tab.ordinal) { NpTab.entries.size }
|
||||
LaunchedEffect(tab) {
|
||||
if (pagerState.currentPage != tab.ordinal) pagerState.animateScrollToPage(tab.ordinal)
|
||||
}
|
||||
LaunchedEffect(pagerState.settledPage) {
|
||||
val t = NpTab.entries[pagerState.settledPage]
|
||||
if (t != tab) tab = t
|
||||
}
|
||||
|
||||
// Preload lyrics as soon as NowPlaying opens — so the Lyrics tab shows instantly
|
||||
val lyricsVm: LyricsViewModel = viewModel()
|
||||
LaunchedEffect(song?.id) {
|
||||
if (song != null) lyricsVm.loadForSong(song)
|
||||
}
|
||||
|
||||
val statusBarTop: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getTop(this).toDp() }
|
||||
val topPad = maxOf(statusBarTop + 16.dp, 52.dp)
|
||||
val navBarBottom: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getBottom(this).toDp() }
|
||||
|
||||
// Apple Music-style: cover scales up while playing
|
||||
val coverScale by animateFloatAsState(
|
||||
targetValue = if (state.isPlaying) 1f else 0.86f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
),
|
||||
label = "coverScale",
|
||||
)
|
||||
|
||||
// Extract dominant color from cover art for background tinting
|
||||
val context = LocalContext.current
|
||||
var dominantColor by remember(coverUrl) { mutableStateOf<Color?>(null) }
|
||||
LaunchedEffect(coverUrl) {
|
||||
dominantColor = null
|
||||
if (coverUrl.isNullOrBlank()) return@LaunchedEffect
|
||||
try {
|
||||
val loader = ImageLoader(context)
|
||||
val req = ImageRequest.Builder(context).data(coverUrl).allowHardware(false).size(80).build()
|
||||
val result = loader.execute(req)
|
||||
if (result is SuccessResult) {
|
||||
val bitmap = (result.drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect
|
||||
val palette = withContext(Dispatchers.Default) { Palette.from(bitmap).generate() }
|
||||
val swatch = palette.vibrantSwatch ?: palette.mutedSwatch ?: palette.dominantSwatch
|
||||
swatch?.rgb?.let { dominantColor = Color(it) }
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// Blend vibrant color toward darker for the background without losing the hue
|
||||
val rawBgColor = dominantColor?.let { c ->
|
||||
Color(
|
||||
red = (c.red * 0.55f).coerceIn(0f, 1f),
|
||||
green = (c.green * 0.55f).coerceIn(0f, 1f),
|
||||
blue = (c.blue * 0.55f).coerceIn(0f, 1f),
|
||||
)
|
||||
} ?: Color(0xFF0D0D1A)
|
||||
val animatedBgColor by animateColorAsState(
|
||||
targetValue = rawBgColor,
|
||||
animationSpec = tween(600),
|
||||
label = "npBg",
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// Dominant-color gradient — covers top 70% of screen
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to animatedBgColor,
|
||||
0.65f to animatedBgColor.copy(alpha = 0.5f),
|
||||
1f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
// Light scrim for readability
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.15f)))
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// Zwei Groessenfragen entscheiden das Layout, nicht ein Geraetename:
|
||||
// schmal — passt die Kopfzeile mit drei Reitern noch in voller Groesse? (Flip-Deckel)
|
||||
// kurz — bleibt nach Kopfzeile und Raendern ueberhaupt Platz? (Flip-Deckel, Querformat)
|
||||
// Die Schwelle liegt bewusst UNTER 360 dp: 360 ist noch ein gewoehnliches Handy und
|
||||
// soll die Kopfzeile unveraendert behalten. Erfasst werden damit der Deckel eines Fold
|
||||
// (~352 dp) und — ueber `kurz` — das Frontdisplay eines Flip, das bei aehnlicher Breite
|
||||
// nur ein Drittel der Hoehe hat und wo drei Reiter in voller Groesse nicht hinpassen.
|
||||
val schmal = maxWidth < 356.dp || maxHeight < 560.dp
|
||||
val kurz = maxHeight < 560.dp
|
||||
val obenPad = if (kurz) maxOf(statusBarTop + 4.dp, 20.dp) else topPad
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = obenPad, bottom = maxOf(navBarBottom, if (kurz) 8.dp else 20.dp))
|
||||
.pointerInput(Unit) {
|
||||
var dragY = 0f
|
||||
detectVerticalDragGestures(
|
||||
onDragEnd = { if (dragY > 120f) onDismiss(); dragY = 0f },
|
||||
onDragCancel = { dragY = 0f },
|
||||
onVerticalDrag = { _, dy -> if (dy > 0) dragY += dy },
|
||||
)
|
||||
},
|
||||
) {
|
||||
// ── Top bar ────────────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
// Ohne Grenze stehen Schliessen-Knopf und Menue auf einem breiten Display
|
||||
// ganz aussen und die Reiter einsam in der Mitte.
|
||||
.widthIn(max = NP_LISTE_MAX)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = if (schmal) 8.dp else 20.dp)
|
||||
.padding(bottom = if (kurz) 10.dp else 28.dp),
|
||||
) {
|
||||
NpCircleBtn(onClick = onDismiss) {
|
||||
Icon(Icons.Default.KeyboardArrowDown, "Schließen",
|
||||
tint = Color.White, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
// Tab pills
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.White.copy(alpha = 0.1f))
|
||||
.padding(3.dp),
|
||||
) {
|
||||
NpTab.entries.forEach { t ->
|
||||
val sel = pagerState.currentPage == t.ordinal
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
|
||||
.clickable { tab = t }
|
||||
.padding(
|
||||
horizontal = if (schmal) 9.dp else 18.dp,
|
||||
vertical = if (kurz) 6.dp else 9.dp,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
when (t) {
|
||||
NpTab.PLAYER -> "Player"
|
||||
NpTab.LYRICS -> "Lyrics"
|
||||
NpTab.QUEUE -> "Queue"
|
||||
},
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = if (schmal) 11.sp else 13.sp,
|
||||
color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onShowOptions != null) {
|
||||
NpCircleBtn(onClick = onShowOptions) {
|
||||
Icon(Icons.Default.MoreVert, "Optionen",
|
||||
tint = Color.White, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(36.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Swipeable tab content (Player / Lyrics / Queue) ────────────
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = !wischTitel,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
) { page ->
|
||||
// Die ganze Player-Seite nimmt die Wischgeste an, nicht nur das Cover: sonst muesste
|
||||
// man genau das Bild treffen, und darunter liegt die halbe Seite. Auf Warteschlange
|
||||
// und Text liegt sie NICHT — dort wuerde sie mit dem Ziehen in der Liste streiten.
|
||||
var seitenDrag by remember { mutableStateOf(0f) }
|
||||
val wischbar = wischTitel && NpTab.entries[page] == NpTab.PLAYER
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(
|
||||
if (!wischbar) Modifier else Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = {
|
||||
if (seitenDrag < -80) controller.skipNext()
|
||||
else if (seitenDrag > 80) controller.skipPrev()
|
||||
seitenDrag = 0f
|
||||
},
|
||||
onDragCancel = { seitenDrag = 0f },
|
||||
onHorizontalDrag = { _, d -> seitenDrag += d },
|
||||
)
|
||||
}
|
||||
),
|
||||
) {
|
||||
when (NpTab.entries[page]) {
|
||||
// ── PLAYER TAB ─────────────────────────────────────────────────
|
||||
NpTab.PLAYER -> BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
var dragX by remember { mutableStateOf(0f) }
|
||||
// Das Cover nimmt das KLEINSTE aus drei Maszen: verfuegbare Breite (minus der
|
||||
// bisherigen 24 dp Rand je Seite), die Hoehe abzueglich dessen, was der Rest der
|
||||
// Seite braucht, und eine feste Obergrenze. Auf einem hohen Handy gewinnt die
|
||||
// Breite — dort bleibt alles wie bisher. Aufgeklappt greifen die anderen beiden.
|
||||
val coverKante = minOf(
|
||||
maxWidth - 48.dp,
|
||||
(maxHeight - if (kurz) NP_RESTBEDARF_KURZ else NP_RESTBEDARF).coerceAtLeast(120.dp),
|
||||
NP_COVER_MAX,
|
||||
)
|
||||
val spalte = minOf(maxWidth, NP_SPALTE_MAX)
|
||||
// Vor dem Rollbereich festhalten: innerhalb des Box-Lambdas ist `maxHeight` des
|
||||
// aeusseren Messrahmens nicht mehr erreichbar (Compose sperrt gleich markierte
|
||||
// Layout-Bereiche gegeneinander ab).
|
||||
val seitenHoehe = maxHeight
|
||||
// Greift ueberhaupt eine der neuen Grenzen? Nur dann weicht das Layout von dem
|
||||
// ab, was ein hohes Handy bisher zeigte. Solange die BREITE das engste Mass ist
|
||||
// — also auf jedem gewoehnlichen Handy —, bleibt es beim alten Aufbau: oben
|
||||
// beginnend mit 28 dp Abstand. Erst wenn Hoehe oder Obergrenze beissen (Fold
|
||||
// aufgeklappt, Tablet, Querformat), wird senkrecht zentriert; oben klebend mit
|
||||
// einem Loch darunter saehe dort falsch aus.
|
||||
val gedeckelt = coverKante < maxWidth - 48.dp || kurz
|
||||
|
||||
// Passt es, wird es angeordnet wie oben beschrieben; passt es nicht, wird
|
||||
// gescrollt. Beides mit einem Aufbau: der Inhalt ist mindestens so hoch wie die
|
||||
// Seite, darf aber darueber hinauswachsen — dann hat der Rollbereich etwas zu
|
||||
// rollen. Auf dem Frontdisplay eines Flip bleibt sonst nichts uebrig, was ohne
|
||||
// Abschneiden darstellbar waere. Passt alles, ist der Rollweg null: dann
|
||||
// verhaelt sich die Seite wie ohne Rollbereich.
|
||||
// Die Wischgeste zum Schliessen bleibt erhalten: ein Rollbereich ohne Weg
|
||||
// verbraucht die Geste nicht, sie erreicht also weiter die Seite darunter.
|
||||
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = if (gedeckelt) Arrangement.Center else Arrangement.Top,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = seitenHoehe),
|
||||
) {
|
||||
if (!gedeckelt) Spacer(Modifier.height(28.dp))
|
||||
// Cover art — gedeckelt, scale-animates like Apple Music
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = song?.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(coverKante)
|
||||
.graphicsLayer { scaleX = coverScale; scaleY = coverScale }
|
||||
.shadow(40.dp, RoundedCornerShape(20.dp))
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(NsSurface2)
|
||||
.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = {
|
||||
if (dragX < -80) controller.skipNext()
|
||||
else if (dragX > 80) controller.skipPrev()
|
||||
dragX = 0f
|
||||
},
|
||||
onDragCancel = { dragX = 0f },
|
||||
onHorizontalDrag = { _, d -> dragX += d },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(if (kurz) 12.dp else 24.dp))
|
||||
|
||||
// ── Song info ──────────────────────────────────────────────
|
||||
Column(Modifier.width(spalte).padding(horizontal = 28.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
song?.title ?: "Nichts spielt",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp,
|
||||
letterSpacing = (-0.4).sp, color = Color.White,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
// Order matches the web player: title → album → artists.
|
||||
if (!song?.album.isNullOrBlank()) {
|
||||
Text(
|
||||
song!!.album!!,
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
}
|
||||
if (song != null && !song.artist.isNullOrBlank()) {
|
||||
// Prefer OpenSubsonic artists array (has IDs per contributor).
|
||||
// Fall back to splitting artist string by ';' (same as web app).
|
||||
val raw: List<com.novasonic.app.api.model.SongArtist> =
|
||||
if (song.artists.size > 1) song.artists
|
||||
else song.artist!!.split(";").map { part ->
|
||||
val n = part.trim()
|
||||
com.novasonic.app.api.model.SongArtist(
|
||||
id = if (n.equals(song.artist?.trim(), ignoreCase = true)) song.artistId ?: "" else "",
|
||||
name = n,
|
||||
)
|
||||
}.filter { it.name.isNotBlank() }
|
||||
|
||||
val artistList = raw
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
artistList.forEachIndexed { idx, a ->
|
||||
if (idx > 0) {
|
||||
Text(
|
||||
" · ",
|
||||
fontFamily = Outfit,
|
||||
fontSize = 14.sp,
|
||||
color = Color.White.copy(alpha = 0.35f),
|
||||
)
|
||||
}
|
||||
val navigable = onArtistClick != null && a.id.isNotBlank()
|
||||
Text(
|
||||
text = a.name,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
color = if (navigable) NsAccent else NsAccent.copy(alpha = 0.55f),
|
||||
modifier = if (navigable)
|
||||
Modifier.pointerInput(a.id) {
|
||||
detectTapGestures { onArtistClick!!(a.id) }
|
||||
}
|
||||
else Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onStarToggle != null && song != null) {
|
||||
val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState()
|
||||
val starred = starOverrides[song.id] ?: (song.starred != null)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(40.dp).clip(CircleShape)
|
||||
.clickable(onClick = onStarToggle),
|
||||
) {
|
||||
Icon(
|
||||
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
null,
|
||||
tint = if (starred) NsAccent else Color.White.copy(0.5f),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(if (kurz) 10.dp else 20.dp))
|
||||
|
||||
// ── Seek bar ───────────────────────────────────────────────
|
||||
Column(Modifier.width(spalte).padding(horizontal = 24.dp)) {
|
||||
val progress = if (state.durationMs > 0)
|
||||
(state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 0f
|
||||
|
||||
NpSeekBar(
|
||||
progress = progress,
|
||||
onSeek = { controller.seekTo((it * state.durationMs).toLong()) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatMs(state.positionMs), fontFamily = JetBrainsMono, fontSize = 11.sp,
|
||||
color = Color.White.copy(0.4f))
|
||||
Text(formatMs(state.durationMs), fontFamily = JetBrainsMono, fontSize = 11.sp,
|
||||
color = Color.White.copy(0.4f))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(if (kurz) 4.dp else 12.dp))
|
||||
|
||||
// ── Controls ───────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier.width(spalte).padding(horizontal = 16.dp),
|
||||
) {
|
||||
NpCircleBtn(onClick = { controller.toggleShuffle() }, aktiv = state.shuffled) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
|
||||
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
|
||||
}
|
||||
NpCircleBtn(onClick = { controller.skipPrev() }) {
|
||||
Icon(Icons.Default.SkipPrevious, null, modifier = Modifier.size(34.dp),
|
||||
tint = Color.White)
|
||||
}
|
||||
// Big play/pause
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(if (kurz) 56.dp else 68.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.clickable { controller.togglePlay() },
|
||||
) {
|
||||
Icon(
|
||||
if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
null,
|
||||
tint = Color.Black, modifier = Modifier.size(34.dp),
|
||||
)
|
||||
}
|
||||
NpCircleBtn(onClick = { controller.skipNext() }) {
|
||||
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
|
||||
}
|
||||
val wiederholt = state.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF
|
||||
NpCircleBtn(onClick = { controller.toggleRepeat() }, aktiv = wiederholt) {
|
||||
val (icon, tint) = when (state.repeatMode) {
|
||||
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
|
||||
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
|
||||
else -> Icons.Default.Repeat to Color.White.copy(0.65f)
|
||||
}
|
||||
Icon(icon, null, modifier = Modifier.size(22.dp), tint = tint)
|
||||
}
|
||||
}
|
||||
} // end Column (gedeckelte Player-Spalte)
|
||||
} // end Box (Rollbereich, falls die Seite nicht reicht)
|
||||
}
|
||||
|
||||
// ── LYRICS TAB ─────────────────────────────────────────────────
|
||||
NpTab.LYRICS -> if (song != null) {
|
||||
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
||||
val lyricsListState = rememberLazyListState()
|
||||
val density = LocalDensity.current
|
||||
var lyricsViewH by remember { mutableIntStateOf(0) }
|
||||
// Mitlaufen: Standard AUS. Der Schalter sitzt hier im Reiter, nicht in den
|
||||
// Einstellungen — man entscheidet beim Lesen, nicht vorher.
|
||||
val folgtMit by produceState(initialValue = false, prefsCtx) {
|
||||
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings
|
||||
.collect { value = it.lyricsFolgenAutomatisch }
|
||||
}
|
||||
val folgenScope = rememberCoroutineScope()
|
||||
// Anchor the active line at 40% from the top — slightly above middle reads more naturally.
|
||||
val anchorFrac = 0.40f
|
||||
|
||||
// 200ms lookahead — same as web app for better sync feel
|
||||
val activeIdx = if (lyricsState.lines.isNotEmpty())
|
||||
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0)
|
||||
else -1
|
||||
|
||||
// Center the active line using its *measured* position — no cumulative drift, so
|
||||
// wrapped (multi-line) lyrics stay centered. Re-runs once the viewport is measured.
|
||||
LaunchedEffect(activeIdx, lyricsViewH, folgtMit) {
|
||||
// Ohne Mitlaufen wird nur hervorgehoben, nicht gescrollt — sonst springt der
|
||||
// Text unter dem Finger weg, sobald die naechste Zeile beginnt.
|
||||
if (!folgtMit) return@LaunchedEffect
|
||||
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
|
||||
val info = lyricsListState.layoutInfo
|
||||
val anchor = info.viewportStartOffset +
|
||||
((info.viewportEndOffset - info.viewportStartOffset) * anchorFrac).toInt()
|
||||
val item = info.visibleItemsInfo.firstOrNull { it.index == activeIdx }
|
||||
if (item != null) {
|
||||
val delta = (item.offset + item.size / 2 - anchor).toFloat()
|
||||
lyricsListState.animateScrollBy(
|
||||
delta,
|
||||
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
|
||||
)
|
||||
} else {
|
||||
// Big jump (instrumental gap → chorus): approximate-anchor now, refine next tick
|
||||
lyricsListState.animateScrollToItem(activeIdx, -anchor)
|
||||
}
|
||||
}
|
||||
|
||||
if (lyricsState.lines.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = NP_LISTE_MAX)
|
||||
.fillMaxWidth().padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
if (folgtMit) NsAccent.copy(0.18f) else Color.White.copy(0.08f)
|
||||
)
|
||||
.clickable {
|
||||
folgenScope.launch {
|
||||
com.novasonic.app.data.PrefsStore(prefsCtx)
|
||||
.saveLyricsFolgen(!folgtMit)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 11.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowDown, null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = if (folgtMit) NsAccent else Color.White.copy(0.55f),
|
||||
)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text(
|
||||
if (folgtMit) "Folgt" else "Mitlaufen",
|
||||
fontFamily = Outfit, fontSize = 11.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (folgtMit) NsAccent else Color.White.copy(0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
lyricsState.isLoading -> Box(
|
||||
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = NsAccent.copy(0.7f),
|
||||
modifier = Modifier.size(28.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("Lade Lyrics…", fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = Color.White.copy(0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
lyricsState.notFound -> Box(
|
||||
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("Keine Lyrics verfügbar", fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
lyricsState.lines.isNotEmpty() -> LazyColumn(
|
||||
state = lyricsListState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
// Gedeckelt: eine Textzeile ueber 800 dp liest sich nicht mehr.
|
||||
.widthIn(max = NP_LISTE_MAX)
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { lyricsViewH = it.height },
|
||||
// Asymmetric padding so the first/last lines can still reach the anchor point.
|
||||
contentPadding = if (lyricsViewH > 0) PaddingValues(
|
||||
top = with(density) { (lyricsViewH * anchorFrac).toDp() },
|
||||
bottom = with(density) { (lyricsViewH * (1f - anchorFrac)).toDp() },
|
||||
) else PaddingValues(vertical = 220.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
itemsIndexed(lyricsState.lines, key = { idx, _ -> idx }) { idx, line ->
|
||||
LyricsLineItem(
|
||||
text = line.text,
|
||||
isActive = idx == activeIdx,
|
||||
isPast = idx < activeIdx,
|
||||
onSeek = { controller.seekTo(line.timeMs) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lyricsState.plainText.isNotBlank() -> LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
.widthIn(max = NP_LISTE_MAX).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 24.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = lyricsState.plainText,
|
||||
fontFamily = Outfit, fontSize = 15.sp,
|
||||
color = Color.White.copy(0.8f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── QUEUE TAB ──────────────────────────────────────────────────
|
||||
NpTab.QUEUE -> {
|
||||
// Radio mode toggle
|
||||
if (onToggleRadio != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.widthIn(max = NP_LISTE_MAX)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (state.radioMode) NsAccent.copy(alpha = 0.18f)
|
||||
else Color.White.copy(alpha = 0.07f)
|
||||
)
|
||||
.clickable(onClick = onToggleRadio)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Radio, null,
|
||||
tint = if (state.radioMode) NsAccent else Color.White.copy(0.55f),
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Radio-Modus",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
color = if (state.radioMode) NsAccent else Color.White.copy(0.85f),
|
||||
)
|
||||
Text(
|
||||
"Lädt ähnliche Songs automatisch nach",
|
||||
fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = Color.White.copy(0.4f),
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.radioMode,
|
||||
onCheckedChange = { onToggleRadio() },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = Color.White.copy(0.6f),
|
||||
uncheckedTrackColor = Color.White.copy(0.15f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.queue.isEmpty()) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("Warteschlange leer", fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = Color.White.copy(0.4f))
|
||||
}
|
||||
} else {
|
||||
// ── Warteschlange bearbeiten ────────────────────────────
|
||||
// Gezogen wird am Griff, nicht an der Zeile: die Liste selbst scrollt, und
|
||||
// beides an derselben Stelle liesse sich nicht auseinanderhalten.
|
||||
// Getauscht wird LAUFEND, sobald der Finger eine Zeilenhoehe zurueckgelegt
|
||||
// hat — dadurch braucht es kein Geisterbild, die Liste zeigt die neue
|
||||
// Reihenfolge sofort.
|
||||
val zeilePx = with(LocalDensity.current) { 56.dp.toPx() }
|
||||
var ziehIdx by remember { mutableIntStateOf(-1) }
|
||||
var ziehOffset by remember { mutableFloatStateOf(0f) }
|
||||
val queueLaenge = rememberUpdatedState(state.queue.size)
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
.widthIn(max = NP_LISTE_MAX).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp),
|
||||
) {
|
||||
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
|
||||
val active = state.currentIndex == idx
|
||||
val wirdGezogen = ziehIdx == idx
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
if (wirdGezogen) { translationY = ziehOffset; shadowElevation = 12f }
|
||||
}
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(
|
||||
when {
|
||||
wirdGezogen -> Color.White.copy(0.16f)
|
||||
active -> Color.White.copy(0.08f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
)
|
||||
.clickable { controller.seekToItem(idx) }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = queueCoverUrl?.invoke(qSong.coverArt),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color.White.copy(0.08f)),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(qSong.title, fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp, color = if (active) NsAccent else Color.White,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!qSong.artist.isNullOrBlank()) {
|
||||
Text(qSong.artist!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(0.4f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
Icon(Icons.AutoMirrored.Filled.VolumeUp, null,
|
||||
tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
IconButton(
|
||||
onClick = { controller.entferneAusQueue(idx) },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Close, "Aus der Warteschlange entfernen",
|
||||
tint = Color.White.copy(0.45f), modifier = Modifier.size(16.dp))
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.DragHandle, "Zum Verschieben ziehen",
|
||||
tint = Color.White.copy(if (wirdGezogen) 0.9f else 0.45f),
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.padding(6.dp)
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
// Der erste Kontakt wird sofort verbraucht, sonst
|
||||
// beansprucht die LazyColumn die Geste, sobald die
|
||||
// Wischschwelle faellt.
|
||||
val start = awaitFirstDown(requireUnconsumed = false)
|
||||
start.consume()
|
||||
var aktuell = idx
|
||||
var rest = 0f
|
||||
ziehIdx = aktuell
|
||||
try {
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val p = ev.changes.firstOrNull { it.id == start.id } ?: break
|
||||
if (!p.pressed) break
|
||||
rest += p.positionChange().y
|
||||
p.consume()
|
||||
while (rest >= zeilePx && aktuell < queueLaenge.value - 1) {
|
||||
controller.verschiebeInQueue(aktuell, aktuell + 1)
|
||||
aktuell++; rest -= zeilePx
|
||||
}
|
||||
while (rest <= -zeilePx && aktuell > 0) {
|
||||
controller.verschiebeInQueue(aktuell, aktuell - 1)
|
||||
aktuell--; rest += zeilePx
|
||||
}
|
||||
ziehIdx = aktuell
|
||||
ziehOffset = rest
|
||||
}
|
||||
} finally {
|
||||
ziehIdx = -1; ziehOffset = 0f
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end when
|
||||
} // end inner Column
|
||||
} // end HorizontalPager
|
||||
}
|
||||
} // end BoxWithConstraints (schmal/kurz)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NpSeekBar(
|
||||
progress: Float,
|
||||
onSeek: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragProgress by remember { mutableStateOf(0f) }
|
||||
val displayProgress = if (isDragging) dragProgress else progress
|
||||
|
||||
val trackHeight by animateDpAsState(
|
||||
targetValue = if (isDragging) 5.dp else 3.dp,
|
||||
animationSpec = tween(120),
|
||||
label = "seekTrackHeight",
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.height(32.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val p = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
onSeek(p)
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
isDragging = true
|
||||
dragProgress = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
},
|
||||
onDragEnd = { onSeek(dragProgress); isDragging = false },
|
||||
onDragCancel = { isDragging = false },
|
||||
onDrag = { change, _ ->
|
||||
dragProgress = (change.position.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
// Background track
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(trackHeight)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.White.copy(alpha = 0.2f))
|
||||
)
|
||||
// Filled portion
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(displayProgress.coerceIn(0f, 1f))
|
||||
.height(trackHeight)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.White)
|
||||
.align(Alignment.CenterStart)
|
||||
)
|
||||
// Thumb — visible only while dragging
|
||||
if (isDragging) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(14.dp)
|
||||
.offset(x = (maxWidth * displayProgress - 7.dp).coerceIn(0.dp, maxWidth - 14.dp))
|
||||
.align(Alignment.CenterStart)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NpCircleBtn(
|
||||
onClick: () -> Unit,
|
||||
aktiv: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
// Ein eingefaerbtes Symbol allein ist als "an" schwer zu erkennen — auf dem dunklen Cover
|
||||
// sieht Akzentfarbe wie eine Schattierung aus. Ist der Schalter an, liegt deshalb zusaetzlich
|
||||
// ein getoenter Kreis darunter und ein Punkt darunter, wie in gaengigen Playern.
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (aktiv) NsAccent.copy(alpha = 0.22f) else Color.Transparent)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
content()
|
||||
if (aktiv) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 5.dp)
|
||||
.size(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMs(ms: Long): String {
|
||||
val s = (ms / 1000).coerceAtLeast(0)
|
||||
return "%d:%02d".format(s / 60, s % 60)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LyricsLineItem(
|
||||
text: String,
|
||||
isActive: Boolean,
|
||||
isPast: Boolean,
|
||||
onSeek: () -> Unit,
|
||||
) {
|
||||
val animColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isActive -> Color.White
|
||||
isPast -> Color.White.copy(alpha = 0.45f)
|
||||
else -> Color.White.copy(alpha = 0.22f)
|
||||
},
|
||||
animationSpec = tween(300),
|
||||
label = "lyricColor",
|
||||
)
|
||||
val animScale by animateFloatAsState(
|
||||
targetValue = if (isActive) 1.04f else 1f,
|
||||
animationSpec = tween(300),
|
||||
label = "lyricScale",
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 28.sp,
|
||||
color = animColor,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { scaleX = animScale; scaleY = animScale }
|
||||
.clickable { onSeek() }
|
||||
.padding(horizontal = 20.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.RemoveCircle
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.offline.DownloadState
|
||||
import com.novasonic.app.offline.toSong
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.OfflineDetailViewModel
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
|
||||
@Composable
|
||||
fun OfflineDetailScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: OfflineDetailViewModel = viewModel()
|
||||
val group by vm.group.collectAsStateWithLifecycle()
|
||||
val tracks by vm.tracks.collectAsStateWithLifecycle()
|
||||
|
||||
var editMode by remember { mutableStateOf(false) }
|
||||
// The group flow starts as null before Room emits — don't treat that initial null as "deleted".
|
||||
var everLoaded by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(group) { if (group != null) everLoaded = true }
|
||||
// Only leave once the group was loaded and has since disappeared (last song removed).
|
||||
LaunchedEffect(everLoaded, group) { if (everLoaded && group == null) onBack() }
|
||||
|
||||
val g = group ?: run {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
val songs = remember(tracks) { tracks.map { it.toSong() } }
|
||||
// Nur FERTIGE Titel sind offline abspielbar. Die Liste zeigt weiter alle (auch wartende und
|
||||
// fehlgeschlagene), aber in die Warteschlange dürfen sie nicht: für sie gibt es keine lokale
|
||||
// Datei, also liefert streamUrl() die Netz-Adresse — und die tut offline genau nichts.
|
||||
val spielbar = remember(tracks) {
|
||||
tracks.filter { it.state == DownloadState.DONE }.map { it.toSong() }
|
||||
}
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val spieleAb: (List<com.novasonic.app.api.model.Song>, Int) -> Unit = { liste, i ->
|
||||
val fehlend = songs.size - spielbar.size
|
||||
if (fehlend > 0) android.widget.Toast.makeText(
|
||||
ctx, "$fehlend Titel noch nicht heruntergeladen — übersprungen",
|
||||
android.widget.Toast.LENGTH_SHORT).show()
|
||||
onPlay(liste, i)
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
model = coverUrl(g.coverArt),
|
||||
contentDescription = g.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||
1.0f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Column(
|
||||
Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
g.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Offline · ${tracks.size} Titel",
|
||||
fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle / Edit row ─────────────────────────────────────
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, -1) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { editMode = !editMode },
|
||||
modifier = Modifier.height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(horizontal = 14.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (editMode) Icons.Default.Done else Icons.Default.Edit, null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (editMode) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { index, song ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
// Position in der abspielbaren Liste suchen — die Anzeige enthält auch
|
||||
// Titel, die noch fehlen, die Indizes decken sich also nicht.
|
||||
onPlay = {
|
||||
val i = spielbar.indexOfFirst { it.id == song.id }
|
||||
if (i >= 0) onPlay(spielbar, i)
|
||||
else android.widget.Toast.makeText(
|
||||
ctx, "Dieser Titel ist noch nicht heruntergeladen",
|
||||
android.widget.Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (editMode) {
|
||||
IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) {
|
||||
Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen",
|
||||
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.PartialAlbumState
|
||||
|
||||
private fun normTrack(s: String?) =
|
||||
// Keep Unicode letters/numbers so Japanese track titles aren't all emptied to "" (which made
|
||||
// every downloaded JP track "match" every missing one in the partial-album split).
|
||||
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "").trim()
|
||||
|
||||
@Composable
|
||||
fun PartialAlbumScreen(
|
||||
partialAlbum: PartialAlbumState,
|
||||
playerState: PlayerState,
|
||||
coverUrl: (String?) -> String?,
|
||||
previewingId: Long?,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onPreview: (ItunesItem) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
) {
|
||||
val ia = partialAlbum.itunesAlbum
|
||||
val nd = partialAlbum.ndAlbum
|
||||
|
||||
val artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
?: coverUrl(nd.coverArt)
|
||||
|
||||
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
AsyncImage(
|
||||
model = artUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(42.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
nd.name, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
nd.artist ?: ia.artistName ?: "",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onDownload(ia) }) {
|
||||
Icon(Icons.Default.Download, "Album laden", tint = NsAccent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
if (partialAlbum.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
// Cross-reference: which iTunes tracks have no ND match?
|
||||
val ndNames = partialAlbum.ndSongs.map { normTrack(it.title) }.toSet()
|
||||
val downloadedSongs = partialAlbum.ndSongs.sortedBy { it.track ?: 999 }
|
||||
val missingTracks = partialAlbum.iTracks
|
||||
.filter { normTrack(it.trackName) !in ndNames }
|
||||
.sortedBy { it.trackNumber ?: 999 }
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
// ── Play / Shuffle the downloaded tracks (you can still enjoy the album even when not
|
||||
// every track is downloaded yet) ─────────────────────────────────────────────────
|
||||
if (downloadedSongs.isNotEmpty()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsAccent)
|
||||
.clickable { onPlay(downloadedSongs, 0) }
|
||||
.padding(vertical = 11.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, tint = NsBackground, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Abspielen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsBackground)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable { onPlay(downloadedSongs, -1) }
|
||||
.padding(vertical = 11.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, tint = NsOnSurface, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Zufall", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Downloaded tracks ─────────────────────────────────────────────
|
||||
if (downloadedSongs.isNotEmpty()) {
|
||||
item {
|
||||
PartialSectionHeader(
|
||||
title = "Heruntergeladen",
|
||||
count = downloadedSongs.size,
|
||||
accent = false,
|
||||
)
|
||||
}
|
||||
itemsIndexed(downloadedSongs, key = { _, s -> "dl_${s.id}" }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(downloadedSongs, idx) },
|
||||
onStarToggle = {},
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Missing tracks (iTunes only) ──────────────────────────────────
|
||||
if (missingTracks.isNotEmpty()) {
|
||||
item {
|
||||
PartialSectionHeader(
|
||||
title = "Nicht heruntergeladen",
|
||||
count = missingTracks.size,
|
||||
accent = true,
|
||||
)
|
||||
}
|
||||
itemsIndexed(missingTracks, key = { _, t -> "miss_${t.trackId ?: 0L}" }) { _, track ->
|
||||
ItunesArtistTrackRow(
|
||||
track = track,
|
||||
isPreviewing = previewingId == track.trackId,
|
||||
onPreview = { onPreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadedSongs.isEmpty() && missingTracks.isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Tracks gefunden", color = NsOnSurface2, fontFamily = Outfit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PartialSectionHeader(title: String, count: Int, accent: Boolean) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp, end = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = if (accent) NsOnSurface2.copy(0.7f) else NsOnSurface2,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"$count",
|
||||
fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2.copy(0.5f),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.4f),
|
||||
modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.PlaylistViewModel
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
import com.novasonic.app.ui.formatGesamtdauer
|
||||
|
||||
@Composable
|
||||
fun PlaylistDetailScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onDeleted: () -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: PlaylistViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
var editMode by remember { mutableStateOf(false) }
|
||||
var showRenameDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
val downloadGroup by vm.downloadGroup.collectAsStateWithLifecycle()
|
||||
val isDownloading by vm.isDownloading.collectAsStateWithLifecycle()
|
||||
|
||||
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
|
||||
vm.uploadCover(bytes, mimeType)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.deleted) { if (state.deleted) onDeleted() }
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val pl = state.playlist ?: run {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
state.error ?: "Playlist nicht gefunden",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = onBack) { Text("Zurück") }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
model = coverUrl(pl.coverArt),
|
||||
contentDescription = pl.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||
1.0f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
// Edit + Delete buttons (top-right)
|
||||
Row(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(end = 14.dp, top = 14.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable { showRenameDialog = true },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Edit, "Bearbeiten",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable { showDeleteDialog = true },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Delete, "Löschen",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
pl.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// Der Besitzer stand hier bisher bei JEDER Liste, auch bei den eigenen, und ohne
|
||||
// Beschriftung — bei einer geteilten Liste ist genau das die interessante Angabe.
|
||||
val meta = listOfNotNull(
|
||||
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
|
||||
// Gesamtdauer: Subsonic liefert sie beim Playlist-Objekt mit, es kostet
|
||||
// also keinen zusaetzlichen Aufruf.
|
||||
if (pl.duration > 0) formatGesamtdauer(pl.duration) else null,
|
||||
pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }?.let { "von $it" },
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
meta, fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
)
|
||||
}
|
||||
if (!pl.comment.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle / Edit / Download ──────────────────────────────
|
||||
// Four buttons in one row left no room for labels (the Play/Shuffle text was being
|
||||
// clipped away), so all four are equal-width and icon-only. Play stays filled as the
|
||||
// primary action; the rest are outlined and tint accent when active/done.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, -1) }, // -1 = gemischt, der Player mischt
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
}
|
||||
// Toggle edit mode for removing songs
|
||||
OutlinedButton(
|
||||
onClick = { editMode = !editMode },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (editMode) Icons.Default.Done else Icons.Default.Edit,
|
||||
if (editMode) "Bearbeiten beenden" else "Titel entfernen",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (editMode) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
// Download button — tappable unless fully done; resumes / re-downloads missing songs
|
||||
val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false
|
||||
OutlinedButton(
|
||||
onClick = { if (!dlDone) vm.downloadPlaylist() },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
when {
|
||||
dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent)
|
||||
else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(pl.entry, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
|
||||
)
|
||||
}
|
||||
if (editMode) {
|
||||
IconButton(
|
||||
onClick = { vm.removeSong(idx) },
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.RemoveCircleOutline,
|
||||
"Entfernen",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Rename dialog ─────────────────────────────────────────────────────────
|
||||
if (showRenameDialog) {
|
||||
var nameInput by remember { mutableStateOf(pl.name) }
|
||||
var commentInput by remember { mutableStateOf(pl.comment ?: "") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRenameDialog = false },
|
||||
title = { Text("Playlist bearbeiten", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = nameInput,
|
||||
onValueChange = { nameInput = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = commentInput,
|
||||
onValueChange = { commentInput = it },
|
||||
label = { Text("Beschreibung") },
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
TextButton(
|
||||
onClick = { showRenameDialog = false; imagePicker.launch("image/*") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.AddAPhoto, null,
|
||||
tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Cover hochladen", color = NsAccent, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { vm.rename(nameInput, commentInput); showRenameDialog = false },
|
||||
enabled = nameInput.isNotBlank(),
|
||||
) {
|
||||
Text("Speichern", color = NsAccent, fontFamily = Outfit, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRenameDialog = false }) {
|
||||
Text("Abbrechen", color = NsOnSurface2)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Delete confirmation dialog ────────────────────────────────────────────
|
||||
if (showDeleteDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Playlist löschen?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Text(
|
||||
"\"${pl.name}\" wird dauerhaft gelöscht.",
|
||||
fontFamily = Outfit, color = NsOnSurface2,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { showDeleteDialog = false; vm.delete() },
|
||||
enabled = !state.isSaving,
|
||||
) {
|
||||
Text("Löschen", color = MaterialTheme.colorScheme.error,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Abbrechen", color = NsOnSurface2)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.NsAccent
|
||||
import com.novasonic.app.ui.theme.NsOnSurface2
|
||||
import com.novasonic.app.viewmodel.SearchViewModel
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onSongPlay: (List<Song>, Int) -> Unit,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
) {
|
||||
val vm: SearchViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
SearchBar(
|
||||
query = state.query,
|
||||
onQueryChange = vm::onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
|
||||
when {
|
||||
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
state.query.isBlank() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.Search, contentDescription = null,
|
||||
tint = NsOnSurface2, modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Search songs, albums, artists",
|
||||
style = MaterialTheme.typography.bodyMedium, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
state.searchResult != null -> {
|
||||
val r = state.searchResult!!
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
||||
if (r.song.isNotEmpty()) {
|
||||
item { ResultHeader("Songs") }
|
||||
items(r.song, key = { it.id }) { song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
// Play only the tapped song; Radio mode (if on) fills the rest.
|
||||
onPlay = { onSongPlay(listOf(song), 0) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (r.album.isNotEmpty()) {
|
||||
item { ResultHeader("Albums") }
|
||||
items(r.album, key = { it.id }) { album ->
|
||||
ListItem(
|
||||
headlineContent = { Text(album.name) },
|
||||
supportingContent = { Text(album.artist ?: "",
|
||||
style = MaterialTheme.typography.labelSmall) },
|
||||
modifier = Modifier.fillMaxWidth().clickable { onAlbumClick(album) },
|
||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (r.artist.isNotEmpty()) {
|
||||
item { ResultHeader("Artists") }
|
||||
items(r.artist, key = { it.id }) { artist ->
|
||||
ListItem(
|
||||
headlineContent = { Text(artist.name) },
|
||||
supportingContent = { Text("${artist.albumCount} albums",
|
||||
style = MaterialTheme.typography.labelSmall) },
|
||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchBar(query: String, onQueryChange: (String) -> Unit, modifier: Modifier) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Search…") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||
singleLine = true,
|
||||
modifier = modifier,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface2,
|
||||
cursorColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = NsAccent,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ConnStatus
|
||||
import com.novasonic.app.viewmodel.SettingsViewModel
|
||||
|
||||
private val SCROBBLE_OPTIONS = listOf(
|
||||
"15s" to "15 Sekunden",
|
||||
"30s" to "30 Sekunden",
|
||||
"third" to "1/3 der Song-Länge",
|
||||
"two_thirds" to "2/3 der Song-Länge",
|
||||
"end" to "Song-Ende",
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {}) {
|
||||
val vm: SettingsViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
var showAbsKey by remember { mutableStateOf(false) }
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
var scrobbleExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
if (showLogoutDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLogoutDialog = false },
|
||||
title = { Text("Abmelden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = { Text("Wirklich abmelden? Alle lokalen Einstellungen werden gelöscht.", fontFamily = Outfit) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { vm.logout(); showLogoutDialog = false }) {
|
||||
Text("Abmelden", color = MaterialTheme.colorScheme.error, fontFamily = Outfit)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLogoutDialog = false }) { Text("Abbrechen", fontFamily = Outfit) }
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
"Einstellungen",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp,
|
||||
color = NsOnSurface,
|
||||
)
|
||||
|
||||
// ── 📊 Statistics (TOP) ──────────────────────────────────────────────
|
||||
OutlinedButton(
|
||||
onClick = onOpenStats,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
) {
|
||||
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onOpenCompare,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
) {
|
||||
Text("🎧 Musik-Match", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
// ── Save button (TOP) ────────────────────────────────────────────────
|
||||
Spacer(Modifier.height(4.dp))
|
||||
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
||||
|
||||
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
||||
SectionHeader("🎨 Theme")
|
||||
ThemePicker(currentThemeId = state.theme, onSelect = { id -> vm.onTheme(id); applyNsTheme(id) })
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 📊 Bibliothek ────────────────────────────────────────────────────
|
||||
SectionHeader("📊 Bibliothek")
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Abspielhäufigkeit anzeigen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Zeigt Wiedergabezähler in Song-Reihen an", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Switch(
|
||||
checked = state.showPlayCount, onCheckedChange = vm::onShowPlayCount,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ── ▶ Wiedergabe ────────────────────────────────────────────────────
|
||||
SectionHeader("▶ Wiedergabe")
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Wischen wechselt den Titel", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text(
|
||||
if (state.wischenWechseltTitel)
|
||||
"Im Now-Playing: seitwärts wischen springt zum nächsten oder vorherigen Titel"
|
||||
else
|
||||
"Im Now-Playing: seitwärts wischen blättert zwischen Player, Warteschlange und Text",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.wischenWechseltTitel,
|
||||
onCheckedChange = { vm.onWischenWechseltTitel(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ── 📥 Downloads ─────────────────────────────────────────────────────
|
||||
SectionHeader("📥 Offline-Downloads")
|
||||
|
||||
var wlanWarnung by remember { mutableStateOf(false) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Nur über WLAN herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Pausiert unterwegs und macht im WLAN von selbst weiter", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Switch(
|
||||
// Ausschalten nur mit Rueckfrage: eine grosse Playlist sind schnell mehrere Gigabyte,
|
||||
// und der Downloader arbeitet sich ohne Zutun durch, bis alles da ist.
|
||||
checked = state.downloadWifiOnly,
|
||||
onCheckedChange = { an -> if (an) vm.onDownloadWifiOnly(true) else wlanWarnung = true },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (wlanWarnung) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { wlanWarnung = false },
|
||||
title = { Text("Über mobile Daten laden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Text(
|
||||
"Downloads laufen dann auch ohne WLAN weiter — und zwar so lange, bis alles " +
|
||||
"geladen ist. Eine Playlist mit 1000 Titeln sind leicht mehrere Gigabyte.",
|
||||
fontFamily = Outfit, fontSize = 14.sp,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { vm.onDownloadWifiOnly(false); wlanWarnung = false }) {
|
||||
Text("Trotzdem erlauben", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { wlanWarnung = false }) {
|
||||
Text("Abbrechen", fontFamily = Outfit, color = NsAccent)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
|
||||
onValueChange = {}, readOnly = true,
|
||||
label = { Text("Scrobble senden nach", fontFamily = Outfit) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(scrobbleExpanded) },
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = scrobbleExpanded, onDismissRequest = { scrobbleExpanded = false }, containerColor = NsSurface2) {
|
||||
SCROBBLE_OPTIONS.forEach { (key, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
|
||||
onClick = { vm.onScrobbleMode(key); scrobbleExpanded = false },
|
||||
colors = MenuDefaults.itemColors(textColor = if (key == state.scrobbleMode) NsAccent else NsOnSurface),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 📚 Audiobookshelf ────────────────────────────────────────────────
|
||||
SectionHeader("📚 Audiobookshelf")
|
||||
|
||||
NsTextField(
|
||||
value = state.absUrl, onValueChange = vm::onAbsUrl,
|
||||
label = "Server URL", placeholder = "https://audiobooks.beispiel.de",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
NsTextField(
|
||||
value = state.absApiKey, onValueChange = vm::onAbsApiKey,
|
||||
label = "API Key",
|
||||
hint = "Einstellungen → Profil → API Token in Audiobookshelf",
|
||||
visualTransformation = if (showAbsKey) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardType = KeyboardType.Password,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showAbsKey = !showAbsKey }) {
|
||||
Icon(
|
||||
if (showAbsKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null, tint = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
ConnStatusRow(status = state.absStatus, label = "Nicht konfiguriert", onTest = vm::testAbs)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Kapitel-Fortschrittsanzeige", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Fortschrittsbalken zeigt aktuelles Kapitel statt Gesamtlänge", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Switch(
|
||||
checked = state.absChapterTrackMode, onCheckedChange = vm::onAbsChapterTrackMode,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🔑 Discover APIs ─────────────────────────────────────────────────
|
||||
SectionHeader("🔑 Discover APIs")
|
||||
|
||||
NsTextField(
|
||||
value = state.lastFmKey, onValueChange = vm::onLastFmKey,
|
||||
label = "Last.fm API Key",
|
||||
hint = "Kostenlos unter last.fm/api/account/create",
|
||||
)
|
||||
// Vorbelegt aus BuildConfig (siehe build.gradle.kts): auf einem frischen Geraet muss nichts
|
||||
// eingetippt werden. Das Feld bleibt trotzdem bedienbar — zieht der Dienst um, laesst sich
|
||||
// das hier korrigieren, ohne ein neues APK zu bauen.
|
||||
NsTextField(
|
||||
value = state.backendUrl, onValueChange = vm::onBackendUrl,
|
||||
label = "NovaSonic Backend URL",
|
||||
hint = "Für Download-to-Server via yt-dlp",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend)
|
||||
|
||||
NsTextField(
|
||||
value = state.scrobbleUrl, onValueChange = vm::onScrobbleUrl,
|
||||
label = "NovaSonic Scrobble URL",
|
||||
hint = "Play-Log für Statistiken (Port 3710)",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble)
|
||||
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
||||
SectionHeader("🌐 Navidrome Server")
|
||||
|
||||
NsTextField(
|
||||
value = state.serverUrl, onValueChange = vm::onServerUrl,
|
||||
label = "Server URL", placeholder = "https://musik.beispiel.de",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
NsTextField(
|
||||
value = state.username, onValueChange = vm::onUsername,
|
||||
label = "Benutzername",
|
||||
)
|
||||
NsTextField(
|
||||
value = state.password, onValueChange = vm::onPassword,
|
||||
label = "Passwort",
|
||||
hint = "(unverändert lassen wenn kein Reset gewünscht)",
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardType = KeyboardType.Password,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(
|
||||
if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null, tint = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { showLogoutDialog = true },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(
|
||||
brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error)
|
||||
),
|
||||
) {
|
||||
Text("Abmelden", color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveButton(vm: SettingsViewModel, isSaving: Boolean, savedOk: Boolean, error: String?) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (error != null) {
|
||||
Text(error, color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontSize = 13.sp)
|
||||
}
|
||||
if (savedOk) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Check, null, tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Gespeichert", color = NsAccent, fontFamily = Outfit, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = vm::save,
|
||||
enabled = !isSaving,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
|
||||
} else {
|
||||
Text("Speichern", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnStatusRow(status: ConnStatus, label: String, onTest: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
) {
|
||||
when (status) {
|
||||
ConnStatus.Idle -> Spacer(Modifier.size(10.dp))
|
||||
ConnStatus.Checking -> CircularProgressIndicator(modifier = Modifier.size(10.dp), strokeWidth = 1.5.dp, color = NsAccent)
|
||||
is ConnStatus.Ok -> Box(Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF4CAF50)))
|
||||
is ConnStatus.Err -> Box(Modifier.size(10.dp).clip(CircleShape).background(Color(0xFFE53935)))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = when (status) {
|
||||
ConnStatus.Idle -> label
|
||||
ConnStatus.Checking -> "Verbinde…"
|
||||
is ConnStatus.Ok -> status.text
|
||||
is ConnStatus.Err -> status.text
|
||||
},
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = when (status) {
|
||||
is ConnStatus.Ok -> Color(0xFF4CAF50)
|
||||
is ConnStatus.Err -> Color(0xFFE53935)
|
||||
else -> NsOnSurface2
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onTest, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text("Testen", fontFamily = Outfit, fontSize = 12.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
|
||||
letterSpacing = 0.5.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
HorizontalDivider(color = NsBorder)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NsTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
placeholder: String = "",
|
||||
hint: String = "",
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = value, onValueChange = onValueChange,
|
||||
label = { Text(label, fontFamily = Outfit) },
|
||||
placeholder = if (placeholder.isNotBlank()) { { Text(placeholder, color = NsOnSurface2) } } else null,
|
||||
singleLine = true,
|
||||
visualTransformation = visualTransformation,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||
trailingIcon = trailingIcon,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
if (hint.isNotBlank()) {
|
||||
Text(hint, fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemePicker(currentThemeId: String, onSelect: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
NS_THEMES.forEach { theme ->
|
||||
val isActive = theme.id == currentThemeId
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(if (isActive) theme.surface else NsSurface)
|
||||
.border(
|
||||
width = if (isActive) 2.dp else 1.dp,
|
||||
color = if (isActive) theme.accent else NsBorder,
|
||||
shape = RoundedCornerShape(9.dp),
|
||||
)
|
||||
.clickable { onSelect(theme.id) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Box(Modifier.size(22.dp).clip(CircleShape).background(theme.accent))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = theme.name, fontFamily = Outfit,
|
||||
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
|
||||
fontSize = 14.sp, color = if (isActive) theme.onSurface else NsOnSurface2,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (isActive) Icon(Icons.Default.Check, null, tint = theme.accent, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent, focusedLabelColor = NsAccent, cursorColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3, unfocusedLabelColor = NsOnSurface2,
|
||||
focusedTextColor = NsOnSurface, unfocusedTextColor = NsOnSurface,
|
||||
)
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.ui.component.Rollbalken
|
||||
|
||||
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
|
||||
|
||||
/** So viele gefolgte Kuenstler stehen in der Reihe, der Rest hinter "Alle anzeigen". */
|
||||
private const val GEZEIGT = 10
|
||||
|
||||
@Composable
|
||||
fun StarredScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onArtistClick: (String) -> Unit = {},
|
||||
onAlbumClick: (String) -> Unit = {},
|
||||
onOpenNews: () -> Unit = {},
|
||||
onOpenFollowed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val repo = context.app.repository
|
||||
|
||||
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
|
||||
// getStarred2 liefert Kuenstler und Alben im SELBEN Paket wie die Songs — die Uebersicht
|
||||
// "wem folge ich" kostet also keinen zusaetzlichen Aufruf.
|
||||
var gefolgteKuenstler by remember { mutableStateOf<List<com.novasonic.app.api.model.Artist>>(emptyList()) }
|
||||
var gemerkteAlben by remember { mutableStateOf<List<com.novasonic.app.api.model.Album>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var sort by remember { mutableStateOf(StarredSort.NEWEST) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
val markiert = repo.getStarred()
|
||||
songs = markiert.song
|
||||
gefolgteKuenstler = markiert.artist.sortedBy { it.name.lowercase() }
|
||||
gemerkteAlben = markiert.album.sortedByDescending { it.starred ?: "" }
|
||||
loading = false
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val sorted = remember(songs, sort) {
|
||||
when (sort) {
|
||||
StarredSort.NEWEST -> songs.sortedByDescending { it.starred ?: "" }
|
||||
StarredSort.OLDEST -> songs.sortedBy { it.starred ?: "" }
|
||||
StarredSort.AZ -> songs.sortedBy { it.title.lowercase() }
|
||||
StarredSort.BY_ARTIST -> songs.sortedBy { "${it.artist?.lowercase()}${it.title.lowercase()}" }
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 24.dp, bottom = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Favoriten",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Einstieg in die Zeitleiste: hier stehen die gefolgten Künstler, also liegt die Frage
|
||||
// "was gibt es Neues von denen" nahe.
|
||||
if (gefolgteKuenstler.isNotEmpty()) {
|
||||
TextButton(onClick = onOpenNews) {
|
||||
Text("✨ Neu", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort chips
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 12.dp),
|
||||
) {
|
||||
StarredSort.entries.forEach { s ->
|
||||
FilterChip(
|
||||
selected = sort == s,
|
||||
onClick = { sort = s },
|
||||
label = {
|
||||
Text(when (s) {
|
||||
StarredSort.NEWEST -> "Neueste"
|
||||
StarredSort.OLDEST -> "Älteste"
|
||||
StarredSort.AZ -> "A-Z"
|
||||
StarredSort.BY_ARTIST -> "Künstler"
|
||||
}, fontSize = 12.sp)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
selectedLeadingIconColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
if (loading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
} else if (error != null) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(error!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
} else if (sorted.isEmpty() && gefolgteKuenstler.isEmpty() && gemerkteAlben.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Noch keine Favoriten", color = NsOnSurface2)
|
||||
}
|
||||
} else {
|
||||
val listState = rememberLazyListState()
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(state = listState) {
|
||||
if (gefolgteKuenstler.isNotEmpty()) {
|
||||
item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Die LazyRow baut zwar nur Sichtbares, aber eine endlose Reihe ist
|
||||
// zum Durchsehen untauglich — der Rest gehoert in die gefilterte
|
||||
// Kuenstlerliste. GEZEIGT deckt zwei Bildschirmbreiten ab.
|
||||
items(gefolgteKuenstler.take(GEZEIGT), key = { it.id }) { a ->
|
||||
GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) }
|
||||
}
|
||||
if (gefolgteKuenstler.size > GEZEIGT) {
|
||||
item(key = "alle_kuenstler") {
|
||||
AlleAnzeigen(gefolgteKuenstler.size - GEZEIGT, onOpenFollowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (gemerkteAlben.isNotEmpty()) {
|
||||
item { FavoritenLabel("Gemerkte Alben (${gemerkteAlben.size})") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(gemerkteAlben, key = { it.id }) { al ->
|
||||
AlbumCard(album = al, coverUrl = coverUrl(al.coverArt)) { onAlbumClick(al.id) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (sorted.isNotEmpty() && (gefolgteKuenstler.isNotEmpty() || gemerkteAlben.isNotEmpty())) {
|
||||
item { FavoritenLabel("Songs (${sorted.size})") }
|
||||
}
|
||||
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(sorted, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
Rollbalken(
|
||||
state = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
|
||||
farbe = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FavoritenLabel(titel: String) {
|
||||
Text(
|
||||
titel,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GefolgterKuenstler(name: String, bild: String?, albumCount: Int, onClick: () -> Unit) {
|
||||
Column(Modifier.width(110.dp).clickable(onClick = onClick)) {
|
||||
AsyncImage(
|
||||
model = bild, contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text("$albumCount Alben", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlleAnzeigen(weitere: Int, onClick: () -> Unit) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(110.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("→", fontSize = 26.sp, color = NsAccent)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Alle anzeigen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
maxLines = 1, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text("+$weitere weitere", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2, maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ScrobbleStats
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.StatsView
|
||||
import com.novasonic.app.viewmodel.StatsViewModel
|
||||
|
||||
private val MONTHS = listOf(
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun StatsScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
onBack: () -> Unit,
|
||||
onArtistClick: (String) -> Unit = {},
|
||||
onAlbumClick: (String) -> Unit = {},
|
||||
vm: StatsViewModel = viewModel(),
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// ── Top bar ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text(
|
||||
"Meine Statistiken",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
|
||||
color = NsOnSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Navigator: scope chips + year/month stepper ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
listOf(StatsView.ALL to "Alles", StatsView.YEAR to "Jahr", StatsView.MONTH to "Monat").forEach { (v, l) ->
|
||||
val active = v == state.view
|
||||
Text(
|
||||
l,
|
||||
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (active) Color.White else NsOnSurface2,
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) NsAccent else Color.Transparent)
|
||||
.clickable { vm.setView(v) }
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
if (state.view != StatsView.ALL) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
StepArrow("‹", enabled = !vm.atStart()) { vm.step(-1) }
|
||||
Text(
|
||||
if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}",
|
||||
fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
StepArrow("›", enabled = !vm.atCurrent()) { vm.step(1) }
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
|
||||
!state.configured -> CenterMessage(
|
||||
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
|
||||
)
|
||||
state.error != null -> CenterMessage(state.error!!, NsError)
|
||||
(state.stats?.totalPlays ?: 0) == 0 -> CenterMessage(
|
||||
"Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.",
|
||||
)
|
||||
else -> StatsContent(state.stats!!, coverUrl, onArtistClick, onAlbumClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsContent(
|
||||
d: ScrobbleStats,
|
||||
coverUrl: (String?) -> String?,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||
) {
|
||||
// Tiles
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(bottom = 18.dp)) {
|
||||
Tile(d.totalPlays.toString(), "Plays", Modifier.weight(1f))
|
||||
Tile(fmtTime(d.totalSeconds), "Hörzeit", Modifier.weight(1f))
|
||||
Tile(if (d.topArtists.size >= 10) "10+" else d.topArtists.size.toString(), "Künstler", Modifier.weight(1f))
|
||||
}
|
||||
|
||||
if (d.series.isNotEmpty()) {
|
||||
SectionHeader("Verlauf")
|
||||
SeriesChart(d.series)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topArtists.isNotEmpty()) {
|
||||
SectionHeader("Top-Künstler")
|
||||
d.topArtists.forEachIndexed { i, a ->
|
||||
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true,
|
||||
onClick = a.artistId?.let { id -> { onArtistClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topTracks.isNotEmpty()) {
|
||||
SectionHeader("Top-Songs")
|
||||
d.topTracks.forEachIndexed { i, t ->
|
||||
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays,
|
||||
onClick = t.albumId?.let { id -> { onAlbumClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topAlbums.isNotEmpty()) {
|
||||
SectionHeader("Top-Alben")
|
||||
d.topAlbums.forEachIndexed { i, a ->
|
||||
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays,
|
||||
onClick = a.albumId?.let { id -> { onAlbumClick(id) } })
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.topGenres.isNotEmpty()) {
|
||||
SectionHeader("Top-Genres")
|
||||
d.topGenres.forEachIndexed { i, g -> GenreRow(i + 1, g.name ?: "—", g.plays) }
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (d.byHour.any { it.plays > 0 }) {
|
||||
SectionHeader("Hör-Uhr (Stunde)")
|
||||
HourClock(d.byHour.map { it.plays })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreRow(rank: Int, name: String, plays: Int) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
color = NsOnSurface2, modifier = Modifier.width(22.dp),
|
||||
)
|
||||
Text(
|
||||
name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
|
||||
)
|
||||
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepArrow(glyph: String, enabled: Boolean, onClick: () -> Unit) {
|
||||
Text(
|
||||
glyph,
|
||||
fontFamily = Outfit, fontSize = 20.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (enabled) NsOnSurface else NsOnSurface2.copy(alpha = 0.35f),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tile(value: String, label: String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Text(value, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, color = NsOnSurface, maxLines = 1)
|
||||
Text(label, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(t: String) {
|
||||
Text(
|
||||
t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
|
||||
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopRow(
|
||||
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(vertical = 5.dp),
|
||||
) {
|
||||
Text(
|
||||
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
color = NsOnSurface2, modifier = Modifier.width(22.dp),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(if (circular) CircleShape else RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = cover, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesChart(series: List<com.novasonic.app.api.model.StatBucket>) {
|
||||
val max = (series.maxOfOrNull { it.plays } ?: 1).coerceAtLeast(1)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
modifier = Modifier.fillMaxWidth().height(110.dp).horizontalScroll(rememberScrollState()),
|
||||
) {
|
||||
series.forEach { s ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(30.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(20.dp)
|
||||
.height((6 + (84 * s.plays / max)).dp)
|
||||
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
|
||||
.background(NsAccent),
|
||||
)
|
||||
Text(
|
||||
seriesLabel(s.bucket), fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
|
||||
maxLines = 1, modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HourClock(hours: List<Int>) {
|
||||
val max = (hours.maxOrNull() ?: 1).coerceAtLeast(1)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
modifier = Modifier.fillMaxWidth().height(72.dp),
|
||||
) {
|
||||
hours.forEachIndexed { h, plays ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height((2 + (48 * plays / max)).dp)
|
||||
.clip(RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp))
|
||||
.background(if (plays > 0) NsAccent else NsSurface2),
|
||||
)
|
||||
Text(
|
||||
if (h % 6 == 0) h.toString() else "",
|
||||
fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenterMessage(msg: String, color: Color = NsOnSurface2) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
// "3h 12m" / "12m" / "45s"
|
||||
private fun fmtTime(sec: Long): String {
|
||||
val h = sec / 3600
|
||||
val m = (sec % 3600) / 60
|
||||
return when {
|
||||
h > 0 -> "${h}h ${m}m"
|
||||
m > 0 -> "${m}m"
|
||||
else -> "${sec}s"
|
||||
}
|
||||
}
|
||||
|
||||
// "2025-08" -> "08.25", "2025-08-03" -> "03.08", "2025" -> "2025"
|
||||
private fun seriesLabel(b: String): String = when {
|
||||
b.length == 7 -> b.substring(5) + "." + b.substring(2, 4)
|
||||
b.length == 10 -> b.substring(8) + "." + b.substring(5, 7)
|
||||
else -> b
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.novasonic.app.ui.theme
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// ── Dynamic theme state — Compose tracks reads automatically ─────────────────
|
||||
object NsColors {
|
||||
var themeId by mutableStateOf("dark-ocean") // currently applied theme id (readable)
|
||||
var accent by mutableStateOf(Color(0xFF4F8EF7))
|
||||
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
|
||||
var bg by mutableStateOf(Color(0xFF070D14))
|
||||
var bg2 by mutableStateOf(Color(0xFF0C1520))
|
||||
var surface by mutableStateOf(Color(0xFF182435))
|
||||
var surface2 by mutableStateOf(Color(0xFF1F2F42))
|
||||
var onSurface by mutableStateOf(Color(0xFFEDF2FF))
|
||||
var onSurface2 by mutableStateOf(Color(0xFF7088AA))
|
||||
var onSurface3 by mutableStateOf(Color(0xFF445566))
|
||||
var border by mutableStateOf(Color(0x12FFFFFF))
|
||||
var error by mutableStateOf(Color(0xFFCF6679))
|
||||
}
|
||||
|
||||
// Convenience accessors — all reads in composables are state-tracked
|
||||
val NsBackground get() = NsColors.bg
|
||||
val NsBg2 get() = NsColors.bg2
|
||||
val NsSurface get() = NsColors.surface
|
||||
val NsSurface2 get() = NsColors.surface2
|
||||
val NsAccent get() = NsColors.accent
|
||||
val NsAccentDim get() = NsColors.accentDim
|
||||
val NsOnSurface get() = NsColors.onSurface
|
||||
val NsOnSurface2 get() = NsColors.onSurface2
|
||||
val NsOnSurface3 get() = NsColors.onSurface3
|
||||
val NsBorder get() = NsColors.border
|
||||
val NsError get() = NsColors.error
|
||||
|
||||
// ── Theme definitions — mirrors src/themes.js ────────────────────────────────
|
||||
data class NsTheme(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val accent: Color,
|
||||
val accentDim: Color,
|
||||
val bg: Color,
|
||||
val bg2: Color,
|
||||
val surface: Color,
|
||||
val surface2: Color,
|
||||
val onSurface: Color,
|
||||
val onSurface2: Color,
|
||||
val onSurface3: Color,
|
||||
)
|
||||
|
||||
val NS_THEMES = listOf(
|
||||
NsTheme(
|
||||
"dark-ocean", "Dark Ocean",
|
||||
Color(0xFF4F8EF7), Color(0xFF2A6CD6),
|
||||
Color(0xFF070D14), Color(0xFF0C1520),
|
||||
Color(0xFF182435), Color(0xFF1F2F42),
|
||||
Color(0xFFEDF2FF), Color(0xFF7088AA), Color(0xFF445566),
|
||||
),
|
||||
NsTheme(
|
||||
"dark-amber", "Dark Amber",
|
||||
Color(0xFFE8A020), Color(0xFFC07010),
|
||||
Color(0xFF07070F), Color(0xFF0D0D1A),
|
||||
Color(0xFF1A1A2E), Color(0xFF22223A),
|
||||
Color(0xFFF0EEFC), Color(0xFF8888AA), Color(0xFF555577),
|
||||
),
|
||||
NsTheme(
|
||||
"forest", "Forest",
|
||||
Color(0xFF4CAF7D), Color(0xFF2D7A52),
|
||||
Color(0xFF060D08), Color(0xFF0B1410),
|
||||
Color(0xFF162218), Color(0xFF1D2E20),
|
||||
Color(0xFFEDFAF2), Color(0xFF7A9E85), Color(0xFF455C4A),
|
||||
),
|
||||
NsTheme(
|
||||
"rose-pine", "Rosé Pine",
|
||||
Color(0xFFEB6F92), Color(0xFFB8415E),
|
||||
Color(0xFF191724), Color(0xFF1F1D2E),
|
||||
Color(0xFF21202E), Color(0xFF403D52),
|
||||
Color(0xFFE0DEF4), Color(0xFF908CAA), Color(0xFF6E6A86),
|
||||
),
|
||||
NsTheme(
|
||||
"jade-green", "Jade",
|
||||
Color(0xFF2ECC71), Color(0xFF1F9E55),
|
||||
Color(0xFF07100C), Color(0xFF0C1813),
|
||||
Color(0xFF162A21), Color(0xFF1E3A2D),
|
||||
Color(0xFFE8FFF1), Color(0xFF8FBFA4), Color(0xFF557A63),
|
||||
),
|
||||
NsTheme(
|
||||
"violet", "Violet",
|
||||
Color(0xFFA78BFA), Color(0xFF7C5CF0),
|
||||
Color(0xFF0D0916), Color(0xFF140E20),
|
||||
Color(0xFF221835), Color(0xFF2D2145),
|
||||
Color(0xFFF2EDFF), Color(0xFF9B8BBD), Color(0xFF655780),
|
||||
),
|
||||
NsTheme(
|
||||
"crimson", "Crimson",
|
||||
Color(0xFFE05263), Color(0xFFB03445),
|
||||
Color(0xFF120708), Color(0xFF1A0B0D),
|
||||
Color(0xFF2B1316), Color(0xFF3A1A1E),
|
||||
Color(0xFFFFEEF0), Color(0xFFBF8D94), Color(0xFF7D545C),
|
||||
),
|
||||
)
|
||||
|
||||
fun applyNsTheme(themeId: String) {
|
||||
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
|
||||
NsColors.themeId = t.id
|
||||
NsColors.accent = t.accent
|
||||
NsColors.accentDim = t.accentDim
|
||||
NsColors.bg = t.bg
|
||||
NsColors.bg2 = t.bg2
|
||||
NsColors.surface = t.surface
|
||||
NsColors.surface2 = t.surface2
|
||||
NsColors.onSurface = t.onSurface
|
||||
NsColors.onSurface2 = t.onSurface2
|
||||
NsColors.onSurface3 = t.onSurface3
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.novasonic.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun NovaSonicTheme(content: @Composable () -> Unit) {
|
||||
// Reads NsColors mutableState — recomposes automatically when theme changes
|
||||
val colorScheme = darkColorScheme(
|
||||
background = NsColors.bg,
|
||||
surface = NsColors.surface,
|
||||
surfaceVariant = NsColors.surface2,
|
||||
primary = NsColors.accent,
|
||||
onPrimary = NsColors.bg,
|
||||
secondary = NsColors.accentDim,
|
||||
onSecondary = NsColors.bg,
|
||||
onBackground = NsColors.onSurface,
|
||||
onSurface = NsColors.onSurface,
|
||||
onSurfaceVariant = NsColors.onSurface2,
|
||||
outline = NsColors.border,
|
||||
error = NsColors.error,
|
||||
)
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = NovaSonicTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.novasonic.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.googlefonts.Font
|
||||
import androidx.compose.ui.text.googlefonts.GoogleFont
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.R
|
||||
|
||||
private val provider = GoogleFont.Provider(
|
||||
providerAuthority = "com.google.android.gms.fonts",
|
||||
providerPackage = "com.google.android.gms",
|
||||
certificates = R.array.com_google_android_gms_fonts_certs,
|
||||
)
|
||||
|
||||
private val OutfitFont = GoogleFont("Outfit")
|
||||
private val JetBrainsMonoFont = GoogleFont("JetBrains Mono")
|
||||
|
||||
val Outfit = FontFamily(
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Light),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Normal),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Medium),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.SemiBold),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Bold),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.ExtraBold),
|
||||
)
|
||||
|
||||
val JetBrainsMono = FontFamily(
|
||||
Font(JetBrainsMonoFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Normal),
|
||||
Font(JetBrainsMonoFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Medium),
|
||||
)
|
||||
|
||||
val NovaSonicTypography = Typography(
|
||||
displayLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 36.sp, letterSpacing = (-0.5).sp),
|
||||
displayMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 28.sp, letterSpacing = (-0.25).sp),
|
||||
titleLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, letterSpacing = 0.sp),
|
||||
titleMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, letterSpacing = 0.15.sp),
|
||||
titleSmall = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, letterSpacing = 0.1.sp),
|
||||
bodyLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 16.sp, letterSpacing = 0.5.sp),
|
||||
bodyMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 14.sp, letterSpacing = 0.25.sp),
|
||||
bodySmall = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 12.sp, letterSpacing = 0.4.sp),
|
||||
labelLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, letterSpacing = 0.1.sp),
|
||||
labelMedium = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 12.sp, letterSpacing = 0.5.sp, color = NsOnSurface2),
|
||||
labelSmall = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 11.sp, letterSpacing = 0.5.sp, color = NsOnSurface2),
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.DownloadState
|
||||
import com.novasonic.app.offline.GroupType
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AlbumUiState(
|
||||
val album: Album? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
/** Album merken — optimistisch ueber StarState, faellt bei einem Fehlschlag zurueck. */
|
||||
fun merkenUmschalten(albumId: String, aktuellMarkiert: Boolean) =
|
||||
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, albumId, aktuellMarkiert)
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
private val albumId: String? = savedState["albumId"]
|
||||
|
||||
private val _state = MutableStateFlow(AlbumUiState())
|
||||
val state: StateFlow<AlbumUiState> = _state
|
||||
|
||||
val downloadGroup: StateFlow<DownloadGroupEntity?> = offlineRepo.allGroupsFlow
|
||||
.map { groups -> groups.find { it.groupId == "album:$albumId" } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
/** True while any track in this album's group is queued or actively downloading. */
|
||||
val isDownloading: StateFlow<Boolean> = offlineRepo.tracksForGroupFlow("album:$albumId")
|
||||
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
init {
|
||||
val id = albumId
|
||||
if (id.isNullOrBlank()) {
|
||||
_state.value = AlbumUiState(isLoading = false, error = "Keine Album-ID übergeben")
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val album = repo.getAlbum(id)
|
||||
_state.value = AlbumUiState(album = album, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = AlbumUiState(error = e.message ?: e.toString(), isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAlbum() {
|
||||
val album = _state.value.album ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
offlineRepo.enqueueGroup(
|
||||
type = GroupType.ALBUM,
|
||||
sourceId = album.id,
|
||||
name = album.name,
|
||||
coverArt = album.coverArt,
|
||||
songs = album.song,
|
||||
)
|
||||
}.onFailure { android.util.Log.e("AlbumViewModel", "enqueueGroup failed", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ReleasesClient
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistKonzert
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Welcher Reiter gezeigt wird. Getrennte Listen, weil die Zeit in verschiedene Richtungen läuft. */
|
||||
enum class NewsReiter { RELEASES, KONZERTE }
|
||||
|
||||
data class ArtistNewsUiState(
|
||||
val reiter: NewsReiter = NewsReiter.RELEASES,
|
||||
val releases: List<ArtistRelease> = emptyList(),
|
||||
val konzerte: List<ArtistKonzert> = emptyList(),
|
||||
val keinSchluessel: Boolean = false,
|
||||
/** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */
|
||||
val vorhanden: Set<String> = emptySet(),
|
||||
val ohneZuordnung: List<String> = emptyList(),
|
||||
val tage: Int = 90,
|
||||
val isLoading: Boolean = true,
|
||||
val keineGefolgten: Boolean = false,
|
||||
val keinBackend: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* "Neu von deinen Künstlern". Gefolgt = Navidromes Stern, die Künstlerliste kommt also aus
|
||||
* getStarred2. Die Deezer-Auflösung macht das Backend — dort liegen die Pins und der gemeinsame
|
||||
* Cache, sonst würde jedes Gerät bei jedem Öffnen dieselben Abfragen wiederholen.
|
||||
*/
|
||||
class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(ArtistNewsUiState())
|
||||
val state: StateFlow<ArtistNewsUiState> = _state
|
||||
|
||||
init { laden(90) }
|
||||
|
||||
/** Reiter wechseln und, falls noch nichts geladen ist, nachladen. */
|
||||
fun zeigeReiter(r: NewsReiter) {
|
||||
_state.value = _state.value.copy(reiter = r)
|
||||
if (r == NewsReiter.KONZERTE && _state.value.konzerte.isEmpty()) ladeKonzerte()
|
||||
}
|
||||
|
||||
/** Zeitfenster setzen — gibt es nur bei den Veroeffentlichungen. */
|
||||
fun setzeTage(tage: Int) = laden(tage)
|
||||
|
||||
// Kein Zeitfenster: Ticketmaster liefert ohnehin nur Anstehendes, und dort will man alles
|
||||
// Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
|
||||
fun ladeKonzerte() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null,
|
||||
keineGefolgten = false, keinBackend = false, keinSchluessel = false)
|
||||
try {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
|
||||
return@launch
|
||||
}
|
||||
val kuenstler = repo.getStarred().artist.map { ArtistRef(it.id, it.name) }
|
||||
if (kuenstler.isEmpty()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
|
||||
return@launch
|
||||
}
|
||||
val antwort = ReleasesClient.konzerte(backendUrl, kuenstler)
|
||||
_state.value = _state.value.copy(
|
||||
konzerte = antwort.konzerte,
|
||||
keinSchluessel = antwort.keinSchluessel,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun laden(tage: Int = _state.value.tage) = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, tage = tage, error = null,
|
||||
reiter = NewsReiter.RELEASES, keineGefolgten = false, keinBackend = false)
|
||||
try {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
|
||||
return@launch
|
||||
}
|
||||
val markiert = repo.getStarred()
|
||||
val kuenstler = markiert.artist.map { ArtistRef(it.id, it.name) }
|
||||
if (kuenstler.isEmpty()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val antwort = ReleasesClient.fetch(backendUrl, kuenstler, tage)
|
||||
|
||||
// Was liegt schon in der Bibliothek? Verglichen wird über Künstler + Albumname —
|
||||
// Deezers Album-Id hat mit Navidromes nichts zu tun.
|
||||
val vorhanden = withContext(Dispatchers.IO) {
|
||||
val ausFavoriten = markiert.album.map { schluessel(it.artist ?: "", it.name) }
|
||||
val ausBibliothek = kuenstler.chunked(6).flatMap { haeppchen ->
|
||||
haeppchen.map { k ->
|
||||
async {
|
||||
runCatching { repo.getArtist(k.id).album.map { schluessel(k.name, it.name) } }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
}.awaitAll().flatten()
|
||||
}
|
||||
(ausFavoriten + ausBibliothek).toSet()
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
releases = antwort.releases,
|
||||
ohneZuordnung = antwort.ohneZuordnung,
|
||||
vorhanden = vorhanden,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun hatSchon(rel: ArtistRelease): Boolean =
|
||||
schluessel(rel.artistName, rel.title) in _state.value.vorhanden
|
||||
|
||||
fun herunterladen(rel: ArtistRelease, onDone: (Boolean, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) { onDone(false, "Kein Backend konfiguriert"); return@launch }
|
||||
val request = DownloadRequest(
|
||||
jobId = "job_${System.currentTimeMillis()}",
|
||||
type = "album",
|
||||
title = rel.title,
|
||||
artist = rel.artistName,
|
||||
artworkUrl = rel.cover.takeIf { it.isNotBlank() },
|
||||
collectionId = rel.albumId.toLongOrNull(),
|
||||
itunesId = rel.albumId.toLongOrNull(),
|
||||
)
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess {
|
||||
NotificationStore.startPoll(
|
||||
backendUrl, request.jobId, request.title, request.artist ?: "",
|
||||
navType = "album", navAlbum = rel.title, navArtist = rel.artistName,
|
||||
)
|
||||
onDone(true, "✓ Download gestartet")
|
||||
}
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun norm(s: String) = s.lowercase()
|
||||
.replace(Regex("[^a-z0-9]"), "")
|
||||
fun schluessel(kuenstler: String, album: String) = "${norm(kuenstler)}~~${norm(album)}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.EventsClient
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ItunesClient
|
||||
import com.novasonic.app.api.model.Artist
|
||||
import com.novasonic.app.api.model.ArtistOverride
|
||||
import com.novasonic.app.api.model.ConcertEvent
|
||||
import com.novasonic.app.api.model.ArtistInfo
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class ArtistUiState(
|
||||
val artist: Artist? = null,
|
||||
val info: ArtistInfo? = null,
|
||||
val topSongs: List<Song> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
// iTunes
|
||||
val iAlbums: List<ItunesItem> = emptyList(),
|
||||
val iTracks: List<ItunesItem> = emptyList(),
|
||||
val itunesLoading: Boolean = false,
|
||||
// Shuffle all
|
||||
val isShuffling: Boolean = false,
|
||||
val pendingShufflePlay: List<Song>? = null,
|
||||
// Sub-album detail (tapped not-downloaded album)
|
||||
val subAlbumItem: ItunesItem? = null,
|
||||
val subAlbumTracks: List<ItunesItem>? = null,
|
||||
val subAlbumTitle: String = "",
|
||||
// Partial album detail (tapped incomplete album — shows downloaded + missing tracks)
|
||||
val partialAlbum: PartialAlbumState? = null,
|
||||
// Concerts
|
||||
val events: List<ConcertEvent> = emptyList(),
|
||||
val eventsError: String? = null, // non-null → the fetch failed (vs. genuinely no concerts)
|
||||
val showEventsSheet: Boolean = false,
|
||||
// Deezer fan count of the matched artist (shown under the name), null if unknown
|
||||
val fanCount: Long? = null,
|
||||
// Preview + download
|
||||
val previewingId: Long? = null,
|
||||
val downloadSheetItem: ItunesItem? = null,
|
||||
// Artist pin (same-name disambiguation): the active pin, the image it overrides the hero with,
|
||||
// and the picker state (link/id paste + Deezer search candidates).
|
||||
val pin: ArtistOverride? = null,
|
||||
val pinnedImage: String? = null,
|
||||
// When pinned, the Similar-Artists row uses Deezer "related" (correct for the pinned artist)
|
||||
// instead of Last.fm's list (keyed to the wrong/merged Navidrome artist).
|
||||
val pinnedRelated: List<ItunesItem> = emptyList(),
|
||||
val showPinPicker: Boolean = false,
|
||||
val pinSearching: Boolean = false,
|
||||
val pinCandidates: List<ItunesItem> = emptyList(),
|
||||
)
|
||||
|
||||
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
/** Folgen = Navidromes Stern auf dem Kuenstler. Laeuft ueber StarState, damit der Zustand
|
||||
* sofort umspringt und bei einem Fehlschlag von selbst zurueckfaellt. */
|
||||
fun folgenUmschalten(artistId: String, aktuellMarkiert: Boolean) =
|
||||
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, artistId, aktuellMarkiert)
|
||||
|
||||
private val player = app.app.playerController
|
||||
private val preview = app.app.previewController
|
||||
private val artistId: String? = savedState["artistId"]
|
||||
|
||||
private val _state = MutableStateFlow(ArtistUiState())
|
||||
val state: StateFlow<ArtistUiState> = _state
|
||||
|
||||
// Scroll of the artist page + the "alle anzeigen" overlay, hoisted so tapping an album/artist and
|
||||
// coming back returns you to the same spot (the artist route entry, hence this VM, is retained).
|
||||
val pageListState = androidx.compose.foundation.lazy.LazyListState()
|
||||
val allAlbumsGrid = androidx.compose.foundation.lazy.grid.LazyGridState()
|
||||
val allAlbumsQuery = androidx.compose.runtime.mutableStateOf("")
|
||||
|
||||
init {
|
||||
if (artistId.isNullOrBlank()) {
|
||||
_state.value = ArtistUiState(isLoading = false, error = "Keine Künstler-ID übergeben")
|
||||
} else {
|
||||
load()
|
||||
}
|
||||
// Mirror the app-level preview so track rows show which clip plays (bar lives in AppNavigation).
|
||||
viewModelScope.launch {
|
||||
preview.state.collect { ps ->
|
||||
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
val artistId = artistId ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = ArtistUiState(isLoading = true, itunesLoading = true)
|
||||
try {
|
||||
val artistDef = async { repo.getArtist(artistId) }
|
||||
val infoDef = async { runCatching { repo.getArtistInfo(artistId) }.getOrNull() }
|
||||
|
||||
val artist = artistDef.await()
|
||||
val info = infoDef.await()
|
||||
|
||||
val topDef = async { runCatching { repo.getTopSongs(artist.name, artist.id) }.getOrDefault(emptyList()) }
|
||||
// A manual pin (shared, server-side) fixes same-name collisions — resolve it up front
|
||||
// so the Deezer enrichment below uses the pinned artist, not a name-guess.
|
||||
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() }
|
||||
// Navidrome can hold the same track twice (single + album track) → duplicate top-song
|
||||
// rows ("PIXELATED KISSES" x2). Collapse by title+album (keeps the highest play count).
|
||||
val top = topDef.await().distinctBy { "${it.title}|${it.album}".lowercase() }
|
||||
val pin = pinDef.await()
|
||||
|
||||
_state.value = ArtistUiState(
|
||||
artist = artist, info = info, topSongs = top,
|
||||
isLoading = false, itunesLoading = true,
|
||||
pin = pin, pinnedImage = pin?.image?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
// Fetch iTunes + events in parallel without blocking the main screen
|
||||
launch { loadItunesData(artist.name, pin) }
|
||||
launch {
|
||||
val backendUrl = repo.getBackendUrl()
|
||||
runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }
|
||||
.onSuccess { _state.value = _state.value.copy(events = it, eventsError = null) }
|
||||
.onFailure { e ->
|
||||
android.util.Log.w("NovaSonic", "Konzert-Fetch fehlgeschlagen für ${artist.name}", e)
|
||||
_state.value = _state.value.copy(events = emptyList(), eventsError = e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadItunesData(artistName: String, pin: ArtistOverride? = null) {
|
||||
// Pinned: skip the name-guess entirely and enrich straight from the pinned Deezer artist id.
|
||||
val pinnedId = pin?.deezerArtistId?.toLongOrNull()
|
||||
if (pinnedId != null) {
|
||||
_state.value = _state.value.copy(
|
||||
fanCount = pin.fanCount ?: _state.value.fanCount,
|
||||
pinnedImage = pin.image.takeIf { it.isNotBlank() } ?: _state.value.pinnedImage,
|
||||
)
|
||||
coroutineScope {
|
||||
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(pinnedId) }.getOrDefault(emptyList()) }
|
||||
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(pinnedId, 10) }.getOrDefault(emptyList()) }
|
||||
val relatedD = async { runCatching { ItunesClient.lookupRelatedArtists(pinnedId, 12) }.getOrDefault(emptyList()) }
|
||||
_state.value = _state.value.copy(
|
||||
iAlbums = iAlbumsD.await(), iTracks = iTracksD.await(),
|
||||
pinnedRelated = relatedD.await(), itunesLoading = false,
|
||||
)
|
||||
}
|
||||
enrichPartialCounts()
|
||||
return
|
||||
}
|
||||
// Not pinned → clear any stale pinned-related from a previous pin.
|
||||
_state.value = _state.value.copy(pinnedRelated = emptyList())
|
||||
|
||||
val searchResp = runCatching {
|
||||
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
|
||||
}.getOrNull()
|
||||
|
||||
val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
||||
// artists are fan-sorted (ItunesClient), so firstOrNull gives the most popular match.
|
||||
// Prefer an EXACT normalized-name match: "Flay!" must not resolve to the far more popular
|
||||
// "K.FLAY" (its name merely contains "flay"). Only fall back to a loose contains-match.
|
||||
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||
val q = norm(artistName)
|
||||
val iArtist = (
|
||||
artists.firstOrNull { norm(it.artistName) == q }
|
||||
?: artists.firstOrNull {
|
||||
val n = it.artistName ?: return@firstOrNull false
|
||||
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
|
||||
}
|
||||
?: artists.firstOrNull()
|
||||
)
|
||||
val iArtistId = iArtist?.artistId
|
||||
if (iArtist?.fanCount != null) _state.value = _state.value.copy(fanCount = iArtist.fanCount)
|
||||
|
||||
if (iArtistId != null) {
|
||||
coroutineScope {
|
||||
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(iArtistId) }.getOrDefault(emptyList()) }
|
||||
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(iArtistId, 10) }.getOrDefault(emptyList()) }
|
||||
_state.value = _state.value.copy(
|
||||
iAlbums = iAlbumsD.await(),
|
||||
iTracks = iTracksD.await(),
|
||||
itunesLoading = false,
|
||||
)
|
||||
}
|
||||
enrichPartialCounts()
|
||||
} else {
|
||||
_state.value = _state.value.copy(itunesLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deezer's /artist/{id}/albums omits nb_tracks, so an owned album's real total is unknown and it
|
||||
* never got flagged "Unvollständig" (iCount stayed 0). Mirror the web: for MATCHED (owned) albums
|
||||
* whose Deezer trackCount is unknown, fetch the album's track list once to learn the real total,
|
||||
* then update state so categorizeAlbums can detect partials. Only matched albums are enriched —
|
||||
* a couple of calls, not the whole discography.
|
||||
*/
|
||||
private suspend fun enrichPartialCounts() {
|
||||
val ndAlbums = _state.value.artist?.album ?: return
|
||||
val iAlbums = _state.value.iAlbums
|
||||
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return
|
||||
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||
val enriched = coroutineScope {
|
||||
iAlbums.map { ia ->
|
||||
val cid = ia.collectionId
|
||||
val known = (ia.trackCount ?: 0) > 0
|
||||
val matched = ndAlbums.any { a ->
|
||||
val n = norm(a.name); val i = norm(ia.collectionName)
|
||||
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
|
||||
}
|
||||
if (!known && matched && cid != null) async {
|
||||
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
|
||||
if (n > 0) ia.copy(trackCount = n) else ia
|
||||
} else async { ia }
|
||||
}.map { it.await() }
|
||||
}
|
||||
_state.value = _state.value.copy(iAlbums = enriched)
|
||||
}
|
||||
|
||||
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
|
||||
|
||||
fun loadSubAlbum(album: ItunesItem) {
|
||||
val id = album.collectionId ?: return
|
||||
_state.value = _state.value.copy(
|
||||
subAlbumItem = album,
|
||||
subAlbumTitle = album.collectionName ?: "",
|
||||
subAlbumTracks = null,
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val tracks = runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
|
||||
_state.value = _state.value.copy(subAlbumTracks = tracks)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissSubAlbum() {
|
||||
_state.value = _state.value.copy(subAlbumItem = null, subAlbumTracks = null, subAlbumTitle = "")
|
||||
}
|
||||
|
||||
// ── Partial album (incomplete album: shows downloaded + missing iTunes tracks) ──
|
||||
|
||||
fun loadPartialAlbum(nd: com.novasonic.app.api.model.Album, ia: ItunesItem) {
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(ndAlbum = nd, itunesAlbum = ia, isLoading = true),
|
||||
)
|
||||
viewModelScope.launch {
|
||||
coroutineScope {
|
||||
val ndSongsD = async { runCatching { repo.getAlbum(nd.id).song }.getOrDefault(emptyList()) }
|
||||
val iTracksD = async {
|
||||
ia.collectionId?.let { id ->
|
||||
runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
|
||||
} ?: emptyList()
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(
|
||||
ndAlbum = nd,
|
||||
itunesAlbum = ia,
|
||||
ndSongs = ndSongsD.await(),
|
||||
iTracks = iTracksD.await(),
|
||||
isLoading = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPartialAlbum() {
|
||||
_state.value = _state.value.copy(partialAlbum = null)
|
||||
}
|
||||
|
||||
// ── Download sheet ───────────────────────────────────────────────────────
|
||||
|
||||
fun shuffleAll() {
|
||||
val artist = _state.value.artist ?: return
|
||||
if (_state.value.isShuffling) return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isShuffling = true)
|
||||
val allSongs = artist.album.flatMap { album ->
|
||||
runCatching { repo.getAlbum(album.id).song }.getOrDefault(emptyList())
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
isShuffling = false,
|
||||
pendingShufflePlay = allSongs.ifEmpty { null }, // gemischt wird im Player
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeShufflePlay() {
|
||||
_state.value = _state.value.copy(pendingShufflePlay = null)
|
||||
}
|
||||
|
||||
fun openEventsSheet() { _state.value = _state.value.copy(showEventsSheet = true) }
|
||||
fun dismissEventsSheet() { _state.value = _state.value.copy(showEventsSheet = false) }
|
||||
|
||||
fun showDownloadSheet(item: ItunesItem) {
|
||||
_state.value = _state.value.copy(downloadSheetItem = item)
|
||||
}
|
||||
|
||||
fun dismissDownloadSheet() {
|
||||
_state.value = _state.value.copy(downloadSheetItem = null)
|
||||
}
|
||||
|
||||
fun downloadItem(item: ItunesItem, type: String, onDone: (Boolean, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
onDone(false, "Kein Backend konfiguriert — bitte in Einstellungen eintragen")
|
||||
return@launch
|
||||
}
|
||||
val jobId = "job_${System.currentTimeMillis()}"
|
||||
val artwork = item.artworkUrl100?.replace("100x100bb", "600x600bb")
|
||||
val request = when (type) {
|
||||
"album" -> DownloadRequest(
|
||||
jobId = jobId, type = "album",
|
||||
title = item.collectionName ?: item.artistName ?: "Unknown",
|
||||
artist = item.artistName, artworkUrl = artwork,
|
||||
collectionId = item.collectionId, itunesId = item.collectionId,
|
||||
)
|
||||
"artist" -> DownloadRequest(
|
||||
jobId = jobId, type = "artist",
|
||||
title = item.artistName ?: "Unknown",
|
||||
artist = item.artistName, artworkUrl = artwork,
|
||||
)
|
||||
else -> DownloadRequest(
|
||||
jobId = jobId, type = "track",
|
||||
title = item.trackName ?: item.collectionName ?: "Unknown",
|
||||
artist = item.artistName, album = item.collectionName,
|
||||
artworkUrl = artwork, itunesId = item.trackId, collectionId = item.collectionId,
|
||||
)
|
||||
}
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess {
|
||||
NotificationStore.startPoll(backendUrl, request.jobId, request.title, request.artist ?: "")
|
||||
onDone(true, "✓ Download gestartet")
|
||||
}
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 30s preview (app-level PreviewController → global control bar, survives navigation) ──
|
||||
|
||||
fun togglePreview(track: ItunesItem) {
|
||||
val url = track.previewUrl ?: return
|
||||
val id = track.trackId ?: return
|
||||
player.pause()
|
||||
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
|
||||
}
|
||||
|
||||
fun stopPreview() = preview.stop()
|
||||
|
||||
// ── Artist pin (same-name disambiguation) ────────────────────────────────
|
||||
|
||||
fun openPinPicker() {
|
||||
_state.value = _state.value.copy(showPinPicker = true, pinCandidates = emptyList(), pinSearching = true)
|
||||
val name = _state.value.artist?.name ?: return
|
||||
viewModelScope.launch {
|
||||
val res = runCatching { ItunesClient.search(name, entity = "musicArtist", limit = 8) }.getOrNull()
|
||||
val cands = res?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
||||
_state.value = _state.value.copy(pinCandidates = cands, pinSearching = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPinPicker() { _state.value = _state.value.copy(showPinPicker = false) }
|
||||
|
||||
/** Pin one of the searched candidates. */
|
||||
fun pinCandidate(item: ItunesItem) {
|
||||
val dzId = item.artistId ?: return
|
||||
applyPin(ArtistOverride(
|
||||
deezerArtistId = dzId.toString(),
|
||||
name = item.artistName ?: _state.value.artist?.name ?: "",
|
||||
image = item.artworkUrl100 ?: "",
|
||||
fanCount = item.fanCount,
|
||||
))
|
||||
}
|
||||
|
||||
/** Pin by a pasted Deezer link or bare id (the reliable route when search can't surface it). */
|
||||
fun pinByLink(raw: String, onResult: (Boolean) -> Unit) {
|
||||
val id = parseDeezerArtistId(raw)?.toLongOrNull()
|
||||
if (id == null) { onResult(false); return }
|
||||
viewModelScope.launch {
|
||||
val a = runCatching { ItunesClient.lookupArtist(id) }.getOrNull()
|
||||
if (a?.artistId == null) { onResult(false); return@launch }
|
||||
applyPin(ArtistOverride(
|
||||
deezerArtistId = a.artistId.toString(),
|
||||
name = a.artistName ?: _state.value.artist?.name ?: "",
|
||||
image = a.artworkUrl100 ?: "",
|
||||
fanCount = a.fanCount,
|
||||
))
|
||||
onResult(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a (Deezer-related) artist name to a Navidrome library artist id, so a pinned artist's
|
||||
* "Similar" chips are tappable when that artist is actually in the library. Exact name match only
|
||||
* (case-insensitive) to avoid navigating to the wrong artist; null if not in the library.
|
||||
*/
|
||||
suspend fun libraryArtistId(name: String?): String? {
|
||||
val n = name?.trim()?.lowercase() ?: return null
|
||||
if (n.isEmpty()) return null
|
||||
val res = runCatching { repo.search(n) }.getOrNull() ?: return null
|
||||
return res.artist.firstOrNull { it.name.trim().lowercase() == n }?.id
|
||||
}
|
||||
|
||||
fun clearPin() {
|
||||
val id = artistId ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching { repo.removeArtistOverride(id) }
|
||||
_state.value = _state.value.copy(pin = null, pinnedImage = null, showPinPicker = false, itunesLoading = true)
|
||||
loadItunesData(_state.value.artist?.name ?: "", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPin(ov: ArtistOverride) {
|
||||
val id = artistId ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching { repo.setArtistOverride(id, ov) }
|
||||
_state.value = _state.value.copy(
|
||||
pin = ov,
|
||||
pinnedImage = ov.image.takeIf { it.isNotBlank() },
|
||||
showPinPicker = false,
|
||||
itunesLoading = true,
|
||||
)
|
||||
loadItunesData(_state.value.artist?.name ?: "", ov)
|
||||
}
|
||||
}
|
||||
|
||||
// Pull a Deezer artist id out of a pasted URL (deezer.com/.../artist/12345) or a bare id.
|
||||
private fun parseDeezerArtistId(raw: String): String? {
|
||||
val s = raw.trim()
|
||||
if (s.isEmpty()) return null
|
||||
Regex("artist/(\\d+)").find(s)?.let { return it.groupValues[1] }
|
||||
return if (s.matches(Regex("\\d+"))) s else null
|
||||
}
|
||||
|
||||
// No onCleared stopPreview — the preview is app-level so it keeps playing (controllable via the
|
||||
// global bar) after leaving this screen.
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.CompareResult
|
||||
import com.novasonic.app.api.model.ScrobbleUser
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class CompareUiState(
|
||||
val loading: Boolean = true,
|
||||
val configured: Boolean = true,
|
||||
val error: String? = null,
|
||||
val me: String = "",
|
||||
val users: List<ScrobbleUser> = emptyList(), // candidates (excludes me)
|
||||
val partner: String? = null, // chosen userB; null → show picker
|
||||
val result: CompareResult? = null,
|
||||
)
|
||||
|
||||
/** Head-to-Head "Musik-Match": pick a partner from the play-log, then compare. */
|
||||
class CompareViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val _state = MutableStateFlow(CompareUiState())
|
||||
val state: StateFlow<CompareUiState> = _state
|
||||
|
||||
init { loadUsers() }
|
||||
|
||||
fun loadUsers() {
|
||||
viewModelScope.launch {
|
||||
_state.value = CompareUiState(loading = true)
|
||||
val settings = repo.appSettingsFlow().first()
|
||||
if (settings.scrobbleUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(loading = false, configured = false)
|
||||
return@launch
|
||||
}
|
||||
val resp = repo.fetchScrobbleUsers()
|
||||
_state.value = if (resp == null)
|
||||
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||
else
|
||||
_state.value.copy(
|
||||
loading = false, configured = true, error = null,
|
||||
me = resp.me,
|
||||
users = resp.users.filter { it.user != resp.me },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Back to the partner picker. */
|
||||
fun clearPartner() {
|
||||
_state.value = _state.value.copy(partner = null, result = null, error = null)
|
||||
}
|
||||
|
||||
fun choosePartner(name: String) {
|
||||
_state.value = _state.value.copy(partner = name, loading = true, error = null, result = null)
|
||||
viewModelScope.launch {
|
||||
val res = repo.fetchScrobbleCompare(name)
|
||||
_state.value = if (res == null)
|
||||
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||
else
|
||||
_state.value.copy(loading = false, result = res, error = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ItunesClient
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.ItunesRssEntry
|
||||
import com.novasonic.app.api.model.Song
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import com.novasonic.app.api.model.artworkHd
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class ExploreTab { LIBRARY, GLOBAL }
|
||||
|
||||
data class PartialAlbumState(
|
||||
val ndAlbum: Album,
|
||||
val itunesAlbum: ItunesItem,
|
||||
val ndSongs: List<Song> = emptyList(),
|
||||
val iTracks: List<ItunesItem> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
)
|
||||
|
||||
data class ItunesArtistState(
|
||||
val artist: ItunesItem,
|
||||
val isLoading: Boolean = true,
|
||||
val ndAlbums: List<Album> = emptyList(),
|
||||
val iAlbums: List<ItunesItem> = emptyList(),
|
||||
val iTracks: List<ItunesItem> = emptyList(),
|
||||
val topSongs: List<Song> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
data class ExploreUiState(
|
||||
val tab: ExploreTab = ExploreTab.LIBRARY,
|
||||
val query: String = "",
|
||||
// iTunes search results
|
||||
val artists: List<ItunesItem> = emptyList(),
|
||||
val albums: List<ItunesItem> = emptyList(),
|
||||
val tracks: List<ItunesItem> = emptyList(),
|
||||
// Album drill-down
|
||||
val albumTracks: List<ItunesItem>? = null,
|
||||
val albumTitle: String = "",
|
||||
val albumItem: ItunesItem? = null,
|
||||
// Artist detail (iTunes)
|
||||
val artistDetail: ItunesArtistState? = null,
|
||||
// Which "all albums" section is expanded inside the iTunes artist overlay (by title), or null.
|
||||
// Hoisted here so it survives drilling into an album (nav route OR overlay) and coming back.
|
||||
val artistAllAlbums: String? = null,
|
||||
// Partial album detail (opened from artist's "Unvollständig" section)
|
||||
val partialAlbum: PartialAlbumState? = null,
|
||||
// Download sheet
|
||||
val downloadSheetItem: ItunesItem? = null,
|
||||
// Charts (shown when query is empty in GLOBAL tab)
|
||||
val charts: List<ItunesRssEntry> = emptyList(),
|
||||
// Loading states
|
||||
val isSearching: Boolean = false,
|
||||
val chartsLoading: Boolean = false,
|
||||
// Preview
|
||||
val previewingId: Long? = null, // trackId currently playing preview
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val player = app.app.playerController
|
||||
private val preview = app.app.previewController
|
||||
|
||||
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
||||
val state: StateFlow<ExploreUiState> = _state
|
||||
|
||||
// Global-search results scroll — hoisted so it survives an album/artist drill-in and back.
|
||||
val searchListState = LazyListState()
|
||||
// "Alle anzeigen" overlay (Deezer artist albums): scroll + search query, hoisted so an album
|
||||
// drill-in and back keeps both (the overlay itself leaves composition on drill-in).
|
||||
val allAlbumsGrid = androidx.compose.foundation.lazy.grid.LazyGridState()
|
||||
val allAlbumsQuery = androidx.compose.runtime.mutableStateOf("")
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
loadCharts()
|
||||
// Mirror the app-level preview state so the track rows still show which clip is playing.
|
||||
viewModelScope.launch {
|
||||
preview.state.collect { ps ->
|
||||
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectTab(tab: ExploreTab) {
|
||||
_state.value = _state.value.copy(tab = tab, albumTracks = null)
|
||||
}
|
||||
|
||||
fun onQueryChange(q: String) {
|
||||
_state.value = _state.value.copy(query = q, albumTracks = null)
|
||||
searchJob?.cancel()
|
||||
if (q.isBlank()) {
|
||||
_state.value = _state.value.copy(artists = emptyList(), albums = emptyList(), tracks = emptyList())
|
||||
return
|
||||
}
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(350)
|
||||
search(q)
|
||||
}
|
||||
}
|
||||
|
||||
private fun search(q: String) {
|
||||
// Strip leading aesthetic punctuation (".diedlonely", "†…") so iTunes actually surfaces the
|
||||
// artist instead of nothing. Falls back to the raw query if it was only symbols.
|
||||
val term = q.trimStart { !it.isLetterOrDigit() }.trim().ifBlank { q }
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSearching = true, error = null)
|
||||
try {
|
||||
val resp = ItunesClient.search(term)
|
||||
val results = resp.results
|
||||
val artists = results.filter { it.wrapperType == "artist" }
|
||||
_state.value = _state.value.copy(
|
||||
artists = artists,
|
||||
albums = results.filter { it.wrapperType == "collection" },
|
||||
tracks = results.filter { it.wrapperType == "track" && it.kind == "song" },
|
||||
isSearching = false,
|
||||
)
|
||||
enrichArtistArt(artists)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSearching = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* iTunes artist entities carry no artwork, so search results render as grey circles. Mirror the
|
||||
* web PWA: fetch one album per artist and borrow its cover. Runs after the main results are shown
|
||||
* (list appears instantly) and only applies if the user is still viewing the same artist set.
|
||||
*/
|
||||
private fun enrichArtistArt(artists: List<ItunesItem>) {
|
||||
if (artists.none { it.artworkUrl100.isNullOrBlank() && it.artistName != null }) return
|
||||
viewModelScope.launch {
|
||||
val enriched = artists.map { a ->
|
||||
async {
|
||||
if (!a.artworkUrl100.isNullOrBlank()) return@async a
|
||||
val name = a.artistName ?: return@async a
|
||||
val art = runCatching {
|
||||
ItunesClient.search(name, entity = "album", limit = 1).results
|
||||
.firstOrNull { it.wrapperType == "collection" }?.artworkUrl100
|
||||
}.getOrNull()
|
||||
if (art != null) a.copy(artworkUrl100 = art) else a
|
||||
}
|
||||
}.awaitAll()
|
||||
val cur = _state.value.artists
|
||||
if (cur.map { it.artistId } == artists.map { it.artistId }) {
|
||||
_state.value = _state.value.copy(artists = enriched)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAlbum(album: ItunesItem) {
|
||||
val id = album.collectionId ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSearching = true, albumTracks = null,
|
||||
albumTitle = album.collectionName ?: "", albumItem = album)
|
||||
try {
|
||||
val tracks = ItunesClient.lookupAlbum(id)
|
||||
_state.value = _state.value.copy(albumTracks = tracks, isSearching = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSearching = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAlbum() {
|
||||
_state.value = _state.value.copy(albumTracks = null, albumItem = null)
|
||||
}
|
||||
|
||||
/** Vom geöffneten Album zu dessen Künstler springen. Deezer legt das artist-Objekt samt id
|
||||
* jedem Albumtreffer bei, der Sprung kostet also keinen zusätzlichen Suchaufruf. Die
|
||||
* Albumansicht wird geschlossen, sonst läge sie weiter über der Künstlerseite. */
|
||||
fun openArtistOfAlbum(album: ItunesItem) {
|
||||
val id = album.artistId ?: return
|
||||
val name = album.artistName ?: return
|
||||
_state.value = _state.value.copy(albumTracks = null, albumItem = null, partialAlbum = null)
|
||||
openArtistDetail(ItunesItem(wrapperType = "artist", artistId = id, artistName = name))
|
||||
}
|
||||
|
||||
fun openArtistAllAlbums(title: String) { _state.value = _state.value.copy(artistAllAlbums = title) }
|
||||
fun closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) }
|
||||
|
||||
fun openArtistDetail(artist: ItunesItem) {
|
||||
_state.value = _state.value.copy(
|
||||
artistDetail = ItunesArtistState(artist = artist, isLoading = true),
|
||||
artistAllAlbums = null,
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val artistId = artist.artistId
|
||||
val artistName = artist.artistName ?: return@launch
|
||||
|
||||
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) }
|
||||
// Bewusst ohne id: artistId ist hier DEEZERS Kuenstler-ID, nicht Navidromes. Sie hier
|
||||
// mitzugeben wuerde Navidrome eine fremde ID unterschieben.
|
||||
val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) }
|
||||
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
|
||||
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
|
||||
|
||||
val ndAlbums = ndAlbumsD.await()
|
||||
val iAlbums = iAlbumsD.await()
|
||||
// Aus einem Album konstruierte Künstler tragen weder Bild noch Fanzahl — einmal
|
||||
// nachschlagen, damit die Kopfzeile genauso aussieht wie aus der Künstlersuche.
|
||||
val voll = if (artistId != null && artist.artworkUrl100 == null && artist.fanCount == null)
|
||||
runCatching { ItunesClient.lookupArtist(artistId) }.getOrNull() ?: artist
|
||||
else artist
|
||||
_state.value = _state.value.copy(
|
||||
artistDetail = ItunesArtistState(
|
||||
artist = voll,
|
||||
isLoading = false,
|
||||
ndAlbums = ndAlbums,
|
||||
iAlbums = iAlbums,
|
||||
iTracks = iTracksD.await(),
|
||||
topSongs = topSongsD.await(),
|
||||
),
|
||||
)
|
||||
// Deezer's artist-albums list omits nb_tracks, so owned albums never show "Unvollständig".
|
||||
// Resolve the real total for matched albums (like web) so partials are detected.
|
||||
val enriched = enrichPartialCounts(ndAlbums, iAlbums)
|
||||
if (enriched !== iAlbums) {
|
||||
_state.value.artistDetail?.let { d ->
|
||||
if (d.artist.artistId == artist.artistId) {
|
||||
_state.value = _state.value.copy(artistDetail = d.copy(iAlbums = enriched))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** For MATCHED (owned) albums whose Deezer trackCount is unknown, fetch the album's track list
|
||||
* once to learn the real total. Only owned albums are enriched (a couple of calls). */
|
||||
private suspend fun enrichPartialCounts(
|
||||
ndAlbums: List<Album>, iAlbums: List<ItunesItem>,
|
||||
): List<ItunesItem> {
|
||||
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return iAlbums
|
||||
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||
var changed = false
|
||||
val out = coroutineScope {
|
||||
iAlbums.map { ia ->
|
||||
val cid = ia.collectionId
|
||||
val known = (ia.trackCount ?: 0) > 0
|
||||
val matched = ndAlbums.any { a ->
|
||||
val n = norm(a.name); val i = norm(ia.collectionName)
|
||||
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
|
||||
}
|
||||
if (!known && matched && cid != null) async {
|
||||
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
|
||||
if (n > 0) { changed = true; ia.copy(trackCount = n) } else ia
|
||||
} else async { ia }
|
||||
}.map { it.await() }
|
||||
}
|
||||
return if (changed) out else iAlbums
|
||||
}
|
||||
|
||||
fun closeArtistDetail() {
|
||||
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null, artistAllAlbums = null)
|
||||
}
|
||||
|
||||
fun loadPartialAlbum(nd: Album, ia: ItunesItem) {
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(ndAlbum = nd, itunesAlbum = ia, isLoading = true),
|
||||
)
|
||||
viewModelScope.launch {
|
||||
coroutineScope {
|
||||
val ndSongsD = async { runCatching { repo.getAlbum(nd.id).song }.getOrDefault(emptyList()) }
|
||||
val iTracksD = async {
|
||||
ia.collectionId?.let { id ->
|
||||
runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
|
||||
} ?: emptyList()
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(
|
||||
ndAlbum = nd,
|
||||
itunesAlbum = ia,
|
||||
ndSongs = ndSongsD.await(),
|
||||
iTracks = iTracksD.await(),
|
||||
isLoading = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPartialAlbum() {
|
||||
_state.value = _state.value.copy(partialAlbum = null)
|
||||
}
|
||||
|
||||
fun showDownloadSheet(item: ItunesItem) {
|
||||
_state.value = _state.value.copy(downloadSheetItem = item)
|
||||
}
|
||||
|
||||
fun dismissDownloadSheet() {
|
||||
_state.value = _state.value.copy(downloadSheetItem = null)
|
||||
}
|
||||
|
||||
fun downloadItem(item: ItunesItem, type: String, onDone: (Boolean, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
onDone(false, "Kein Backend konfiguriert — bitte in Einstellungen eintragen")
|
||||
return@launch
|
||||
}
|
||||
val jobId = "job_${System.currentTimeMillis()}"
|
||||
val artwork = item.artworkUrl100?.replace("100x100bb", "600x600bb")
|
||||
val request = when (type) {
|
||||
"album" -> DownloadRequest(
|
||||
jobId = jobId, type = "album",
|
||||
title = item.collectionName ?: item.artistName ?: "Unknown",
|
||||
artist = item.artistName,
|
||||
artworkUrl = artwork,
|
||||
collectionId = item.collectionId,
|
||||
itunesId = item.collectionId,
|
||||
)
|
||||
"artist" -> DownloadRequest(
|
||||
jobId = jobId, type = "artist",
|
||||
title = item.artistName ?: "Unknown",
|
||||
artist = item.artistName,
|
||||
artworkUrl = artwork,
|
||||
)
|
||||
else -> DownloadRequest(
|
||||
jobId = jobId, type = "track",
|
||||
title = item.trackName ?: item.collectionName ?: "Unknown",
|
||||
artist = item.artistName,
|
||||
album = item.collectionName,
|
||||
artworkUrl = artwork,
|
||||
itunesId = item.trackId,
|
||||
collectionId = item.collectionId,
|
||||
)
|
||||
}
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess {
|
||||
// App-lifetime poll → success/failure notification even after leaving the screen
|
||||
// (previously only the artist page + importer notified; search/Bibliothek was silent).
|
||||
NotificationStore.startPoll(
|
||||
backendUrl, request.jobId, request.title, request.artist ?: "",
|
||||
navType = type, navAlbum = item.collectionName ?: "", navArtist = item.artistName ?: "",
|
||||
)
|
||||
onDone(true, "✓ Download gestartet")
|
||||
}
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCharts() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val entries = ItunesClient.topAlbums()
|
||||
_state.value = _state.value.copy(charts = entries, chartsLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(chartsLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 30s preview ─────────────────────────────────────────────────────────
|
||||
|
||||
fun togglePreview(track: ItunesItem) {
|
||||
val url = track.previewUrl ?: return
|
||||
val id = track.trackId ?: return
|
||||
player.pause() // don't overlap the 30s clip with library playback
|
||||
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
|
||||
}
|
||||
|
||||
fun stopPreview() = preview.stop()
|
||||
|
||||
// NOTE: no onCleared stopPreview — the preview is app-level on purpose, so leaving the Explore
|
||||
// tab keeps it playing with the global control bar (see AppNavigation).
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Genre
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class GenreUiState(
|
||||
val genres: List<Genre> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class GenreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val repo = app.app.repository
|
||||
private val _state = MutableStateFlow(GenreUiState())
|
||||
val state: StateFlow<GenreUiState> = _state
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = GenreUiState(isLoading = true)
|
||||
runCatching { repo.getGenres().filter { it.songCount > 0 }.sortedByDescending { it.songCount } }
|
||||
.onSuccess { _state.value = GenreUiState(genres = it, isLoading = false) }
|
||||
.onFailure { _state.value = GenreUiState(error = it.message, isLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Genre
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class HomeUiState(
|
||||
val heroAlbum: Album? = null,
|
||||
val heroLabel: String = "",
|
||||
val recentAlbums: List<Album> = emptyList(),
|
||||
val recentlyPlayedAlbums: List<Album> = emptyList(),
|
||||
val frequentAlbums: List<Album> = emptyList(),
|
||||
val randomAlbums: List<Album> = emptyList(),
|
||||
val genres: List<Genre> = emptyList(),
|
||||
val username: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class HomeViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(HomeUiState(isLoading = true))
|
||||
val state: StateFlow<HomeUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repo.appSettingsFlow().collect { s ->
|
||||
_state.value = _state.value.copy(username = s.username)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val recentDef = async { runCatching { repo.getRecentAlbums(20) } }
|
||||
val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) } }
|
||||
val frequentDef = async { runCatching { repo.getFrequentAlbums(20) } }
|
||||
val randomDef = async { runCatching { repo.getRandomAlbums(20) } }
|
||||
val genresDef = async { runCatching { repo.getGenres() } }
|
||||
|
||||
val recentResult = recentDef.await()
|
||||
val recentlyPlayedResult = recentlyPlayedDef.await()
|
||||
val frequentResult = frequentDef.await()
|
||||
val randomResult = randomDef.await()
|
||||
val genresResult = genresDef.await()
|
||||
|
||||
if (recentResult.isFailure) {
|
||||
_state.value = HomeUiState(error = recentResult.exceptionOrNull()?.message)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val recent = recentResult.getOrDefault(emptyList())
|
||||
val recentlyPlayed = recentlyPlayedResult.getOrDefault(emptyList())
|
||||
val frequent = frequentResult.getOrDefault(emptyList())
|
||||
val random = randomResult.getOrDefault(emptyList())
|
||||
val genres = genresResult.getOrDefault(emptyList())
|
||||
|
||||
// Hero pool = frequent.take(5) ∪ recentlyPlayed.take(5), deduped — matches web
|
||||
val frequentIds = frequent.take(5).map { it.id }.toSet()
|
||||
val seen = mutableSetOf<String>()
|
||||
val pool = (frequent.take(5) + recentlyPlayed.take(5))
|
||||
.filter { it.id.isNotBlank() && seen.add(it.id) }
|
||||
val hero = pool.randomOrNull()
|
||||
val heroLabel = if (hero != null && frequentIds.contains(hero.id)) "MEISTGESPIELT" else "ZULETZT GEHÖRT"
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
heroAlbum = hero,
|
||||
heroLabel = heroLabel,
|
||||
recentAlbums = recent,
|
||||
recentlyPlayedAlbums = recentlyPlayed,
|
||||
frequentAlbums = frequent,
|
||||
randomAlbums = random,
|
||||
genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount },
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = HomeUiState(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.api.model.ImportResolveResponse
|
||||
import com.novasonic.app.api.model.ImportTrack
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ImporterUiState(
|
||||
val backendUrl: String = "",
|
||||
val link: String = "",
|
||||
val platform: String? = null, // spotify | soundcloud | youtube
|
||||
val isResolving: Boolean = false,
|
||||
val resolved: ImportResolveResponse? = null,
|
||||
val error: String? = null,
|
||||
// Download job
|
||||
val isDownloading: Boolean = false,
|
||||
val jobStatus: String? = null, // pending | downloading | done | error
|
||||
val jobMessage: String? = null,
|
||||
val jobCompleted: Int = 0,
|
||||
val jobTotal: Int = 0,
|
||||
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null
|
||||
val previewingId: Long? = null,
|
||||
|
||||
// Playlist aus dem Import anlegen (wie im Web-Importer). Nur sinnvoll ab zwei Titeln.
|
||||
val createPlaylist: Boolean = true,
|
||||
val playlistName: String = "",
|
||||
val playlistStatus: String? = null,
|
||||
)
|
||||
|
||||
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val player = app.app.playerController
|
||||
private val preview = app.app.previewController
|
||||
|
||||
private val _state = MutableStateFlow(ImporterUiState())
|
||||
val state: StateFlow<ImporterUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repo.appSettingsFlow().collect { s ->
|
||||
_state.value = _state.value.copy(backendUrl = s.backendUrl)
|
||||
}
|
||||
}
|
||||
// Mirror the app-level preview so the track rows show which clip is playing.
|
||||
viewModelScope.launch {
|
||||
preview.state.collect { ps ->
|
||||
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 30s preview ─────────────────────────────────────────────────────────
|
||||
// Import tracks have no numeric id, so key the app-level preview by the previewUrl's hash.
|
||||
fun previewId(track: ImportTrack): Long = track.previewUrl?.hashCode()?.toLong() ?: 0L
|
||||
|
||||
fun togglePreview(track: ImportTrack) {
|
||||
val url = track.previewUrl ?: return
|
||||
player.pause() // don't overlap the 30s clip with library playback
|
||||
preview.toggle(
|
||||
previewId(track), url,
|
||||
track.trackName ?: "", track.artistName ?: "", track.artworkUrl100,
|
||||
)
|
||||
}
|
||||
|
||||
fun stopPreview() = preview.stop()
|
||||
|
||||
fun setCreatePlaylist(v: Boolean) { _state.value = _state.value.copy(createPlaylist = v) }
|
||||
fun setPlaylistName(v: String) { _state.value = _state.value.copy(playlistName = v) }
|
||||
|
||||
fun onLink(v: String) {
|
||||
_state.value = _state.value.copy(
|
||||
link = v,
|
||||
platform = ImporterClient.platformFor(v.trim()),
|
||||
resolved = null,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun resolve() {
|
||||
val s = _state.value
|
||||
val url = s.link.trim()
|
||||
if (url.isBlank()) return
|
||||
if (s.backendUrl.isBlank()) {
|
||||
_state.value = s.copy(error = "Backend-URL in Einstellungen konfigurieren")
|
||||
return
|
||||
}
|
||||
val platform = ImporterClient.platformFor(url)
|
||||
if (platform == null) {
|
||||
_state.value = s.copy(error = "Nicht unterstützter Link. Spotify, YouTube oder SoundCloud einfügen.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isResolving = true, error = null, resolved = null)
|
||||
val client = ImporterClient(s.backendUrl)
|
||||
try {
|
||||
val resp = client.resolve(platform, url)
|
||||
if (resp.error != null) {
|
||||
_state.value = _state.value.copy(isResolving = false, error = mapError(resp.error))
|
||||
} else {
|
||||
_state.value = _state.value.copy(isResolving = false, resolved = resp)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
isResolving = false,
|
||||
error = "Backend nicht erreichbar: ${e.message}",
|
||||
)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAll() {
|
||||
val s = _state.value
|
||||
val resolved = s.resolved ?: return
|
||||
if (s.backendUrl.isBlank() || resolved.tracks.isEmpty()) return
|
||||
|
||||
val jobId = "job_${System.currentTimeMillis()}"
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isDownloading = true, jobStatus = "pending", jobMessage = null,
|
||||
jobCompleted = 0, jobTotal = resolved.tracks.size,
|
||||
)
|
||||
val client = ImporterClient(s.backendUrl)
|
||||
try {
|
||||
client.download(
|
||||
DownloadRequest(
|
||||
jobId = jobId,
|
||||
type = if (resolved.type == "track") "track" else "playlist",
|
||||
title = resolved.name ?: "Import",
|
||||
artist = resolved.artist,
|
||||
artworkUrl = resolved.artworkUrl,
|
||||
tracks = resolved.tracks,
|
||||
)
|
||||
)
|
||||
// App-lifetime poll → notification delivered even if user navigates away
|
||||
NotificationStore.startPoll(
|
||||
backendUrl = s.backendUrl,
|
||||
jobId = jobId,
|
||||
title = resolved.name ?: "Import",
|
||||
artist = resolved.artist ?: "",
|
||||
navType = resolved.type ?: "",
|
||||
navAlbum = resolved.name ?: "",
|
||||
navArtist = resolved.artist ?: "",
|
||||
)
|
||||
// In-screen progress poll (cancelled on navigation — only for UI)
|
||||
pollJob(client, jobId)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
isDownloading = false, jobStatus = "error",
|
||||
jobMessage = "Download fehlgeschlagen: ${e.message}",
|
||||
)
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollJob(client: ImporterClient, jobId: String) {
|
||||
val s = _state.value
|
||||
val title = s.resolved?.name ?: "Import"
|
||||
val artist = s.resolved?.artist ?: ""
|
||||
try {
|
||||
repeat(600) { // up to ~20 min at 2s interval
|
||||
delay(2000)
|
||||
val st = runCatching { client.status(jobId) }.getOrNull() ?: return@repeat
|
||||
_state.value = _state.value.copy(
|
||||
jobStatus = st.status,
|
||||
jobMessage = st.message,
|
||||
jobCompleted = st.completed ?: _state.value.jobCompleted,
|
||||
jobTotal = st.total ?: _state.value.jobTotal,
|
||||
)
|
||||
if (st.status == "done" || st.status == "error") {
|
||||
_state.value = _state.value.copy(isDownloading = false)
|
||||
val cur = _state.value
|
||||
if (st.status == "done" && cur.createPlaylist && cur.resolved!!.tracks.size > 1) {
|
||||
buildPlaylist(cur.playlistName.ifBlank { cur.resolved.name ?: "Import" },
|
||||
cur.resolved.tracks)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
_state.value = _state.value.copy(isDownloading = false)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt nach dem Import eine Navidrome-Playlist aus den importierten Titeln an — dieselbe Abfolge
|
||||
* wie im Web: Scan anstossen, kurz warten, jeden Titel suchen, gefundene IDs in eine neue
|
||||
* Playlist. Titel, die Navidrome (noch) nicht kennt, werden gezaehlt statt zu scheitern.
|
||||
*/
|
||||
private suspend fun buildPlaylist(name: String, tracks: List<ImportTrack>) {
|
||||
_state.value = _state.value.copy(playlistStatus = "Navidrome scannt…")
|
||||
runCatching { repo.startScan() }
|
||||
delay(4000)
|
||||
|
||||
val norm = { s: String -> s.lowercase().replace(Regex("[^\\p{L}\\p{N}]+"), "") }
|
||||
val ids = mutableListOf<String>()
|
||||
var fehlend = 0
|
||||
for (t in tracks) {
|
||||
val titel = t.trackName ?: continue
|
||||
val treffer = runCatching {
|
||||
val q = listOfNotNull(t.artistName, titel).joinToString(" ")
|
||||
val gefunden = repo.search(q).song
|
||||
gefunden.firstOrNull { norm(it.title) == norm(titel) }
|
||||
?: repo.search(titel).song.firstOrNull { norm(it.title) == norm(titel) }
|
||||
}.getOrNull()
|
||||
if (treffer != null) ids += treffer.id else fehlend++
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = "Playlist: ${ids.size}/${tracks.size} gefunden…")
|
||||
}
|
||||
|
||||
if (ids.isEmpty()) {
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = "Keine Titel in Navidrome gefunden — Playlist nicht angelegt.")
|
||||
return
|
||||
}
|
||||
val res = runCatching { repo.createPlaylist(name, ids) }
|
||||
_state.value = _state.value.copy(
|
||||
playlistStatus = res.fold(
|
||||
{ "Playlist \"$name\" angelegt: ${ids.size}/${tracks.size} Titel" +
|
||||
if (fehlend > 0) " ($fehlend nicht gefunden)" else "" },
|
||||
{ "Playlist fehlgeschlagen: ${it.message}" },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapError(code: String): String = when (code) {
|
||||
"EDITORIAL_PLAYLIST" ->
|
||||
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
|
||||
"RATE_LIMITED" -> "Spotify Rate-Limit erreicht — bitte kurz warten."
|
||||
else -> code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.ArtistIndex
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class LibraryTab { PLAYLISTS, OFFLINE, ALBUMS, ARTISTS }
|
||||
enum class AlbumSort { NEWEST, OLDEST, AZ }
|
||||
|
||||
data class LibraryUiState(
|
||||
val tab: LibraryTab = LibraryTab.PLAYLISTS,
|
||||
// Two pre-fetched lists — avoids re-fetch when switching sort
|
||||
val albumsByNewest: List<Album> = emptyList(), // type=newest (Navidrome add-date)
|
||||
val albumsByName: List<Album> = emptyList(), // type=alphabeticalByName (for AZ + Oldest)
|
||||
val albumSort: AlbumSort = AlbumSort.NEWEST,
|
||||
val artistIndexes: List<ArtistIndex> = emptyList(),
|
||||
/** IDs der markierten ("gefolgten") Künstler — getArtists liefert kein `starred` mit. */
|
||||
val gefolgteIds: Set<String> = emptySet(),
|
||||
val nurGefolgte: Boolean = false,
|
||||
val playlists: List<Playlist> = emptyList(),
|
||||
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
|
||||
val currentUser: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
|
||||
val state: StateFlow<LibraryUiState> = _state
|
||||
|
||||
// Scroll positions live in the VM so they survive drilling into an album/artist and back (the
|
||||
// Library back-stack entry, hence this VM, is retained). A full teardown of the tab → fresh VM
|
||||
// → back at the top, as expected.
|
||||
val albumsGridState = LazyGridState()
|
||||
val artistsListState = LazyListState()
|
||||
|
||||
init {
|
||||
loadPlaylists()
|
||||
viewModelScope.launch {
|
||||
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
|
||||
_state.value = _state.value.copy(currentUser = u)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAlbumSort(sort: AlbumSort) {
|
||||
if (sort == _state.value.albumSort) return
|
||||
_state.value = _state.value.copy(albumSort = sort)
|
||||
// Eine andere Sortierung heißt eine andere Liste — dort oben anfangen. Bewusst hier und
|
||||
// nicht als LaunchedEffect im Screen: der feuert auch bei jedem Neu-Composen (z. B. nach
|
||||
// dem Zurück aus einem Album) und hätte die gemerkte Scrollposition jedes Mal verworfen.
|
||||
viewModelScope.launch { albumsGridState.scrollToItem(0) }
|
||||
}
|
||||
|
||||
fun selectTab(tab: LibraryTab) {
|
||||
_state.value = _state.value.copy(tab = tab)
|
||||
when (tab) {
|
||||
LibraryTab.ALBUMS -> if (_state.value.albumsByNewest.isEmpty() && _state.value.albumsByName.isEmpty()) loadAlbums()
|
||||
LibraryTab.ARTISTS -> if (_state.value.artistIndexes.isEmpty()) loadArtists()
|
||||
LibraryTab.PLAYLISTS -> if (_state.value.playlists.isEmpty()) loadPlaylists()
|
||||
LibraryTab.OFFLINE -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAlbums() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val newestDef = async { runCatching { repo.getRecentAlbums(500) } }
|
||||
val azDef = async { runCatching { repo.getAlphaAlbums(500) } }
|
||||
val newestResult = newestDef.await()
|
||||
val azResult = azDef.await()
|
||||
if (newestResult.isFailure) {
|
||||
_state.value = _state.value.copy(error = newestResult.exceptionOrNull()?.message, isLoading = false)
|
||||
return@launch
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
albumsByNewest = newestResult.getOrDefault(emptyList()),
|
||||
albumsByName = azResult.getOrDefault(emptyList()),
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun nurGefolgteUmschalten(an: Boolean) {
|
||||
_state.value = _state.value.copy(nurGefolgte = an)
|
||||
if (an) ladeGefolgte()
|
||||
}
|
||||
|
||||
/** Markierte Künstler nachladen. Frisch bei jedem Einschalten, damit ein eben entfolgter
|
||||
* Künstler nicht noch in der Liste steht. */
|
||||
private fun ladeGefolgte() = viewModelScope.launch {
|
||||
runCatching { repo.getStarred().artist.map { it.id }.toSet() }
|
||||
.onSuccess { _state.value = _state.value.copy(gefolgteIds = it) }
|
||||
}
|
||||
|
||||
private fun loadArtists() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val indexes = repo.getArtists()
|
||||
_state.value = _state.value.copy(artistIndexes = indexes, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPlaylists() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val pls = repo.getPlaylists()
|
||||
_state.value = _state.value.copy(playlists = pls, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun createPlaylist(name: String, onSuccess: (String) -> Unit = {}) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val newPl = repo.createPlaylist(trimmed, emptyList())
|
||||
val pls = runCatching { repo.getPlaylists() }.getOrDefault(_state.value.playlists)
|
||||
_state.value = _state.value.copy(playlists = pls)
|
||||
onSuccess(newPl.id)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadPlaylists() = loadPlaylists()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.SubsonicClient
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LoginUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val success: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class LoginViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(LoginUiState())
|
||||
val state: StateFlow<LoginUiState> = _state
|
||||
|
||||
fun login(serverUrl: String, username: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = LoginUiState(isLoading = true)
|
||||
try {
|
||||
val client = SubsonicClient(serverUrl, username, password)
|
||||
client.ping()
|
||||
client.close()
|
||||
repo.saveCredentials(serverUrl, username, password)
|
||||
_state.value = LoginUiState(success = true)
|
||||
} catch (e: Exception) {
|
||||
val msg = when {
|
||||
e.message?.contains("Unable to resolve host") == true -> "Cannot reach server. Check the URL."
|
||||
e.message?.contains("40") == true -> "Wrong username or password."
|
||||
else -> e.message ?: "Connection failed."
|
||||
}
|
||||
_state.value = LoginUiState(error = msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user