Compare commits
504
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5130ae24a9 | ||
|
|
e67ccaf52d | ||
|
|
6a28ba786c | ||
|
|
40efa207bb | ||
|
|
1812500523 | ||
|
|
1aff19e319 | ||
|
|
f1eabf7099 | ||
|
|
c7bebd5691 | ||
|
|
392da7e021 | ||
|
|
88533b8835 | ||
|
|
36b09856ed | ||
|
|
446c583f7f | ||
|
|
c03463b66b | ||
|
|
b63d93114a | ||
|
|
5d7ef60159 | ||
|
|
1f13374438 | ||
|
|
f03c6dfabd | ||
|
|
e5bb2bf2d3 | ||
|
|
8a60ab2b98 | ||
|
|
64232a39fe | ||
|
|
0823c1ca86 | ||
|
|
11a31f85aa | ||
|
|
c6990cfd7b | ||
|
|
5093ce1f9d | ||
|
|
4842c89c4e | ||
|
|
7ba5b8ce9a | ||
|
|
2dd3d7c7e5 | ||
|
|
5c69a8137c | ||
|
|
17414ca888 | ||
|
|
ae48ed4129 | ||
|
|
3df1990d41 | ||
|
|
4da1fb25e2 | ||
|
|
c052d6e913 | ||
|
|
e05daf3824 | ||
|
|
52ba73506f | ||
|
|
98ad8ed579 | ||
|
|
d785a4c09a | ||
|
|
62fefbb5e7 | ||
|
|
0e8eb311af | ||
|
|
ee65183555 | ||
|
|
e4cdbe44e7 | ||
|
|
c9fe1da1b0 | ||
|
|
5559857c15 | ||
|
|
1a44f8bd73 | ||
|
|
70fd299637 | ||
|
|
3a8b879abf | ||
|
|
a6e6af0333 | ||
|
|
467ee8a0fe | ||
|
|
7b4e221881 | ||
|
|
4f83eebe79 | ||
|
|
790f02e5b4 | ||
|
|
6894e8a94a | ||
|
|
a8f56a9bc6 | ||
|
|
89aeeb7421 | ||
|
|
b9a5c6468f | ||
|
|
84f1fd006d | ||
|
|
79b878c546 | ||
|
|
da185e1d96 | ||
|
|
3128b06b24 | ||
|
|
7e07fef7ba | ||
|
|
f79817410b | ||
|
|
d3a1bf6a82 | ||
|
|
377a6954e3 | ||
|
|
9fe8656a21 | ||
|
|
f97e31178c | ||
|
|
52079536b6 | ||
|
|
0874fd4f9f | ||
|
|
2d0ee021e6 | ||
|
|
084c1728cd | ||
|
|
7894790436 | ||
|
|
e4cc93d362 | ||
|
|
654cde6796 | ||
|
|
4b2f277ff7 | ||
|
|
4f152028f4 | ||
|
|
9f134883d9 | ||
|
|
d99145cce6 | ||
|
|
2553534cc8 | ||
|
|
32036b7312 | ||
|
|
bc068551de | ||
|
|
a5048abd2e | ||
|
|
ea293a498f | ||
|
|
494361191a | ||
|
|
ac4f09c8b8 | ||
|
|
0e05740df7 | ||
|
|
4c4dee4e55 | ||
|
|
aa8d58d01f | ||
|
|
96e3cb0451 | ||
|
|
92edaa9da4 | ||
|
|
47cf3d13e5 | ||
|
|
162bf530a2 | ||
|
|
e47450b20d | ||
|
|
eeb822bbb2 | ||
|
|
43312be65d | ||
|
|
b0fb1b200d | ||
|
|
0fbb1385f1 | ||
|
|
4856f0ca57 | ||
|
|
9d8a5a22bb | ||
|
|
68bec67880 | ||
|
|
e893b7cc12 | ||
|
|
1b7c05f66d | ||
|
|
ba382aa6d9 | ||
|
|
66099c12f0 | ||
|
|
5bcb8b09a8 | ||
|
|
aa239f8e6a | ||
|
|
369a5e82b3 | ||
|
|
bd8919fb39 | ||
|
|
a47b2be177 | ||
|
|
27eb209945 | ||
|
|
7bec106350 | ||
|
|
c037ee623d | ||
|
|
30ef5300cf | ||
|
|
6d0d1b28dc | ||
|
|
6fcf3179be | ||
|
|
ee6df1e1fe | ||
|
|
f5343efd37 | ||
|
|
cf88d87791 | ||
|
|
757c7045ba | ||
|
|
55370ad760 | ||
|
|
11dec04fea | ||
|
|
e78df7bdb9 | ||
|
|
d5470a6440 | ||
|
|
47f1605965 | ||
|
|
751cc05b89 | ||
|
|
10e3e0eb1d | ||
|
|
a1a4416bb6 |
No files matched your search
+6
-17
@@ -3,6 +3,7 @@
|
||||
# Pre-fills the URLs on the setup screen
|
||||
NAVIDROME_URL=https://music.yourdomain.com
|
||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
||||
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # play-log / stats service (port 3710)
|
||||
|
||||
# Absolute path to your Navidrome music library on the host
|
||||
MUSIC_PATH=/srv/navidrome/music
|
||||
@@ -11,21 +12,9 @@ AUDIO_FMT=mp3 # mp3 | flac | opus | best (used by yt-dlp)
|
||||
AUDIO_QUALITY=0 # 0 = best quality (used by yt-dlp)
|
||||
MAX_PARALLEL=2
|
||||
|
||||
# Download backend: ytdlp | squid | auto
|
||||
# ytdlp - always use yt-dlp
|
||||
# squid - always use squid.wtf (Qobuz)
|
||||
# auto - try squid.wtf first, fall back to yt-dlp if not found (default)
|
||||
DOWNLOAD_BACKEND=auto
|
||||
DOWNLOAD_BACKEND=ytdlp
|
||||
|
||||
# Quality for squid.wtf / Qobuz downloads
|
||||
# mp3 - MP3 320 kbps
|
||||
# flac - FLAC 16-bit / 44.1 kHz
|
||||
# hi-res - FLAC 24-bit / 96 kHz
|
||||
SQUID_QUALITY=mp3
|
||||
|
||||
# Spotify Importer (optional — free, no Premium needed)
|
||||
# 1. Go to https://developer.spotify.com/dashboard and create an app
|
||||
# 2. Set any name, Redirect URI: http://localhost
|
||||
# 3. Copy the Client ID and Client Secret below
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
# Ticketmaster Events (optional — free API key)
|
||||
# 1. Sign up at https://developer.ticketmaster.com/
|
||||
# 2. Create an app — copy the Consumer Key
|
||||
TICKETMASTER_API_KEY=
|
||||
@@ -2,3 +2,4 @@
|
||||
*.log
|
||||
node_modules/
|
||||
dist/
|
||||
scripts/.env.release
|
||||
+599
@@ -0,0 +1,599 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to NovaSonic are documented here.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features and fixes together).
|
||||
|
||||
---
|
||||
|
||||
## Platform Parity Matrix
|
||||
|
||||
| Feature | Web (PWA) | Android |
|
||||
|----------------------------------|:---------:|:-------:|
|
||||
| Music playback (Navidrome) | ✅ | ✅ |
|
||||
| Seek bar / scrubbing | ✅ | ✅ |
|
||||
| Queue management | ✅ | ✅ |
|
||||
| Shuffle / Repeat | ✅ | ✅ |
|
||||
| Radio mode (similar songs) | ✅ | ✅ |
|
||||
| Now Playing overlay | ✅ | ✅ |
|
||||
| Dominant color background | ✅ | ✅ |
|
||||
| Cover art scale animation | ❌ | ✅ |
|
||||
| Synced lyrics (LRCLIB) | ✅ | ✅ |
|
||||
| Lyrics autoscroll | ✅ | ✅ |
|
||||
| Star / favorite song | ✅ | ✅ |
|
||||
| Multi-artist navigation | ✅ | ✅ |
|
||||
| Album detail page | ✅ | ✅ |
|
||||
| Artist page (Navidrome) | ✅ | ✅ |
|
||||
| Artist page (Deezer fallback) | ✅ | ✅ |
|
||||
| Global search source | Deezer | Deezer |
|
||||
| Same-name artist disambiguation | ✅ | ✅ |
|
||||
| Artist fan count shown | ✅ | ✅ |
|
||||
| 30s preview control bar | ✅ | ✅ |
|
||||
| Concert events (Ticketmaster) | ✅ | ✅ |
|
||||
| Genre browsing | ✅ | ✅ |
|
||||
| Global search | ✅ | ✅ |
|
||||
| Playlist browsing | ✅ | ✅ |
|
||||
| Playlist create/edit | ✅ | ✅ |
|
||||
| Playlist cover upload | ✅ | ✅ |
|
||||
| Spotify / YouTube importer | ✅ | ✅ |
|
||||
| Metadata editor (tags + cover) | ✅ | ❌ |
|
||||
| Song delete (from disk) | ✅ | ❌ |
|
||||
| Import notification | ✅ | ✅ |
|
||||
| Import notification → nav | ✅ | ✅ |
|
||||
| ABS (Audiobookshelf) playback | ✅ | ✅ |
|
||||
| ABS chapter navigation | ✅ | ✅ |
|
||||
| ABS sleep timer | ✅ | ✅ |
|
||||
| ABS playback speed | ✅ | ✅ |
|
||||
| ABS library search | ✅ | ✅ |
|
||||
| ABS book descriptions (HTML) | ✅ | ✅ |
|
||||
| Media notification | ✅ | ✅ |
|
||||
| ABS notification (modern style) | ❌ | ✅ |
|
||||
| Android Auto | — | ✅ |
|
||||
| Offline download (albums/playlists) | ❌ | ✅ |
|
||||
| Offline download (single songs) | ❌ | ✅ |
|
||||
| Offline playback | ❌ | ✅ |
|
||||
| Offline detail / manage downloads | ❌ | ✅ |
|
||||
| Player bar on all screens | ✅ | ✅ |
|
||||
| Timestamped play-log (own scrobble) | ✅ | ✅ |
|
||||
| Listening statistics view | ✅ | ✅ |
|
||||
| Stats cover art (artist/album) | ✅ | ✅ |
|
||||
| Musik-Match (Head-to-Head) | ✅ | ✅ |
|
||||
| Yearly Wrapped | 🔜 | 🔜 |
|
||||
| Romaji / Japanese sort | ✅ | ❌ |
|
||||
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## [2.2] — 2026-08-22
|
||||
|
||||
A cleanup-and-parity release: the **Song Importer moves into the Global Search field** (one field,
|
||||
like Android), concert events become **reliable across web/Android**, several Android gaps close, and
|
||||
**YouTube downloads are recovered** after they broke on two independent fronts.
|
||||
|
||||
### Added
|
||||
- **In-app updater (Android)** — on launch the app asks the backend (`/api/apk/info`) whether a newer
|
||||
**versionCode** than this build is available and, if so, offers "Laden & installieren": it downloads
|
||||
the APK from `/api/apk` (which proxies the fixed Gitea release) into a single cache file, then hands
|
||||
it to the system installer. No APK pile-up (the cache file is wiped before each download); an update
|
||||
keeps app data / offline downloads (same package + signature = in-place install). The user still
|
||||
confirms the system install and grants "unknown apps" once — Android requires that for sideloading.
|
||||
`publish-apk` now **auto-increments `versionCode`** each build and writes it into the release so the
|
||||
check is a reliable integer compare (commit the bumped `build.gradle.kts`).
|
||||
- **Android APK download in Settings (Web)** — a "🤖 Android App" section in Settings offers the
|
||||
latest APK so co-users self-serve instead of the maintainer hand-delivering builds. The button
|
||||
appears only when an APK is available and shows version/size/date. The backend serves it
|
||||
(`GET /api/apk`, metadata at `/api/apk/info`) from one of two sources, in order:
|
||||
1. **A fixed Gitea release** (tag `apk-latest`) whose `.apk` asset you overwrite each build —
|
||||
constant ~30 MB, uploadable from anywhere. The backend fetches it **server-side** over the LAN
|
||||
(`GITEA_URL`/`GITEA_REPO`/`GITEA_TOKEN`/`GITEA_APK_TAG` env), so Gitea can stay internal-only and
|
||||
the token never reaches the browser. Release *assets* live in Gitea's attachment store, not the
|
||||
git history, so `clone`/`pull` stay small.
|
||||
2. **Local fallback** — `docker cp app-release.apk novasonic-backend:/data/novasonic.apk` (optional
|
||||
`/data/novasonic.version.txt`).
|
||||
The Android *source* is already in the repo; only build artifacts stay out of git.
|
||||
- **Unified Global Search field (Web)** — the separate Song-Importer page/sidebar entry is gone. The
|
||||
one search field now auto-detects: a **link** (Spotify/SoundCloud/YouTube) opens the importer
|
||||
preview, plain **text** runs a Deezer search. Cleaner left nav, same behaviour as Android.
|
||||
- **30s preview in the importer (Android)** — each resolved track now has a ▶/⏹ button that plays its
|
||||
Deezer/Spotify preview clip through the same app-level preview bar as Explore (library playback
|
||||
pauses; the row highlights while playing). Parity with the web importer.
|
||||
- **Scroll & search position remembered on Back (Web + Android)** — going into an album/artist and
|
||||
back now returns you to the same scroll spot with your typed query still there, instead of jumping
|
||||
to the top. Web: each history entry's scroll (and the open local/header search) is snapshotted and
|
||||
restored on Back/Forward; a genuine new navigation still starts at the top. Android: the Alben /
|
||||
Künstler lists and the global/local search-results scroll are hoisted into their ViewModels, so
|
||||
they survive a drill-in/back for as long as you're in that tab.
|
||||
- **Partial album — Play / Shuffle + track menu (Android)** — an incomplete album's downloaded
|
||||
tracks now have **Abspielen** and **Zufall** buttons and a per-row 3-dot menu, so you can still
|
||||
play/shuffle what you have while the rest downloads.
|
||||
- **Swipe between tabs (Android)** — a left/right swipe now switches tabs in **Now Playing**
|
||||
(Player ↔ Lyrics ↔ Queue) and in the **Bibliothek** (Playlists ↔ Alben ↔ Künstler ↔ Offline),
|
||||
two-way synced with the tab bar, matching the existing Erkunden swipe. On Now Playing the cover's
|
||||
own swipe (skip track) still wins, since it's a child gesture.
|
||||
|
||||
### Fixed
|
||||
- **Concert events reliability (Web + Android)** — Ticketmaster's free `keyword` search intermittently
|
||||
returns a partial subset (or none), so back-to-back calls disagreed (web showed 7, a later live
|
||||
call got only 2, Android sometimes showed 0). The web had a private 30-min cache masking this; the
|
||||
cache now lives **on the backend** and **prefers the larger recent result**, so a flaky response
|
||||
can't shrink a good list and every client stays consistent (1 h TTL self-heals).
|
||||
- **Concert date/time (Web + Android)** — shows the **venue-local** time (Ticketmaster's `localDate`/
|
||||
`localTime`) instead of the UTC instant, so a 20:00 show no longer displayed as 08:00. Android
|
||||
parses the naive local datetime (was crashing on zone-less values → missing times).
|
||||
- **Concert rows uneven (Android)** — rebuilt each row with a fixed-width calendar-style date block
|
||||
(day big, month/year + time small), so every row is the same height and the divider/venue align.
|
||||
- **Concert load failures were silent (Android)** — the fetch swallowed errors and showed an empty
|
||||
list. Errors now surface ("Konzerte konnten nicht geladen werden" + reason), with a request
|
||||
timeout and non-2xx treated as a real failure.
|
||||
- **Web importer preview layout** — the SoundCloud track row was missing its grid number-cell, so the
|
||||
4-column `.gtrack-item` grid squeezed the cover into 28px and the title into 52px (title wrapped
|
||||
one character per line). Added the missing cell.
|
||||
- **Header search dropdown covered the sidebar (Web)** — `#header-search-overlay` was
|
||||
`position:fixed; left:0; right:0` (full viewport), overlapping the left sidebar. It's now anchored
|
||||
to the search bar (`position:absolute` inside the wrap), so it drops down at the bar's width and the
|
||||
sidebar stays visible.
|
||||
- **Android artist albums incomplete** — `lookupArtistAlbums` filtered to `record_type == "album"`,
|
||||
dropping EPs and singles that Deezer (and the web app) show. Now returns all, sorted album → EP →
|
||||
single.
|
||||
- **Japanese/CJK albums vanished from the artist discography (Web + Android)** — the album-name
|
||||
normaliser stripped everything except `a-z0-9`, so any Japanese/CJK title collapsed to an empty
|
||||
string. All of an artist's JP singles then normalised identically (`""`), so they de-duplicated
|
||||
into one entry and even "matched" each other — e.g. inabakumori (稲葉曇) showed only 2 of 9 releases,
|
||||
the rest hidden behind one album. The normaliser now keeps Unicode letters/numbers (`\p{L}\p{N}`),
|
||||
so JP titles stay distinct and the full discography (In Library / Unvollständig / Noch nicht
|
||||
heruntergeladen) shows correctly. Same fix applied to the partial-album track match and the
|
||||
Deezer-artist name match.
|
||||
- **Android "Unvollständig" (partial albums) never showed** — Deezer's `/artist/{id}/albums` omits
|
||||
`nb_tracks`, so an owned album's real total was unknown (0) and it never got flagged incomplete;
|
||||
every partly-downloaded album looked complete. The artist VM now resolves the real track count for
|
||||
**matched (owned)** albums via a per-album lookup (like web), so the "Unvollständig" section — with
|
||||
the downloaded/total badge and the downloaded-vs-missing split screen — populates correctly on both
|
||||
the library and Deezer artist pages.
|
||||
- **"Falscher Künstler?" pin button minimised (Web + Android)** — reduced to a small ⚙ icon-only pill
|
||||
in the artist hero's corner instead of a full-width labelled button.
|
||||
- **Downloads from search/Bibliothek were silent (Android)** — a download started from the global
|
||||
search / Bibliothek-Global tab fired only a transient toast and then registered no
|
||||
success/failure notification (only the importer and artist page did). It now goes through the same
|
||||
app-lifetime `NotificationStore` poll, so it notifies on completion even after you leave the screen.
|
||||
- **YouTube downloads (HTTP 403 + "Signature solving failed")** — two things had broken downloads:
|
||||
1. Recent yt-dlp needs an **external JavaScript runtime** to solve YouTube's signature/nsig
|
||||
challenge — without it, formats were skipped and the media 403'd. The backend now installs
|
||||
**Deno**, which yt-dlp uses automatically.
|
||||
2. A bug in the current yt-dlp **stable** 403'd the `android_vr` client on *every* video
|
||||
([yt-dlp#17456](https://github.com/yt-dlp/yt-dlp/issues/17456), fixed in nightly). The backend
|
||||
now pulls yt-dlp from the **nightly** channel (`pip install --pre 'yt-dlp[default]'`).
|
||||
No cookies, PO-token service, or proxy needed — the earlier 403 that looked like an IP block was
|
||||
this stable bug.
|
||||
|
||||
---
|
||||
|
||||
## [2.1] — 2026-08-14
|
||||
|
||||
Builds on the 2.0 listening archive with a **head-to-head "Musik-Match"**, brings the **artist pin**
|
||||
to Android at parity with web, and fixes a batch of same-name-artist and player rough edges.
|
||||
|
||||
### Added
|
||||
- **Musik-Match (Head-to-Head) — Web + Android** — compare two users' listening taste from the
|
||||
shared play-log. New scrobble routes `GET /scrobble/users` (partner picker) and
|
||||
`GET /scrobble/compare?with=` — the match score is the **cosine of the two users' artist-play
|
||||
vectors** (0–100). Shows shared artists (mirrored bars), shared tracks, and each side's unique top
|
||||
artists. Opened from Settings ("🎧 Musik-Match"). Only aggregates are exposed, never one user's raw
|
||||
history.
|
||||
- **Artist pin on Android (parity with web)** — read AND write the shared server-side pins
|
||||
(`/api/artist-overrides`) that fix same-name-artist collisions. A "⚙ Falscher Künstler?/Gepinnt"
|
||||
pill on the artist hero opens a picker (Deezer search + a **paste-a-Deezer-link/id** route, the
|
||||
reliable way when `/search/artist` can't surface a low-profile same-name artist); when pinned, the
|
||||
page enriches straight from the pinned Deezer id (albums / top tracks / fans / image). Pins set on
|
||||
either platform apply on both.
|
||||
- **Swipe between Erkunden sub-tabs (Android)** — a left/right swipe switches Bibliothek ↔ Global,
|
||||
two-way synced with the tab bar (HorizontalPager).
|
||||
- **Similar Artists with profile pictures (Web)** — the artist page's "Ähnliche Künstler" now render
|
||||
as avatar chips, matching the Android app (previously plain text).
|
||||
|
||||
### Fixed
|
||||
- **A failed direct-link download reported success** — the direct-URL path returned `success` even
|
||||
when no audio was produced (e.g. a YouTube 403), so the job showed "Fertig!" and the UI (web +
|
||||
Android) said the track was added. It now reports failure with a clear message.
|
||||
- **Artist pin — search & similar artists (Web + Android)** — a pinned artist now shows the correct
|
||||
Deezer photo in library search (not the wrong same-name person's), and its "Similar Artists" use
|
||||
Deezer's **related** for the pinned artist instead of Last.fm's list (which was keyed to the
|
||||
merged/wrong Navidrome artist and went "weird").
|
||||
- **Android player — seek broke after seamless track transitions** — skipping through
|
||||
already-buffered tracks left `durationMs` stuck at 0 (the player stayed READY, so `syncState()`
|
||||
wasn't re-called and `ctrl.duration` was still `TIME_UNSET`), so the seek bar snapped playback to
|
||||
the start (or to a stale offset). `durationMs` now self-heals via the ticker + a fallback to the
|
||||
song's known duration. No app restart needed.
|
||||
- **Erkunden swipe polish (Android)** — pre-compose the neighbour page so the first swipe doesn't
|
||||
hitch, drive the tab indicator off the pager so it moves with the swipe, and keep the Top-Alben
|
||||
charts anchored to the top (were vertically centered).
|
||||
- **Web** — the pin picker crashed (`esc()` on a numeric Deezer id) and the pin button moved to the
|
||||
banner's top-right; pinning by a pasted Deezer link/id was added.
|
||||
- **Android** — Settings spacing: the gap now sits below "Musik-Match", grouping it with "Meine
|
||||
Statistiken".
|
||||
|
||||
---
|
||||
|
||||
## [2.0] — 2026-08-07
|
||||
|
||||
Big change: **NovaSonic gets its own self-hosted, multi-user listening archive.** Navidrome only stores a play *count* and the *last* play date — no timestamped history — so per-day/month/year stats and a yearly "Wrapped" were impossible. Koito and Spindle (the off-the-shelf options) are effectively single-user. Since NovaSonic *is* the player, it now records every counted play into an **isolated scrobble service** with its own database, and shows the results in a **statistics view on web and Android**. This runs **in addition to** the existing Navidrome/Last.fm scrobble — it never replaces it.
|
||||
|
||||
### Added
|
||||
- **New `novasonic-scrobble` service** — a small, isolated container (`node:22-slim` + built-in `node:sqlite`, own volume) that owns the play-log. Kept separate from the download backend on purpose: the backend is heavy (yt-dlp/ffmpeg) and restarts regularly, while an archive needs uptime + data integrity. Routes:
|
||||
- `POST /scrobble/plays` — batch ingest, **idempotent by uuid** (safe offline-retry)
|
||||
- `GET /scrobble/history` — raw chronological log (the archive)
|
||||
- `GET /scrobble/stats` — totals, top artists/albums/tracks, day/month/year series, 24h listening clock (on-the-fly `GROUP BY strftime`, local-tz bucketing)
|
||||
- `DELETE /scrobble/plays?user=` — purge one user's rows (test cleanup)
|
||||
- `GET /scrobble/health` — liveness
|
||||
- **Multi-user**: every request carries the same Subsonic token creds the app already uses; the service verifies them via Navidrome `ping.view` (cached), so the stored `user` is real, not spoofable. No new login.
|
||||
- **Play capture (Web + Android)** — fires at the *existing* counted-play moment (respects the user's `scrobbleMode`, consistent with Last.fm), with an **offline-safe queue** (localStorage on web, a JSON file on Android) flushed as an idempotent batch on start and when connectivity returns.
|
||||
- **Statistics view (Web + Android)** — opened from Settings ("📊 Meine Statistiken"): totals tiles (**Plays** + listening time), a day/month/year play series, top artists/tracks/albums, and a 24h listening clock, with period tabs (Alles / Dieses Jahr / Letzte 30 Tage).
|
||||
- **Cover art in stats (Web + Android)** — top artists show a round artist image, tracks/albums a square cover, resolved client-side from Navidrome (the scrobble service stays cover-agnostic).
|
||||
- **`SCROBBLE_URL`** environment variable (frontend) + a **Scrobble URL** field with a test button in Settings on both platforms.
|
||||
|
||||
### Changed
|
||||
- **Backend** — base image bumped `node:20-slim` → **`node:22-slim`** (Node 20 reaches end-of-life April 2026; enables the built-in `node:sqlite`).
|
||||
|
||||
### Fixed
|
||||
- **Downloads** — remix / live / acoustic / instrumental tracks were skipped as duplicates of the base track: duplicate detection stripped *all* bracketed content, collapsing "Song (Remix)" → "Song". Version-marker brackets are now preserved, so a distinct recording downloads (noise brackets like `(feat. X)` / `(Official Video)` are still stripped). Verified against Deezer + unit-tested both directions.
|
||||
- **UI** — dragging the mouse near a field edge (Global search, importer) started selecting the surrounding titles/labels instead of feeling like an app. `user-select` is now disabled on the `#app` shell; real text fields and lyrics stay selectable.
|
||||
|
||||
### Notes
|
||||
- The scrobble subdomain **must be excluded from any SSO / forward-auth gateway** (Authelia/Authentik/etc.), exactly 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`).
|
||||
- Deploy: add `SCROBBLE_URL` to `.env`, `docker compose up -d novasonic-scrobble`, and route port **3710** through your reverse proxy.
|
||||
- Planned next: a yearly **Wrapped** (`/scrobble/wrapped` + story page) and an optional **ListenBrainz relay** — not in this release.
|
||||
|
||||
---
|
||||
|
||||
## [1.3] — 2026-07-31
|
||||
|
||||
Big change: **the entire metadata/search stack moved from Apple's iTunes to Deezer** — on web, backend downloads *and* Android. Apple's iTunes `/search` returns **403 from server/datacenter IPs**, which had broken global search, charts, artist/album/single downloads and the metadata-editor autofill in production. Deezer is free, needs no key, allows more requests, and returns explicit versions + real artist images + track durations. Web and Android now share the **same** source, so an artist resolves to one consistent name everywhere.
|
||||
|
||||
### Changed
|
||||
- **Web + Android + Backend** — Global search, album/artist lookups, artist top tracks and the "Neu & Beliebt" charts all run on **Deezer** (`api.deezer.com`). Results are mapped to the existing item shape, so the UI is unchanged; artist cards now show real photos (iTunes artist entities had none)
|
||||
- **Backend (download)** — Single-track, album and **artist** downloads, plus the metadata-editor autofill, now enrich from Deezer:
|
||||
- `backend/lib/deezer.js` — new module: `lookupTrackMeta`, `fetchAlbumTracks`, `searchAlbumTracks`, `fetchArtistAlbums`
|
||||
- Album/artist `album_artist` still comes from the album-level artist (Deezer `album.artist`), keeping soundtracks from splitting
|
||||
- Genre comes from the Deezer album (one album-level genre); per-track artist is preserved for mixed-artist albums
|
||||
- The autofill endpoint moved `/api/itunes/lookup` → **`/api/meta/lookup`**
|
||||
- **Web + Android** — All user-visible "iTunes" / "Apple Music" labels are now "Deezer" (search badge, charts badge, preview bar, editor autofill button, artist "populär" heading)
|
||||
- **Backend** — Removed the dead iTunes `/search` proxy and the Apple charts RSS proxy; the Deezer proxy (`/api/deezer/*`) now also serves charts and the editor lookup
|
||||
- **Web** — Renamed the leftover `itunes*` frontend identifiers to `deezer*` (`itunesSearch` → `deezerSearch`, `itunesLookup*` → `deezerLookup*`, `itunesData`/`itunesArtistId` → `deezerData`/`deezerArtistId`, the `itunes-album-detail` route/DOM ids, the `badge-itunes` CSS class, …). Purely internal — no behaviour change; only the factual "Apple's iTunes /search 403" comments keep the old name
|
||||
|
||||
### Added
|
||||
- **Web + Android** — **Same-name artist disambiguation**: Deezer's artist search isn't ranked by popularity, so a name collision (5 artists named "Joji") returned a niche one first — the artist page then loaded the wrong discography and owned songs didn't show as downloaded. Artists are now sorted by Deezer fan count, and on a local artist page (web) with a real collision the pick is tie-broken by **song overlap** with your library (each artist has different songs). The **fan count is shown** on search artist cards and on both the Deezer *and* local Navidrome artist pages (web + Android)
|
||||
- **Android** — **Global 30s-preview control bar**: a preview started on an album/artist/Explore screen used to keep playing with no way to stop it once you navigated away (the local artist page had its *own* uncontrollable preview too). All previews now run through one app-level player with a bar (above the mini player, on every screen) to pause/resume/stop it; starting real playback cancels the preview
|
||||
- **Backend** — Unit tests for the Deezer mapping/matching logic (`backend/test/deezer.test.js`, run with `npm test`), incl. per-track numbering, mixed-artist album-artist stability, explicit flags and the Japanese-title match guard. A live smoke test runs with `RUN_LIVE=1`
|
||||
|
||||
### Fixed
|
||||
- **Android** — After **tabbing out and back into the app** with NowPlaying (or an ABS player) open, pressing back first popped the NavHost **invisibly to Home** and only *then* dismissed the overlay — the focus was lost. Returning from the background re-registers the NavController's own back callback on top of the dispatcher, stealing back-priority from the overlays. The overlay back-callbacks are now re-asserted on `ON_RESUME` (and on navigation), so back closes the overlay first again
|
||||
- **Web + Android** — A finished-download **notification led to the wrong song**: it resolved the album by name via `search3`, and when the exact artist match wasn't found (e.g. the new album isn't scanned yet), it fell back to a same-named album by a **different** artist (or the first result). Several artists share an album title ("I LIKE IT!"), so tapping the notification opened a completely different song. Now the artist must match when known (fuzzy for multi-artist/album-artist drift); otherwise it shows "wird evtl. gerade gescannt" instead of opening a wrong album
|
||||
- **Web** — Downloading a not-yet-owned album from an artist page searched YouTube for "**Unknown Artist** <album>" and failed. Deezer's `/artist/{id}/albums` omits the `artist` object, so the discography cards had an empty artist name → the modal fell back to the literal `"Unknown Artist"`. Fixed by backfilling the known artist name in `deezerLookupArtistAlbums` and defaulting the download modal to **Album** for album cards (was always **Track**)
|
||||
- **Web + Android** — Owned albums were wrongly shown as **"Unvollständig 1 / 0 Tracks"** on the artist page. Deezer's `/artist/{id}/albums` list omits `nb_tracks`, so the total read as 0 and every matched album looked incomplete. Web now resolves the real track count from the Deezer album endpoint for **matched (owned) albums only** and treats an unknown total as complete; Android treats an unknown total as complete (never a false partial)
|
||||
|
||||
### Notes
|
||||
- Deploy: `git pull`, then rebuild the frontend (`docker compose up -d --build novasonic-frontend`) and restart the backend (`docker compose restart novasonic-backend`). Service worker bumped to `ns-v54`
|
||||
- Titles a user picked stay as chosen; only derived fields (album, album-artist, genre, year, artwork) follow Deezer
|
||||
|
||||
---
|
||||
|
||||
## [1.2] — 2026-07-25
|
||||
|
||||
Follow-up to 1.1: web history navigation, correct download tagging for mixed-artist albums, and UI polish.
|
||||
|
||||
### Added
|
||||
- **Android** — Directional bottom-nav tab transitions: the slide follows the tapped tab's position (a tab to the right slides in from the right, one to the left from the left) instead of a fixed, sometimes-random-feeling direction. Applied across all four enter/exit/pop transitions; detail-screen push/pop is unchanged
|
||||
- **Web** — Pressing **Enter** in the importer link field starts the import (same as the Importieren button)
|
||||
|
||||
### Fixed
|
||||
- **Web** — Browser / mouse **Forward** navigation now works (previously only Back did): `popstate` applies the target history entry (`nsIdx`) for both directions, and the back handler no longer `pushState()`s over the browser's forward stack. In-app back buttons route through `history.back()` so the browser and app stacks stay in sync
|
||||
- **Web** — Deleting a song or playlist refreshes the current view immediately (drops the cached list) instead of leaving the removed item visible for up to ~3 minutes
|
||||
- **Web** — Album detail header showed the **first track's** artist (e.g. "TWICE" on a soundtrack) instead of the album artist; it now reads the album's own album-artist fields from `getAlbum` (matches the Android app)
|
||||
- **Web** — Metadata autofill (iTunes / MusicBrainz) normalizes the trailing `" & "` in a multi-artist credit ("A, B & C" → "A; B; C"), but only when the value is already a list, so band names like "Simon & Garfunkel" stay intact
|
||||
- **Android** — Erkunden (Apple) overlay back no longer *sometimes* jumped to Home: the root-level back callback is re-asserted on every navigation, so it can't lose back-priority to the NavHost after an album drill-down or tab switch
|
||||
- **Backend (download)** — Mixed-artist albums search **per track**: the YTM/YouTube query and channel filter use each track's own performer, so soundtrack tracks by other artists (TWICE's "Strategy", MeloMance's "Love, Maybe") are found instead of failing with "Nicht eindeutig gefunden"
|
||||
- **Backend (download)** — `album_artist` is derived from iTunes' album-level `collectionArtistName` (identical across the album) instead of the request's artist — which could be the lead single's performer, so a whole KPop soundtrack was tagged `album_artist = TWICE`. Applies to album and artist downloads; each track still gets its own per-track artist tag
|
||||
- **Backend (download)** — Official audio is matched by yt-dlp's `artist` metadata, not just the channel name, so uploads on label channels (BANGTANTV, HYBE, "… - Topic") win over fan reuploads — e.g. BTS "Pied Piper" now grabs the official track. (`channel_is_verified` was rejected as a signal: lyric videos and fan MVs are also on verified channels)
|
||||
- **Backend (download)** — Western artists are no longer tagged with the Japanese store's katakana album-artist ("DJスネイク" for DJ Snake): the JP-first iTunes lookup now keeps Japanese names only for Japanese songs and uses the US/English names otherwise (affects the `album_artist` tag and the on-disk folder)
|
||||
- **Backend (download)** — Release year is back-filled from iTunes on single-track downloads where the source omitted it (the year tag was previously left empty)
|
||||
|
||||
### Changed
|
||||
- **Web + Android** — Playing a song from search results queues only that song; Radio mode (if on) fills the rest — closer to Spotify. Album / playlist play still queues the whole list
|
||||
- **Android** — NowPlaying track info is ordered title → album → artists, matching the web player
|
||||
- **Web** — NowPlaying favorite star is vertically centered with the whole title / album / artist block instead of pinned to the title line
|
||||
|
||||
---
|
||||
|
||||
## [1.1] — 2026-07-19
|
||||
|
||||
Polish release: Android navigation and offline resilience, download accuracy for artists with stylised names, and a fix for the lyrics outage caused by lrclib rate-limiting.
|
||||
|
||||
### Added
|
||||
- **Android** — Mini player is now visible on every screen (album, artist, playlist detail), not only the top-level tabs; the tab bar stays top-level-only
|
||||
- **Android** — Song search results (local library) got the 3-dot context menu: play, insert next / queue, add to playlist, favorite, go to album, song info, download
|
||||
- **Android** — Artist images in the Apple-Music (Erkunden) search: iTunes artist entities carry no artwork at all, so each artist now borrows the cover of one of their albums; a person icon replaces the empty grey circle where none is found
|
||||
- **Web + Android** — Playlist cover art is shown in the "add to playlist" picker, so playlists are identifiable at a glance
|
||||
- **Web** — "Now playing" scrobble is sent at track start (`scrobble?submission=false`), so Navidrome shows the current track while it plays instead of only on completion
|
||||
|
||||
### Fixed
|
||||
- **Web + Android** — Lyrics stopped loading entirely: a burst of requests (prefetch + the 4-request cascade + fast skipping) got the client IP throttled by lrclib, so every lookup hung until it timed out. Fixed with per-song in-flight de-duplication, a prefetch debounced to 1.5 s after track start, a descriptive User-Agent, and a longer 8 s timeout
|
||||
- **Android** — App lag and stutter while scrolling and switching tabs: `coverArtUrl()` and `streamUrl()` built a fresh Ktor `HttpClient` for every cover-art request and never closed it — heavy allocation and GC churn. Both now reuse the cached client
|
||||
- **Android** — Star / favorite gave no visual feedback: a global `StarState` now flips the icon optimistically the moment it's tapped (everywhere it appears) and rolls back if the API call fails
|
||||
- **Android** — Back from an Erkunden (Apple) artist or album jumped straight to Home. Those screens are in-screen overlays on the `erkunden` destination and their `BackHandler` lost back priority to the NavHost, which popped the whole tab. Back is handled at the app root now: album → artist, artist → search
|
||||
- **Android** — The Erkunden artist's "show all albums" view was lost when drilling into an album and back (it lived in local `remember` state); the open section now lives in the ViewModel and survives both route and overlay round-trips
|
||||
- **Android** — Album back-navigation from a local artist returned to the collapsed artist page instead of the "all albums" view it was opened from
|
||||
- **Android** — The mini player sat behind the phone's gesture/navigation bar on detail screens, where there is no tab bar to absorb the system inset
|
||||
- **Android** — The download sheet for new songs and the song context sheet opened only half-expanded, hiding the download button behind the system navigation bar until dragged up; both open fully now
|
||||
- **Android** — The Offline tab was blocked by the online library's loading spinner, so downloads were unreachable while the library loaded or the server was unreachable. It reads from the local Room DB and now loads independently
|
||||
- **Backend (download)** — Artist lookups ignored leading aesthetic punctuation (e.g. `.diedlonely`), so the iTunes match missed, fell through to a title-only match, and grabbed an unrelated same-titled track — whose wrong duration then also misled the YouTube search filter. Leading non-alphanumeric characters are trimmed before lookup
|
||||
- **Backend (metadata)** — The metadata editor showed "[Unknown Album]" where the library shows "Singles": tracks downloaded without an album had no album tag written at all. The album tag now always gets a value (defaulting to `Singles`), and file resolution falls back to `findExistingTrack` for stale or rebased paths
|
||||
- **Web** — Now Playing showed a stale cover after rapid skips
|
||||
- **Web** — Home row scroll arrows caused layout thrash while scrolling: arrow visibility is a rAF-throttled class toggle now, with `getBoundingClientRect()` only on resize / content change
|
||||
|
||||
### Changed
|
||||
- **Android** — Favorite is a heart everywhere; the Now Playing screen still used a star
|
||||
- **Android** — Playlist detail actions are uniform, equal-width and icon-only (play / shuffle / edit / download). Since the offline-download button was added, four buttons shared one row and the Play/Shuffle labels were being clipped away
|
||||
- **Android** — The "add to playlist" search field matches the styling of the other search bars
|
||||
- **Web** — Track switching uses a gapless hand-off between two `<audio>` elements, making skips effectively instant (it does not fix the Windows media-tile flicker — see Known Bugs)
|
||||
|
||||
---
|
||||
|
||||
## [1.0] — 2026-06-25
|
||||
|
||||
🎉 **First stable release.** NovaSonic is feature-complete across web (PWA) and Android: streaming, lyrics, artist pages, playlists, importer/downloads, Audiobookshelf, and — new in this release — full offline downloads on Android. Further work lands as point releases.
|
||||
|
||||
### Added
|
||||
- **Android** — Offline downloads: download albums and playlists to device storage with a single tap; album download button is in the album hero row (▶ / shuffle / ↓), playlist button next to the edit toggle. Tapping again resumes / re-downloads any missing songs (already-downloaded tracks are skipped)
|
||||
- **Android** — Offline tab in Library (position 2, next to Playlists): lists downloaded groups with cover art, track count, and an in-progress bar; shows total MB used
|
||||
- **Android** — Offline detail screen: tap a group to view its songs (with cover art, like the online playlist), play / shuffle, and remove individual songs from the device via the edit toggle. Works fully offline (reads from Room, no network)
|
||||
- **Android** — Offline playback: tap ▶ on a fully-downloaded group to play locally; downloaded songs are also served from disk automatically when opened from any album or playlist view
|
||||
- **Android** — Downloaded badge: albums and playlists in the Library tabs show a ↓-in-circle badge on the cover when fully downloaded
|
||||
- **Android** — Duplicate-safe storage: a song shared by an album and a playlist is stored once (keyed by song ID) and reference-counted on delete — removing one group keeps the file if another still needs it
|
||||
- **Web + Android** — Lyrics prefetch: lyrics are fetched the moment a track starts playing (not only when the Lyrics tab is opened), so opening Lyrics is instant. The lrclib cache is warmed on every song change
|
||||
- **Web (PWA)** — Desktop install polish: `manifest.json` now ships PNG icons (192/512) for a crisp taskbar/Start-menu icon, drops the `portrait` lock so the window resizes freely, and adds a stable app `id` — installs as a standalone desktop window (no browser chrome)
|
||||
- **Web** — Keyboard shortcuts (desktop): Space = play/pause, ←/→ = seek 5 s, Shift+←/→ = previous/next track, ↑/↓ = volume. Ignored while typing in inputs/search fields
|
||||
- **Web** — Horizontal rows (Home) now have hover scroll arrows; only the scrollable direction is shown (no left arrow at the start, no right arrow at the end)
|
||||
|
||||
### Fixed
|
||||
- **Android** — Download crash on Android 15 (targetSdk 35): `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned for apps targeting API 35; WorkManager's expedited path used it and the resulting exception killed the app ~1.5 s after tapping download. Fixed by dropping `setExpedited()` and all foreground-service use from `DownloadWorker` — downloads run as silent background work with progress shown in the Offline tab
|
||||
- **Android** — Offline detail screen flashed open then immediately returned: its initial empty state (before Room emitted) was mistaken for "deleted". Now shows a brief spinner until loaded, and only leaves when the last song is actually removed
|
||||
- **Android** — Lyrics autoscroll: rewrote centering to use the active line's *measured* position in a `LazyColumn` instead of a fixed line-height estimate. Eliminates the stutter and the downward drift on wrapped (multi-line) lyrics — the active line now tracks smoothly and sits at a natural ~40 %-from-top reading anchor
|
||||
- **Web** — Home now refreshes on an explicit Home click (matching Android): the album rows re-fetch and the random row re-randomizes instead of serving the 5-minute cache. Back/forward navigation still keeps the cached state
|
||||
- **Web (PWA)** — Media Session metadata is now mutated in place instead of replaced with a new `MediaMetadata` each track (reduces churn). Note: this alone does not stop the Windows media tile from briefly closing/reopening on skip — see Known Bugs
|
||||
|
||||
### Internal
|
||||
- **Android** — Offline system: `Room` DB (`novasonic_offline.db`) with `downloaded_tracks`, `download_groups`, `group_track_cross_refs`; `OfflineRepository` holds hot `StateFlow` maps for O(1) local-path lookups; `DownloadWorker` streams audio + cover art via `HttpURLConnection` to `context.filesDir/offline/{audio|covers}/`. `MusicRepository.streamUrl()` checks local files first, so playback uses the `file://` URI on every entry point
|
||||
|
||||
---
|
||||
|
||||
## [0.9] — 2026-06-22
|
||||
|
||||
### Added
|
||||
- **UI** — New NovaSonic app icon: 5-bar equalizer waveform with blue→purple gradient (`#7eb8ff` → `#8b3cf7`) on dark background — replaces the generic music-note placeholder
|
||||
- **UI** — Favicon link added to `<head>` with SVG + PNG fallback (`icon-192.png`) so browser tabs show the icon
|
||||
- **UI** — Android launcher icon (`ic_launcher.xml`) updated to match the new equalizer design using the same blue→purple gradient via `aapt:attr`
|
||||
- **UI (web)** — Sidebar logo updated from ♫ emoji to the new equalizer SVG (`icon.svg`)
|
||||
- **nginx** — `/app/` alias added so reverse-proxy deployments can resolve static assets (icon, manifest) under the `/app` prefix without Vite `base` changes
|
||||
|
||||
### Fixed
|
||||
- **UI** — Player bar star/favorite button now vertically centred with the full track info block (title + album + artist), not pinned to the top of the title line
|
||||
- **UI** — Shuffle status badge no longer shifts when the player bar is active (removed `overflow:hidden` from `.player-info-wrap` that was clipping the status row)
|
||||
- **UI (mobile)** — Duplicate NovaSonic logo + name removed from mobile header next to the search bar
|
||||
- **Backend (download)** — Duplicate detection now matches case-insensitively (DJ OKAWARI vs DJ Okawari) and across all album folders under the artist folder, comparing normalized title without track prefix or release/variant suffix — prevents re-downloading songs whose filename drifted between runs
|
||||
- **Backend (download)** — iTunes `collectionArtistName` (album artist) is now captured and passed through the download pipeline as the `album_artist` tag; fixes compilation albums showing "Various Artists" for some tracks and the individual track artist for others
|
||||
- **Metadata editor (autofill)** — iTunes button now fills all available fields: title (`trackName`), artist (`artistName`), album-artist (`collectionArtistName`), album, genre, year, track #, disc #, total tracks, cover
|
||||
- **Metadata editor (autofill)** — MusicBrainz button now fills album-artist (from release artist-credit) and disc # in addition to the existing fields
|
||||
|
||||
### Changed
|
||||
- **Backend** — Japanese tracks now stored with original Japanese names in folders, filenames, and all main tags (title/artist/album/album\_artist); Romaji goes only into sort tags (TITLESORT / ARTISTSORT / ALBUMARTISTSORT / ALBUMSORT) for sorting and search in Navidrome. Previously, romaji was applied to filenames and tags too, causing name drift, duplicate misses, and wrong iTunes resolutions (e.g. "Hisaishi Yuzuru" instead of "Joe Hisaishi")
|
||||
- **Backend** — Metadata editor: files and album/artist folders are renamed using the original name typed in the form (may be Japanese), not romaji; sort tags still receive the romaji conversion
|
||||
- **Backend** — `fix-artist-sort` script now uses the original (kanji) artist name for folder and filename; `artistSort` (romaji) is only written to ARTISTSORT / ALBUMARTISTSORT sort tags
|
||||
- **Backend** — `album_artist` tag in downloaded tracks now consistently uses `mainArtist` (the iTunes-resolved album artist) rather than the raw per-track artist. This prevents Navidrome from splitting one album into multiple when track artists vary (e.g. compilations)
|
||||
- **Backend (download)** — No-duration cascade now ends after step 4 (same Mittelweg philosophy as the with-duration branch): no title-only or filter-free fallback grabs that pull wrong covers (observed: Yorushika 車窓 → Piano Echoes Shasou)
|
||||
- **Backend (download)** — `--max-filesize 30M` added to all yt-dlp calls; Mittelweg/no-duration steps also cap at 2× expected duration — a 28-min video can no longer be saved as a ~52 MB mp3
|
||||
|
||||
### Investigated (album splitting root causes)
|
||||
1. Missing `album_artist` tag / inconsistent value across tracks — fixed above (mainArtist + collectionArtistName)
|
||||
2. `tagAlbumArtist` was using `rawArtist` (per-track Spotify name) instead of `mainArtist` — fixed
|
||||
3. Album name comes from iTunes `collectionName` — consistent within a collection, no fix needed
|
||||
4. Year from iTunes `releaseDate` — album-level date, consistent within a collection
|
||||
5. MBIDs not set — still missing; not the primary splitting cause
|
||||
|
||||
---
|
||||
|
||||
## [0.8] — 2026-06-19
|
||||
|
||||
### Removed
|
||||
- **Backend** — squid.wtf (Qobuz) download backend removed; downloads now exclusively use yt-dlp. `DOWNLOAD_BACKEND` and `SQUID_QUALITY` env vars are no longer read.
|
||||
|
||||
### Changed
|
||||
- **Backend** — Download cascade "Mittelweg": when a track's duration is known but no result matches the duration window, the cascade no longer falls back to blind title-only/no-filter grabs (which pulled same-titled videos by other uploaders). It now relaxes only the duration constraint while still requiring the artist channel — and if nothing matches, the job fails cleanly with "Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen" instead of silently saving the wrong song
|
||||
- **Backend** — Duration match margin tightened from ±15% to ±10% (min 10s) to reject near-miss false positives
|
||||
- **Backend** — YTM search uses the `web_music` player client so YouTube Music "Art Track" / topic uploads are reachable (the previous `android_vr` client reported many as "video not available")
|
||||
- **Backend** — Search query uses a space between artist and title ("MINA recognize") instead of a dash, matching how the YTM search box ranks the exact track
|
||||
|
||||
### Fixed
|
||||
- **Backend** — YTM search query: album name was appended for short-title tracks, causing duplicated words (e.g. `"MINA - recognize recognize Single"`); album is now only appended for Japanese titles, matching spotdl behaviour
|
||||
- **Backend** — Downloaded filename now strips the release suffix → `01 - Artist - Title.mp3` (no `- Single`/`- EP` in the filename); the album folder keeps the full album name. The embedded title tag is cleaned the same way
|
||||
- **Backend** — Duration metadata from the iTunes resolver now reaches the yt-dlp duration filter for all importer paths (single, album, playlist, global search), so the right-length track is preferred
|
||||
- **Backend** — Metadata editor / song delete: file resolution now tolerates a release-suffix mismatch between Navidrome's stored stem and the on-disk filename; the delete "file not found" error now reports the actual resolved path for diagnosis
|
||||
- **Backend** — Download no longer aborts the search cascade when a single result is geo-blocked or unavailable (e.g. "This video is not available") — it continues to the next candidate
|
||||
|
||||
---
|
||||
|
||||
## [0.7] — 2026-06-18
|
||||
|
||||
### Added
|
||||
- **Android** — Playlist create: "+" FAB on the Playlists tab opens a dialog to create a new playlist
|
||||
- **Android** — Playlist edit dialog (pencil icon): edit name, description, and upload cover — all in one place; matches web edit modal
|
||||
- **Android** — Playlist delete: trash icon in the playlist hero with confirmation dialog; navigates back to library on success
|
||||
- **Android** — Playlist song removal: edit-mode toggle in the playlist action row reveals a "−" button per song; removes via `updatePlaylist.view?songIndexToRemove`
|
||||
- **Android** — Playlist cover upload: image picker in the edit dialog uploads via Navidrome REST API (`POST /api/playlist/{id}/image`)
|
||||
- **Android** — ABS notification: current chapter name shown as the main title; book title moves to the artist line; updates automatically on chapter change without interrupting playback
|
||||
- **Android** — Home "Zuletzt gehört" section: recently played albums row matching the web's `type=recent` row
|
||||
- **Android** — Library Albums: sort chips (Neueste / Älteste / A-Z); Neueste uses `type=newest` from Navidrome (add-date order); scrolls to top on sort change
|
||||
- **Android** — Artist fast-scroll index bar: draggable letter strip on the right side of the Artists list with letter bubble while dragging
|
||||
- **Web** — Playlist edit modal: "Cover entfernen" button removed
|
||||
|
||||
### Fixed
|
||||
- **Android** — In-app notification bell: notifications were never delivered after navigating away from the Importer screen (poll ran in `viewModelScope`, cancelled on navigation); polling now uses `NotificationStore.startPoll()` with an app-lifetime scope
|
||||
- **Android** — Home hero card: pool now matches web — union of `frequent(5) ∪ recentlyPlayed(5)` deduped, random pick; label is "MEISTGESPIELT" or "ZULETZT GEHÖRT" (was hardcoded "AKTUELL")
|
||||
- **Android** — Library Albums "Neueste": was incorrectly sorting by release year client-side; now uses `type=newest` from Navidrome (recently added to library)
|
||||
- **Android** — Artist screen crash: duplicate key in `LazyColumn` when Navidrome returns the same song ID twice in `topSongs`
|
||||
- **Android** — "Alle Alben anzeigen" crash: duplicate key when two albums share name + year
|
||||
- **Android** — "Alle Alben anzeigen": albums were left-aligned inside grid cells; now centered
|
||||
- **Android** — "Alle Alben anzeigen" search bar styling matches app design
|
||||
- **Android** — ABS notification button order: Samsung One UI mapping corrected; now shows Kapitel zurück | 30s zurück | Play/Pause | 30s vor | Kapitel vor
|
||||
|
||||
---
|
||||
|
||||
## [0.6] — 2026-06-17
|
||||
|
||||
### Added
|
||||
- **Android** — Lyrics preload on NowPlaying open; lyrics load in background while on Player tab, so switching to Lyrics tab is instant
|
||||
|
||||
### Fixed
|
||||
- **Android** — Home hero card: play button now sits to the right of title/artist, not below
|
||||
- **Android** — ABS media notification now uses Media3's modern media card style (blurred cover art background) instead of a plain notification; identical look to the music notification
|
||||
- **Android** — ABS notification buttons corrected: now shows exactly Kapitel zurück | 30s zurück | Play/Pause | 30s vor | Kapitel vor in both compact and expanded view; previously showed double back-button and 30s-forward was cut off
|
||||
- **Android** — Lyrics autoscroll switches from fixed tween to spring animation (DampingRatioNoBouncy, StiffnessLow); rapid line changes no longer produce choppy stop-and-restart scrolling
|
||||
|
||||
### Internal
|
||||
- **Android** — ABS notification rebuilt using a custom `DefaultMediaNotificationProvider` subclass (`AbsAwareNotificationProvider`) registered via `setMediaNotificationProvider()`; overrides `getMediaButtons()` to return exactly 5 session-command buttons when ABS is active, bypassing `setCustomLayout()` which only appends to (not replaces) the standard skip-prev/play-pause/skip-next buttons
|
||||
- **Android** — Removed abandoned `AbsPlayerService.kt` (unreferenced, unregistered, referenced missing drawable)
|
||||
|
||||
---
|
||||
|
||||
## [0.5] — 2026-06
|
||||
|
||||
### Added
|
||||
- **Android** — ABS full playback: chapter list, playback speed control, sleep timer, progress sync to ABS server
|
||||
- **Android** — ABS 30s skip-back / skip-forward buttons in media notification (compact + expanded view)
|
||||
- **Android** — ABS library search bar in Books tab (server-side search via `/api/libraries/{id}/search`, 400ms debounce)
|
||||
- **Android** — Notification sheet: shows import/download history with status icons; "Zum Album →" / "Öffnen →" button navigates to album or playlist on tap
|
||||
- **Web** — Notification sheet: "Zum Album →" link button after download completes
|
||||
- **Android** — NowPlaying lyrics: 200ms lookahead for better sync feel (matches web)
|
||||
|
||||
### Fixed
|
||||
- **Web + Android** — ABS book descriptions: HTML tags (`<p>`, `</p>`) now stripped before display
|
||||
- **Android** — NowPlaying background: switched from `darkMutedSwatch` to `vibrantSwatch` (55% darken) for more visible color tinting
|
||||
- **Android** — NowPlaying top bar: fixed layout overflow with navigation labels
|
||||
- **Android** — Lyrics scroll: removed Phase 2 "drift" animation; now simply centers active line (matches web `scrollTo({ behavior: 'smooth' })`)
|
||||
- **Android** — ABS skip: library list skip fixed on first load
|
||||
|
||||
---
|
||||
|
||||
## [0.4] — 2026-05
|
||||
|
||||
### Added
|
||||
- **Android** — Full native app rewrite in Kotlin / Jetpack Compose (replaces React Native / Expo attempt)
|
||||
- **Android** — Media3 / ExoPlayer music playback with `MusicService` (`MediaLibraryService`)
|
||||
- **Android** — NowPlaying screen: cover art with scale animation (Apple Music style), seek bar with drag scrubbing, queue tab, lyrics tab
|
||||
- **Android** — Dominant color background in NowPlaying (Palette API, animated on song change)
|
||||
- **Android** — Multi-artist display with per-artist tap-to-navigate in NowPlaying
|
||||
- **Android** — Synced lyrics via LRCLIB (parallel requests, in-memory cache)
|
||||
- **Android** — ABS (Audiobookshelf) integration: login, library browse, book detail, chapter playback
|
||||
- **Android** — Android Auto support: browsable tree (Recent, Favorites, Playlists), play-all / shuffle pseudo-items
|
||||
- **Android** — Radio mode: auto-queues similar songs via Navidrome `getSimilarSongs`
|
||||
- **Android** — Global search (Navidrome)
|
||||
- **Android** — Genre browsing with play button per genre
|
||||
- **Android** — Concert events via Ticketmaster on artist page
|
||||
- **Android** — Spotify / YouTube importer (connects to backend)
|
||||
- **Android** — Notification sheet in-app (import/download job progress)
|
||||
- **Android** — Settings: Navidrome URL/credentials, ABS URL/token, theme, display name
|
||||
|
||||
### Fixed
|
||||
- **Android** — Login flash on startup (credentials loaded before nav resolves)
|
||||
- **Android** — Album name casing normalization
|
||||
|
||||
---
|
||||
|
||||
## [0.3] — 2025-Q4
|
||||
|
||||
### Added
|
||||
- **Web** — Artist page: hero banner, iTunes + Navidrome album categorization, all-songs view
|
||||
- **Web** — Concert events via Ticketmaster (replaces Bandsintown); 30min server-side cache
|
||||
- **Web** — Playlist editing: name, description, public toggle, cover image upload
|
||||
- **Web** — Global search: artist cards with images, explicit badge (E)
|
||||
- **Web** — Romaji display for Japanese artists/albums (Navidrome `sortName` tag)
|
||||
- **Web** — iTunes album detail page (for tracks not in Navidrome)
|
||||
- **Web** — Mobile search bar in header (tap icon)
|
||||
- **Web** — Smooth progress bar scrubbing with touch drag
|
||||
|
||||
### Fixed
|
||||
- **Web** — Multi-artist splitting: handles `·`, `/`, `;` separators; each name navigates to own artist profile
|
||||
- **Web** — Lyrics: LRCLIB fuzzy fallback, title-only search fallback, parallel requests, in-memory cache
|
||||
- **Web** — Lyrics scrollbar hidden, fade edges on active line
|
||||
- **Web** — Artwork: hi-res cover embedded; consistent `album_artist` tag for Navidrome grouping
|
||||
- **Web** — `feat.`/`ft.`/`featuring` converted to `/` in artist tag for correct Navidrome multi-artist split
|
||||
|
||||
---
|
||||
|
||||
## [0.2] — 2025-Q3
|
||||
|
||||
### Added
|
||||
- **Web** — Spotify / YouTube / SoundCloud importer: resolve link → yt-dlp download → optional Navidrome playlist creation
|
||||
- **Web** — iTunes per-track artwork lookup for playlist downloads
|
||||
- **Web** — Synced lyrics via LRCLIB in Now Playing overlay (200ms lookahead)
|
||||
- **Web** — Lyrics embedding in downloaded track metadata
|
||||
- **Web** — Explicit metadata tag + E badge in search
|
||||
- **Web** — Disc number and compilation metadata tags
|
||||
- **Web** — Genre metadata from iTunes `primaryGenreName`
|
||||
- **Web** — SW update banner (notify user, no force-reload)
|
||||
- **Web** — Duplicate detection before download (shows toast on skip)
|
||||
- **Web** — Player drag feedback; swipe-up player bar opens Now Playing
|
||||
|
||||
### Fixed
|
||||
- **Web** — Spotify importer: multi-artist parsing, GraphQL embed format, locale URL support, public web-player token
|
||||
- **Web** — Playlist cover auto-refresh after upload
|
||||
- **Web** — Service worker: network-first strategy, does not intercept cross-origin requests, auto-reloads all PWA windows on SW update
|
||||
- **Web** — Bottom navigation: full-width edge-to-edge layout
|
||||
|
||||
---
|
||||
|
||||
## [0.1] — 2025-Q2
|
||||
|
||||
### Added
|
||||
- **Web** — Initial PWA music player (Vite + ES modules)
|
||||
- **Web** — Navidrome Subsonic API integration (albums, songs, playlists, search, cover art)
|
||||
- **Web** — Album and playlist browsing
|
||||
- **Web** — Basic player (play/pause, skip, shuffle, repeat)
|
||||
- **Web** — Media Session API (lock screen / notification controls)
|
||||
- **Web** — Service worker with offline support
|
||||
- **Web** — Docker setup: nginx frontend + Node.js backend + Navidrome
|
||||
- **Web** — Home screen: hero card (last played album), genre grid, recently added, most played, discover
|
||||
|
||||
---
|
||||
|
||||
## Planned / Backlog
|
||||
|
||||
### Features
|
||||
- **Web + Android** — **Share by link** — share a song/album to another user of the same instance via one `https://…` link that deep-links into the **PWA on desktop** and the **Android app on phone** (like Spotify/YouTube links). Android **App Links** (`intent-filter` + hosted `/.well-known/assetlinks.json` with the *release* signing SHA-256) open the app directly; PWA `launch_handler`/`handle_links` capture links on desktop. Link format leaning query param `?song=<id>` (no server SPA-rewrite; PWA reads it on load). Carries only the song id — no secrets; playback uses the recipient's own Navidrome creds. Songs first → albums/artists/playlists. Web: `navigator.share()`; Android: `ACTION_SEND`.
|
||||
- **Web + Android** — **Yearly Wrapped** — a yearly story (`/scrobble/wrapped` + web story page + Android pager), built on the existing stats aggregations.
|
||||
- **Web + Android** — **Household leaderboard (>2 users)** — "who listened most this month" + top artists across all users; the broader multi-user version of the already-built 2-user Musik-Match. Needs wider cross-user reads (admin/household mode, or an aggregate endpoint exposing per-user totals only, never raw histories).
|
||||
- **Backend (optional)** — **ListenBrainz relay** — mirror counted plays to ListenBrainz.
|
||||
- **Web + Android** — **Discover Mixes (auto-download, temporary)** — themed/random mixes for discovering unknown music, Spotify-style (e.g. "Rock", "Charts", "Random Discovery"). Idea:
|
||||
- Generate a mix from a theme/genre/charts (sources: Last.fm / ListenBrainz similar + iTunes charts), then **auto-download** the tracks via the yt-dlp backend so they play locally.
|
||||
- **Temporary by default:** tracks **auto-delete after a configurable time** (e.g. 7 days) so the library doesn't fill up with stuff you didn't keep.
|
||||
- **Keep what you like:** per-track (or whole-mix) option to **promote into the permanent library** instead of letting it expire.
|
||||
- Goal: completely random/unfamiliar music you don't already know — like Spotify's curated/discovery playlists, but self-hosted.
|
||||
- Open questions: where downloaded-but-temporary tracks live vs. the main library (separate folder + retention job), how Navidrome should/shouldn't index them, and dedupe against tracks you already own.
|
||||
- **Web + Android** — **Artist following + new-release & concert notifications** — follow artists and get notified when they drop something new or play nearby. Idea:
|
||||
- A **follow list** of artists (stored per user, e.g. in app settings / a small backend table).
|
||||
- A **"Following" view** listing all artists you follow (grid of artist photos), as an entry point to their pages — a dedicated overview so you can see and manage who you follow, plus a follow/unfollow toggle on the artist page.
|
||||
- **New-release alerts:** periodic check for new albums/singles by followed artists (sources: iTunes lookup / MusicBrainz / Last.fm), surfaced in the existing in-app notification bell (and optionally push).
|
||||
- **Concert alerts:** reuse the existing **Ticketmaster** events integration to notify when a followed artist has nearby/upcoming shows.
|
||||
- Ties into the importer: a "download this new release" action straight from the notification.
|
||||
- **Android** — Playlist drag-and-drop / sort order
|
||||
- **Android** — Artist page iTunes fallback (for artists not in Navidrome)
|
||||
- **Android** — Romaji / Japanese sort support
|
||||
- **Web + Android** — Backend settings UI rework (move, reorganize)
|
||||
- **Web + Android** — yt-dlp backend update / maintenance
|
||||
- **Web + Andoird** - Safe last played Queue, where it stopped and show last played song loaded (rn only on web kinda with the last song played loaded)
|
||||
- **Android** - Andorid Auto Sync last played and queue too with Phone then, rn its not really syncing or safin what last played on Android Auto, also check if selecting a song to play on Android Auto works that song is getting played and in a playlist it will play the queue a this point then like it works already in Android and Web
|
||||
|
||||
### Known Bugs
|
||||
- **Android (lyrics)** — Synced-lyrics auto-scroll is **not smooth**: it follows playback and highlights the right line, but the list jumps to it rather than gliding. Web autoscroll is smooth. Not yet fixed.
|
||||
- **Web (PWA, desktop)** — The Windows media tile (SMTC) briefly closes and reopens when skipping tracks. Swapping the `<audio>` element's source leaves a momentary gap with no active media output, so Windows tears down and rebuilds the tile. A real fix needs one continuous source (MSE), which requires streaming an MSE-compatible format. The native Android app is unaffected. (The two-element gapless handoff added in 1.1 makes skips effectively instant but does **not** fix this — switching elements still ends and restarts the OS session.)
|
||||
- **Backend (download)** — The duplicate check requires at least a `firstArtist` folder match. If the same song was previously stored under a very different artist name (e.g. before an iTunes-name fix), it will not be detected as a duplicate and will be re-downloaded.
|
||||
- **Backend (metadata/sorting)** — MusicBrainz `release['artist-credit']` is often absent in the standard recording search response (requires `inc=artist-credits` which can slow the endpoint). The albumArtist field from MusicBrainz autofill may fall back to the recording artist rather than the release artist.
|
||||
- **Backend (metadata/sorting)** — MBIDs are not embedded in downloaded tracks; Navidrome cannot use them for authoritative album grouping (low priority — album splitting is mostly solved via consistent `album_artist`).
|
||||
@@ -0,0 +1,213 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives at
|
||||
`/opt/navidrome/music`. Restart after backend changes: `docker compose restart novasonic-backend`
|
||||
(this also re-pulls the latest yt-dlp).
|
||||
|
||||
## 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.
|
||||
- **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,89 +1,171 @@
|
||||
# NovaSonic
|
||||
|
||||
A self-hosted music streaming PWA for Navidrome / Subsonic-compatible servers.
|
||||
Built with Vite + vanilla ES modules, served via nginx in Docker.
|
||||
A self-hosted music streaming PWA and Android app for Navidrome / Subsonic-compatible servers.
|
||||
Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlayer (Android).
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- Stream music from your Navidrome / Subsonic library
|
||||
- Browse artists, albums, playlists, starred tracks and random picks
|
||||
- Queue management with shuffle, repeat (all / once) and radio mode (endless random)
|
||||
- Now Playing overlay with swipe-to-dismiss and lock screen media controls (Android/iOS)
|
||||
- iTunes / Last.fm integration — charts, global search, 30s previews, recommendations
|
||||
- Download tracks from YouTube/SoundCloud via yt-dlp directly into your music library
|
||||
- Duplicate detection — skips tracks that already exist on the server
|
||||
- Edit playlists (name, description, cover art, public toggle)
|
||||
- PWA — installable, offline-capable via service worker
|
||||
- Dark theme, mobile-first responsive layout
|
||||
- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
|
||||
- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
|
||||
- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
|
||||
- **Artist pages** — Hero card, bio / similar artists / top songs, Ticketmaster events, and a Deezer cross-reference showing owned vs. missing tracks. Same-name artists can be **pinned** (Deezer search or a pasted Deezer link/id) to fix the wrong-discography and wrong-photo problem; a pinned artist's similar list comes from Deezer.
|
||||
- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
|
||||
- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with Deezer metadata enrichment and duplicate detection.
|
||||
- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save.
|
||||
- **Offline** (Android) — Download albums, playlists, or single songs to private storage; an Offline tab to play and manage them (works without a server connection); downloaded tracks play from disk on every screen.
|
||||
- **Listening stats** — A separate, self-hosted scrobble service records **timestamped plays** (multi-user; Navidrome only keeps a play *count*). A statistics view (Web + Android, from Settings) shows totals, top artists/tracks/albums with cover art, a day/month/year play series, and a 24h listening clock, plus a **Musik-Match** that compares two users' taste. Runs *in addition to* Navidrome/Last.fm scrobbling.
|
||||
- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync.
|
||||
|
||||
Web is an installable PWA (themes, German UI); Android is native Jetpack Compose + Media3/ExoPlayer with a mini player, media/ABS notifications, and Android Auto. See [CHANGELOG.md](CHANGELOG.md) for the full web/Android parity matrix.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Music_App/
|
||||
├── src/ # ES modules (Vite entry)
|
||||
NovaSonic/
|
||||
├── src/
|
||||
│ ├── player/
|
||||
│ │ ├── player.js # Playback, queue, transport, media session, preload
|
||||
│ │ ├── nowplaying.js # Now Playing overlay + synced lyrics
|
||||
│ │ └── scrobbleLog.js # Play-log capture + offline queue → scrobble service
|
||||
│ ├── views/
|
||||
│ │ ├── detail.js # Artist / album detail pages
|
||||
│ │ ├── views.js # Navigation, page loaders, search
|
||||
│ │ ├── events.js # Ticketmaster concert events
|
||||
│ │ ├── home.js # Home page
|
||||
│ │ ├── search.js # Search, artist/album navigation
|
||||
│ │ ├── playlists.js # Playlist management
|
||||
│ │ ├── spotify.js # Spotify importer
|
||||
│ │ ├── youtube.js # YouTube importer
|
||||
│ │ ├── soundcloud.js # SoundCloud importer
|
||||
│ │ ├── navigation.js # Client-side router
|
||||
│ │ └── abs.js # Audiobookshelf integration
|
||||
│ ├── ui/
|
||||
│ │ ├── ui.js # Modals, sidebar, context menu
|
||||
│ │ ├── components.js # Song rows, cards, shared UI
|
||||
│ │ ├── downloads.js # Download modal + job tracker
|
||||
│ │ ├── metadata.js # Metadata editor
|
||||
│ │ ├── modals.js # Song info, events modals
|
||||
│ │ ├── notify.js # Toast notifications
|
||||
│ │ ├── stats.js # Listening statistics view (overlay)
|
||||
│ │ └── settings.js # Settings page
|
||||
│ ├── state.js # Shared mutable state (S object)
|
||||
│ ├── utils.js # md5, esc, formatDur, toast
|
||||
│ ├── api.js # Subsonic API, Navidrome REST, iTunes API
|
||||
│ ├── player.js # Playback, transport, queue, media session, preview
|
||||
│ ├── views.js # Navigation, page loaders, card makers, search
|
||||
│ ├── ui.js # Modals, overlays, sidebar, settings, context menu
|
||||
│ ├── pwa.js # Service worker registration + update banner
|
||||
│ ├── utils.js # Helpers (md5, esc, formatDur, toast)
|
||||
│ ├── api.js # Subsonic API, Deezer API, cover art
|
||||
│ └── main.js # App init, audio wiring, window globals
|
||||
├── public/ # Static assets (copied as-is to dist/)
|
||||
├── backend/
|
||||
│ ├── novasonic-server.js # Express API: downloads, metadata, events proxy, ABS proxy
|
||||
│ └── lib/
|
||||
│ ├── config.js # Env var → config object
|
||||
│ ├── jobs.js # In-memory download job store
|
||||
│ ├── deezer.js # Deezer metadata: track/album/artist lookups
|
||||
│ ├── metadata.js # Artwork, filename helpers, duplicate detection
|
||||
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
|
||||
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
|
||||
│ └── romaji.js # Japanese → romaji search index
|
||||
├── scrobble/ # Isolated listening-stats service (own container + DB)
|
||||
│ └── scrobble-server.js # node:sqlite play-log: ingest / history / stats / health
|
||||
├── android/ # Native Android app (Jetpack Compose + Media3)
|
||||
│ └── app/src/main/kotlin/com/novasonic/app/
|
||||
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient (Deezer-backed)
|
||||
│ ├── data/ # PrefsStore (DataStore), MusicRepository, StarState
|
||||
│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads)
|
||||
│ ├── player/ # PlayerController, MusicService (Media3)
|
||||
│ ├── ui/
|
||||
│ │ ├── screen/ # HomeScreen, NowPlayingScreen, AbsScreen, SettingsScreen, …
|
||||
│ │ ├── component/ # MiniPlayer, SongRow, SongContextSheet, …
|
||||
│ │ └── theme/ # Color, Type, Theme
|
||||
│ └── viewmodel/ # ViewModel per screen
|
||||
├── public/
|
||||
│ ├── sw.js # Service worker
|
||||
│ ├── manifest.json # PWA manifest
|
||||
│ ├── icon.svg # App icon
|
||||
│ └── config.js # Runtime config placeholder (generated by Docker)
|
||||
├── backend/
|
||||
│ └── novasonic-server.js # Node.js download backend (yt-dlp + ffmpeg)
|
||||
├── index.html # App shell + Vite entry point
|
||||
├── styles.css # All styles
|
||||
│ └── config.js # Runtime config (generated by Docker entrypoint)
|
||||
├── index.html
|
||||
├── styles.css
|
||||
├── vite.config.js
|
||||
├── Dockerfile # Multi-stage: Node build → nginx serve
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── nginx.conf
|
||||
└── docker-entrypoint.sh # Injects NS_SERVER_URL / NS_BACKEND_URL at startup
|
||||
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL / SCROBBLE_URL at runtime
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Setup
|
||||
## Setup
|
||||
|
||||
### Frontend only
|
||||
### 1. Clone and configure
|
||||
|
||||
```bash
|
||||
git clone <repo>
|
||||
cd NovaSonic
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
```env
|
||||
NAVIDROME_URL=https://music.yourdomain.com
|
||||
BACKEND_URL=https://novasonic-api.yourdomain.com
|
||||
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # listening-stats service
|
||||
|
||||
MUSIC_PATH=/srv/navidrome/music
|
||||
|
||||
AUDIO_FMT=mp3 # mp3 | flac | opus | best
|
||||
AUDIO_QUALITY=0 # 0 = best (yt-dlp)
|
||||
MAX_PARALLEL=2
|
||||
|
||||
TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
|
||||
```
|
||||
|
||||
### 2. Start
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Environment variables (set in `docker-compose.yml` or via `-e`):
|
||||
Frontend on port 8080, backend on port 3700, scrobble service on port 3710. Route each through your reverse proxy. **Important:** the scrobble subdomain must *not* sit behind an SSO / forward-auth gateway — it authenticates itself via Navidrome (`ping.view`), and a gateway in front redirects the app's API calls to a login page.
|
||||
|
||||
### 3. First launch
|
||||
|
||||
1. Enter your Navidrome server URL, username, and password
|
||||
2. Optionally add a Last.fm API key in Settings for bios and recommendations
|
||||
3. Optionally connect Audiobookshelf in Settings
|
||||
4. Optionally add the NovaSonic backend URL in Settings to enable downloads
|
||||
5. Optionally add the NovaSonic scrobble URL in Settings to record listening stats
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Frontend container
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `NS_SERVER_URL` | Pre-fills the Navidrome server URL on the login screen |
|
||||
| `NS_BACKEND_URL` | Pre-fills the download backend URL |
|
||||
| `NAVIDROME_URL` | Pre-fills the Navidrome server URL on the login screen |
|
||||
| `BACKEND_URL` | Pre-fills the download backend URL on the login screen |
|
||||
| `SCROBBLE_URL` | Pre-fills the listening-stats (scrobble) service URL on the login screen |
|
||||
|
||||
### With download backend
|
||||
|
||||
The backend requires `yt-dlp` and `ffmpeg`. A separate `docker-compose.yml` lives in `backend/`.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Environment variables for the backend:
|
||||
### Backend container
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `3700` | Port the backend listens on |
|
||||
| `MUSIC_PATH` | `/music` | Path to your Navidrome music folder (mount as volume) |
|
||||
| `AUDIO_FMT` | `mp3` | Output format (`mp3`, `flac`, `opus`, …) |
|
||||
| `MUSIC_PATH` | `/music` | Absolute path to your Navidrome music folder |
|
||||
| `AUDIO_FMT` | `mp3` | Download format (`mp3`, `flac`, `opus`, `best`) |
|
||||
| `AUDIO_QUALITY` | `0` | yt-dlp audio quality (0 = best) |
|
||||
| `MAX_PARALLEL` | `2` | Maximum concurrent downloads |
|
||||
| `MAX_PARALLEL` | `2` | Max concurrent download jobs |
|
||||
| `TICKETMASTER_API_KEY` | — | Free key from [developer.ticketmaster.com](https://developer.ticketmaster.com) |
|
||||
|
||||
### Scrobble container
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `3710` | Port the scrobble service listens on |
|
||||
| `DATA_PATH` | `/data` | Where the play-log SQLite DB is stored (own volume) |
|
||||
| `NAVIDROME_URL` | — | Used to verify user credentials via `ping.view` (multi-user auth) |
|
||||
|
||||
---
|
||||
|
||||
@@ -95,12 +177,3 @@ npm run dev # Vite dev server with HMR
|
||||
npm run build # Production build → dist/
|
||||
npm run preview # Preview the production build locally
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## First Launch
|
||||
|
||||
1. Open the app in a browser
|
||||
2. Enter your Navidrome server URL, username and password
|
||||
3. Optionally add a Last.fm API key (Settings) for recommendations and similar artists
|
||||
4. Optionally add the backend URL (Settings) to enable yt-dlp downloads
|
||||
@@ -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,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.novasonic.app"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.novasonic.app"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
buildConfigField("String", "NOVASONIC_URL", "\"https://music.asianxpolish.duckdns.org\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
@@ -28,22 +31,77 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
jvmTarget = "11"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.splashscreen)
|
||||
|
||||
// Compose
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.icons)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.navigation.compose)
|
||||
|
||||
// Lifecycle / ViewModel
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
|
||||
// Media3
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
implementation(libs.media3.common)
|
||||
implementation(libs.media3.datasource)
|
||||
implementation(libs.media3.ui)
|
||||
|
||||
// Ktor
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.android)
|
||||
implementation(libs.ktor.client.content.neg)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
implementation(libs.ktor.client.logging)
|
||||
|
||||
// Kotlinx
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlin.reflect)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// WorkManager
|
||||
implementation(libs.work.runtime.ktx)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore.preferences)
|
||||
|
||||
// Coil
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Palette (dominant cover color extraction in NowPlaying)
|
||||
implementation("androidx.palette:palette-ktx:1.0.0")
|
||||
|
||||
// MediaStyle for ABS notification (already transitive via media3-session, made explicit)
|
||||
implementation("androidx.media:media:1.7.0")
|
||||
|
||||
// Google Fonts (Outfit)
|
||||
implementation(libs.compose.ui.google.fonts)
|
||||
}
|
||||
@@ -7,8 +7,12 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<!-- In-app updater: install a downloaded APK (user still confirms the install) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
|
||||
<application
|
||||
android:name=".NovaSonicApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -20,7 +24,7 @@
|
||||
android:largeHeap="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
@@ -32,7 +36,7 @@
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".MusicService"
|
||||
android:name=".player.MusicService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@@ -41,7 +45,19 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Android Auto support -->
|
||||
|
||||
<!-- FileProvider — hands the downloaded update APK to the system installer -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
|
||||
<!-- Android Auto -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
data class Track(
|
||||
val id: String,
|
||||
val url: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val album: String?,
|
||||
val artworkUrl: String?,
|
||||
val durationMs: Long
|
||||
) {
|
||||
fun toMediaItem(): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setAlbumTitle(album)
|
||||
.apply {
|
||||
if (!artworkUrl.isNullOrEmpty()) setArtworkUri(Uri.parse(artworkUrl))
|
||||
if (durationMs > 0) setDurationMs(durationMs)
|
||||
}
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setUri(url)
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(obj: JSONObject) = Track(
|
||||
id = obj.optString("id", ""),
|
||||
url = obj.optString("url", ""),
|
||||
title = obj.optString("title", ""),
|
||||
artist = obj.optString("artist", ""),
|
||||
album = obj.optString("album").takeIf { it.isNotEmpty() },
|
||||
artworkUrl = obj.optString("artworkUrl").takeIf { it.isNotEmpty() },
|
||||
durationMs = obj.optLong("durationMs", 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTrackList(json: String): List<Track> = try {
|
||||
val arr = JSONArray(json)
|
||||
List(arr.length()) { Track.fromJson(arr.getJSONObject(it)) }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
@@ -1,250 +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
|
||||
|
||||
// ─── 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)
|
||||
}
|
||||
|
||||
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) 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
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (mediaController != null) {
|
||||
updateStateSnapshot()
|
||||
callJs("onReady")
|
||||
}
|
||||
}
|
||||
}
|
||||
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("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.
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
this, Manifest.permission.POST_NOTIFICATIONS
|
||||
) && _notifPermissionAskedBefore() -> 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
|
||||
) {
|
||||
// Denied — open settings so the user can flip the toggle themselves.
|
||||
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()
|
||||
}
|
||||
@@ -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,186 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ─── 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()
|
||||
|
||||
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()
|
||||
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
.setBitmapLoader(bitmapLoader) // use cached loader for notification artwork
|
||||
.build()
|
||||
|
||||
player.addListener(PlayerListener())
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
player.stop()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
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.
|
||||
private fun warmArtworkCache(count: Int = 3) {
|
||||
val currentIdx = player.currentMediaItemIndex
|
||||
val total = player.mediaItemCount
|
||||
for (offset in 0 until count) {
|
||||
val idx = currentIdx + offset
|
||||
if (idx >= total) break
|
||||
val item = player.getMediaItemAt(idx)
|
||||
if (item.mediaMetadata.artworkUri != null) {
|
||||
bitmapLoader.loadBitmapFromMetadata(item.mediaMetadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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"
|
||||
}
|
||||
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,43 @@
|
||||
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()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
offlineRepository = OfflineRepository(applicationContext) // must be first
|
||||
repository = MusicRepository(applicationContext)
|
||||
playerController = PlayerController(applicationContext, repository)
|
||||
playerController.connect()
|
||||
absPlayerController = AbsPlayerController(applicationContext)
|
||||
// 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,149 +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
|
||||
ctrl.setMediaItems(items, startIndex.coerceAtLeast(0), 0L)
|
||||
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,58 @@
|
||||
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.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()
|
||||
|
||||
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,240 @@
|
||||
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()
|
||||
|
||||
suspend fun getTopSongs(artist: String, count: Int = 10): List<com.novasonic.app.api.model.Song> =
|
||||
get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song
|
||||
?: emptyList()
|
||||
|
||||
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))
|
||||
|
||||
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,53 @@
|
||||
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)
|
||||
|
||||
/** 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,128 @@
|
||||
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 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(),
|
||||
)
|
||||
|
||||
@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,201 @@
|
||||
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)
|
||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
||||
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 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 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,136 @@
|
||||
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.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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_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")
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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(
|
||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
||||
username = prefs[KEY_USERNAME] ?: "",
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
||||
username = prefs[KEY_USERNAME] ?: "",
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
|
||||
backendUrl = prefs[KEY_BACKEND_URL] ?: "",
|
||||
scrobbleUrl = prefs[KEY_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,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
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,114 @@
|
||||
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 val offlineRepo = applicationContext.app.offlineRepository
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val groupId = inputData.getString(EXTRA_GROUP_ID) ?: return Result.failure()
|
||||
val group = offlineRepo.getGroup(groupId) ?: return Result.failure()
|
||||
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)
|
||||
var done = group.downloadedTracks
|
||||
val dao = OfflineDatabase.get(applicationContext).offlineDao()
|
||||
|
||||
return try {
|
||||
for (songId in songIds) {
|
||||
if (offlineRepo.getTrackState(songId) == DownloadState.DONE) {
|
||||
done++
|
||||
dao.updateGroupProgress(groupId, 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("DownloadWorker", "Failed to download song $songId", e)
|
||||
audioFile.delete()
|
||||
offlineRepo.markFailed(songId)
|
||||
}
|
||||
|
||||
dao.updateGroupProgress(groupId, done)
|
||||
}
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.e("DownloadWorker", "Worker failed for group $groupId", e)
|
||||
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,93 @@
|
||||
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?
|
||||
|
||||
@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>>
|
||||
|
||||
@Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId")
|
||||
suspend fun removeCrossRef(groupId: String, songId: String)
|
||||
|
||||
/** 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,211 @@
|
||||
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)
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(appContext)
|
||||
.enqueueUniqueWork(groupId, ExistingWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
dao.deleteGroup(groupId) // cascades GroupTrackCrossRef rows
|
||||
for (songId in songIds) {
|
||||
if (dao.groupCountForSong(songId) == 0) {
|
||||
_audioMap.value[songId]?.let { File(it).delete() }
|
||||
dao.deleteTrackById(songId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a single track from a group, deleting the file if no other group still needs it. */
|
||||
suspend fun removeSongFromGroup(groupId: String, songId: String) {
|
||||
dao.removeCrossRef(groupId, songId)
|
||||
if (dao.groupCountForSong(songId) == 0) {
|
||||
_audioMap.value[songId]?.let { File(it).delete() }
|
||||
dao.deleteTrackById(songId)
|
||||
}
|
||||
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,595 @@
|
||||
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?) { 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,357 @@
|
||||
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 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()
|
||||
}, context.mainExecutor)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback commands ─────────────────────────────────────────────────────
|
||||
|
||||
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
|
||||
// If ABS is active, close it — user explicitly switched to music
|
||||
(context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs ->
|
||||
if (abs.isActive) abs.close()
|
||||
}
|
||||
val ctrl = controller ?: return
|
||||
queue = songs
|
||||
originalQueue = songs
|
||||
isManuallyShuffled = false
|
||||
ctrl.shuffleModeEnabled = false
|
||||
val items = songs.map { it.toMediaItem(repo) }
|
||||
ctrl.setMediaItems(items, startIndex, 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))
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (!isManuallyShuffled) {
|
||||
isManuallyShuffled = true
|
||||
val played = queue.take(currentIdx + 1)
|
||||
val remaining = queue.drop(currentIdx + 1).shuffled()
|
||||
queue = played + remaining
|
||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
||||
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
||||
} else {
|
||||
isManuallyShuffled = false
|
||||
val currentSong = queue.getOrNull(currentIdx)
|
||||
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
|
||||
val restOfOriginal = originalQueue.drop(origIdx + 1)
|
||||
queue = originalQueue.take(origIdx + 1) + restOfOriginal
|
||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
||||
restOfOriginal.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
||||
}
|
||||
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()) {
|
||||
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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) } }
|
||||
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,657 @@
|
||||
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()
|
||||
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 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.shuffled(), 0)
|
||||
}}
|
||||
},
|
||||
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,
|
||||
)
|
||||
}
|
||||
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.shuffled(), 0)
|
||||
}}
|
||||
},
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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,51 @@
|
||||
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)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
NovaSonicTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
NovaSonicRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,199 @@
|
||||
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.material.icons.filled.Notifications
|
||||
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
|
||||
|
||||
@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(Icons.Default.Notifications, 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,105 @@
|
||||
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,
|
||||
) {
|
||||
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() }) }
|
||||
}
|
||||
|
||||
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,138 @@
|
||||
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)
|
||||
.padding(start = if (isPlaying) 0.dp else 4.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,223 @@
|
||||
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.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 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
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
model = coverUrl(album.coverArt),
|
||||
contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
// gradient overlay
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||
1.0f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
// back button — frosted circle
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
// album info overlaid at bottom
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
album.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!album.artist.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
album.artist!!, fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
|
||||
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
|
||||
Modifier.clickable { onArtistClick(album.artistId!!) }
|
||||
else Modifier,
|
||||
)
|
||||
}
|
||||
val meta = listOfNotNull(
|
||||
album.year?.toString(),
|
||||
album.genre?.takeIf { it.isNotBlank() },
|
||||
if (album.songCount > 0) "${album.songCount} Songs" else null,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
meta, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle row ────────────────────────────────────────────
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onPlay(album.song, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onPlay(album.song.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
val isFullyDownloaded = downloadGroup != null &&
|
||||
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
|
||||
downloadGroup!!.totalTracks > 0
|
||||
OutlinedButton(
|
||||
// Tappable unless fully done — resumes / re-downloads any missing songs.
|
||||
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
|
||||
modifier = Modifier.height(46.dp).width(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(
|
||||
when {
|
||||
isFullyDownloaded -> MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
else -> NsAccent.copy(alpha = 0.55f)
|
||||
}
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
when {
|
||||
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Downloaded", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
|
||||
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
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 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, 0)
|
||||
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 },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(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(),
|
||||
)
|
||||
// "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,
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
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 = { query = it },
|
||||
placeholder = { Text("Suchen…", color = NsOnSurface2) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||
trailingIcon = if (query.isNotEmpty()) { {
|
||||
IconButton(onClick = { query = "" }) {
|
||||
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(
|
||||
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,345 @@
|
||||
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),
|
||||
) {
|
||||
ScoreRing(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))
|
||||
}
|
||||
|
||||
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 ScoreRing(d: CompareResult) {
|
||||
val score = d.matchScore.coerceIn(0, 100)
|
||||
val verdict = when {
|
||||
score >= 80 -> "Seelenverwandte 🎯"
|
||||
score >= 60 -> "Starkes Match 🔥"
|
||||
score >= 40 -> "Solide Überschneidung 👍"
|
||||
score >= 20 -> "Ein paar Gemeinsamkeiten 🙂"
|
||||
else -> "Ziemlich unterschiedlich 🌗"
|
||||
}
|
||||
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(Modifier.size(132.dp), contentAlignment = Alignment.Center) {
|
||||
val accent = NsAccent
|
||||
val track = NsSurface2
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val stroke = 14.dp.toPx()
|
||||
val inset = stroke / 2
|
||||
val arcSize = Size(size.width - stroke, size.height - stroke)
|
||||
drawArc(
|
||||
color = track, startAngle = 0f, sweepAngle = 360f, useCenter = false,
|
||||
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize,
|
||||
style = Stroke(width = stroke),
|
||||
)
|
||||
drawArc(
|
||||
color = accent, startAngle = -90f, sweepAngle = score * 3.6f, useCenter = false,
|
||||
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize,
|
||||
style = Stroke(width = stroke),
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("$score%", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 30.sp, color = NsOnSurface)
|
||||
Text("Match", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(verdict, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsOnSurface)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("${d.userA} ⟷ ${d.userB}", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
@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,705 @@
|
||||
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,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
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,
|
||||
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 = {},
|
||||
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)
|
||||
}
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
|
||||
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,96 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.*
|
||||
|
||||
@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 -> LazyColumn {
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(songs, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
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.Notifications
|
||||
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
|
||||
|
||||
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(Icons.Default.Notifications, "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,305 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,478 @@
|
||||
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,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
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.shuffled(), 0) },
|
||||
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,622 @@
|
||||
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.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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()
|
||||
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
|
||||
LaunchedEffect(state.albumSort) { albumGridState.scrollToItem(0) }
|
||||
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()
|
||||
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
|
||||
// Map letter → flat LazyColumn index of its header item
|
||||
val letterToIndex = remember(state.artistIndexes) {
|
||||
buildMap<String, Int> {
|
||||
var flat = 0
|
||||
state.artistIndexes.forEach { idx ->
|
||||
put(idx.name, flat)
|
||||
flat += 1 + idx.artist.size
|
||||
}
|
||||
}
|
||||
}
|
||||
var activeLetter by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
state.artistIndexes.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 ->
|
||||
PlaylistCard(
|
||||
name = pl.name,
|
||||
subtitle = "${pl.songCount} Titel",
|
||||
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,130 @@
|
||||
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()
|
||||
|
||||
var serverUrl by remember { mutableStateOf("") }
|
||||
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,746 @@
|
||||
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
|
||||
|
||||
private enum class NpTab { PLAYER, LYRICS, QUEUE }
|
||||
|
||||
@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) }
|
||||
|
||||
// Swipe left/right between Player / Lyrics / Queue, kept in sync with the tab pills (like
|
||||
// Erkunden). A horizontal drag that starts on the cover still skips tracks — that gesture is a
|
||||
// child pointerInput and consumes the drag before the pager sees it.
|
||||
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)))
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topPad, bottom = maxOf(navBarBottom, 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.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 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 = 18.dp, vertical = 9.dp),
|
||||
) {
|
||||
Text(
|
||||
when (t) {
|
||||
NpTab.PLAYER -> "Player"
|
||||
NpTab.LYRICS -> "Lyrics"
|
||||
NpTab.QUEUE -> "Queue"
|
||||
},
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 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,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
) { page ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
when (NpTab.entries[page]) {
|
||||
// ── PLAYER TAB ─────────────────────────────────────────────────
|
||||
NpTab.PLAYER -> {
|
||||
var dragX by remember { mutableStateOf(0f) }
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
// Cover art — full content width, scale-animates like Apple Music
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = song?.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.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(24.dp))
|
||||
|
||||
// ── Song info ──────────────────────────────────────────────
|
||||
Column(Modifier.fillMaxWidth().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(20.dp))
|
||||
|
||||
// ── Seek bar ───────────────────────────────────────────────
|
||||
Column(Modifier.fillMaxWidth().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(12.dp))
|
||||
|
||||
// ── Controls ───────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
NpCircleBtn(onClick = { controller.toggleShuffle() }) {
|
||||
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(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)
|
||||
}
|
||||
NpCircleBtn(onClick = { controller.toggleRepeat() }) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── 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) }
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.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).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
|
||||
.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 {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp),
|
||||
) {
|
||||
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
|
||||
val active = state.currentIndex == idx
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end when
|
||||
} // end inner Column
|
||||
} // end HorizontalPager
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(onClick = onClick),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
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,185 @@
|
||||
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.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.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
|
||||
|
||||
@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() } }
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
model = coverUrl(g.coverArt),
|
||||
contentDescription = g.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||
1.0f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
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 (songs.isNotEmpty()) onPlay(songs, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (songs.isNotEmpty()) onPlay(songs.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { editMode = !editMode },
|
||||
modifier = Modifier.height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(horizontal = 14.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (editMode) Icons.Default.Done else Icons.Default.Edit, null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (editMode) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { index, song ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
onPlay = { onPlay(songs, index) },
|
||||
)
|
||||
}
|
||||
if (editMode) {
|
||||
IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) {
|
||||
Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen",
|
||||
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -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.shuffled(), 0) }
|
||||
.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,389 @@
|
||||
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.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
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
model = coverUrl(pl.coverArt),
|
||||
contentDescription = pl.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||
1.0f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
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,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
pl.owner?.takeIf { it.isNotBlank() },
|
||||
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
meta, fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
)
|
||||
}
|
||||
if (!pl.comment.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle / Edit / Download ──────────────────────────────
|
||||
// Four buttons in one row left no room for labels (the Play/Shuffle text was being
|
||||
// clipped away), so all four are equal-width and icon-only. Play stays filled as the
|
||||
// primary action; the rest are outlined and tint accent when active/done.
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
}
|
||||
// Toggle edit mode for removing songs
|
||||
OutlinedButton(
|
||||
onClick = { editMode = !editMode },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (editMode) Icons.Default.Done else Icons.Default.Edit,
|
||||
if (editMode) "Bearbeiten beenden" else "Titel entfernen",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (editMode) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
// Download button — tappable unless fully done; resumes / re-downloads missing songs
|
||||
val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false
|
||||
OutlinedButton(
|
||||
onClick = { if (!dlDone) vm.downloadPlaylist() },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
when {
|
||||
dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent)
|
||||
else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(pl.entry, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
|
||||
)
|
||||
}
|
||||
if (editMode) {
|
||||
IconButton(
|
||||
onClick = { vm.removeSong(idx) },
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.RemoveCircleOutline,
|
||||
"Entfernen",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
|
||||
// ── 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,403 @@
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
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,124 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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.*
|
||||
|
||||
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
|
||||
|
||||
@Composable
|
||||
fun StarredScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val repo = context.app.repository
|
||||
|
||||
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var sort by remember { mutableStateOf(StarredSort.NEWEST) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
songs = repo.getStarred().song
|
||||
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()) {
|
||||
Text(
|
||||
"Favoriten",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 24.dp, bottom = 8.dp),
|
||||
)
|
||||
|
||||
// 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()) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Noch keine Favoriten", color = NsOnSurface2)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(sorted, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,102 @@
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
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,69 @@
|
||||
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
|
||||
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,438 @@
|
||||
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
|
||||
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
|
||||
|
||||
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) }.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.shuffled().ifEmpty { null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,361 @@
|
||||
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()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()) }
|
||||
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()
|
||||
_state.value = _state.value.copy(
|
||||
artistDetail = ItunesArtistState(
|
||||
artist = artist,
|
||||
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,191 @@
|
||||
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,
|
||||
)
|
||||
|
||||
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 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)
|
||||
return
|
||||
}
|
||||
}
|
||||
_state.value = _state.value.copy(isDownloading = false)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
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,118 @@
|
||||
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.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(),
|
||||
val playlists: List<Playlist> = emptyList(),
|
||||
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() }
|
||||
|
||||
fun setAlbumSort(sort: AlbumSort) {
|
||||
_state.value = _state.value.copy(albumSort = sort)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadArtists() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val indexes = repo.getArtists()
|
||||
_state.value = _state.value.copy(artistIndexes = indexes, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPlaylists() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val pls = repo.getPlaylists()
|
||||
_state.value = _state.value.copy(playlists = pls, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun createPlaylist(name: String, onSuccess: (String) -> Unit = {}) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val newPl = repo.createPlaylist(trimmed, emptyList())
|
||||
val pls = runCatching { repo.getPlaylists() }.getOrDefault(_state.value.playlists)
|
||||
_state.value = _state.value.copy(playlists = pls)
|
||||
onSuccess(newPl.id)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadPlaylists() = loadPlaylists()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.SubsonicClient
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LoginUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val success: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class LoginViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(LoginUiState())
|
||||
val state: StateFlow<LoginUiState> = _state
|
||||
|
||||
fun login(serverUrl: String, username: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = LoginUiState(isLoading = true)
|
||||
try {
|
||||
val client = SubsonicClient(serverUrl, username, password)
|
||||
client.ping()
|
||||
client.close()
|
||||
repo.saveCredentials(serverUrl, username, password)
|
||||
_state.value = LoginUiState(success = true)
|
||||
} catch (e: Exception) {
|
||||
val msg = when {
|
||||
e.message?.contains("Unable to resolve host") == true -> "Cannot reach server. Check the URL."
|
||||
e.message?.contains("40") == true -> "Wrong username or password."
|
||||
else -> e.message ?: "Connection failed."
|
||||
}
|
||||
_state.value = LoginUiState(error = msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.LrcLibClient
|
||||
import com.novasonic.app.api.LrcLine
|
||||
import com.novasonic.app.api.parseLrc
|
||||
import com.novasonic.app.api.model.Song
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LyricsUiState(
|
||||
val lines: List<LrcLine> = emptyList(), // non-empty = synced
|
||||
val plainText: String = "", // fallback plain lyrics
|
||||
val isLoading: Boolean = false,
|
||||
val notFound: Boolean = false,
|
||||
val songId: String = "",
|
||||
)
|
||||
|
||||
class LyricsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val _state = MutableStateFlow(LyricsUiState())
|
||||
val state: StateFlow<LyricsUiState> = _state
|
||||
|
||||
fun loadForSong(song: Song) {
|
||||
if (_state.value.songId == song.id && !_state.value.isLoading) return
|
||||
_state.value = LyricsUiState(isLoading = true, songId = song.id)
|
||||
viewModelScope.launch {
|
||||
val result = LrcLibClient.getLyrics(
|
||||
artist = song.artist ?: "",
|
||||
title = song.title,
|
||||
album = song.album,
|
||||
durationSecs = song.duration,
|
||||
)
|
||||
_state.value = when {
|
||||
result == null -> LyricsUiState(notFound = true, songId = song.id)
|
||||
result.instrumental -> LyricsUiState(plainText = "🎵 Instrumentalstück", songId = song.id)
|
||||
!result.syncedLyrics.isNullOrBlank() -> LyricsUiState(lines = parseLrc(result.syncedLyrics), songId = song.id)
|
||||
!result.plainLyrics.isNullOrBlank() -> LyricsUiState(plainText = result.plainLyrics, songId = song.id)
|
||||
else -> LyricsUiState(notFound = true, songId = song.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.DownloadedTrackEntity
|
||||
import com.novasonic.app.offline.toSong
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class OfflineDetailViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
// groupId is passed URL-encoded in the route; Navigation decodes it back to "album:xyz".
|
||||
private val groupId: String = savedState["groupId"] ?: ""
|
||||
|
||||
val group: StateFlow<DownloadGroupEntity?> = offlineRepo.allGroupsFlow
|
||||
.map { groups -> groups.find { it.groupId == groupId } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val tracks: StateFlow<List<DownloadedTrackEntity>> = offlineRepo.tracksForGroupFlow(groupId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun songs(): List<Song> = tracks.value.map { it.toSong() }
|
||||
|
||||
fun deleteSong(songId: String) {
|
||||
viewModelScope.launch { offlineRepo.removeSongFromGroup(groupId, songId) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.toSong
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class OfflineViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
|
||||
val groups: StateFlow<List<DownloadGroupEntity>> = offlineRepo.allGroupsFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val storageBytes: StateFlow<Long> = groups
|
||||
.map { withContext(Dispatchers.IO) { offlineRepo.storageUsedBytes() } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L)
|
||||
|
||||
fun deleteGroup(groupId: String) {
|
||||
viewModelScope.launch { offlineRepo.deleteGroup(groupId) }
|
||||
}
|
||||
|
||||
suspend fun songsForGroup(groupId: String): List<Song> =
|
||||
offlineRepo.tracksForGroup(groupId).map { it.toSong() }
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.PlaylistWithSongs
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.DownloadState
|
||||
import com.novasonic.app.offline.GroupType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PlaylistUiState(
|
||||
val playlist: PlaylistWithSongs? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val isSaving: Boolean = false,
|
||||
val deleted: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
private val playlistId: String? = savedState["playlistId"]
|
||||
|
||||
private val _state = MutableStateFlow(PlaylistUiState())
|
||||
val state: StateFlow<PlaylistUiState> = _state
|
||||
|
||||
val downloadGroup: StateFlow<DownloadGroupEntity?> = offlineRepo.allGroupsFlow
|
||||
.map { groups -> groups.find { it.groupId == "playlist:$playlistId" } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
/** True while any track in this playlist's group is queued or actively downloading. */
|
||||
val isDownloading: StateFlow<Boolean> = offlineRepo.tracksForGroupFlow("playlist:$playlistId")
|
||||
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
init { load() }
|
||||
|
||||
fun load() {
|
||||
val id = playlistId
|
||||
if (id.isNullOrBlank()) {
|
||||
_state.value = PlaylistUiState(isLoading = false, error = "Keine Playlist-ID übergeben")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val pl = repo.getPlaylist(id)
|
||||
_state.value = PlaylistUiState(playlist = pl, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = PlaylistUiState(isLoading = false, error = e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rename(newName: String, newComment: String? = null) {
|
||||
val id = playlistId ?: return
|
||||
val pl = _state.value.playlist ?: return
|
||||
val trimmed = newName.trim()
|
||||
if (trimmed.isBlank()) return
|
||||
val comment = newComment?.trim() ?: pl.comment
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSaving = true)
|
||||
try {
|
||||
repo.updatePlaylistMeta(id, trimmed, comment)
|
||||
_state.value = _state.value.copy(
|
||||
playlist = pl.copy(name = trimmed, comment = comment),
|
||||
isSaving = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
val id = playlistId ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSaving = true)
|
||||
try {
|
||||
repo.deletePlaylist(id)
|
||||
_state.value = _state.value.copy(deleted = true, isSaving = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadCover(imageBytes: ByteArray, mimeType: String) {
|
||||
val id = playlistId ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSaving = true)
|
||||
try {
|
||||
repo.uploadPlaylistCover(id, imageBytes, mimeType)
|
||||
// Reload to get the updated coverArt ID from Navidrome
|
||||
val pl = repo.getPlaylist(id)
|
||||
_state.value = _state.value.copy(playlist = pl, isSaving = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCover() {
|
||||
val id = playlistId ?: return
|
||||
val pl = _state.value.playlist ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSaving = true)
|
||||
try {
|
||||
repo.removePlaylistCover(id)
|
||||
_state.value = _state.value.copy(
|
||||
playlist = pl.copy(coverArt = null),
|
||||
isSaving = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadPlaylist() {
|
||||
val pl = _state.value.playlist ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
offlineRepo.enqueueGroup(
|
||||
type = GroupType.PLAYLIST,
|
||||
sourceId = pl.id,
|
||||
name = pl.name,
|
||||
coverArt = pl.coverArt,
|
||||
songs = pl.entry,
|
||||
)
|
||||
}.onFailure { android.util.Log.e("PlaylistViewModel", "downloadPlaylist failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSong(index: Int) {
|
||||
val id = playlistId ?: return
|
||||
val pl = _state.value.playlist ?: return
|
||||
if (index < 0 || index >= pl.entry.size) return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repo.removeSongsFromPlaylist(id, listOf(index))
|
||||
val newSongs = pl.entry.toMutableList().also { it.removeAt(index) }
|
||||
_state.value = _state.value.copy(
|
||||
playlist = pl.copy(entry = newSongs, songCount = newSongs.size)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.ArtistOverride
|
||||
import com.novasonic.app.api.model.SearchResult3
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val searchResult: SearchResult3? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
// Artist pins (by Navidrome artist id) so a pinned artist shows the correct picture in results.
|
||||
val overrides: Map<String, ArtistOverride> = emptyMap(),
|
||||
)
|
||||
|
||||
class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(SearchUiState())
|
||||
val state: StateFlow<SearchUiState> = _state
|
||||
|
||||
// Results scroll — hoisted so it survives a drill-in/back within the search.
|
||||
val listState = LazyListState()
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val ov = runCatching { repo.artistOverrides() }.getOrDefault(emptyMap())
|
||||
if (ov.isNotEmpty()) _state.value = _state.value.copy(overrides = ov)
|
||||
}
|
||||
}
|
||||
|
||||
fun onQueryChange(q: String) = onSearch(q)
|
||||
|
||||
fun onSearch(q: String) {
|
||||
_state.value = _state.value.copy(query = q)
|
||||
searchJob?.cancel()
|
||||
if (q.isBlank()) {
|
||||
_state.value = _state.value.copy(searchResult = null, isLoading = false)
|
||||
return
|
||||
}
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(300)
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val results = repo.search(q)
|
||||
_state.value = _state.value.copy(searchResult = results, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.AbsClient
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.ui.theme.NsColors
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.android.Android
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class ConnStatus {
|
||||
object Idle : ConnStatus()
|
||||
object Checking : ConnStatus()
|
||||
data class Ok(val text: String) : ConnStatus()
|
||||
data class Err(val text: String) : ConnStatus()
|
||||
}
|
||||
|
||||
data class SettingsUiState(
|
||||
val serverUrl: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val lastFmKey: String = "",
|
||||
val backendUrl: String = "",
|
||||
val scrobbleUrl: String = "",
|
||||
val scrobbleMode: String = "30s",
|
||||
val showPlayCount: Boolean = false,
|
||||
val theme: String = "dark-ocean",
|
||||
val absUrl: String = "",
|
||||
val absApiKey: String = "",
|
||||
val absChapterTrackMode: Boolean = false,
|
||||
val isSaving: Boolean = false,
|
||||
val savedOk: Boolean = false,
|
||||
val error: String? = null,
|
||||
val absStatus: ConnStatus = ConnStatus.Idle,
|
||||
val backendStatus: ConnStatus = ConnStatus.Idle,
|
||||
val scrobbleStatus: ConnStatus = ConnStatus.Idle,
|
||||
)
|
||||
|
||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
// Seed the theme with the already-applied one so the picker doesn't jump before DataStore emits.
|
||||
private val _state = MutableStateFlow(SettingsUiState(theme = NsColors.themeId))
|
||||
val state: StateFlow<SettingsUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repo.appSettingsFlow().collect { s ->
|
||||
_state.value = _state.value.copy(
|
||||
serverUrl = s.serverUrl,
|
||||
username = s.username,
|
||||
password = s.password,
|
||||
lastFmKey = s.lastFmKey,
|
||||
backendUrl = s.backendUrl,
|
||||
scrobbleUrl = s.scrobbleUrl,
|
||||
scrobbleMode = s.scrobbleMode,
|
||||
showPlayCount = s.showPlayCount,
|
||||
theme = s.theme,
|
||||
absUrl = s.absUrl,
|
||||
absApiKey = s.absApiKey,
|
||||
absChapterTrackMode = s.absChapterTrackMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onServerUrl(v: String) = update { it.copy(serverUrl = v, savedOk = false) }
|
||||
fun onUsername(v: String) = update { it.copy(username = v, savedOk = false) }
|
||||
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
|
||||
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) }
|
||||
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) }
|
||||
fun onScrobbleUrl(v: String) = update { it.copy(scrobbleUrl = v, savedOk = false, scrobbleStatus = ConnStatus.Idle) }
|
||||
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
|
||||
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
|
||||
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
||||
fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) }
|
||||
fun onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) }
|
||||
|
||||
fun onTheme(id: String) {
|
||||
update { it.copy(theme = id) }
|
||||
viewModelScope.launch { repo.saveTheme(id) }
|
||||
}
|
||||
|
||||
fun testAbs() {
|
||||
val url = _state.value.absUrl.trim().trimEnd('/')
|
||||
val key = _state.value.absApiKey.trim()
|
||||
if (url.isBlank() || key.isBlank()) {
|
||||
update { it.copy(absStatus = ConnStatus.Err("URL und API Key eingeben")) }
|
||||
return
|
||||
}
|
||||
update { it.copy(absStatus = ConnStatus.Checking) }
|
||||
viewModelScope.launch {
|
||||
val backend = _state.value.backendUrl.trim().trimEnd('/')
|
||||
val ok = runCatching { AbsClient(url, key, backend).ping() }.getOrDefault(false)
|
||||
update { it.copy(absStatus = if (ok) ConnStatus.Ok("Verbunden · $url") else ConnStatus.Err("Nicht erreichbar")) }
|
||||
}
|
||||
}
|
||||
|
||||
fun testBackend() {
|
||||
val url = _state.value.backendUrl.trim().trimEnd('/')
|
||||
if (url.isBlank()) {
|
||||
update { it.copy(backendStatus = ConnStatus.Err("Backend URL eingeben")) }
|
||||
return
|
||||
}
|
||||
update { it.copy(backendStatus = ConnStatus.Checking) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val http = HttpClient(Android) {
|
||||
install(HttpTimeout) { requestTimeoutMillis = 5_000; connectTimeoutMillis = 4_000 }
|
||||
}
|
||||
val resp = http.get("$url/api/health")
|
||||
http.close()
|
||||
if (resp.status == HttpStatusCode.OK) {
|
||||
update { it.copy(backendStatus = ConnStatus.Ok("Verbunden · $url")) }
|
||||
} else {
|
||||
update { it.copy(backendStatus = ConnStatus.Err("HTTP ${resp.status.value}")) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
update { it.copy(backendStatus = ConnStatus.Err("Nicht erreichbar")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun testScrobble() {
|
||||
val url = _state.value.scrobbleUrl.trim().trimEnd('/')
|
||||
if (url.isBlank()) {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Err("Scrobble URL eingeben")) }
|
||||
return
|
||||
}
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Checking) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val http = HttpClient(Android) {
|
||||
install(HttpTimeout) { requestTimeoutMillis = 5_000; connectTimeoutMillis = 4_000 }
|
||||
}
|
||||
val resp = http.get("$url/scrobble/health")
|
||||
http.close()
|
||||
if (resp.status == HttpStatusCode.OK) {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Ok("Verbunden · $url")) }
|
||||
} else {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Err("HTTP ${resp.status.value}")) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
update { it.copy(scrobbleStatus = ConnStatus.Err("Nicht erreichbar")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val s = _state.value
|
||||
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
|
||||
_state.value = s.copy(error = "Server, Benutzername und Passwort sind Pflichtfelder")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = s.copy(isSaving = true, error = null)
|
||||
try {
|
||||
repo.saveAppSettings(PrefsStore.AppSettings(
|
||||
serverUrl = s.serverUrl,
|
||||
username = s.username,
|
||||
password = s.password,
|
||||
lastFmKey = s.lastFmKey.trim(),
|
||||
backendUrl = s.backendUrl.trimEnd('/'),
|
||||
scrobbleUrl = s.scrobbleUrl.trimEnd('/'),
|
||||
scrobbleMode = s.scrobbleMode,
|
||||
showPlayCount = s.showPlayCount,
|
||||
theme = s.theme,
|
||||
absUrl = s.absUrl.trimEnd('/'),
|
||||
absApiKey = s.absApiKey.trim(),
|
||||
absChapterTrackMode = s.absChapterTrackMode,
|
||||
))
|
||||
_state.value = _state.value.copy(isSaving = false, savedOk = true)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch { repo.saveCredentials("", "", "") }
|
||||
}
|
||||
|
||||
private inline fun update(block: (SettingsUiState) -> SettingsUiState) {
|
||||
_state.value = block(_state.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.ScrobbleStats
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
|
||||
enum class StatsView { ALL, YEAR, MONTH }
|
||||
|
||||
data class StatsUiState(
|
||||
val loading: Boolean = true,
|
||||
val configured: Boolean = true,
|
||||
val error: String? = null,
|
||||
val view: StatsView = StatsView.ALL,
|
||||
val year: Int = Calendar.getInstance().get(Calendar.YEAR),
|
||||
val month: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11
|
||||
val firstPlayedAt: Long? = null, // earliest logged play → navigator lower bound
|
||||
val stats: ScrobbleStats? = null,
|
||||
)
|
||||
|
||||
class StatsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val _state = MutableStateFlow(StatsUiState())
|
||||
val state: StateFlow<StatsUiState> = _state
|
||||
|
||||
init { load() }
|
||||
|
||||
fun setView(v: StatsView) {
|
||||
if (v == _state.value.view) return
|
||||
_state.value = _state.value.copy(view = v)
|
||||
load()
|
||||
}
|
||||
|
||||
/** Step the current year/month window by ±1 (no-op in ALL). */
|
||||
fun step(dir: Int) {
|
||||
val s = _state.value
|
||||
when (s.view) {
|
||||
StatsView.YEAR -> _state.value = s.copy(year = s.year + dir)
|
||||
StatsView.MONTH -> {
|
||||
var m = s.month + dir
|
||||
var y = s.year
|
||||
if (m < 0) { m = 11; y-- }
|
||||
if (m > 11) { m = 0; y++ }
|
||||
_state.value = s.copy(year = y, month = m)
|
||||
}
|
||||
else -> return
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
/** True when the shown window is the current one — used to disable stepping into the future. */
|
||||
fun atCurrent(): Boolean {
|
||||
val s = _state.value
|
||||
val now = Calendar.getInstance()
|
||||
return when (s.view) {
|
||||
StatsView.YEAR -> s.year >= now.get(Calendar.YEAR)
|
||||
StatsView.MONTH -> s.year > now.get(Calendar.YEAR) ||
|
||||
(s.year == now.get(Calendar.YEAR) && s.month >= now.get(Calendar.MONTH))
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the shown window is at/before the first logged play — caps stepping backwards. */
|
||||
fun atStart(): Boolean {
|
||||
val s = _state.value
|
||||
val first = s.firstPlayedAt ?: return false
|
||||
val c = Calendar.getInstance().apply { timeInMillis = first * 1000 }
|
||||
val fy = c.get(Calendar.YEAR)
|
||||
val fm = c.get(Calendar.MONTH)
|
||||
return when (s.view) {
|
||||
StatsView.YEAR -> s.year <= fy
|
||||
StatsView.MONTH -> s.year < fy || (s.year == fy && s.month <= fm)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun startOf(year: Int, month: Int, day: Int): Long {
|
||||
val c = Calendar.getInstance().apply {
|
||||
clear()
|
||||
set(year, month, day, 0, 0, 0)
|
||||
}
|
||||
return c.timeInMillis / 1000
|
||||
}
|
||||
|
||||
private fun range(): Triple<Long, Long, String> {
|
||||
val s = _state.value
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
return when (s.view) {
|
||||
StatsView.ALL -> Triple(0L, nowSec + 1, "month")
|
||||
StatsView.YEAR -> Triple(startOf(s.year, 0, 1), startOf(s.year + 1, 0, 1), "month")
|
||||
StatsView.MONTH -> Triple(startOf(s.year, s.month, 1), startOf(s.year, s.month + 1, 1), "day")
|
||||
}
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
val settings = repo.appSettingsFlow().first()
|
||||
if (settings.scrobbleUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(loading = false, configured = false)
|
||||
return@launch
|
||||
}
|
||||
val (from, to, bucket) = range()
|
||||
val tz = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000
|
||||
val stats = repo.fetchScrobbleStats(from, to, tz, bucket, 10)
|
||||
_state.value = if (stats == null)
|
||||
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||
else
|
||||
_state.value.copy(
|
||||
loading = false, configured = true, stats = stats,
|
||||
// Keep the earliest-play bound once known (a windowed query may return null for it).
|
||||
firstPlayedAt = stats.firstPlayedAt ?: _state.value.firstPlayedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group
|
||||
android:pivotX="12"
|
||||
android:scaleX="-1">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,5L12,1L7,6L12,11L12,7C15.31,7 18,9.69 18,13C18,16.31 15.31,19 12,19C8.69,19 6,16.31 6,13L4,13C4,17.42 7.58,21 12,21C16.42,21 20,17.42 20,13C20,8.58 16.42,5 12,5Z"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M6,19h4L10,5H6v14zM14,5v14h4L18,5h-4z"/>
|
||||
</vector>
|
||||
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user