Compare commits
470
Commits
5130ae24a9
...
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 | ||
|
|
6391857fda | ||
|
|
acb15d8c00 | ||
|
|
0663b1c923 | ||
|
|
2a2e5ea495 | ||
|
|
308a27d293 | ||
|
|
d8908c69e9 | ||
|
|
08ca8303f3 | ||
|
|
45cb62198e | ||
|
|
52f5385988 | ||
|
|
eb47a42f15 | ||
|
|
25d80eb559 | ||
|
|
961511b3d0 | ||
|
|
8704a17b4f | ||
|
|
ccdcda74ec | ||
|
|
01407ef3f1 | ||
|
|
bb927e8120 | ||
|
|
570eb803c2 | ||
|
|
89241a4fe6 | ||
|
|
36a0b38eca | ||
|
|
b924f01b2a | ||
|
|
21d55d8ab0 | ||
|
|
1277913ac1 | ||
|
|
a8e54b3a41 | ||
|
|
59120fd7b6 | ||
|
|
ff3bb782d3 | ||
|
|
7496c28663 | ||
|
|
ac474b1760 | ||
|
|
3252706dc2 | ||
|
|
d539a43307 | ||
|
|
85d8e8eae7 | ||
|
|
2012251e58 | ||
|
|
491d58875c | ||
|
|
47d530a11d | ||
|
|
e5076f47d9 | ||
|
|
4e6f8782ca | ||
|
|
64c107da0b | ||
|
|
d94dadf309 | ||
|
|
963ef4cbb0 | ||
|
|
28428aa362 | ||
|
|
d777301c20 | ||
|
|
0a2736e893 | ||
|
|
8c3a23ad14 | ||
|
|
db7a515ee4 | ||
|
|
ef40795a9c | ||
|
|
c21f3be696 | ||
|
|
1d5f4a620d | ||
|
|
3c9a8e892a | ||
|
|
0552b3f5d0 | ||
|
|
1f1a5cd3a6 | ||
|
|
6dc47a2c77 | ||
|
|
63ff8920bf | ||
|
|
52451f5086 | ||
|
|
07b8114cae | ||
|
|
a2714dddaa | ||
|
|
0788c801fa | ||
|
|
050aff6dbe | ||
|
|
2d303083a0 | ||
|
|
ba28518d8a | ||
|
|
9ab3dab508 | ||
|
|
20545f71be | ||
|
|
9d81cdde31 | ||
|
|
c405465f5c | ||
|
|
8989b15767 | ||
|
|
2df5a54985 | ||
|
|
3db71c24a4 | ||
|
|
fce53e409d | ||
|
|
6fa9751fb6 | ||
|
|
4f83055edd | ||
|
|
78024181f0 | ||
|
|
2ae439bfd2 | ||
|
|
760d8d5e1f | ||
|
|
67f4be272a | ||
|
|
21159ad2c5 | ||
|
|
09cec18b33 | ||
|
|
9821334cc8 | ||
|
|
3837783225 | ||
|
|
a9463917e3 | ||
|
|
8f14d013b3 | ||
|
|
b5ffb1e73d | ||
|
|
27acede41d | ||
|
|
c8ccb80f2f | ||
|
|
36f968881b | ||
|
|
b7afa19f70 | ||
|
|
212491aa89 | ||
|
|
e6d85e63a8 | ||
|
|
fb605b0b31 | ||
|
|
c73665af58 | ||
|
|
255ea1969c | ||
|
|
5dda1a7101 | ||
|
|
1c0d3eb4cc |
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
|
# ── URLs pre-filled on the frontend setup screen ─────────────────────────────
|
||||||
NAVIDROME_URL=https://music.yourdomain.com
|
NAVIDROME_URL=https://music.example.com
|
||||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
BACKEND_URL=https://backend.example.com
|
||||||
|
SCROBBLE_URL=https://scrobble.example.com
|
||||||
|
|
||||||
# Absolute path to your Navidrome music library on the host
|
# ── Library / downloads (backend) ────────────────────────────────────────────
|
||||||
MUSIC_PATH=/srv/navidrome/music
|
# Absolute music path — mounted 1:1 into the backend (no remapping), same value inside the container.
|
||||||
|
MUSIC_PATH=/opt/navidrome/music
|
||||||
AUDIO_FMT=mp3 # mp3 | flac | opus | best (used by yt-dlp)
|
AUDIO_FMT=mp3
|
||||||
AUDIO_QUALITY=0 # 0 = best quality (used by yt-dlp)
|
AUDIO_QUALITY=0
|
||||||
MAX_PARALLEL=2
|
MAX_PARALLEL=2
|
||||||
|
DOWNLOAD_BACKEND=ytdlp
|
||||||
|
TICKETMASTER_API_KEY=
|
||||||
|
|
||||||
# Download backend: ytdlp | squid | auto
|
# ── Android APK download button + in-app updater ─────────────────────────────
|
||||||
# ytdlp - always use yt-dlp
|
# The backend proxies the fixed Gitea release ("apk-latest") over the LAN, so Gitea can stay internal.
|
||||||
# squid - always use squid.wtf (Qobuz)
|
GITEA_URL=http://192.168.2.51:3000 # Gitea-Basis, die der BACKEND-CONTAINER erreicht.
|
||||||
# auto - try squid.wtf first, fall back to yt-dlp if not found (default)
|
# Muss nicht Giteas eigene ROOT_URL sein — das Backend baut
|
||||||
DOWNLOAD_BACKEND=auto
|
# 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
|
# ── Fertige Images (nur docker-compose.yml) ──────────────────────────────────
|
||||||
# mp3 - MP3 320 kbps
|
# REGISTRY = where `npm run push-images` pushed to and where the host pulls from.
|
||||||
# flac - FLAC 16-bit / 44.1 kHz
|
# Gitea container registry (lowercase owner): gitea.honeyy.duckdns.org/docker
|
||||||
# hi-res - FLAC 24-bit / 96 kHz
|
# Docker Hub: docker.io/youruser
|
||||||
SQUID_QUALITY=mp3
|
# Plain-HTTP internal registries need the host's Docker daemon to allow it
|
||||||
|
# (/etc/docker/daemon.json → {"insecure-registries":["192.168.2.51:3000"]}).
|
||||||
# Spotify Importer (optional — free, no Premium needed)
|
REGISTRY=gitea.honeyy.duckdns.org/docker
|
||||||
# 1. Go to https://developer.spotify.com/dashboard and create an app
|
TAG=latest
|
||||||
# 2. Set any name, Redirect URI: http://localhost
|
|
||||||
# 3. Copy the Client ID and Client Secret below
|
|
||||||
SPOTIFY_CLIENT_ID=
|
|
||||||
SPOTIFY_CLIENT_SECRET=
|
|
||||||
@@ -2,3 +2,6 @@
|
|||||||
*.log
|
*.log
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
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
|
# NovaSonic
|
||||||
|
|
||||||
A self-hosted music streaming PWA for Navidrome / Subsonic-compatible servers.
|
A self-hosted music streaming PWA and Android app for Navidrome / Subsonic-compatible servers.
|
||||||
Built with Vite + vanilla ES modules, served via nginx in Docker.
|
Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlayer (Android).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Home
|
- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
|
||||||
- Hero card — large banner featuring a random pick from your most-played and recently played albums, labelled "Meistgespielt" or "Zuletzt gehört" accordingly
|
- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
|
||||||
- Genre browser — top 15 genres (by song count) as a colored card grid; click to browse songs, hover-play to instantly shuffle the genre
|
- **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
|
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.
|
||||||
- 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
Music_App/
|
NovaSonic/
|
||||||
├── src/ # ES modules (Vite entry)
|
├── 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)
|
│ ├── state.js # Shared mutable state (S object)
|
||||||
│ ├── utils.js # md5, esc, formatDur, toast
|
│ ├── utils.js # Helpers (md5, esc, formatDur, toast)
|
||||||
│ ├── api.js # Subsonic API, Navidrome REST, iTunes API
|
│ ├── api.js # Subsonic API, Deezer API, cover art
|
||||||
│ ├── 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
|
|
||||||
│ └── main.js # App init, audio wiring, window globals
|
│ └── main.js # App init, audio wiring, window globals
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── novasonic-server.js # Express API: download jobs, metadata edit, resolvers
|
│ ├── novasonic-server.js # Express API: downloads, metadata, events proxy, ABS proxy
|
||||||
│ ├── spotify_resolve.py # Spotify page scraper (track/album/playlist metadata)
|
|
||||||
│ └── lib/
|
│ └── lib/
|
||||||
│ ├── config.js # Backend config (env vars, paths, format)
|
│ ├── config.js # Env var → config object
|
||||||
│ ├── jobs.js # In-memory job store + log helper
|
│ ├── jobs.js # In-memory download job store
|
||||||
│ ├── metadata.js # iTunes lookup, artwork download, lyrics fetch, filename helpers
|
│ ├── deezer.js # Deezer metadata: track/album/artist lookups
|
||||||
│ ├── ytdlp.js # yt-dlp download cascade + ffmpeg metadata embedding
|
│ ├── metadata.js # Artwork, filename helpers, duplicate detection
|
||||||
│ ├── squid.js # Squid/Qobuz download backend
|
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
|
||||||
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks entry point
|
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
|
||||||
│ └── romaji.js # Japanese → romaji index for search
|
│ └── romaji.js # Japanese → romaji search index
|
||||||
├── public/ # Static assets (copied as-is to dist/)
|
├── 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
|
│ ├── sw.js # Service worker
|
||||||
│ ├── manifest.json # PWA manifest
|
│ ├── manifest.json # PWA manifest
|
||||||
│ ├── icon.svg # App icon
|
│ └── config.js # Runtime config (generated by Docker entrypoint)
|
||||||
│ └── config.js # Runtime config placeholder (generated by Docker)
|
├── index.html
|
||||||
├── android/ # Android WebView APK wrapper
|
├── styles.css
|
||||||
├── index.html # App shell + Vite entry point
|
|
||||||
├── styles.css # All styles
|
|
||||||
├── vite.config.js
|
├── vite.config.js
|
||||||
├── Dockerfile # Multi-stage: Node build → nginx serve
|
├── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml # Standard: zieht die fertigen Images aus der Registry
|
||||||
|
├── docker-compose.dev.yml # Variante: baut lokal aus den Dockerfiles
|
||||||
├── nginx.conf
|
├── nginx.conf
|
||||||
└── docker-entrypoint.sh # Injects 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
|
```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
|
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 |
|
| Variable | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `NS_SERVER_URL` | Pre-fills the Navidrome server URL on the login screen |
|
| `NAVIDROME_URL` | Pre-fills the Navidrome server URL on the login screen |
|
||||||
| `NS_BACKEND_URL` | Pre-fills the download backend URL |
|
| `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
|
### Backend container
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PORT` | `3700` | Port the backend listens on |
|
| `PORT` | `3700` | Port the backend listens on |
|
||||||
| `MUSIC_PATH` | `/music` | Path to your Navidrome music folder (mount as volume) |
|
| `MUSIC_PATH` | `/music` | Absolute path to your Navidrome music folder |
|
||||||
| `AUDIO_FMT` | `mp3` | Output format (`mp3`, `flac`, `opus`, …) |
|
| `AUDIO_FMT` | `mp3` | Download format (`mp3`, `flac`, `opus`, `best`) |
|
||||||
| `AUDIO_QUALITY` | `0` | yt-dlp audio quality (0 = 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 build # Production build → dist/
|
||||||
npm run preview # Preview the production build locally
|
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
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Build output
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
app/release/
|
||||||
|
|
||||||
|
# Local config (contains SDK path and server URL — never commit)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
@@ -1,22 +1,52 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.novasonic.app"
|
namespace = "com.novasonic.app"
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.novasonic.app"
|
applicationId = "com.novasonic.app"
|
||||||
minSdk = 24
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 30
|
||||||
versionName = "1.0"
|
versionName = "1.27"
|
||||||
buildConfigField("String", "NOVASONIC_URL", "\"https://music.asianxpolish.duckdns.org\"")
|
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
|
||||||
|
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
|
||||||
|
// Platzhalter stehen und der App-Link greift schlicht nicht.
|
||||||
|
val host1 = (project.findProperty("novasonicHost") as String?) ?: "music.example.com"
|
||||||
|
// Zweiter Host fuer einen Domainwechsel: solange beide Adressen erreichbar sind, oeffnen
|
||||||
|
// Links von der alten UND der neuen in der App. Ohne Angabe wird host1 wiederholt — ein
|
||||||
|
// doppelter Eintrag ist harmlos, ein Platzhalter-Host waere es NICHT: auf Android 11 und
|
||||||
|
// aelter verifiziert das System nur, wenn ALLE angegebenen Hosts eine gueltige
|
||||||
|
// assetlinks.json ausliefern, ein toter Host wuerde also alle blockieren.
|
||||||
|
val host2 = (project.findProperty("novasonicHost2") as String?) ?: host1
|
||||||
|
manifestPlaceholders["shareHost"] = host1
|
||||||
|
manifestPlaceholders["shareHost2"] = host2
|
||||||
|
// Derselbe Host auch fuer AUSGEHENDE Links. Er steht ohnehin schon fest im Manifest, eine
|
||||||
|
// zusaetzliche Einstellung in der App waere also nur eine weitere Adresse zum Pflegen —
|
||||||
|
// und eine, die vom Manifest abweichen koennte, ohne dass es jemand merkt.
|
||||||
|
buildConfigField("String", "SHARE_HOST", "\"$host1\"")
|
||||||
|
|
||||||
|
// Vorgabe-Adressen fuer Backend und Scrobble-Dienst. Sie stehen ohnehin in der
|
||||||
|
// docker-compose.yml — sie zusaetzlich auf jedem Geraet einzutippen ist Arbeit ohne Nutzen.
|
||||||
|
// Leer gelassen verhaelt sich die App wie bisher (Felder sichtbar, nichts vorbelegt).
|
||||||
|
// Zugangsdaten bleiben bewusst aussen vor: die gehoeren pro Person, nicht ins APK.
|
||||||
|
val backendUrl = (project.findProperty("novasonicBackend") as String?) ?: ""
|
||||||
|
val scrobbleUrl = (project.findProperty("novasonicScrobble") as String?) ?: ""
|
||||||
|
val navidromeUrl= (project.findProperty("novasonicNavidrome") as String?) ?: ""
|
||||||
|
buildConfigField("String", "DEFAULT_BACKEND_URL", "\"$backendUrl\"")
|
||||||
|
buildConfigField("String", "DEFAULT_SCROBBLE_URL", "\"$scrobbleUrl\"")
|
||||||
|
buildConfigField("String", "DEFAULT_ND_URL", "\"$navidromeUrl\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,22 +58,77 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "1.8"
|
jvmTarget = "11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Core
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
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.exoplayer)
|
||||||
implementation(libs.media3.session)
|
implementation(libs.media3.session)
|
||||||
implementation(libs.media3.common)
|
implementation(libs.media3.common)
|
||||||
implementation(libs.media3.datasource)
|
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.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 Media3 session / exoplayer classes used by reflection / service binding.
|
||||||
-keep class androidx.media3.** { *; }
|
-keep class androidx.media3.** { *; }
|
||||||
-keep class com.google.android.exoplayer2.** { *; }
|
-keep class com.google.android.exoplayer2.** { *; }
|
||||||
|
|
||||||
|
# Ktors Logging referenziert ueber slf4j den Binder aus slf4j 1.x. Auf Android existiert der nicht
|
||||||
|
# (slf4j 2.x sucht Implementierungen per ServiceLoader, und wir binden ohnehin keine), deshalb bricht
|
||||||
|
# R8 den Release-Build mit "Missing class org.slf4j.impl.StaticLoggerBinder" ab. Die Referenz ist zur
|
||||||
|
# Laufzeit unerreichbar — slf4j faengt das Fehlen selbst ab und schaltet auf einen No-op-Logger.
|
||||||
|
-dontwarn org.slf4j.**
|
||||||
@@ -5,10 +5,14 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<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"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<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.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
|
<application
|
||||||
|
android:name=".NovaSonicApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@drawable/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -20,7 +24,7 @@
|
|||||||
android:largeHeap="true">
|
android:largeHeap="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
|
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
@@ -29,10 +33,23 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Geteilte Links (https://<host>/?song=…) direkt in der App oeffnen.
|
||||||
|
autoVerify greift erst, wenn unter https://<host>/.well-known/assetlinks.json der
|
||||||
|
SHA-256 des RELEASE-Signaturschluessels hinterlegt ist. Ohne das bleibt der Filter
|
||||||
|
wirkungslos, bis der Nutzer die App in den Systemeinstellungen unter
|
||||||
|
"Standardmaessig oeffnen" fuer diese Domain zulaesst. -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="https" android:host="${shareHost}"/>
|
||||||
|
<data android:scheme="https" android:host="${shareHost2}"/>
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".MusicService"
|
android:name=".player.MusicService"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
android:foregroundServiceType="mediaPlayback"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -41,7 +58,19 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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
|
<meta-data
|
||||||
android:name="com.google.android.gms.car.application"
|
android:name="com.google.android.gms.car.application"
|
||||||
android:resource="@xml/automotive_app_desc"/>
|
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,317 +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)
|
|
||||||
|
|
||||||
// ─── 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("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,210 +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.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
// 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, player)
|
|
||||||
.setBitmapLoader(bitmapLoader) // use cached loader for notification artwork
|
|
||||||
.setSessionActivity(notifTapPending)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
player.addListener(PlayerListener())
|
|
||||||
}
|
|
||||||
|
|
||||||
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,153 +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
|
|
||||||
|
|
||||||
// ─── 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()
|
||||||
|
}
|
||||||
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user