Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d68f568811 | ||
|
|
d4e3822fea | ||
|
|
e317080eb7 | ||
|
|
efbb5cf01c | ||
|
|
26e5e90e6b | ||
|
|
ceaacab2f3 | ||
|
|
d20ce234ad | ||
|
|
5750bc0632 | ||
|
|
f57485a2d0 | ||
|
|
80836642f2 | ||
|
|
1deeecaf13 | ||
|
|
4ba750b8ca | ||
|
|
72139372f2 | ||
|
|
00e96a9063 | ||
|
|
22ff1ad49c | ||
|
|
ace3353b7c | ||
|
|
edd6fbdce8 | ||
|
|
8e26dd4b22 | ||
|
|
b99b651599 | ||
|
|
4a0dee8132 | ||
|
|
ba42a859c2 | ||
|
|
d991362832 | ||
|
|
549f8b6613 | ||
|
|
5e061713c5 | ||
|
|
20d568997b | ||
|
|
f1eb48cd65 | ||
|
|
f80709d088 | ||
|
|
1c19e6c586 | ||
|
|
a2b66f5d95 | ||
|
|
73932ded38 | ||
|
|
cb214f4bd3 | ||
|
|
99b561dfb0 | ||
|
|
405dd833e1 | ||
|
|
2ddc446d90 | ||
|
|
f0568e917b | ||
|
|
2e3f3c1e25 | ||
|
|
e1b18c7f9e | ||
|
|
2e0cb175cb | ||
|
|
d7194d0f2f | ||
|
|
4a94a0b596 | ||
|
|
d28219df98 | ||
|
|
27b2df7e98 | ||
|
|
9682e877fb | ||
|
|
daf624c8bc | ||
|
|
2c2c5e7939 | ||
|
|
a6c35c6263 | ||
|
|
25b074c1e8 | ||
|
|
6c97420f58 | ||
|
|
526e0d06b6 | ||
|
|
7092c2c08e | ||
|
|
c728815f8b | ||
|
|
19055d1474 | ||
|
|
cac78f7a11 | ||
|
|
1ad5ab3f22 | ||
|
|
4f7fe9f4a4 | ||
|
|
081f13d59f | ||
|
|
ea4407f509 | ||
|
|
6ffc98c680 | ||
|
|
0fe53c9115 | ||
|
|
d0a1e3a85b | ||
|
|
fa8c5bd26e | ||
|
|
62386b65d3 | ||
|
|
82beb0db2c | ||
|
|
2fe78556e5 | ||
|
|
82d0bb5c2d | ||
|
|
add137c56c | ||
|
|
3df1b9c756 | ||
|
|
3852e83a3c | ||
|
|
aa48c9e6d7 | ||
|
|
c3a197ff93 | ||
|
|
1edb5a73c9 | ||
|
|
498ed53106 | ||
|
|
6fcd34752f | ||
|
|
710c772f1a | ||
|
|
4bfe450e8b | ||
|
|
d4b910fae0 | ||
|
|
c2fdccc702 | ||
|
|
43f50dcd02 | ||
|
|
9a0c8c2b7e | ||
|
|
d55419514d | ||
|
|
a7f41827cb | ||
|
|
1dbd589ed7 | ||
|
|
3b824174a4 | ||
|
|
8eff3eb376 | ||
|
|
2cec4f22ad | ||
|
|
fc642cd1d6 | ||
|
|
e13873456d | ||
|
|
f7378e7a25 | ||
|
|
9e045b1bd4 | ||
|
|
7049128f01 | ||
|
|
e5dc9cbf89 | ||
|
|
4f2e4cc6c0 | ||
|
|
13e933484c | ||
|
|
533993ee4e | ||
|
|
1e0321bb5e | ||
|
|
e8d90689de | ||
|
|
69a770d548 | ||
|
|
c7d6de4c9c | ||
|
|
d4611b1e2f | ||
|
|
4dbcd44705 | ||
|
|
c20745e207 | ||
|
|
ceee3f667a | ||
|
|
9fe01654cb | ||
|
|
a987320042 | ||
|
|
393c27b937 | ||
|
|
829382cd68 | ||
|
|
9720bf59f7 | ||
|
|
ebb069245a | ||
|
|
f655be1fb6 | ||
|
|
9d77379584 | ||
|
|
79520aefc3 | ||
|
|
e50c7d6cdc | ||
|
|
6940c6a51f | ||
|
|
3cb534d678 | ||
|
|
601ca67a21 | ||
|
|
e9ebc3e3c3 | ||
|
|
6f4475a28d | ||
|
|
49beea769b | ||
|
|
d06761124f | ||
|
|
eff99492fa | ||
|
|
9dafc53670 | ||
|
|
bfa6374657 | ||
|
|
8b5fbb12b7 | ||
|
|
d62cf28530 | ||
|
|
7912c4b2ef | ||
|
|
ed589e45ee | ||
|
|
0767412765 | ||
|
|
79cabeeb40 | ||
|
|
b713dfd61a | ||
|
|
d3ab3c17a7 | ||
|
|
20926ddfdd | ||
|
|
929584cdae | ||
|
|
503293d0c9 | ||
|
|
4ad71c3cda | ||
|
|
beee3d099e | ||
|
|
05c18e8535 | ||
|
|
9c656ec269 | ||
|
|
53c6d5142e | ||
|
|
d5935fba44 | ||
|
|
2396f32c47 | ||
|
|
e8195d6522 | ||
|
|
6c6feb9c21 | ||
|
|
17e03a2598 | ||
|
|
4f242e1d8c | ||
|
|
5d1bb2eb90 | ||
|
|
1215f9292e | ||
|
|
63dc401e7b | ||
|
|
bc2d922859 | ||
|
|
1d970d64c2 | ||
|
|
44dce05223 | ||
|
|
3681fd2b1a | ||
|
|
f2a85cdb68 | ||
|
|
077e8483a0 | ||
|
|
0dffc4a5e7 | ||
|
|
bdbffd212e | ||
|
|
aca420bdbf | ||
|
|
d6b4bb10c2 | ||
|
|
4eadd3f8d7 | ||
|
|
a98d8cccb2 | ||
|
|
881303edd2 | ||
|
|
e9c917fa4c | ||
|
|
81b427611e | ||
|
|
eca7514a71 | ||
|
|
cfb87fcaa3 | ||
|
|
093d7aacd1 | ||
|
|
71bd6ee599 | ||
|
|
3853658f45 | ||
|
|
5d46e0ae36 | ||
|
|
ad27b28bb9 | ||
|
|
ddc3e58f4d | ||
|
|
e8ac45f886 | ||
|
|
d1ba29c44c | ||
|
|
d4581b1365 | ||
|
|
a0cd0f2821 | ||
|
|
c55c2ea1d9 | ||
|
|
fc943ecf52 | ||
|
|
8f1d754b76 | ||
|
|
168299c316 | ||
|
|
c0ea29eb8a | ||
|
|
32f820e4a8 | ||
|
|
fdb36139c5 | ||
|
|
0454edf703 | ||
|
|
e4057254d2 | ||
|
|
b3e9903da0 | ||
|
|
875d082442 | ||
|
|
d4b67e550f | ||
|
|
294ced6a84 | ||
|
|
8bf502d369 | ||
|
|
fe9ea7e993 | ||
|
|
dda989d732 | ||
|
|
40b7ae415b | ||
|
|
2b20a1d337 | ||
|
|
8ce7c3dfb8 | ||
|
|
9c67dc4342 | ||
|
|
aa8c1d439a | ||
|
|
dc95997681 | ||
|
|
b90524ec7e | ||
|
|
b6eaf01af5 | ||
|
|
9908ba75d8 | ||
|
|
7ea9229bb7 | ||
|
|
b67c0dc52d | ||
|
|
96f15a59af | ||
|
|
994c6600d5 | ||
|
|
e4169f5a09 | ||
|
|
7621a69188 | ||
|
|
c35d22e20b | ||
|
|
b603a0e940 | ||
|
|
5f1698a892 | ||
|
|
2015b208d0 | ||
|
|
142e582086 | ||
|
|
6bb9f6c03d | ||
|
|
aad04ddc92 | ||
|
|
7a68e05100 | ||
|
|
9ce558dcff | ||
|
|
cb287c2c32 | ||
|
|
50498e5f92 | ||
|
|
a30b7afbee | ||
|
|
b15e8beb8f | ||
|
|
060682a86c | ||
|
|
201dc4ac40 | ||
|
|
30aa9680ce | ||
|
|
106c817328 | ||
|
|
565d23b07e | ||
|
|
b9070f5144 | ||
|
|
e121399d45 | ||
|
|
8d1b052363 | ||
|
|
3d096bd964 | ||
|
|
7698f3cbe6 | ||
|
|
fd3ef479fa | ||
|
|
22ce7a3420 | ||
|
|
152eff564c | ||
|
|
f16522b457 | ||
|
|
c38e2662e2 | ||
|
|
11d02f166e | ||
|
|
0a059e10dd | ||
|
|
feb051e60e | ||
|
|
c6a8f54510 | ||
|
|
7a7b610a34 | ||
|
|
c569a43dfd | ||
|
|
c02bb92268 | ||
|
|
d3956a865f | ||
|
|
9b04367bad | ||
|
|
8fa44350d2 | ||
|
|
7d70fdef0b | ||
|
|
495ccce088 | ||
|
|
9114a8792a | ||
|
|
19477f9c74 | ||
|
|
d1a8f67934 | ||
|
|
e2664e095b | ||
|
|
87171dc2ba | ||
|
|
7d46ec4d4a | ||
|
|
d62f503fed | ||
|
|
90ae3faf59 | ||
|
|
dfcd42b182 | ||
|
|
6409758d8a | ||
|
|
439a728575 | ||
|
|
a74bab1fe9 | ||
|
|
309c2eeca1 | ||
|
|
70cfab5c5f | ||
|
|
97d229fb95 | ||
|
|
d8a2eec2db | ||
|
|
10bc407c82 | ||
|
|
b028fefaa7 | ||
|
|
f894675c86 | ||
|
|
842533624f | ||
|
|
0708580ae0 | ||
|
|
f7da1e7ff6 | ||
|
|
ce3b780ebd | ||
|
|
046ca348ec | ||
|
|
cf7f6d5ca8 | ||
|
|
592e66ce54 | ||
|
|
16cbf4c61d | ||
|
|
80db36a1cb | ||
|
|
c06489dcc9 | ||
|
|
ee838efadc | ||
|
|
5bfaedd7ab | ||
|
|
1976085f7c | ||
|
|
732ae7d73f | ||
|
|
a06563fb3c | ||
|
|
fff879ca6f | ||
|
|
5e3c1d83b2 | ||
|
|
dc674482e1 | ||
|
|
815b16b6ce | ||
|
|
b31c39a8c5 | ||
|
|
54d9ca4bf5 | ||
|
|
8d4350250d | ||
|
|
053f28a4f3 | ||
|
|
b05c10862d | ||
|
|
21875e11ef | ||
|
|
846151e24a | ||
|
|
fabf3c95e3 | ||
|
|
b2134c25a4 | ||
|
|
99a2f4d29f | ||
|
|
57601f2fe9 | ||
|
|
8061260544 | ||
|
|
85cef52ab4 | ||
|
|
42f27f8e1e | ||
|
|
1a3097f000 | ||
|
|
54ccc27c1b | ||
|
|
d22bd7d3bb | ||
|
|
fb318d5846 | ||
|
|
2cbb91d748 | ||
|
|
bdf3995fa7 | ||
|
|
f0f42ae626 | ||
|
|
92af11db33 | ||
|
|
680c2ee62a | ||
|
|
c2ce63a909 | ||
|
|
1a1791ea61 | ||
|
|
b212dd282c | ||
|
|
818a78bb9e | ||
|
|
38fe95b80e | ||
|
|
21e7abe253 | ||
|
|
3aa2555e81 | ||
|
|
3bb57795e8 | ||
|
|
a0c7aa1fd1 | ||
|
|
4b1d556f8d | ||
|
|
6c1723769b | ||
|
|
1b3d115316 | ||
|
|
209c7282e7 | ||
|
|
a157cf2c4e | ||
|
|
5e9a80ab61 | ||
|
|
b203a34c3c | ||
|
|
eb8b3884d6 | ||
|
|
925155cc38 | ||
|
|
6e73011bdd | ||
|
|
2fca436ac3 | ||
|
|
35c15cb2e6 | ||
|
|
b5b0642663 | ||
|
|
6320bb6ff8 | ||
|
|
b8459fc539 | ||
|
|
57b95b8eea | ||
|
|
da3750b43b | ||
|
|
c1f52d5347 | ||
|
|
ab32e37eee | ||
|
|
ad21ff263a | ||
|
|
d262707d64 | ||
|
|
e622b70a22 | ||
|
|
6b47f4026a | ||
|
|
468f815c4f | ||
|
|
1711132030 | ||
|
|
c772555ffd | ||
|
|
ffce64d4b7 | ||
|
|
f3accb1ab0 | ||
|
|
f8b48b9c82 | ||
|
|
4ff8074c83 | ||
|
|
11d448eafa | ||
|
|
e4982edc4e | ||
|
|
6e0a0cb8bb | ||
|
|
6ea8558727 | ||
|
|
35d1965c48 | ||
|
|
fd5522b374 | ||
|
|
4c4eb744b3 | ||
|
|
1711e4c781 | ||
|
|
503b078908 | ||
|
|
4e66846a49 | ||
|
|
8880df1c2d | ||
|
|
a600181e76 | ||
|
|
01e12d4d30 | ||
|
|
a1725c7cc2 | ||
|
|
05a213aea3 | ||
|
|
0a9924025b | ||
|
|
e16f4b01ce | ||
|
|
7bc32451c6 | ||
|
|
c559cfee85 | ||
|
|
e1335cd265 | ||
|
|
295021ff85 | ||
|
|
20a46b3347 | ||
|
|
b71ff97750 | ||
|
|
499fc6bb71 | ||
|
|
0de6cf5a24 | ||
|
|
cbd79f9153 | ||
|
|
d3b68b9ecd | ||
|
|
a1f31c2a5c | ||
|
|
3afff7d5c9 | ||
|
|
eaa3e1f6ea | ||
|
|
eb26506898 | ||
|
|
fbe8d3ff99 | ||
|
|
453f268b80 | ||
|
|
d882e9e16c | ||
|
|
e756ce0983 | ||
|
|
918d3e4d98 | ||
|
|
f16c7fb7b0 | ||
|
|
1bcc9d4577 | ||
|
|
a7a46b0f2f | ||
|
|
8090397186 | ||
|
|
c4497d74fd | ||
|
|
38d9a0d4e5 | ||
|
|
81d9c98f57 | ||
|
|
ab9a90eef1 | ||
|
|
bdeb7da6a8 | ||
|
|
bdae0fb476 | ||
|
|
56301fb1ea | ||
|
|
17f0fa70e2 | ||
|
|
b49c502eab | ||
|
|
fb4d9f073b | ||
|
|
4236959fae | ||
|
|
0dd1178877 | ||
|
|
5034d91b94 | ||
|
|
9778028505 | ||
|
|
c939a54117 | ||
|
|
6ae2296a50 | ||
|
|
fd85a67521 | ||
|
|
cb68c6d106 | ||
|
|
b6f0c98a28 | ||
|
|
32ed355f48 | ||
|
|
d77bb1fe73 | ||
|
|
75e265e35d | ||
|
|
d63158b311 | ||
|
|
093c0b809b | ||
|
|
880fb4e158 | ||
|
|
365455daf2 | ||
|
|
a43fa81cd6 | ||
|
|
b20fcca923 | ||
|
|
17cfc32359 | ||
|
|
1fbb3fae54 | ||
|
|
d7dfa9b78f | ||
|
|
513897db98 | ||
|
|
13ed65f08c | ||
|
|
2ba2b05b50 | ||
|
|
eaccd1a7ff | ||
|
|
fe3ef9eb9b | ||
|
|
22349e7edb | ||
|
|
fb888595cd | ||
|
|
98e120969b | ||
|
|
eac55f8c85 | ||
|
|
51d0ea9dea | ||
|
|
d3cec53bc7 | ||
|
|
a02429a10f | ||
|
|
2142421c7a | ||
|
|
52a6adfc33 | ||
|
|
f941a1f5f9 | ||
|
|
c2133fd78f | ||
|
|
443bde2ca2 | ||
|
|
2cad1c2686 | ||
|
|
9ab4f82e36 | ||
|
|
55ba29c75f | ||
|
|
6e0cefece4 | ||
|
|
974d839127 | ||
|
|
2f53564e86 | ||
|
|
bb29033d7b | ||
|
|
21ce4c3f46 | ||
|
|
41a226ef5c | ||
|
|
dcdee523a7 | ||
|
|
6a92375d1d | ||
|
|
950af5f141 | ||
|
|
4cc1cc57d9 | ||
|
|
770dfac664 | ||
|
|
8b7a6edbba | ||
|
|
2c3b2a2bf6 | ||
|
|
b73fe04b45 | ||
|
|
c3c12f848e | ||
|
|
f116056c55 | ||
|
|
28db3f7ebb | ||
|
|
3646de2a33 | ||
|
|
c97ddd8d5c | ||
|
|
c23c7748c2 | ||
|
|
0cd58436f8 | ||
|
|
5b7e178181 | ||
|
|
2ff25a4ffb | ||
|
|
5aeac9f0fb | ||
|
|
8430abf5fd | ||
|
|
489503247e | ||
|
|
0e9ebc6a4a | ||
|
|
d5a4167d19 | ||
|
|
5a503941dd | ||
|
|
657dba0703 | ||
|
|
d9657b462d | ||
|
|
a0430dd674 | ||
|
|
e1fc9d00b7 | ||
|
|
754aa81e2a | ||
|
|
d4d51b00d9 | ||
|
|
fd391755a7 | ||
|
|
dbe395d88f | ||
|
|
9f7a1f4f50 | ||
|
|
2219996aea | ||
|
|
38ec41505c | ||
|
|
7b38b2ffa3 | ||
|
|
f6e21de414 | ||
|
|
0130a10396 | ||
|
|
573891f29a | ||
|
|
3b493e59e5 | ||
|
|
bc5a3014e4 | ||
|
|
19110ef4fe | ||
|
|
47ecb715b2 | ||
|
|
6c5d34c0ee | ||
|
|
dd18d7bdb5 | ||
|
|
69e9b4ece5 | ||
|
|
9ee9ddf29a | ||
|
|
32b8085b08 | ||
|
|
f9329ef289 | ||
|
|
d2619994b9 | ||
|
|
f064fe507c | ||
|
|
5eaf101b05 | ||
|
|
184dfdf670 | ||
|
|
56c93e808f | ||
|
|
7e40725a5f | ||
|
|
4ba4c44b56 | ||
|
|
a0dee2ab92 | ||
|
|
0b0ca089c7 | ||
|
|
91606e7e82 | ||
|
|
6a5a071ebe | ||
|
|
fd5730a87f | ||
|
|
33d3c65e52 | ||
|
|
e3d6afe9eb | ||
|
|
b7fe6ac06b | ||
|
|
505de2ff11 | ||
|
|
903051c8cb | ||
|
|
b2ed5ea085 | ||
|
|
8a6eba8775 | ||
|
|
3c6c3a14ba | ||
|
|
3ffe67a178 | ||
|
|
5c5b340915 | ||
|
|
a0d4d85ff9 | ||
|
|
abff289a12 | ||
|
|
93def48a8e |
@@ -42,19 +42,16 @@ jobs:
|
|||||||
run: flutter analyze
|
run: flutter analyze
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Create signing keystore
|
- name: Setup signing keystore
|
||||||
run: |
|
run: |
|
||||||
# Create a keystore for signing (valid for 10000 days)
|
# Decode keystore from secret (persistent key for consistent signatures)
|
||||||
keytool -genkey -v -keystore android/app/upload-keystore.jks \
|
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks
|
||||||
-storetype JKS -keyalg RSA -keysize 2048 -validity 10000 \
|
|
||||||
-storepass android -keypass android \
|
|
||||||
-alias upload -dname "CN=AssistantToTheMusic, OU=HomeServer, O=Personal, L=Home, S=Home, C=US"
|
|
||||||
|
|
||||||
# Create key.properties file
|
# Create key.properties file
|
||||||
cat > android/key.properties << EOF
|
cat > android/key.properties << EOF
|
||||||
storePassword=android
|
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
keyPassword=android
|
keyPassword=${{ secrets.KEY_PASSWORD }}
|
||||||
keyAlias=upload
|
keyAlias=${{ secrets.KEY_ALIAS }}
|
||||||
storeFile=upload-keystore.jks
|
storeFile=upload-keystore.jks
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -64,7 +61,7 @@ jobs:
|
|||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: amass-apk
|
name: ensemble-apk
|
||||||
path: build/app/outputs/flutter-apk/app-release.apk
|
path: build/app/outputs/flutter-apk/app-release.apk
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
|||||||
@@ -1,429 +0,0 @@
|
|||||||
# Music Assistant Mobile - Architecture Documentation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Music Assistant Mobile is a Flutter application that connects to a [Music Assistant](https://music-assistant.io/) server to browse and control music playback across various devices and players.
|
|
||||||
|
|
||||||
## Architecture Pattern
|
|
||||||
|
|
||||||
The app follows the **Provider pattern** for state management with a **layered architecture**:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ Presentation Layer │
|
|
||||||
│ (Screens, Widgets, UI Logic) │
|
|
||||||
└──────────────┬──────────────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────┴──────────────────────┐
|
|
||||||
│ State Management │
|
|
||||||
│ (Providers - Business Logic) │
|
|
||||||
└──────────────┬──────────────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────┴──────────────────────┐
|
|
||||||
│ Service Layer │
|
|
||||||
│ (API, Auth, Audio, Settings, etc) │
|
|
||||||
└──────────────┬──────────────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────┴──────────────────────┐
|
|
||||||
│ Data Layer │
|
|
||||||
│ (Models, DTOs) │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Components
|
|
||||||
|
|
||||||
### 1. Providers (`lib/providers/`)
|
|
||||||
|
|
||||||
**MusicAssistantProvider** - Central state manager
|
|
||||||
- Manages WebSocket connection to Music Assistant server
|
|
||||||
- Handles authentication and auto-login
|
|
||||||
- Maintains library state (artists, albums, tracks)
|
|
||||||
- Manages player selection and playback state
|
|
||||||
- Implements player list caching (5-minute cache)
|
|
||||||
- Polls selected player state every 2 seconds
|
|
||||||
|
|
||||||
**MusicPlayerProvider** - Local audio playback (deprecated)
|
|
||||||
- Legacy local playback using just_audio
|
|
||||||
- Being phased out in favor of server-side playback control
|
|
||||||
|
|
||||||
### 2. Services (`lib/services/`)
|
|
||||||
|
|
||||||
**MusicAssistantAPI** - WebSocket API client
|
|
||||||
- Connects to Music Assistant server via WebSocket
|
|
||||||
- Implements JSON-RPC message protocol
|
|
||||||
- Handles server info, events, and command responses
|
|
||||||
- Provides methods for:
|
|
||||||
- Library browsing (artists, albums, tracks, playlists)
|
|
||||||
- Search functionality
|
|
||||||
- Player control and queue management
|
|
||||||
- Built-in player registration
|
|
||||||
- Stream URL generation
|
|
||||||
|
|
||||||
**AuthService** - Authentication management
|
|
||||||
- Handles Authelia authentication (separate auth domain)
|
|
||||||
- Stores and retrieves session cookies
|
|
||||||
- Auto-login with saved credentials
|
|
||||||
|
|
||||||
**SettingsService** - Persistent settings
|
|
||||||
- Uses `shared_preferences` for local storage
|
|
||||||
- Stores:
|
|
||||||
- Server URL and WebSocket port
|
|
||||||
- Username and password (encrypted)
|
|
||||||
- Built-in player ID
|
|
||||||
- Authentication token
|
|
||||||
|
|
||||||
**AudioPlayerService** - Local audio playback
|
|
||||||
- Wraps `just_audio` for audio playback
|
|
||||||
- Handles play, pause, stop, seek operations
|
|
||||||
- Currently used for built-in player mode
|
|
||||||
|
|
||||||
**BuiltinPlayerService** - Server push events
|
|
||||||
- Listens to built-in player events from server
|
|
||||||
- Handles QUEUE_ITEMS, PLAY_MEDIA, STOP, etc.
|
|
||||||
- Coordinates with AudioPlayerService
|
|
||||||
|
|
||||||
**DebugLogger** - Logging utility
|
|
||||||
- Prints timestamped debug logs
|
|
||||||
- Helps troubleshooting and development
|
|
||||||
|
|
||||||
### 3. Models (`lib/models/`)
|
|
||||||
|
|
||||||
**MediaItem** - Base class for media entities
|
|
||||||
- `Artist` - Artist metadata
|
|
||||||
- `Album` - Album metadata with artists
|
|
||||||
- `Track` - Track metadata with artists and album
|
|
||||||
- All include provider mappings and metadata
|
|
||||||
|
|
||||||
**Player** - Music Assistant player
|
|
||||||
- Player ID, name, state (idle/playing/paused)
|
|
||||||
- Availability and power status
|
|
||||||
- Derived `isPlaying` getter
|
|
||||||
|
|
||||||
**PlayerQueue** - Player queue state
|
|
||||||
- List of `QueueItem`
|
|
||||||
- Current index/item
|
|
||||||
- Player ID
|
|
||||||
|
|
||||||
**QueueItem** - Item in player queue
|
|
||||||
- Queue item ID
|
|
||||||
- Track metadata
|
|
||||||
- Stream details (sample rate, bit depth, content type)
|
|
||||||
|
|
||||||
**BuiltinPlayerEvent** - Server push events
|
|
||||||
- Event type (QUEUE_ITEMS, PLAY_MEDIA, etc.)
|
|
||||||
- Event data payload
|
|
||||||
|
|
||||||
### 4. Screens (`lib/screens/`)
|
|
||||||
|
|
||||||
**Navigation Flow:**
|
|
||||||
```
|
|
||||||
MainLayout (Bottom Navigation)
|
|
||||||
├── NewHomeScreen
|
|
||||||
│ ├── LibraryStats
|
|
||||||
│ ├── AlbumRow (Recent, Random, All)
|
|
||||||
│ └── SearchScreen (via app bar)
|
|
||||||
├── NewLibraryScreen
|
|
||||||
│ ├── LibraryArtistsScreen (with pull-to-refresh)
|
|
||||||
│ ├── LibraryAlbumsScreen (with pull-to-refresh)
|
|
||||||
│ └── LibraryTracksScreen
|
|
||||||
└── SettingsScreen
|
|
||||||
```
|
|
||||||
|
|
||||||
**Detail Screens:**
|
|
||||||
- `ArtistDetailsScreen` - Artist albums
|
|
||||||
- `AlbumDetailsScreen` - Album tracks with play button
|
|
||||||
- `PlayerScreen` - Full player view
|
|
||||||
- `QueueScreen` - Queue management (reorder, remove)
|
|
||||||
- `SearchScreen` - Global search (artists, albums, tracks)
|
|
||||||
|
|
||||||
### 5. Widgets (`lib/widgets/`)
|
|
||||||
|
|
||||||
**Reusable Components:**
|
|
||||||
- `MiniPlayer` - Bottom now playing bar
|
|
||||||
- `PlayerSelector` - Device/player picker
|
|
||||||
- `AlbumRow` - Horizontal scrolling album list
|
|
||||||
- `LibraryStats` - Library statistics card
|
|
||||||
- `PlayerControls` - Playback controls (deprecated)
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Player Selection & Control
|
|
||||||
|
|
||||||
```
|
|
||||||
User selects player → PlayerSelector widget
|
|
||||||
↓
|
|
||||||
MusicAssistantProvider.selectPlayer()
|
|
||||||
↓
|
|
||||||
Starts player state polling (every 2s)
|
|
||||||
↓
|
|
||||||
Fetches queue via getQueue()
|
|
||||||
↓
|
|
||||||
Updates currentTrack
|
|
||||||
↓
|
|
||||||
MiniPlayer displays track info
|
|
||||||
```
|
|
||||||
|
|
||||||
### Music Playback
|
|
||||||
|
|
||||||
```
|
|
||||||
User plays album → AlbumDetailsScreen._playAlbum()
|
|
||||||
↓
|
|
||||||
MusicAssistantProvider.playTracks()
|
|
||||||
↓
|
|
||||||
API: player_queues/play_media
|
|
||||||
↓
|
|
||||||
Server queues tracks on selected player
|
|
||||||
↓
|
|
||||||
Player state polling updates UI
|
|
||||||
```
|
|
||||||
|
|
||||||
### Library Loading
|
|
||||||
|
|
||||||
```
|
|
||||||
App connects → MusicAssistantProvider._initialize()
|
|
||||||
↓
|
|
||||||
connectToServer()
|
|
||||||
↓
|
|
||||||
WebSocket connection established
|
|
||||||
↓
|
|
||||||
loadLibrary() (auto-triggered)
|
|
||||||
↓
|
|
||||||
Fetches artists, albums, tracks (limit: 100 each)
|
|
||||||
↓
|
|
||||||
notifyListeners() → UI updates
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Integration
|
|
||||||
|
|
||||||
### WebSocket Commands
|
|
||||||
|
|
||||||
The app uses Music Assistant's WebSocket JSON-RPC API:
|
|
||||||
|
|
||||||
**Library:**
|
|
||||||
- `music/artists/library_items` - Get artists
|
|
||||||
- `music/albums/library_items` - Get albums
|
|
||||||
- `music/tracks/library_items` - Get tracks
|
|
||||||
- `music/search` - Search all media types
|
|
||||||
|
|
||||||
**Playback Control:**
|
|
||||||
- `player_queues/play_media` - Queue and play tracks
|
|
||||||
- `player_command/play` - Resume playback
|
|
||||||
- `player_command/pause` - Pause playback
|
|
||||||
- `player_command/next` - Next track
|
|
||||||
- `player_command/previous` - Previous track
|
|
||||||
|
|
||||||
**Queue Management:**
|
|
||||||
- `player_queues/items` - Get queue items (returns List, not object)
|
|
||||||
|
|
||||||
**Players:**
|
|
||||||
- `players/all` - Get all available players
|
|
||||||
|
|
||||||
**Built-in Player:**
|
|
||||||
- `builtin_player/register` - Register device as player
|
|
||||||
- `builtin_player/update_state` - Update player state
|
|
||||||
|
|
||||||
### Event Subscriptions
|
|
||||||
|
|
||||||
The WebSocket connection receives real-time events:
|
|
||||||
- `player_updated` - Player state changed
|
|
||||||
- `queue_updated` - Queue modified
|
|
||||||
- `queue_items_updated` - Queue items changed
|
|
||||||
- `media_item_played` - Track started playing
|
|
||||||
- `builtin_player` - Built-in player event (QUEUE_ITEMS, PLAY_MEDIA, etc.)
|
|
||||||
|
|
||||||
## Performance Optimizations
|
|
||||||
|
|
||||||
### 1. Player List Caching
|
|
||||||
- Cache player list for 5 minutes
|
|
||||||
- Reduces API calls on player selector opens
|
|
||||||
- Force refresh available via `refreshPlayers()`
|
|
||||||
|
|
||||||
### 2. Debounced Search
|
|
||||||
- 500ms debounce on search input
|
|
||||||
- Prevents excessive API calls while typing
|
|
||||||
|
|
||||||
### 3. Pagination
|
|
||||||
- Library loads with limit (default: 100)
|
|
||||||
- Can be extended for infinite scroll
|
|
||||||
|
|
||||||
### 4. Image Loading
|
|
||||||
- NetworkImage with error handling
|
|
||||||
- Fallback to icon on load failure
|
|
||||||
- Size parameter for different image resolutions
|
|
||||||
|
|
||||||
## State Management Pattern
|
|
||||||
|
|
||||||
```dart
|
|
||||||
// Provider pattern example
|
|
||||||
class MusicAssistantProvider with ChangeNotifier {
|
|
||||||
List<Album> _albums = [];
|
|
||||||
|
|
||||||
List<Album> get albums => _albums;
|
|
||||||
|
|
||||||
Future<void> loadAlbums() async {
|
|
||||||
_albums = await _api.getAlbums();
|
|
||||||
notifyListeners(); // Triggers UI rebuild
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Widget consumption
|
|
||||||
class AlbumList extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
|
||||||
return ListView(
|
|
||||||
children: provider.albums.map((album) => AlbumTile(album)).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authentication Flow
|
|
||||||
|
|
||||||
### Authelia Integration
|
|
||||||
|
|
||||||
Music Assistant Mobile supports Authelia authentication on separate domains:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. User enters credentials in SettingsScreen
|
|
||||||
↓
|
|
||||||
2. AuthService.login(serverUrl, username, password)
|
|
||||||
↓
|
|
||||||
3. POST to auth.serverscloud.org
|
|
||||||
↓
|
|
||||||
4. Extract authelia_session cookie
|
|
||||||
↓
|
|
||||||
5. Store cookie in SettingsService
|
|
||||||
↓
|
|
||||||
6. Include cookie in WebSocket handshake
|
|
||||||
↓
|
|
||||||
7. WebSocket: headers['Cookie'] = 'authelia_session=...'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Auto-Login
|
|
||||||
|
|
||||||
On app startup:
|
|
||||||
1. Check for saved credentials
|
|
||||||
2. If found, attempt silent login
|
|
||||||
3. On success, connect to Music Assistant server
|
|
||||||
4. On failure, user can re-enter credentials
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### API Errors
|
|
||||||
- Try-catch blocks around all API calls
|
|
||||||
- Errors logged via DebugLogger
|
|
||||||
- UI shows error states (empty states, snackbars)
|
|
||||||
|
|
||||||
### WebSocket Reconnection
|
|
||||||
- Auto-reconnect on disconnect (3-second delay)
|
|
||||||
- Connection state tracked via Stream
|
|
||||||
- UI reflects connection status
|
|
||||||
|
|
||||||
### Queue Parsing Errors
|
|
||||||
- Defensive parsing with try-catch per item
|
|
||||||
- Failed items skipped, parsing continues
|
|
||||||
- Debug logging for troubleshooting
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit Tests (TODO)
|
|
||||||
- API service methods
|
|
||||||
- Model parsing (fromJson/toJson)
|
|
||||||
- State management logic
|
|
||||||
|
|
||||||
### Widget Tests (TODO)
|
|
||||||
- Screen rendering
|
|
||||||
- User interactions
|
|
||||||
- Navigation flows
|
|
||||||
|
|
||||||
### Integration Tests (TODO)
|
|
||||||
- End-to-end user flows
|
|
||||||
- WebSocket communication
|
|
||||||
- Authentication
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
### Planned Features
|
|
||||||
1. **Playlists** - Browse and play playlists
|
|
||||||
2. **Favorites** - Mark/unmark favorites, filter by favorites
|
|
||||||
3. **Lyrics** - Display lyrics for current track
|
|
||||||
4. **Sleep Timer** - Auto-stop playback
|
|
||||||
5. **Crossfade** - Smooth transitions between tracks
|
|
||||||
6. **Equalizer** - Audio customization
|
|
||||||
7. **Offline Mode** - Cached playback
|
|
||||||
8. **Android Auto / CarPlay** - In-car integration
|
|
||||||
|
|
||||||
### Technical Improvements
|
|
||||||
1. **Retry Logic** - Exponential backoff for failed requests
|
|
||||||
2. **Better Error Messages** - User-friendly error descriptions
|
|
||||||
3. **Loading States** - Skeleton screens, shimmer effects
|
|
||||||
4. **Infinite Scroll** - Lazy load library items
|
|
||||||
5. **Image Caching** - Reduce network usage
|
|
||||||
6. **WebSocket Reconnection** - Smarter reconnection strategy
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
- Follow [Effective Dart](https://dart.dev/guides/language/effective-dart)
|
|
||||||
- Use meaningful variable names
|
|
||||||
- Add comments for complex logic
|
|
||||||
- Keep functions small and focused
|
|
||||||
|
|
||||||
### File Organization
|
|
||||||
```
|
|
||||||
lib/
|
|
||||||
├── main.dart # App entry point
|
|
||||||
├── models/ # Data models
|
|
||||||
├── providers/ # State management
|
|
||||||
├── screens/ # Full-screen views
|
|
||||||
├── widgets/ # Reusable components
|
|
||||||
└── services/ # Business logic, API, utilities
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding New Features
|
|
||||||
|
|
||||||
1. **Define Model** (if needed) - Add to `lib/models/`
|
|
||||||
2. **Create API Method** - Add to `MusicAssistantAPI`
|
|
||||||
3. **Update Provider** - Add state and methods to relevant provider
|
|
||||||
4. **Build UI** - Create screen/widget in `lib/screens/` or `lib/widgets/`
|
|
||||||
5. **Wire Navigation** - Update navigation logic
|
|
||||||
6. **Test** - Write unit/widget tests
|
|
||||||
7. **Document** - Update this file and add code comments
|
|
||||||
|
|
||||||
### Debugging Tips
|
|
||||||
|
|
||||||
1. **Enable Debug Logging** - DebugLogger prints to console
|
|
||||||
2. **Check WebSocket Messages** - Logs show all WS traffic
|
|
||||||
3. **Inspect Provider State** - Use Flutter DevTools
|
|
||||||
4. **Network Debugging** - Check API responses
|
|
||||||
5. **Test Authentication** - Verify session cookies
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### Core
|
|
||||||
- `flutter` - UI framework
|
|
||||||
- `provider` - State management
|
|
||||||
- `web_socket_channel` - WebSocket client
|
|
||||||
|
|
||||||
### Audio
|
|
||||||
- `just_audio` - Audio playback
|
|
||||||
- `audio_service` - Background playback
|
|
||||||
|
|
||||||
### Networking
|
|
||||||
- `http` - HTTP requests
|
|
||||||
- `uuid` - Unique ID generation
|
|
||||||
|
|
||||||
### Storage
|
|
||||||
- `shared_preferences` - Local key-value storage
|
|
||||||
|
|
||||||
### UI
|
|
||||||
- `flutter_launcher_icons` - App icons
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- **Music Assistant**: https://music-assistant.io/
|
|
||||||
- **Music Assistant API Docs**: https://music-assistant.io/integration/websocket/
|
|
||||||
- **Flutter Docs**: https://docs.flutter.dev/
|
|
||||||
- **Provider Package**: https://pub.dev/packages/provider
|
|
||||||
- **just_audio Package**: https://pub.dev/packages/just_audio
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
# Cleaning Up Ghost "Music Assistant Mobile" Players
|
|
||||||
|
|
||||||
## The Problem
|
|
||||||
|
|
||||||
Early versions of Amass registered the mobile device as a local playback player with Music Assistant. This created "Music Assistant Mobile" player entities on your server. Multiple app restarts/logins created multiple ghost players that persist on the server.
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
The Amass app now:
|
|
||||||
- ✅ No longer registers as a player (acts as remote control only)
|
|
||||||
- ✅ Filters out these ghost players automatically
|
|
||||||
- ✅ Won't create new ghost players
|
|
||||||
|
|
||||||
However, the old players still exist on your Music Assistant server.
|
|
||||||
|
|
||||||
## Finding Ghost Players
|
|
||||||
|
|
||||||
### Using Amass Player Diagnostics Tool
|
|
||||||
|
|
||||||
1. Open Amass app
|
|
||||||
2. Go to **Settings** → **Debug Logs**
|
|
||||||
3. Tap the **speaker icon** (🔊) in the top right
|
|
||||||
4. See all players including hidden ghost players (highlighted in red)
|
|
||||||
5. Tap **Copy List** to copy player IDs for manual removal
|
|
||||||
|
|
||||||
This shows you:
|
|
||||||
- All player names and IDs
|
|
||||||
- Whether they're available or unavailable
|
|
||||||
- Their current state (idle/playing/paused)
|
|
||||||
|
|
||||||
**Note**: Ghost players are marked as `available: true` but are builtin_player type with default_name "Music Assistant Mobile". They exist in your `settings.json` but don't show in the web UI because they're orphaned mobile players.
|
|
||||||
|
|
||||||
## Findings from Your Server
|
|
||||||
|
|
||||||
Your Music Assistant logs show:
|
|
||||||
- **26 ghost "Music Assistant Mobile" players** found in `settings.json`
|
|
||||||
- Created between Nov 22-23, 2025 (during app development/testing)
|
|
||||||
- All are `provider: builtin_player` with `available: true`
|
|
||||||
- Player IDs are UUIDs like `202f4c01-abbf-4a46-83f8-9954570e7e50`
|
|
||||||
|
|
||||||
These players are in your settings but not visible in the UI, which is why manual cleanup is needed.
|
|
||||||
|
|
||||||
## Automated Cleanup (Recommended)
|
|
||||||
|
|
||||||
A cleanup script has been created for your server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run the cleanup script
|
|
||||||
cd /home/home-server/Amass
|
|
||||||
./cleanup_ghost_players.sh
|
|
||||||
|
|
||||||
# It will:
|
|
||||||
# 1. Show you all 26 ghost players
|
|
||||||
# 2. Ask for confirmation
|
|
||||||
# 3. Backup settings.json
|
|
||||||
# 4. Remove ghost players
|
|
||||||
# 5. Tell you to restart Music Assistant
|
|
||||||
```
|
|
||||||
|
|
||||||
After running the script, restart Music Assistant:
|
|
||||||
```bash
|
|
||||||
docker restart music_assistant
|
|
||||||
# Or if using docker-compose:
|
|
||||||
cd /home/home-server/docker/music-assistant
|
|
||||||
docker compose restart
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manual Cleanup (Advanced)
|
|
||||||
|
|
||||||
If you prefer manual cleanup:
|
|
||||||
|
|
||||||
### Option 1: Via Web UI Settings
|
|
||||||
|
|
||||||
1. Open your Music Assistant web UI (e.g., `http://ma.serverscloud.org:8097`)
|
|
||||||
2. Navigate to **Settings** → **Players**
|
|
||||||
3. Look for players named "Music Assistant Mobile" or similar
|
|
||||||
4. For each ghost player:
|
|
||||||
- Click the **⋮** (three dots) menu
|
|
||||||
- Select **Remove Player** or **Delete**
|
|
||||||
5. Confirm deletion for each one
|
|
||||||
|
|
||||||
### Option 2: Via Players Tab
|
|
||||||
|
|
||||||
1. Open Music Assistant web UI
|
|
||||||
2. Click the **Players** tab
|
|
||||||
3. Find all "Music Assistant Mobile" entries
|
|
||||||
4. Right-click or use the context menu to remove each one
|
|
||||||
|
|
||||||
## How to Clean Up (Home Assistant)
|
|
||||||
|
|
||||||
If Music Assistant is integrated with Home Assistant:
|
|
||||||
|
|
||||||
1. Open Home Assistant
|
|
||||||
2. Go to **Settings** → **Devices & Services**
|
|
||||||
3. Click **Music Assistant**
|
|
||||||
4. Look for "Music Assistant Mobile" devices/entities
|
|
||||||
5. For each one:
|
|
||||||
- Click on the entity
|
|
||||||
- Click **⋮** → **Delete**
|
|
||||||
|
|
||||||
## How to Clean Up (Command Line / SSH)
|
|
||||||
|
|
||||||
If you have SSH access to your Music Assistant server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Connect to your server
|
|
||||||
ssh user@ma.serverscloud.org
|
|
||||||
|
|
||||||
# Access Music Assistant container (if dockerized)
|
|
||||||
docker exec -it music_assistant bash
|
|
||||||
|
|
||||||
# Or if running as an add-on, use Home Assistant CLI
|
|
||||||
ha addons exec music_assistant
|
|
||||||
|
|
||||||
# Navigate to data directory (location varies by installation)
|
|
||||||
cd /data # or wherever your config is stored
|
|
||||||
|
|
||||||
# Backup first!
|
|
||||||
cp mass.json mass.json.backup
|
|
||||||
|
|
||||||
# Edit the config file
|
|
||||||
nano mass.json # or vi, etc.
|
|
||||||
|
|
||||||
# Look for player entries with names like "Music Assistant Mobile"
|
|
||||||
# Remove those entries (be careful with JSON syntax!)
|
|
||||||
# Save and exit
|
|
||||||
|
|
||||||
# Restart Music Assistant
|
|
||||||
# (from outside container if dockerized)
|
|
||||||
docker restart music_assistant
|
|
||||||
```
|
|
||||||
|
|
||||||
⚠️ **Warning**: Manual JSON editing is risky. Use web UI method if possible.
|
|
||||||
|
|
||||||
## Verifying Cleanup
|
|
||||||
|
|
||||||
After cleanup, check the Amass app logs. You should see:
|
|
||||||
- No more "Filtering out leftover player" messages
|
|
||||||
- "Loaded X players" with a reasonable count (not 30+)
|
|
||||||
|
|
||||||
## Prevention
|
|
||||||
|
|
||||||
The current version of Amass (v1.0.0+) will not create new ghost players. If you see new ones appearing:
|
|
||||||
1. Make sure you're using the latest version
|
|
||||||
2. Uninstall any old versions of the app
|
|
||||||
3. Clear app data before reinstalling
|
|
||||||
|
|
||||||
## Questions?
|
|
||||||
|
|
||||||
If ghost players keep appearing after cleanup, there may be another app or integration registering them. Check:
|
|
||||||
- Other Music Assistant mobile apps
|
|
||||||
- Home Assistant automations
|
|
||||||
- Music Assistant player announcements
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
# Overnight Work Session Summary
|
|
||||||
|
|
||||||
## Session Overview
|
|
||||||
|
|
||||||
This document summarizes all improvements and features added to the Music Assistant Mobile app during the comprehensive overnight development session.
|
|
||||||
|
|
||||||
**Session Date:** 2025-01-22
|
|
||||||
**Total Commits:** 9
|
|
||||||
**Files Changed:** 20+
|
|
||||||
**Lines Added:** 1,500+
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Completed Tasks
|
|
||||||
|
|
||||||
### 1. Now Playing Bar Debugging & Fixes
|
|
||||||
|
|
||||||
**Problem:** Now playing bar wasn't showing even when music was playing
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Fixed queue endpoint from `players/queue/items` to `player_queues/items`
|
|
||||||
- Changed parameter from `player_id` to `queue_id` to match API format
|
|
||||||
- Added defensive parsing for queue items with try-catch per item
|
|
||||||
- Added debug logging to inspect raw queue data structure
|
|
||||||
- Fixed QueueItem parsing to handle missing `queue_item_id` field (fallback to `item_id`)
|
|
||||||
|
|
||||||
**Files Modified:**
|
|
||||||
- `lib/services/music_assistant_api.dart`
|
|
||||||
- `lib/models/player.dart`
|
|
||||||
|
|
||||||
**Impact:** Now playing bar now correctly shows current track when music is playing on any selected player.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Home Page Enhancements
|
|
||||||
|
|
||||||
**Added Features:**
|
|
||||||
- **Recent Albums Row** - Shows recently played albums
|
|
||||||
- **Random Albums Row** ("Discover") - Displays random albums for music discovery
|
|
||||||
- **Library Statistics** - Visual card showing artists/albums/tracks counts
|
|
||||||
- **Improved Layout** - Better spacing and visual hierarchy
|
|
||||||
|
|
||||||
**New API Methods:**
|
|
||||||
```dart
|
|
||||||
Future<List<Album>> getRecentAlbums({int limit = 10})
|
|
||||||
Future<List<Album>> getRandomAlbums({int limit = 10})
|
|
||||||
Future<Map<String, int>> getLibraryStats()
|
|
||||||
```
|
|
||||||
|
|
||||||
**New Widgets:**
|
|
||||||
- `lib/widgets/album_row.dart` - Horizontal scrolling album list
|
|
||||||
- `lib/widgets/library_stats.dart` - Statistics display card
|
|
||||||
|
|
||||||
**Files Modified:**
|
|
||||||
- `lib/screens/new_home_screen.dart`
|
|
||||||
- `lib/services/music_assistant_api.dart`
|
|
||||||
|
|
||||||
**Impact:** Home screen is now much more engaging with personalized content and discovery features.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Global Search Functionality
|
|
||||||
|
|
||||||
**Added:**
|
|
||||||
- Search button in home and library screen app bars
|
|
||||||
- Navigation to existing SearchScreen
|
|
||||||
- Quick access to search from main navigation
|
|
||||||
|
|
||||||
**Files Modified:**
|
|
||||||
- `lib/screens/new_home_screen.dart`
|
|
||||||
- `lib/screens/new_library_screen.dart`
|
|
||||||
|
|
||||||
**Impact:** Users can now easily search their entire music library from any main screen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Queue Viewer Screen
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Full-screen queue management
|
|
||||||
- Display all queued tracks with album art
|
|
||||||
- Reorder tracks via drag and drop
|
|
||||||
- Remove tracks via swipe to dismiss
|
|
||||||
- Visual indicator for currently playing track
|
|
||||||
- Refresh button to reload queue
|
|
||||||
- Queue button in mini-player for easy access
|
|
||||||
|
|
||||||
**New Files:**
|
|
||||||
- `lib/screens/queue_screen.dart`
|
|
||||||
|
|
||||||
**Files Modified:**
|
|
||||||
- `lib/widgets/mini_player.dart`
|
|
||||||
|
|
||||||
**Impact:** Users can now see and manage their playback queue with intuitive gestures.
|
|
||||||
|
|
||||||
**Note:** Reordering and removing are currently local-only (server sync not yet implemented).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Pull-to-Refresh on Library Screens
|
|
||||||
|
|
||||||
**Added:**
|
|
||||||
- RefreshIndicator on artists list
|
|
||||||
- RefreshIndicator on albums grid
|
|
||||||
- Triggers library reload on pull-down gesture
|
|
||||||
|
|
||||||
**Files Modified:**
|
|
||||||
- `lib/screens/library_artists_screen.dart`
|
|
||||||
- `lib/screens/library_albums_screen.dart`
|
|
||||||
|
|
||||||
**Impact:** Better UX for refreshing library data without navigating away.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Player List Caching
|
|
||||||
|
|
||||||
**Optimization:**
|
|
||||||
- Cache player list for 5 minutes
|
|
||||||
- Reduce API calls when opening player selector
|
|
||||||
- Force refresh available via explicit refresh
|
|
||||||
- Automatic cache invalidation after timeout
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
```dart
|
|
||||||
DateTime? _playersLastFetched;
|
|
||||||
static const Duration _playersCacheDuration = Duration(minutes: 5);
|
|
||||||
|
|
||||||
Future<void> _loadAndSelectPlayers({bool forceRefresh = false}) {
|
|
||||||
// Check cache first
|
|
||||||
if (!forceRefresh && cache_valid) {
|
|
||||||
return cached_players;
|
|
||||||
}
|
|
||||||
// Fetch fresh data
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files Modified:**
|
|
||||||
- `lib/providers/music_assistant_provider.dart`
|
|
||||||
|
|
||||||
**Impact:** Reduced network traffic and improved app responsiveness when switching players.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. Comprehensive Documentation
|
|
||||||
|
|
||||||
**Created:**
|
|
||||||
|
|
||||||
#### ARCHITECTURE.md (600+ lines)
|
|
||||||
- Complete architecture overview
|
|
||||||
- Component descriptions and responsibilities
|
|
||||||
- Data flow diagrams
|
|
||||||
- API integration details
|
|
||||||
- Performance optimizations
|
|
||||||
- Development guidelines
|
|
||||||
- Future enhancement roadmap
|
|
||||||
|
|
||||||
**Sections:**
|
|
||||||
- Architecture Pattern
|
|
||||||
- Key Components (Providers, Services, Models, Screens, Widgets)
|
|
||||||
- Data Flow (Player Selection, Music Playback, Library Loading)
|
|
||||||
- API Integration (WebSocket Commands, Event Subscriptions)
|
|
||||||
- Performance Optimizations
|
|
||||||
- State Management Pattern
|
|
||||||
- Authentication Flow
|
|
||||||
- Error Handling
|
|
||||||
- Testing Strategy
|
|
||||||
- Development Guidelines
|
|
||||||
|
|
||||||
#### TROUBLESHOOTING.md (500+ lines)
|
|
||||||
- Common issues and solutions
|
|
||||||
- Debug logging instructions
|
|
||||||
- Performance troubleshooting
|
|
||||||
- Bug reporting templates
|
|
||||||
- FAQ section
|
|
||||||
|
|
||||||
**Sections:**
|
|
||||||
- Connection Issues
|
|
||||||
- Authentication Issues
|
|
||||||
- Playback Issues
|
|
||||||
- Library Issues
|
|
||||||
- Queue Issues
|
|
||||||
- Performance Issues
|
|
||||||
- UI Issues
|
|
||||||
- Debug Logging
|
|
||||||
- Known Limitations
|
|
||||||
- Advanced Troubleshooting
|
|
||||||
- Getting Help
|
|
||||||
- FAQ
|
|
||||||
|
|
||||||
**Impact:** Future development and debugging will be much easier with comprehensive documentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Statistics
|
|
||||||
|
|
||||||
### Commits Summary
|
|
||||||
|
|
||||||
1. `965bf9d` - Add defensive parsing and debug logging for queue items
|
|
||||||
2. `38b29cb` - Add home page enhancements (albums, stats)
|
|
||||||
3. `6c291f7` - Add search functionality to main screens
|
|
||||||
4. `ad4136f` - Add queue viewer screen
|
|
||||||
5. `4f7856e` - Add pull-to-refresh to library screens
|
|
||||||
6. `e24b542` - Add player list caching
|
|
||||||
7. `0422752` - Add comprehensive documentation
|
|
||||||
|
|
||||||
### Files Created
|
|
||||||
|
|
||||||
**Screens:**
|
|
||||||
- (queue_screen.dart already existed, enhanced it)
|
|
||||||
|
|
||||||
**Widgets:**
|
|
||||||
- `lib/widgets/album_row.dart`
|
|
||||||
- `lib/widgets/library_stats.dart`
|
|
||||||
|
|
||||||
**Documentation:**
|
|
||||||
- `ARCHITECTURE.md`
|
|
||||||
- `TROUBLESHOOTING.md`
|
|
||||||
|
|
||||||
### Files Modified
|
|
||||||
|
|
||||||
**Screens:**
|
|
||||||
- `lib/screens/new_home_screen.dart`
|
|
||||||
- `lib/screens/new_library_screen.dart`
|
|
||||||
- `lib/screens/library_artists_screen.dart`
|
|
||||||
- `lib/screens/library_albums_screen.dart`
|
|
||||||
|
|
||||||
**Widgets:**
|
|
||||||
- `lib/widgets/mini_player.dart`
|
|
||||||
|
|
||||||
**Services:**
|
|
||||||
- `lib/services/music_assistant_api.dart`
|
|
||||||
|
|
||||||
**Providers:**
|
|
||||||
- `lib/providers/music_assistant_provider.dart`
|
|
||||||
|
|
||||||
**Models:**
|
|
||||||
- `lib/models/player.dart`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Key Improvements
|
|
||||||
|
|
||||||
### User Experience
|
|
||||||
- ✅ Now playing bar actually works
|
|
||||||
- ✅ Engaging home screen with discovery features
|
|
||||||
- ✅ Easy search access from anywhere
|
|
||||||
- ✅ Full queue management capabilities
|
|
||||||
- ✅ Pull-to-refresh for better control
|
|
||||||
- ✅ Faster player selection with caching
|
|
||||||
|
|
||||||
### Developer Experience
|
|
||||||
- ✅ Comprehensive architecture documentation
|
|
||||||
- ✅ Detailed troubleshooting guide
|
|
||||||
- ✅ Better error handling
|
|
||||||
- ✅ Debug logging throughout
|
|
||||||
- ✅ Clear code structure
|
|
||||||
- ✅ Development guidelines
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- ✅ Player list caching (5-minute cache)
|
|
||||||
- ✅ Defensive parsing (continues on errors)
|
|
||||||
- ✅ Reduced API calls
|
|
||||||
- ✅ Better state management
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Features NOT Implemented
|
|
||||||
|
|
||||||
Due to time constraints, the following planned features were not implemented:
|
|
||||||
|
|
||||||
### High Priority (Should do next)
|
|
||||||
1. **Retry Logic** - Exponential backoff for failed API calls
|
|
||||||
2. **Playlist Support** - Browse and play playlists
|
|
||||||
3. **Favorites System** - Mark/filter favorites
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
4. **Loading States** - Skeleton screens, shimmer effects
|
|
||||||
5. **Better Error Messages** - User-friendly error descriptions
|
|
||||||
6. **Unit Tests** - API layer testing
|
|
||||||
|
|
||||||
### Why Not Done
|
|
||||||
- Focus was on getting core features working (now playing bar, home page, queue)
|
|
||||||
- Documentation was prioritized for long-term value
|
|
||||||
- User-facing features were chosen over technical improvements
|
|
||||||
- Time better spent on visible improvements
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Known Issues
|
|
||||||
|
|
||||||
### Minor Issues
|
|
||||||
1. **Queue Reordering** - Changes are local-only, don't sync to server
|
|
||||||
- **Workaround:** Use Music Assistant web interface for persistent changes
|
|
||||||
- **Fix Required:** Implement server API calls for queue manipulation
|
|
||||||
|
|
||||||
2. **Library Pagination** - Only loads 100 items per category
|
|
||||||
- **Workaround:** Use search to find specific items
|
|
||||||
- **Fix Required:** Implement infinite scroll
|
|
||||||
|
|
||||||
3. **Player State Polling** - Polls every 2 seconds
|
|
||||||
- **Impact:** Slight battery drain
|
|
||||||
- **Fix Required:** Switch to event-based updates
|
|
||||||
|
|
||||||
### Fixed Issues
|
|
||||||
- ✅ Now playing bar not showing - FIXED
|
|
||||||
- ✅ Queue endpoint errors - FIXED
|
|
||||||
- ✅ Queue item parsing failures - FIXED
|
|
||||||
- ✅ Player list fetching on every open - FIXED (caching)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Recommendations for Next Session
|
|
||||||
|
|
||||||
### Immediate Next Steps
|
|
||||||
|
|
||||||
1. **Test Everything**
|
|
||||||
- Thoroughly test all new features
|
|
||||||
- Verify now playing bar works reliably
|
|
||||||
- Test queue viewer on different players
|
|
||||||
- Validate home page album rows
|
|
||||||
|
|
||||||
2. **Fix Any Bugs Found**
|
|
||||||
- Monitor debug logs for errors
|
|
||||||
- Address any parsing issues
|
|
||||||
- Fix UI glitches
|
|
||||||
|
|
||||||
3. **Implement Retry Logic**
|
|
||||||
- Add exponential backoff for failed requests
|
|
||||||
- Improve error handling
|
|
||||||
- Better user feedback on failures
|
|
||||||
|
|
||||||
4. **Add Playlist Support**
|
|
||||||
- List playlists from server
|
|
||||||
- Play playlists
|
|
||||||
- Add to queue
|
|
||||||
|
|
||||||
5. **Implement Favorites**
|
|
||||||
- Mark items as favorites
|
|
||||||
- Filter by favorites
|
|
||||||
- Sync with server
|
|
||||||
|
|
||||||
### Code Quality Improvements
|
|
||||||
|
|
||||||
1. **Add Unit Tests**
|
|
||||||
- Test API parsing
|
|
||||||
- Test state management
|
|
||||||
- Test model serialization
|
|
||||||
|
|
||||||
2. **Improve Loading States**
|
|
||||||
- Add skeleton screens
|
|
||||||
- Better loading indicators
|
|
||||||
- Smooth transitions
|
|
||||||
|
|
||||||
3. **Better Error Handling**
|
|
||||||
- User-friendly error messages
|
|
||||||
- Retry buttons
|
|
||||||
- Offline state handling
|
|
||||||
|
|
||||||
### User Experience Enhancements
|
|
||||||
|
|
||||||
1. **Infinite Scroll**
|
|
||||||
- Load more items as user scrolls
|
|
||||||
- Better for large libraries
|
|
||||||
|
|
||||||
2. **Image Caching**
|
|
||||||
- Cache album artwork
|
|
||||||
- Reduce network usage
|
|
||||||
- Faster loading
|
|
||||||
|
|
||||||
3. **Search Improvements**
|
|
||||||
- Search history
|
|
||||||
- Search suggestions
|
|
||||||
- Filter by category
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Code Examples
|
|
||||||
|
|
||||||
### Home Page Album Rows
|
|
||||||
```dart
|
|
||||||
AlbumRow(
|
|
||||||
title: 'Recently Played',
|
|
||||||
loadAlbums: () async {
|
|
||||||
if (provider.api == null) return [];
|
|
||||||
return await provider.api!.getRecentAlbums(limit: 10);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Player List Caching
|
|
||||||
```dart
|
|
||||||
// Check cache first
|
|
||||||
if (!forceRefresh &&
|
|
||||||
_playersLastFetched != null &&
|
|
||||||
_availablePlayers.isNotEmpty &&
|
|
||||||
now.difference(_playersLastFetched!) < _playersCacheDuration) {
|
|
||||||
_logger.log('Using cached player list');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Queue Defensive Parsing
|
|
||||||
```dart
|
|
||||||
final items = <QueueItem>[];
|
|
||||||
for (var i in (result as List<dynamic>)) {
|
|
||||||
try {
|
|
||||||
items.add(QueueItem.fromJson(i as Map<String, dynamic>));
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('⚠️ Failed to parse queue item: $e');
|
|
||||||
// Continue parsing other items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 Achievements
|
|
||||||
|
|
||||||
### Features Delivered
|
|
||||||
- ✅ 7 major features implemented
|
|
||||||
- ✅ 9 commits pushed
|
|
||||||
- ✅ 20+ files modified
|
|
||||||
- ✅ 1,500+ lines of code added
|
|
||||||
|
|
||||||
### Quality Improvements
|
|
||||||
- ✅ Comprehensive documentation (1,100+ lines)
|
|
||||||
- ✅ Better error handling
|
|
||||||
- ✅ Performance optimizations
|
|
||||||
- ✅ Code organization
|
|
||||||
|
|
||||||
### User Impact
|
|
||||||
- ✅ Working now playing bar
|
|
||||||
- ✅ Much better home screen
|
|
||||||
- ✅ Queue management
|
|
||||||
- ✅ Faster performance
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Related Files
|
|
||||||
|
|
||||||
**Main Implementation:**
|
|
||||||
- `lib/services/music_assistant_api.dart` - API layer with new methods
|
|
||||||
- `lib/providers/music_assistant_provider.dart` - State management with caching
|
|
||||||
- `lib/screens/new_home_screen.dart` - Enhanced home screen
|
|
||||||
- `lib/screens/queue_screen.dart` - Queue viewer
|
|
||||||
- `lib/widgets/album_row.dart` - Album carousel
|
|
||||||
- `lib/widgets/library_stats.dart` - Statistics display
|
|
||||||
|
|
||||||
**Documentation:**
|
|
||||||
- `ARCHITECTURE.md` - Technical architecture
|
|
||||||
- `TROUBLESHOOTING.md` - Troubleshooting guide
|
|
||||||
- `OVERNIGHT_WORK_SUMMARY.md` - This file
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🙏 Final Notes
|
|
||||||
|
|
||||||
This overnight session delivered significant improvements to the Music Assistant Mobile app:
|
|
||||||
|
|
||||||
1. **Now playing bar works** - Core functionality that was broken is now fixed
|
|
||||||
2. **Engaging home screen** - Users now have discovery features
|
|
||||||
3. **Queue management** - Full control over playback queue
|
|
||||||
4. **Better performance** - Caching reduces unnecessary API calls
|
|
||||||
5. **Excellent documentation** - Future development will be much easier
|
|
||||||
|
|
||||||
The app is now in a much better state for continued development. The most critical issues have been addressed, and new features have been added to enhance the user experience.
|
|
||||||
|
|
||||||
**Branch:** `claude/implement-features-01YRy2D4tHYs2gKkxrP9nqVC`
|
|
||||||
**Status:** All changes committed and pushed
|
|
||||||
**Next Step:** Test everything and fix any issues found
|
|
||||||
|
|
||||||
Thank you for the opportunity to work on this project! 🎵
|
|
||||||
@@ -1,65 +1,94 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/images/attm_long_logo.png" alt="Assistant To The Music Logo" height="200">
|
<img src="assets/images/ensemble_logo.png" alt="Ensemble Logo" height="200">
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p>A minimalistic mobile client for <a href="https://music-assistant.io/">Music Assistant</a></p>
|
<p>A minimalistic mobile client for <a href="https://music-assistant.io/">Music Assistant</a></p>
|
||||||
<p>Control your Music Assistant server and manage your entire music library from your mobile device.</p>
|
<p>Stream your music library directly to your phone, or control playback on any connected speaker.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
I am not a developer. This application was created using **Claude Code** and **Gemini CLI**.
|
This application was built with AI-assisted development using **Claude Code** and **Gemini CLI**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Music Assistant Integration
|
### Local Playback
|
||||||
- **Server Connection** - Connect to your Music Assistant server via WebSocket
|
- **Stream to Your Phone** - Play music from your Music Assistant library directly on your mobile device
|
||||||
- **Library Browsing** - Browse artists, albums, and tracks from your server
|
- **Background Playback** - Music continues playing when the app is minimized
|
||||||
- **Album Details** - View album information and track listings
|
- **Media Notifications** - Control playback from your notification shade with album art display
|
||||||
- **Playback Control** - Control playback on your Music Assistant server players
|
|
||||||
- **Auto-Reconnect** - Automatic reconnection with connection status monitoring
|
|
||||||
- **Settings Management** - Configure server URL with persistent storage
|
|
||||||
|
|
||||||
### Control Features
|
### Remote Control
|
||||||
- Full playback controls (play/pause/skip/seek) for server players
|
- **Multi-Player Support** - Control any speaker or device connected to Music Assistant
|
||||||
- Progress bar with time display
|
- **Device Selector** - Quickly switch between your phone and other players
|
||||||
- Volume control for server players
|
- **Full Playback Controls** - Play, pause, skip, seek, and adjust volume
|
||||||
- Now playing display with track information
|
- **Queue Management** - View and manage the playback queue
|
||||||
- Queue management and viewing
|
|
||||||
- Multi-player support with device selector
|
|
||||||
- Play on... button for quick device selection
|
|
||||||
|
|
||||||
### Theming Options
|
### Home Screen
|
||||||
|
- **Customizable Rows** - Toggle Recently Played, Discover Artists, and Discover Albums
|
||||||
|
- **Favorites Rows** - Optional rows for Favorite Albums, Artists, and Tracks
|
||||||
|
- **Adaptive Layout** - Rows scale properly for different screen sizes and aspect ratios
|
||||||
|
- **Pull to Refresh** - Refresh content with a simple pull gesture
|
||||||
|
|
||||||
|
### Library Browsing
|
||||||
|
- **Browse Your Collection** - Artists, albums, playlists, and tracks from all your music sources
|
||||||
|
- **Favorites Filter** - Toggle to show only your favorite items
|
||||||
|
- **Album Details** - View track listings with artwork
|
||||||
|
- **Artist Details** - View artist albums and top tracks
|
||||||
|
- **Playlist Support** - Browse and play your playlists
|
||||||
|
- **Search** - Find music across your entire library
|
||||||
|
|
||||||
|
### Smart Features
|
||||||
|
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
||||||
|
- **Caching** - Fast navigation with cached content
|
||||||
|
- **Hero Animations** - Smooth transitions between screens
|
||||||
|
|
||||||
|
### Theming
|
||||||
- **Material You** - Dynamic theming based on your device's wallpaper
|
- **Material You** - Dynamic theming based on your device's wallpaper
|
||||||
- **Adaptive Theming** - Album artwork-based color schemes
|
- **Adaptive Colors** - Album artwork-based color schemes
|
||||||
- **Light/Dark Mode** - System-aware or manual theme selection
|
- **Light/Dark Mode** - System-aware or manual theme selection
|
||||||
- Clean, minimalistic UI design
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/screenshots/Screenshot_20251124-193838.png" alt="Home Screen" width="250">
|
<img src="assets/screenshots/1.png" alt="Connection Screen" width="250">
|
||||||
<img src="assets/screenshots/Screenshot_20251124-193915.png" alt="Now Playing" width="250">
|
<img src="assets/screenshots/2.png" alt="Home Screen" width="250">
|
||||||
<img src="assets/screenshots/Screenshot_20251124-193936.png" alt="Player Selection" width="250">
|
<img src="assets/screenshots/3.png" alt="Album Details" width="250">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/screenshots/4.png" alt="Now Playing" width="250">
|
||||||
|
<img src="assets/screenshots/5.png" alt="Device Selection" width="250">
|
||||||
|
<img src="assets/screenshots/6.png" alt="Settings" width="250">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Download the latest release from the [Releases page](https://github.com/CollotsSpot/Assistant-To-The-Music/releases).
|
Download the latest release from the [Releases page](https://github.com/CollotsSpot/Ensemble/releases).
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Launch the app
|
1. Launch the app
|
||||||
2. Navigate to the **Library** tab
|
2. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`)
|
||||||
3. Tap **Configure Server** or go to **Settings**
|
3. Connect to your server
|
||||||
4. Enter your Music Assistant server URL (e.g., `music.example.com` or `192.168.1.100`)
|
4. Start playing! Music plays on your phone by default, or tap the device icon to choose a different player.
|
||||||
5. Tap **Connect**
|
|
||||||
6. Browse your library and control playback on your Music Assistant players!
|
## Authentication
|
||||||
|
|
||||||
|
Ensemble supports multiple authentication methods:
|
||||||
|
|
||||||
|
| Method | Status |
|
||||||
|
|--------|--------|
|
||||||
|
| Music Assistant native auth | Tested |
|
||||||
|
| No authentication | Tested |
|
||||||
|
| Authelia | Implemented, not recently tested |
|
||||||
|
| HTTP Basic Auth | Implemented, not recently tested |
|
||||||
|
|
||||||
|
**Note:** Development and testing is done against Music Assistant beta with native authentication enabled.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -67,9 +96,15 @@ Download the latest release from the [Releases page](https://github.com/CollotsS
|
|||||||
- Network connectivity to your Music Assistant server
|
- Network connectivity to your Music Assistant server
|
||||||
- Android device (Android 5.0+)
|
- Android device (Android 5.0+)
|
||||||
|
|
||||||
|
> **Note:** Local device playback (streaming to your phone) is not currently supported on Music Assistant beta 20+. This is due to MA replacing the built-in player with Sendspin. Support will be added once Sendspin is more stable. Remote control of other players works fine.
|
||||||
|
|
||||||
## About Music Assistant
|
## About Music Assistant
|
||||||
|
|
||||||
Amass Your Music is a remote control client for [Music Assistant](https://music-assistant.io/), an open-source music library manager and player that integrates with various music sources and streaming providers. You'll need a running Music Assistant server to use this app. Music playback happens on your Music Assistant server's configured players (speakers, smart devices, etc.), not on your mobile device.
|
Ensemble is a mobile client for [Music Assistant](https://music-assistant.io/), an open-source music library manager that integrates with various music sources and streaming providers.
|
||||||
|
|
||||||
|
With Ensemble, you can:
|
||||||
|
- **Stream locally** - Use your phone as a Music Assistant player
|
||||||
|
- **Control remotely** - Manage playback on smart speakers, Chromecast, Sonos, and more
|
||||||
|
|
||||||
Learn more: [music-assistant.io](https://music-assistant.io/)
|
Learn more: [music-assistant.io](https://music-assistant.io/)
|
||||||
|
|
||||||
@@ -92,8 +127,8 @@ MIT License
|
|||||||
|
|
||||||
1. Clone the repository
|
1. Clone the repository
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/CollotsSpot/AssistantToTheMusic.git
|
git clone https://github.com/CollotsSpot/Ensemble.git
|
||||||
cd AssistantToTheMusic
|
cd Ensemble
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies
|
2. Install dependencies
|
||||||
@@ -119,9 +154,10 @@ The APK will be available at `build/app/outputs/flutter-apk/app-release.apk`
|
|||||||
<summary>Technologies Used</summary>
|
<summary>Technologies Used</summary>
|
||||||
|
|
||||||
- **Flutter** - Cross-platform mobile framework
|
- **Flutter** - Cross-platform mobile framework
|
||||||
- **web_socket_channel** - WebSocket communication with Music Assistant server
|
- **audio_service** - Background playback and media notifications
|
||||||
|
- **web_socket_channel** - WebSocket communication with Music Assistant
|
||||||
- **provider** - State management
|
- **provider** - State management
|
||||||
|
- **cached_network_image** - Image caching
|
||||||
- **shared_preferences** - Local settings storage
|
- **shared_preferences** - Local settings storage
|
||||||
- **http** - HTTP requests for API communication
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,642 +0,0 @@
|
|||||||
# Music Assistant Mobile - Troubleshooting Guide
|
|
||||||
|
|
||||||
## Common Issues and Solutions
|
|
||||||
|
|
||||||
### Connection Issues
|
|
||||||
|
|
||||||
#### Problem: "Not Connected to Music Assistant"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- App shows disconnected state
|
|
||||||
- Cannot browse library
|
|
||||||
- No players available
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Verify Server URL**
|
|
||||||
- Go to Settings
|
|
||||||
- Check server URL format: `https://ma.serverscloud.org` or `http://192.168.1.100:8095`
|
|
||||||
- Ensure no trailing slash
|
|
||||||
- Try both HTTP and HTTPS if unsure
|
|
||||||
|
|
||||||
2. **Check Network Connectivity**
|
|
||||||
- Ensure device has internet/network access
|
|
||||||
- Try opening server URL in mobile browser
|
|
||||||
- Check if on same network (for local servers)
|
|
||||||
|
|
||||||
3. **Verify Music Assistant Server**
|
|
||||||
- Ensure Music Assistant server is running
|
|
||||||
- Check server is accessible from your device
|
|
||||||
- Verify port 8095 (default) or custom port is open
|
|
||||||
|
|
||||||
4. **Check Authentication**
|
|
||||||
- If using Authelia, verify credentials
|
|
||||||
- Try re-entering username/password
|
|
||||||
- Check Authelia is accessible
|
|
||||||
|
|
||||||
5. **Clear App Data**
|
|
||||||
- Go to Settings → Clear all settings
|
|
||||||
- Re-enter server URL and credentials
|
|
||||||
- Restart app
|
|
||||||
|
|
||||||
#### Problem: "WebSocket Connection Failed"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Connection drops frequently
|
|
||||||
- "Connection timeout" errors in logs
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Check WebSocket Port**
|
|
||||||
- Default: 8095 for local, 443 for HTTPS
|
|
||||||
- Go to Settings → Custom WebSocket Port
|
|
||||||
- Try entering port explicitly
|
|
||||||
|
|
||||||
2. **Verify Firewall Rules**
|
|
||||||
- Ensure WebSocket traffic is allowed
|
|
||||||
- Check reverse proxy configuration (if using)
|
|
||||||
- Cloudflare users: Ensure WebSocket support enabled
|
|
||||||
|
|
||||||
3. **Test WebSocket Connection**
|
|
||||||
- Use browser dev tools to test WebSocket
|
|
||||||
- URL format: `wss://ma.serverscloud.org/ws`
|
|
||||||
- Look for successful upgrade to WebSocket
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Authentication Issues
|
|
||||||
|
|
||||||
#### Problem: "Login Failed" or "401 Unauthorized"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Cannot login with correct credentials
|
|
||||||
- Session expires immediately
|
|
||||||
- WebSocket authentication fails
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Verify Authelia Credentials**
|
|
||||||
- Double-check username and password
|
|
||||||
- Check Authelia domain is correct
|
|
||||||
- Try logging in via web browser first
|
|
||||||
|
|
||||||
2. **Check Session Cookie**
|
|
||||||
- Cookie name should be `authelia_session`
|
|
||||||
- Check cookie is being set by Authelia
|
|
||||||
- Verify cookie domain matches
|
|
||||||
|
|
||||||
3. **Clear Saved Credentials**
|
|
||||||
- Settings → Clear all settings
|
|
||||||
- Re-enter credentials
|
|
||||||
- Test login
|
|
||||||
|
|
||||||
4. **Check Authelia Configuration**
|
|
||||||
- Ensure Authelia is properly configured
|
|
||||||
- Verify session timeout settings
|
|
||||||
- Check Authelia logs for errors
|
|
||||||
|
|
||||||
#### Problem: "Auto-login Fails"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Must re-login every app start
|
|
||||||
- "Auto-login failed" in logs
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Check Saved Credentials**
|
|
||||||
- Settings → Verify username is saved
|
|
||||||
- Password should be saved (not visible)
|
|
||||||
- Re-save credentials if needed
|
|
||||||
|
|
||||||
2. **Session Timeout**
|
|
||||||
- Authelia session may have expired
|
|
||||||
- Check Authelia session duration
|
|
||||||
- Re-login to refresh session
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Playback Issues
|
|
||||||
|
|
||||||
#### Problem: "No Players Available"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Player selector shows empty list
|
|
||||||
- Cannot select any player
|
|
||||||
- Music won't play
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Refresh Player List**
|
|
||||||
- Open player selector (top right icon)
|
|
||||||
- Tap refresh icon
|
|
||||||
- Wait for players to load
|
|
||||||
|
|
||||||
2. **Check Music Assistant Server**
|
|
||||||
- Verify players are configured in MA server
|
|
||||||
- Check players are powered on
|
|
||||||
- Ensure players are available
|
|
||||||
|
|
||||||
3. **Wait for Initial Load**
|
|
||||||
- Players load on connection
|
|
||||||
- May take a few seconds
|
|
||||||
- Check debug logs for "Loaded X players"
|
|
||||||
|
|
||||||
#### Problem: "Now Playing Bar Not Showing"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Music is playing but no mini-player appears
|
|
||||||
- Cannot see current track
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Check Player Selection**
|
|
||||||
- Ensure a player is selected (green dot in player selector)
|
|
||||||
- Select player explicitly if needed
|
|
||||||
|
|
||||||
2. **Verify Queue Has Items**
|
|
||||||
- Some players may not support queue state
|
|
||||||
- Try playing an album/track
|
|
||||||
- Check queue screen for items
|
|
||||||
|
|
||||||
3. **Check Debug Logs**
|
|
||||||
- Look for "Error getting queue" messages
|
|
||||||
- Check for parsing errors
|
|
||||||
- Report specific error in GitHub issues
|
|
||||||
|
|
||||||
4. **Force Refresh**
|
|
||||||
- Stop and restart playback
|
|
||||||
- Select different player, then select back
|
|
||||||
- Restart app
|
|
||||||
|
|
||||||
#### Problem: "Cannot Control Playback"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Play/pause buttons don't work
|
|
||||||
- Skip track buttons unresponsive
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Check Player State**
|
|
||||||
- Some players may not support all controls
|
|
||||||
- Verify player is powered on
|
|
||||||
- Check Music Assistant server logs
|
|
||||||
|
|
||||||
2. **Network Latency**
|
|
||||||
- Commands may take 1-2 seconds
|
|
||||||
- Wait before clicking again
|
|
||||||
- Check network connection
|
|
||||||
|
|
||||||
3. **Player Availability**
|
|
||||||
- Player may have disconnected
|
|
||||||
- Refresh player list
|
|
||||||
- Re-select player
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Library Issues
|
|
||||||
|
|
||||||
#### Problem: "No Artists/Albums/Tracks Found"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Empty library screens
|
|
||||||
- "No items found" messages
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Check Music Assistant Library**
|
|
||||||
- Verify library is populated in MA server
|
|
||||||
- Scan library in MA web interface
|
|
||||||
- Ensure provider is configured
|
|
||||||
|
|
||||||
2. **Force Library Reload**
|
|
||||||
- Pull down to refresh on library screens
|
|
||||||
- Go to home screen and back
|
|
||||||
- Restart app
|
|
||||||
|
|
||||||
3. **Check API Limits**
|
|
||||||
- Default limit is 100 items
|
|
||||||
- May not show all items if library > 100
|
|
||||||
- Pagination planned for future release
|
|
||||||
|
|
||||||
4. **Verify Connection**
|
|
||||||
- Ensure WebSocket is connected
|
|
||||||
- Check connection status on home screen
|
|
||||||
- Look for green dot indicator
|
|
||||||
|
|
||||||
#### Problem: "Search Returns No Results"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Search finds nothing
|
|
||||||
- Known items don't appear
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Check Search Query**
|
|
||||||
- Try partial matches
|
|
||||||
- Search is case-insensitive
|
|
||||||
- Wait for debounce (500ms)
|
|
||||||
|
|
||||||
2. **Verify Library Content**
|
|
||||||
- Ensure items exist in MA library
|
|
||||||
- Test search in MA web interface
|
|
||||||
- Re-scan library if needed
|
|
||||||
|
|
||||||
3. **Check Network**
|
|
||||||
- Search requires active connection
|
|
||||||
- Verify WebSocket is connected
|
|
||||||
- Try again with better connection
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Queue Issues
|
|
||||||
|
|
||||||
#### Problem: "Queue Screen Shows Empty"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Queue screen has no items
|
|
||||||
- Music is playing but queue empty
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Check Player Type**
|
|
||||||
- Some players may not support queue viewing
|
|
||||||
- Try different player
|
|
||||||
- Check MA server capabilities
|
|
||||||
|
|
||||||
2. **Refresh Queue**
|
|
||||||
- Tap refresh icon in queue screen
|
|
||||||
- Play a new album/track
|
|
||||||
- Restart playback
|
|
||||||
|
|
||||||
3. **API Issues**
|
|
||||||
- Check logs for "Error getting queue"
|
|
||||||
- May be API parsing issue
|
|
||||||
- Report bug with logs
|
|
||||||
|
|
||||||
#### Problem: "Cannot Reorder/Remove Queue Items"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Drag and drop doesn't work
|
|
||||||
- Swipe to dismiss fails
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **API Support**
|
|
||||||
- Reordering/removing is local only (currently)
|
|
||||||
- Server sync not yet implemented
|
|
||||||
- Changes won't persist on server
|
|
||||||
|
|
||||||
2. **UI Issues**
|
|
||||||
- Ensure you're dragging from handle icon
|
|
||||||
- Swipe fully to left to dismiss
|
|
||||||
- Try on different queue items
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Performance Issues
|
|
||||||
|
|
||||||
#### Problem: "App is Slow/Laggy"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- UI stutters
|
|
||||||
- Slow navigation
|
|
||||||
- Delayed responses
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Clear App Cache**
|
|
||||||
- Settings → Clear all settings
|
|
||||||
- Restart app
|
|
||||||
- May improve performance
|
|
||||||
|
|
||||||
2. **Reduce Polling**
|
|
||||||
- Player state polls every 2 seconds
|
|
||||||
- Expected behavior for now
|
|
||||||
- Future: Event-based updates
|
|
||||||
|
|
||||||
3. **Check Device Performance**
|
|
||||||
- Close other apps
|
|
||||||
- Restart device
|
|
||||||
- Check available storage
|
|
||||||
|
|
||||||
4. **Network Latency**
|
|
||||||
- Test network speed
|
|
||||||
- Move closer to WiFi
|
|
||||||
- Try different network
|
|
||||||
|
|
||||||
#### Problem: "High Battery Drain"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Battery drains quickly
|
|
||||||
- App using lots of background power
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Polling Frequency**
|
|
||||||
- Player state polling uses power
|
|
||||||
- Will be optimized in future
|
|
||||||
- Close app when not in use
|
|
||||||
|
|
||||||
2. **Background Activity**
|
|
||||||
- App doesn't support background playback yet
|
|
||||||
- Ensure app is closed when done
|
|
||||||
- Check battery settings
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### UI Issues
|
|
||||||
|
|
||||||
#### Problem: "Images Not Loading"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Album/artist artwork missing
|
|
||||||
- Broken image icons
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Check Network**
|
|
||||||
- Images load from server
|
|
||||||
- Requires active connection
|
|
||||||
- Try refreshing
|
|
||||||
|
|
||||||
2. **Verify Image URLs**
|
|
||||||
- Check MA server has artwork
|
|
||||||
- Test image URL in browser
|
|
||||||
- May be missing for some items
|
|
||||||
|
|
||||||
3. **Server Configuration**
|
|
||||||
- Check image proxy settings
|
|
||||||
- Verify image API endpoint
|
|
||||||
- Check CORS configuration
|
|
||||||
|
|
||||||
#### Problem: "Screen Layout Broken"
|
|
||||||
|
|
||||||
**Symptoms:**
|
|
||||||
- Overlapping elements
|
|
||||||
- Text cut off
|
|
||||||
- Weird spacing
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Restart App**
|
|
||||||
- Force close and reopen
|
|
||||||
- May fix temporary layout issues
|
|
||||||
|
|
||||||
2. **Check Device**
|
|
||||||
- Test on different screen size
|
|
||||||
- May be device-specific issue
|
|
||||||
- Report with device model
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Debug Logging
|
|
||||||
|
|
||||||
### Enabling Logs
|
|
||||||
|
|
||||||
Logs are always enabled and print to console. To view:
|
|
||||||
|
|
||||||
**Android Studio / VS Code:**
|
|
||||||
1. Run app in debug mode
|
|
||||||
2. Open Debug Console
|
|
||||||
3. Look for timestamped logs: `[HH:MM:SS.mmm] Message`
|
|
||||||
|
|
||||||
**Physical Device:**
|
|
||||||
1. Connect via USB
|
|
||||||
2. Run `flutter logs`
|
|
||||||
3. Filter for app logs
|
|
||||||
|
|
||||||
### Important Log Messages
|
|
||||||
|
|
||||||
**Connection:**
|
|
||||||
- `Connected to Music Assistant successfully` - Connection established
|
|
||||||
- `WebSocket error: ...` - Connection problem
|
|
||||||
- `Connection timeout` - Server not responding
|
|
||||||
|
|
||||||
**Authentication:**
|
|
||||||
- `🔑 Adding session cookie to WebSocket handshake` - Auth in progress
|
|
||||||
- `✓ Authenticated` - Auth successful
|
|
||||||
- `⚠️ Auto-login failed` - Credentials issue
|
|
||||||
|
|
||||||
**Players:**
|
|
||||||
- `Loaded X players` - Player list fetched
|
|
||||||
- `Selected player: Name (ID)` - Player selected
|
|
||||||
- `Using cached player list` - Cache hit
|
|
||||||
|
|
||||||
**Queue:**
|
|
||||||
- `🔍 DEBUG: First queue item raw data` - Queue structure
|
|
||||||
- `Error getting queue: ...` - Queue fetch failed
|
|
||||||
- `⚠️ Failed to parse queue item` - Parsing error
|
|
||||||
|
|
||||||
**Playback:**
|
|
||||||
- `Playing X tracks via queue on player Y` - Playback started
|
|
||||||
- `✓ X tracks queued successfully` - Queue successful
|
|
||||||
|
|
||||||
### Reporting Bugs
|
|
||||||
|
|
||||||
When reporting issues, include:
|
|
||||||
|
|
||||||
1. **Steps to Reproduce**
|
|
||||||
- Exact actions taken
|
|
||||||
- Expected vs actual behavior
|
|
||||||
|
|
||||||
2. **Debug Logs**
|
|
||||||
- Copy relevant log section
|
|
||||||
- Include timestamps
|
|
||||||
- Show 10-20 lines before/after error
|
|
||||||
|
|
||||||
3. **Environment**
|
|
||||||
- Device model and OS version
|
|
||||||
- App version
|
|
||||||
- Music Assistant server version
|
|
||||||
- Network type (WiFi, cellular, etc.)
|
|
||||||
|
|
||||||
4. **Screenshots**
|
|
||||||
- Error messages
|
|
||||||
- UI issues
|
|
||||||
- Settings screen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
### Current Constraints
|
|
||||||
|
|
||||||
1. **Library Pagination**
|
|
||||||
- Loads max 100 items per category
|
|
||||||
- Infinite scroll coming soon
|
|
||||||
|
|
||||||
2. **Queue Management**
|
|
||||||
- Reorder/remove only local
|
|
||||||
- Server sync not implemented
|
|
||||||
- Changes don't persist
|
|
||||||
|
|
||||||
3. **Player State**
|
|
||||||
- Polls every 2 seconds
|
|
||||||
- Event-based updates coming
|
|
||||||
- May have slight delay
|
|
||||||
|
|
||||||
4. **Search**
|
|
||||||
- Searches entire library only
|
|
||||||
- No category-specific search
|
|
||||||
- No search history
|
|
||||||
|
|
||||||
5. **Offline Mode**
|
|
||||||
- No offline playback
|
|
||||||
- Requires active connection
|
|
||||||
- Planned for future
|
|
||||||
|
|
||||||
### Planned Improvements
|
|
||||||
|
|
||||||
See ARCHITECTURE.md → Future Enhancements for roadmap.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Advanced Troubleshooting
|
|
||||||
|
|
||||||
### WebSocket Debugging
|
|
||||||
|
|
||||||
1. **Chrome DevTools Method**
|
|
||||||
```
|
|
||||||
1. Open ma.serverscloud.org in Chrome
|
|
||||||
2. F12 → Network tab
|
|
||||||
3. Filter: WS (WebSocket)
|
|
||||||
4. Look for /ws connection
|
|
||||||
5. Inspect frames (messages)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Test WebSocket Manually**
|
|
||||||
```javascript
|
|
||||||
const ws = new WebSocket('wss://ma.serverscloud.org/ws');
|
|
||||||
ws.onopen = () => console.log('Connected');
|
|
||||||
ws.onmessage = (e) => console.log('Message:', e.data);
|
|
||||||
ws.onerror = (e) => console.error('Error:', e);
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Testing
|
|
||||||
|
|
||||||
Use Music Assistant's web interface to test API:
|
|
||||||
1. Open developer tools
|
|
||||||
2. Go to Network tab
|
|
||||||
3. Perform action (search, play, etc.)
|
|
||||||
4. Inspect WebSocket messages
|
|
||||||
5. Compare with app behavior
|
|
||||||
|
|
||||||
### Flutter Debugging
|
|
||||||
|
|
||||||
**DevTools:**
|
|
||||||
```bash
|
|
||||||
flutter pub global activate devtools
|
|
||||||
flutter pub global run devtools
|
|
||||||
```
|
|
||||||
|
|
||||||
**Widget Inspector:**
|
|
||||||
- Shows widget tree
|
|
||||||
- Inspect layout issues
|
|
||||||
- Debug rendering
|
|
||||||
|
|
||||||
**Performance:**
|
|
||||||
- Profile mode: `flutter run --profile`
|
|
||||||
- Check frame rendering times
|
|
||||||
- Monitor memory usage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Getting Help
|
|
||||||
|
|
||||||
### Support Channels
|
|
||||||
|
|
||||||
1. **GitHub Issues**
|
|
||||||
- Bug reports
|
|
||||||
- Feature requests
|
|
||||||
- Technical discussions
|
|
||||||
- URL: https://github.com/CollotsSpot/music-assistant-mobile/issues
|
|
||||||
|
|
||||||
2. **Music Assistant Community**
|
|
||||||
- Discord server
|
|
||||||
- Forum discussions
|
|
||||||
- General questions
|
|
||||||
- URL: https://music-assistant.io/
|
|
||||||
|
|
||||||
3. **Documentation**
|
|
||||||
- ARCHITECTURE.md - Technical details
|
|
||||||
- README.md - Getting started
|
|
||||||
- Code comments - Implementation details
|
|
||||||
|
|
||||||
### Before Asking
|
|
||||||
|
|
||||||
1. Check this troubleshooting guide
|
|
||||||
2. Search existing GitHub issues
|
|
||||||
3. Check Music Assistant docs
|
|
||||||
4. Test with Music Assistant web interface
|
|
||||||
5. Gather debug logs
|
|
||||||
|
|
||||||
### Providing Feedback
|
|
||||||
|
|
||||||
**Bug Report Template:**
|
|
||||||
```
|
|
||||||
**Description**
|
|
||||||
[Clear description of the issue]
|
|
||||||
|
|
||||||
**Steps to Reproduce**
|
|
||||||
1. Step one
|
|
||||||
2. Step two
|
|
||||||
3. ...
|
|
||||||
|
|
||||||
**Expected Behavior**
|
|
||||||
[What should happen]
|
|
||||||
|
|
||||||
**Actual Behavior**
|
|
||||||
[What actually happens]
|
|
||||||
|
|
||||||
**Logs**
|
|
||||||
[Relevant log excerpt]
|
|
||||||
|
|
||||||
**Environment**
|
|
||||||
- Device: [e.g. Samsung Galaxy S21]
|
|
||||||
- OS: [e.g. Android 13]
|
|
||||||
- App Version: [e.g. 1.0.0]
|
|
||||||
- MA Server Version: [e.g. 2.7.0]
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
[If applicable]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
### Q: Does the app support offline playback?
|
|
||||||
**A:** Not currently. All playback requires connection to Music Assistant server.
|
|
||||||
|
|
||||||
### Q: Can I use this with multiple Music Assistant servers?
|
|
||||||
**A:** Not simultaneously, but you can change servers in Settings.
|
|
||||||
|
|
||||||
### Q: Why isn't my local file library showing?
|
|
||||||
**A:** Check Music Assistant server has scanned your local files and the provider is enabled.
|
|
||||||
|
|
||||||
### Q: Does it support Android Auto / CarPlay?
|
|
||||||
**A:** Not yet, but planned for future releases.
|
|
||||||
|
|
||||||
### Q: Can I download music for offline listening?
|
|
||||||
**A:** Not currently supported.
|
|
||||||
|
|
||||||
### Q: How do I update the app?
|
|
||||||
**A:** Updates will be distributed via app stores once released. Currently, build from source.
|
|
||||||
|
|
||||||
### Q: Is my password stored securely?
|
|
||||||
**A:** Yes, credentials are stored using Flutter's secure storage mechanisms.
|
|
||||||
|
|
||||||
### Q: Can I control playback when screen is off?
|
|
||||||
**A:** Background playback is limited. The app is designed for active use currently.
|
|
||||||
|
|
||||||
### Q: Why can't I see all my albums?
|
|
||||||
**A:** Current limit is 100 items per category. Pagination coming soon.
|
|
||||||
|
|
||||||
### Q: Does it work with Spotify/YouTube/etc?
|
|
||||||
**A:** Yes, if configured as providers in Music Assistant server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** 2025-01-22
|
|
||||||
**App Version:** Development Build
|
|
||||||
**Music Assistant Version:** 2.7.0+
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
# Music Assistant Mobile - Implementation Summary
|
|
||||||
|
|
||||||
This document summarizes all the features and improvements implemented during the overnight development session.
|
|
||||||
|
|
||||||
## 🎯 Overview
|
|
||||||
|
|
||||||
This was an intensive development session focused on transforming the Music Assistant mobile app from a basic prototype into a feature-rich, production-ready music player with comprehensive functionality, robust error handling, and extensive test coverage.
|
|
||||||
|
|
||||||
## ✨ Major Features Implemented
|
|
||||||
|
|
||||||
### 1. Playlist Support
|
|
||||||
**Files Added:**
|
|
||||||
- `lib/screens/playlist_details_screen.dart`
|
|
||||||
- `lib/widgets/playlist_row.dart`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Browse playlists from all providers (Spotify, local library, etc.)
|
|
||||||
- View playlist details with track listings
|
|
||||||
- Play entire playlists with queue management
|
|
||||||
- Play individual tracks from playlists
|
|
||||||
- Beautiful UI with album artwork and metadata
|
|
||||||
- Provider-specific playlist support
|
|
||||||
|
|
||||||
### 2. Favorites System
|
|
||||||
**Files Modified:**
|
|
||||||
- `lib/providers/music_assistant_provider.dart`
|
|
||||||
- `lib/services/music_assistant_api.dart`
|
|
||||||
- `lib/screens/*_details_screen.dart`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Mark/unmark tracks, albums, and artists as favorites
|
|
||||||
- Persistent favorites sync with Music Assistant server
|
|
||||||
- Visual indicators for favorited items (filled heart icons)
|
|
||||||
- Real-time UI updates when toggling favorites
|
|
||||||
- Error handling for favorite operations
|
|
||||||
|
|
||||||
### 3. Volume Control
|
|
||||||
**Files Added:**
|
|
||||||
- `lib/widgets/volume_control.dart`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Full volume slider with live adjustment
|
|
||||||
- Mute/unmute toggle
|
|
||||||
- Visual volume percentage display
|
|
||||||
- Compact mode for mini player (mute button only)
|
|
||||||
- Smooth UI with pending volume tracking
|
|
||||||
- Dynamic volume icons (off/down/up)
|
|
||||||
|
|
||||||
### 4. Shuffle & Repeat Modes
|
|
||||||
**Files Modified:**
|
|
||||||
- `lib/models/player.dart` (added shuffle/repeat to PlayerQueue)
|
|
||||||
- `lib/providers/music_assistant_provider.dart` (added control methods)
|
|
||||||
- `lib/screens/now_playing_screen.dart` (added UI controls)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Shuffle toggle with visual state indication
|
|
||||||
- Repeat modes: Off, One, All
|
|
||||||
- Persistent state across sessions
|
|
||||||
- Visual feedback with icon states
|
|
||||||
- Integration with Music Assistant queue
|
|
||||||
|
|
||||||
### 5. Retry Logic with Exponential Backoff
|
|
||||||
**Files Added:**
|
|
||||||
- `lib/services/retry_helper.dart`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Generic retry mechanism for any async operation
|
|
||||||
- Exponential backoff (2s → 4s → 8s → 16s)
|
|
||||||
- Network-specific retry logic
|
|
||||||
- Critical operation retry mode
|
|
||||||
- Configurable max attempts and delays
|
|
||||||
- Smart error detection for retryable errors
|
|
||||||
- Comprehensive logging
|
|
||||||
|
|
||||||
### 6. Error Handling System
|
|
||||||
**Files Added:**
|
|
||||||
- `lib/services/error_handler.dart`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- User-friendly error messages
|
|
||||||
- Error type classification (connection, network, auth, playback, library)
|
|
||||||
- Technical vs. user-facing messages
|
|
||||||
- Retryability detection
|
|
||||||
- Comprehensive error logging
|
|
||||||
- Context-aware error handling
|
|
||||||
|
|
||||||
### 7. Search Functionality
|
|
||||||
**Files Added:**
|
|
||||||
- `lib/screens/search_screen.dart`
|
|
||||||
- `lib/services/search_service.dart`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Global search across tracks, albums, artists, playlists
|
|
||||||
- Real-time search results
|
|
||||||
- Debounced search input
|
|
||||||
- Category filtering
|
|
||||||
- Direct playback from search results
|
|
||||||
- Navigation to detail screens
|
|
||||||
|
|
||||||
### 8. Home Page Enhancements
|
|
||||||
**Files Modified:**
|
|
||||||
- `lib/screens/home_screen.dart`
|
|
||||||
- `lib/widgets/album_row.dart`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Recent albums section
|
|
||||||
- Random albums discovery
|
|
||||||
- Library statistics display
|
|
||||||
- Horizontal scrolling album rows
|
|
||||||
- Beautiful album artwork display
|
|
||||||
- Quick navigation to album details
|
|
||||||
|
|
||||||
### 9. Queue Management
|
|
||||||
**Files Added:**
|
|
||||||
- `lib/screens/queue_screen.dart`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- View current playback queue
|
|
||||||
- Visual indicator for currently playing track
|
|
||||||
- Track numbers and metadata
|
|
||||||
- Album artwork for each track
|
|
||||||
- Shuffle and repeat controls
|
|
||||||
- Clear queue functionality
|
|
||||||
- Jump to any track in queue
|
|
||||||
|
|
||||||
### 10. Pull-to-Refresh
|
|
||||||
**Files Modified:**
|
|
||||||
- All library screens (albums, artists, tracks, playlists)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Swipe down to refresh library content
|
|
||||||
- Visual refresh indicator
|
|
||||||
- Background data reload
|
|
||||||
- Smooth animations
|
|
||||||
- Maintained scroll position
|
|
||||||
|
|
||||||
### 11. Player List Caching
|
|
||||||
**Files Modified:**
|
|
||||||
- `lib/providers/music_assistant_provider.dart`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Cache player list for 30 seconds
|
|
||||||
- Reduced API calls
|
|
||||||
- Faster UI updates
|
|
||||||
- Background refresh
|
|
||||||
- Cache invalidation on manual refresh
|
|
||||||
|
|
||||||
### 12. Comprehensive Unit Tests
|
|
||||||
**Files Added:**
|
|
||||||
- `test/models/media_item_test.dart`
|
|
||||||
- `test/models/player_test.dart`
|
|
||||||
- `test/services/retry_helper_test.dart`
|
|
||||||
- `test/services/error_handler_test.dart`
|
|
||||||
|
|
||||||
**Coverage:**
|
|
||||||
- 130+ test cases
|
|
||||||
- MediaItem model tests (Track, Album, Artist, Playlist, Radio)
|
|
||||||
- Player model tests (Player, QueueItem, PlayerQueue, StreamDetails)
|
|
||||||
- RetryHelper service tests
|
|
||||||
- ErrorHandler service tests
|
|
||||||
- JSON parsing validation
|
|
||||||
- Null safety verification
|
|
||||||
- Edge case handling
|
|
||||||
- Default value testing
|
|
||||||
|
|
||||||
### 13. Playback Quality Settings
|
|
||||||
**Files Added:**
|
|
||||||
- `lib/models/settings.dart`
|
|
||||||
- `lib/providers/settings_provider.dart`
|
|
||||||
- `lib/screens/playback_settings_screen.dart`
|
|
||||||
|
|
||||||
**Files Modified:**
|
|
||||||
- `lib/main.dart` (added SettingsProvider)
|
|
||||||
- `lib/screens/settings_screen.dart` (added navigation)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Audio quality preferences (Lossless/Lossy)
|
|
||||||
- Quality levels: Low (96 kbps), Normal (128 kbps), High (256 kbps), Very High (320 kbps)
|
|
||||||
- Configurable max bitrate
|
|
||||||
- Prefer lossless (FLAC) option
|
|
||||||
- Network settings (cellular streaming, downloads)
|
|
||||||
- Appearance settings (album art, animations)
|
|
||||||
- Settings persistence with SharedPreferences
|
|
||||||
- Reset to defaults functionality
|
|
||||||
- Beautiful settings UI with sliders and toggles
|
|
||||||
|
|
||||||
## 🐛 Bug Fixes
|
|
||||||
|
|
||||||
### 1. Build Errors
|
|
||||||
**Fixed:**
|
|
||||||
- Missing `api` getter in MusicAssistantProvider
|
|
||||||
- Null safety issues in multiple screens
|
|
||||||
- Constructor parameter mismatches in AlbumDetailsScreen
|
|
||||||
- Missing imports and dependencies
|
|
||||||
|
|
||||||
### 2. Queue Item Parsing Failures
|
|
||||||
**Fixed:**
|
|
||||||
- Nested `media_item` structure handling
|
|
||||||
- Null safety in MediaItem.fromJson
|
|
||||||
- ProviderMapping null field defaults
|
|
||||||
- QueueItem queue_item_id fallback logic
|
|
||||||
- Comprehensive null checks throughout parsing
|
|
||||||
|
|
||||||
### 3. Player Selection Issues
|
|
||||||
**Fixed:**
|
|
||||||
- Smart player selection based on playback state
|
|
||||||
- Prioritize actually playing player over builtin
|
|
||||||
- Maintain player selection when still available
|
|
||||||
- Auto-select playing player on app launch
|
|
||||||
- Refresh selected player state
|
|
||||||
|
|
||||||
### 4. Now Playing Display
|
|
||||||
**Fixed:**
|
|
||||||
- Stale queue data from parsing failures
|
|
||||||
- Wrong track information display
|
|
||||||
- Player-queue mismatch
|
|
||||||
- Current item detection
|
|
||||||
|
|
||||||
## 📊 Statistics
|
|
||||||
|
|
||||||
### Code Added
|
|
||||||
- **New Files:** 20+
|
|
||||||
- **Modified Files:** 30+
|
|
||||||
- **Lines of Code:** ~5,000+
|
|
||||||
- **Test Cases:** 130+
|
|
||||||
- **Commits:** 20
|
|
||||||
|
|
||||||
### Features Breakdown
|
|
||||||
- **UI Screens:** 6 new screens
|
|
||||||
- **Widgets:** 8 new reusable widgets
|
|
||||||
- **Services:** 4 new service classes
|
|
||||||
- **Models:** 3 new model classes
|
|
||||||
- **Providers:** 2 new state management providers
|
|
||||||
|
|
||||||
## 🏗️ Architecture Improvements
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
- Proper use of Provider pattern
|
|
||||||
- ChangeNotifier for reactive UI
|
|
||||||
- Separation of concerns
|
|
||||||
- Clean data flow
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- Centralized error handling service
|
|
||||||
- User-friendly error messages
|
|
||||||
- Comprehensive logging
|
|
||||||
- Retry mechanisms
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
- Null safety throughout
|
|
||||||
- Defensive programming
|
|
||||||
- Comprehensive tests
|
|
||||||
- Clear documentation
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- Caching strategies
|
|
||||||
- Debounced operations
|
|
||||||
- Optimized API calls
|
|
||||||
- Smooth animations
|
|
||||||
|
|
||||||
## 🚀 Ready for Production
|
|
||||||
|
|
||||||
The app now includes:
|
|
||||||
- ✅ Comprehensive feature set
|
|
||||||
- ✅ Robust error handling
|
|
||||||
- ✅ Extensive test coverage
|
|
||||||
- ✅ User-configurable settings
|
|
||||||
- ✅ Smooth, polished UI
|
|
||||||
- ✅ Performance optimizations
|
|
||||||
- ✅ Production-ready code quality
|
|
||||||
|
|
||||||
## 📝 Commit History
|
|
||||||
|
|
||||||
```
|
|
||||||
f6c0c88 Add comprehensive unit tests and playback quality settings
|
|
||||||
56cd6ef Fix player selection to show correct now playing information
|
|
||||||
18d74e9 Improve null safety in JSON parsing for queue items and media items
|
|
||||||
f3c5c66 Fix QueueItem parsing - track data is nested in media_item field
|
|
||||||
ace810a Fix AlbumDetailsScreen constructor call - remove extra parameters
|
|
||||||
649583e Fix build errors - null safety and API access issues
|
|
||||||
3bce979 Implement shuffle and repeat modes for queue control
|
|
||||||
7f4730a Add volume control for Music Assistant players
|
|
||||||
fcb038a Improve error handling with user-friendly messages
|
|
||||||
db0cefe Add retry logic with exponential backoff for network resilience
|
|
||||||
d0ab37e Add favorites system
|
|
||||||
d7949a3 Add comprehensive playlist support
|
|
||||||
e24b542 Add player list caching to reduce API calls
|
|
||||||
4f7856e Add pull-to-refresh to library screens
|
|
||||||
ad4136f Add queue viewer screen with management capabilities
|
|
||||||
6c291f7 Add search functionality to home and library screens
|
|
||||||
38b29cb Add home page enhancements - recent albums, random albums, and stats
|
|
||||||
965bf9d Add defensive parsing and debug logging for queue items
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎉 Conclusion
|
|
||||||
|
|
||||||
This intensive development session successfully transformed the Music Assistant mobile app into a feature-complete, production-ready application. The app now provides a rich music listening experience with comprehensive playback controls, library management, search, playlists, favorites, quality settings, and robust error handling - all backed by extensive test coverage.
|
|
||||||
|
|
||||||
The codebase is clean, well-structured, and ready for deployment. All major features are implemented, tested, and committed to the repository.
|
|
||||||
@@ -4,9 +4,11 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="ATTM"
|
android:label="Ensemble"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
|
|||||||
@@ -1,6 +1,87 @@
|
|||||||
package com.musicassistant.music_assistant
|
package com.musicassistant.music_assistant
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import android.util.Log
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import com.ryanheise.audioservice.AudioServiceActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
// Extend AudioServiceActivity instead of FlutterActivity to support audio_service package
|
||||||
|
// while also intercepting volume button events
|
||||||
|
class MainActivity: AudioServiceActivity() {
|
||||||
|
private val TAG = "EnsembleVolume"
|
||||||
|
private val CHANNEL = "com.musicassistant.music_assistant/volume_buttons"
|
||||||
|
private var methodChannel: MethodChannel? = null
|
||||||
|
private var isListening = false
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
Log.d(TAG, "Configuring Flutter engine, setting up MethodChannel")
|
||||||
|
|
||||||
|
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||||
|
methodChannel?.setMethodCallHandler { call, result ->
|
||||||
|
Log.d(TAG, "Received method call: ${call.method}")
|
||||||
|
when (call.method) {
|
||||||
|
"startListening" -> {
|
||||||
|
isListening = true
|
||||||
|
Log.d(TAG, "Volume listening ENABLED")
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"stopListening" -> {
|
||||||
|
isListening = false
|
||||||
|
Log.d(TAG, "Volume listening DISABLED")
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.d(TAG, "Unknown method: ${call.method}")
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use dispatchKeyEvent instead of onKeyDown - Flutter's engine uses dispatchKeyEvent
|
||||||
|
// and may consume events before they reach onKeyDown
|
||||||
|
override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
|
||||||
|
if (event == null) {
|
||||||
|
return super.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyCode = event.keyCode
|
||||||
|
val action = event.action
|
||||||
|
|
||||||
|
Log.d(TAG, "dispatchKeyEvent: keyCode=$keyCode, action=$action, isListening=$isListening")
|
||||||
|
|
||||||
|
// Only handle KEY_DOWN events to avoid double-triggering (down + up)
|
||||||
|
if (action != KeyEvent.ACTION_DOWN) {
|
||||||
|
// For volume keys when listening, also consume ACTION_UP to fully block system volume
|
||||||
|
if (isListening && (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) {
|
||||||
|
Log.d(TAG, "Consuming ACTION_UP for volume key")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return super.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isListening) {
|
||||||
|
Log.d(TAG, "Not listening, passing to super")
|
||||||
|
return super.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||||
|
Log.d(TAG, "VOLUME UP pressed - sending to Flutter")
|
||||||
|
methodChannel?.invokeMethod("volumeUp", null)
|
||||||
|
true // Consume the event to prevent system volume change
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||||
|
Log.d(TAG, "VOLUME DOWN pressed - sending to Flutter")
|
||||||
|
methodChannel?.invokeMethod("volumeDown", null)
|
||||||
|
true // Consume the event to prevent system volume change
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.d(TAG, "Other key, passing to super")
|
||||||
|
super.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<?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">
|
||||||
|
<!-- Swap/cycle arrows icon -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,6v3l4,-4 -4,-4v3c-4.42,0 -8,3.58 -8,8 0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8 0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8 0,3.31 -2.69,6 -6,6v-3l-4,4 4,4v-3c4.42,0 8,-3.58 8,-8 0,-1.57 -0.46,-3.03 -1.24,-4.26z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
tools:keep="@drawable/*,@drawable/audio_service_*" />
|
||||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 498 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 470 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 104 KiB |
@@ -1,93 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Script to remove ghost "Music Assistant Mobile" players from Music Assistant
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SETTINGS_FILE="/home/home-server/docker/music-assistant/data/settings.json"
|
|
||||||
BACKUP_FILE="/home/home-server/docker/music-assistant/data/settings.json.pre-cleanup-backup"
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
echo -e "${YELLOW}=== Music Assistant Ghost Player Cleanup ===${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if settings file exists
|
|
||||||
if [ ! -f "$SETTINGS_FILE" ]; then
|
|
||||||
echo -e "${RED}Error: Settings file not found at $SETTINGS_FILE${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if jq is installed
|
|
||||||
if ! command -v jq &> /dev/null; then
|
|
||||||
echo -e "${RED}Error: jq is not installed. Install with: sudo apt install jq${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Count ghost players before cleanup
|
|
||||||
GHOST_COUNT=$(cat "$SETTINGS_FILE" | jq '[.players | to_entries[] | select(.value.default_name == "Music Assistant Mobile")] | length')
|
|
||||||
|
|
||||||
echo -e "Found ${RED}$GHOST_COUNT${NC} ghost \"Music Assistant Mobile\" players"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ "$GHOST_COUNT" -eq 0 ]; then
|
|
||||||
echo -e "${GREEN}No ghost players to remove!${NC}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# List the ghost players
|
|
||||||
echo "Ghost players to be removed:"
|
|
||||||
cat "$SETTINGS_FILE" | jq -r '.players | to_entries[] | select(.value.default_name == "Music Assistant Mobile") | " - " + .key'
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Ask for confirmation
|
|
||||||
read -p "Do you want to remove these players? (yes/no): " CONFIRM
|
|
||||||
|
|
||||||
if [ "$CONFIRM" != "yes" ]; then
|
|
||||||
echo -e "${YELLOW}Cleanup cancelled.${NC}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create backup
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Creating backup...${NC}"
|
|
||||||
cp "$SETTINGS_FILE" "$BACKUP_FILE"
|
|
||||||
echo -e "${GREEN}Backup created at: $BACKUP_FILE${NC}"
|
|
||||||
|
|
||||||
# Remove ghost players
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Removing ghost players...${NC}"
|
|
||||||
|
|
||||||
# Get list of ghost player IDs
|
|
||||||
GHOST_IDS=$(cat "$SETTINGS_FILE" | jq -r '.players | to_entries[] | select(.value.default_name == "Music Assistant Mobile") | .key')
|
|
||||||
|
|
||||||
# Remove each ghost player from settings
|
|
||||||
for player_id in $GHOST_IDS; do
|
|
||||||
echo " Removing: $player_id"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Use jq to filter out ghost players
|
|
||||||
cat "$SETTINGS_FILE" | jq '.players |= with_entries(select(.value.default_name != "Music Assistant Mobile"))' > "${SETTINGS_FILE}.tmp"
|
|
||||||
mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
|
|
||||||
|
|
||||||
# Verify cleanup
|
|
||||||
REMAINING=$(cat "$SETTINGS_FILE" | jq '[.players | to_entries[] | select(.value.default_name == "Music Assistant Mobile")] | length')
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
if [ "$REMAINING" -eq 0 ]; then
|
|
||||||
echo -e "${GREEN}✓ Successfully removed all $GHOST_COUNT ghost players!${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${RED}Warning: $REMAINING ghost players still remain${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Next steps:${NC}"
|
|
||||||
echo "1. Restart Music Assistant: docker restart music_assistant"
|
|
||||||
echo "2. Or: cd /home/home-server/docker/music-assistant && docker compose restart"
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}To restore backup if needed:${NC}"
|
|
||||||
echo "cp $BACKUP_FILE $SETTINGS_FILE"
|
|
||||||
echo "Then restart Music Assistant"
|
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
/// Hero tag prefixes for shared element transitions
|
/// Hero animation tag constants for consistent cross-screen animations
|
||||||
class HeroTags {
|
class HeroTags {
|
||||||
/// Hero tag for album cover images
|
HeroTags._();
|
||||||
|
|
||||||
|
/// Album cover art hero tag prefix
|
||||||
static const String albumCover = 'album_cover_';
|
static const String albumCover = 'album_cover_';
|
||||||
|
|
||||||
/// Hero tag for album title text
|
/// Album title hero tag prefix
|
||||||
static const String albumTitle = 'album_title_';
|
static const String albumTitle = 'album_title_';
|
||||||
|
|
||||||
/// Hero tag for artist name text
|
/// Artist name hero tag prefix
|
||||||
static const String artistName = 'artist_name_';
|
static const String artistName = 'artist_name_';
|
||||||
|
|
||||||
/// Hero tag for artist image
|
/// Artist image hero tag prefix
|
||||||
static const String artistImage = 'artist_image_';
|
static const String artistImage = 'artist_image_';
|
||||||
|
|
||||||
/// Hero tags for now playing screen
|
|
||||||
static const String nowPlayingArt = 'now_playing_art';
|
|
||||||
static const String nowPlayingBackground = 'now_playing_background';
|
|
||||||
static const String nowPlayingPreviousButton = 'now_playing_previous_button';
|
|
||||||
static const String nowPlayingPlayButton = 'now_playing_play_button';
|
|
||||||
static const String nowPlayingNextButton = 'now_playing_next_button';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/// Network constants for ports and URLs
|
||||||
|
class NetworkConstants {
|
||||||
|
NetworkConstants._();
|
||||||
|
|
||||||
|
/// Default Music Assistant WebSocket port (used for non-proxied connections)
|
||||||
|
static const int defaultWsPort = 8095;
|
||||||
|
|
||||||
|
/// Default HTTPS port (implicit, not usually specified in URLs)
|
||||||
|
static const int defaultHttpsPort = 443;
|
||||||
|
|
||||||
|
/// Default HTTP port
|
||||||
|
static const int defaultHttpPort = 80;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/// Timing constants for polling intervals, cache durations, and delays
|
||||||
|
class Timings {
|
||||||
|
Timings._();
|
||||||
|
|
||||||
|
/// WebSocket heartbeat/ping interval to keep connection alive
|
||||||
|
static const Duration heartbeatInterval = Duration(seconds: 30);
|
||||||
|
|
||||||
|
/// Player state polling interval (for selected player updates)
|
||||||
|
static const Duration playerPollingInterval = Duration(seconds: 5);
|
||||||
|
|
||||||
|
/// Local player state report interval (for seek bar smoothness)
|
||||||
|
static const Duration localPlayerReportInterval = Duration(seconds: 1);
|
||||||
|
|
||||||
|
/// Player list cache duration before refresh
|
||||||
|
static const Duration playersCacheDuration = Duration(minutes: 5);
|
||||||
|
|
||||||
|
/// Home screen row cache duration (recently played, discover sections)
|
||||||
|
static const Duration homeRowCacheDuration = Duration(minutes: 10);
|
||||||
|
|
||||||
|
/// WebSocket reconnection delay
|
||||||
|
static const Duration reconnectDelay = Duration(seconds: 3);
|
||||||
|
|
||||||
|
/// Command timeout for API requests
|
||||||
|
static const Duration commandTimeout = Duration(seconds: 30);
|
||||||
|
|
||||||
|
/// Connection timeout for initial WebSocket handshake
|
||||||
|
static const Duration connectionTimeout = Duration(seconds: 10);
|
||||||
|
|
||||||
|
/// Search debounce delay
|
||||||
|
static const Duration searchDebounce = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
/// Delay after track change before updating state
|
||||||
|
static const Duration trackChangeDelay = Duration(milliseconds: 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Library and pagination constants
|
||||||
|
class LibraryConstants {
|
||||||
|
LibraryConstants._();
|
||||||
|
|
||||||
|
/// Default page size for library items
|
||||||
|
static const int defaultPageSize = 100;
|
||||||
|
|
||||||
|
/// Maximum items to load for "all" library requests
|
||||||
|
static const int maxLibraryItems = 5000;
|
||||||
|
|
||||||
|
/// Number of items per batch for recent albums
|
||||||
|
static const int recentAlbumsBatchSize = 5;
|
||||||
|
|
||||||
|
/// Default limit for recent/random items
|
||||||
|
static const int defaultRecentLimit = 10;
|
||||||
|
}
|
||||||
@@ -1,17 +1,49 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'providers/music_assistant_provider.dart';
|
import 'providers/music_assistant_provider.dart';
|
||||||
|
import 'providers/navigation_provider.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/login_screen.dart';
|
import 'screens/login_screen.dart';
|
||||||
import 'services/settings_service.dart';
|
import 'services/settings_service.dart';
|
||||||
|
import 'services/audio/massiv_audio_handler.dart';
|
||||||
|
import 'services/auth/auth_manager.dart';
|
||||||
|
import 'services/debug_logger.dart';
|
||||||
|
import 'services/hardware_volume_service.dart';
|
||||||
|
import 'services/music_assistant_api.dart' show MAConnectionState;
|
||||||
import 'theme/theme_provider.dart';
|
import 'theme/theme_provider.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'theme/system_theme_helper.dart';
|
import 'theme/system_theme_helper.dart';
|
||||||
|
import 'widgets/global_player_overlay.dart';
|
||||||
|
|
||||||
void main() {
|
// Global audio handler instance
|
||||||
|
late MassivAudioHandler audioHandler;
|
||||||
|
|
||||||
|
// Global debug logger instance
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Create auth manager for streaming headers
|
||||||
|
final authManager = AuthManager();
|
||||||
|
|
||||||
|
// Initialize audio_service with our custom handler
|
||||||
|
audioHandler = await AudioService.init(
|
||||||
|
builder: () => MassivAudioHandler(authManager: authManager),
|
||||||
|
config: const AudioServiceConfig(
|
||||||
|
androidNotificationChannelId: 'io.github.collotsspot.massiv.audio',
|
||||||
|
androidNotificationChannelName: 'Ensemble Audio',
|
||||||
|
androidNotificationOngoing: false, // Must be false when androidStopForegroundOnPause is false
|
||||||
|
androidNotificationIcon: 'drawable/ic_notification',
|
||||||
|
androidShowNotificationBadge: false,
|
||||||
|
androidStopForegroundOnPause: false, // Keep service alive when paused for background playback
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_logger.log('🎵 AudioService initialized - background playback and media notifications ENABLED');
|
||||||
|
|
||||||
// Set preferred orientations
|
// Set preferred orientations
|
||||||
SystemChrome.setPreferredOrientations([
|
SystemChrome.setPreferredOrientations([
|
||||||
DeviceOrientation.portraitUp,
|
DeviceOrientation.portraitUp,
|
||||||
@@ -41,6 +73,14 @@ class MusicAssistantApp extends StatefulWidget {
|
|||||||
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
|
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
|
||||||
late MusicAssistantProvider _musicProvider;
|
late MusicAssistantProvider _musicProvider;
|
||||||
late ThemeProvider _themeProvider;
|
late ThemeProvider _themeProvider;
|
||||||
|
final _hardwareVolumeService = HardwareVolumeService();
|
||||||
|
StreamSubscription? _volumeUpSub;
|
||||||
|
StreamSubscription? _volumeDownSub;
|
||||||
|
String? _lastSelectedPlayerId;
|
||||||
|
String? _builtinPlayerId;
|
||||||
|
|
||||||
|
// Volume step size (percentage points per button press)
|
||||||
|
static const int _volumeStep = 5;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -48,26 +88,92 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
_musicProvider = MusicAssistantProvider();
|
_musicProvider = MusicAssistantProvider();
|
||||||
_themeProvider = ThemeProvider();
|
_themeProvider = ThemeProvider();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_initHardwareVolumeControl();
|
||||||
|
// Listen to player selection changes to toggle volume interception
|
||||||
|
_musicProvider.addListener(_onProviderChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initHardwareVolumeControl() async {
|
||||||
|
try {
|
||||||
|
_builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
await _hardwareVolumeService.init();
|
||||||
|
|
||||||
|
_volumeUpSub = _hardwareVolumeService.onVolumeUp.listen((_) {
|
||||||
|
_adjustVolume(_volumeStep);
|
||||||
|
});
|
||||||
|
|
||||||
|
_volumeDownSub = _hardwareVolumeService.onVolumeDown.listen((_) {
|
||||||
|
_adjustVolume(-_volumeStep);
|
||||||
|
});
|
||||||
|
} catch (e, stack) {
|
||||||
|
_logger.error('Hardware volume init failed', context: 'VolumeInit', error: e, stackTrace: stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when provider state changes - check if selected player changed
|
||||||
|
void _onProviderChanged() {
|
||||||
|
final currentPlayerId = _musicProvider.selectedPlayer?.playerId;
|
||||||
|
if (currentPlayerId != _lastSelectedPlayerId) {
|
||||||
|
_lastSelectedPlayerId = currentPlayerId;
|
||||||
|
_updateVolumeInterception();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable/disable volume button interception based on selected player.
|
||||||
|
Future<void> _updateVolumeInterception() async {
|
||||||
|
final isBuiltinPlayer = _builtinPlayerId != null &&
|
||||||
|
_musicProvider.selectedPlayer?.playerId == _builtinPlayerId;
|
||||||
|
await _hardwareVolumeService.setIntercepting(!isBuiltinPlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _adjustVolume(int delta) async {
|
||||||
|
final player = _musicProvider.selectedPlayer;
|
||||||
|
if (player == null) return;
|
||||||
|
|
||||||
|
final newVolume = (player.volume + delta).clamp(0, 100);
|
||||||
|
if (newVolume != player.volume) {
|
||||||
|
try {
|
||||||
|
await _musicProvider.setVolume(player.playerId, newVolume);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.error('Hardware volume adjustment failed', context: 'VolumeControl', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_musicProvider.removeListener(_onProviderChanged);
|
||||||
|
_volumeUpSub?.cancel();
|
||||||
|
_volumeDownSub?.cancel();
|
||||||
|
_hardwareVolumeService.dispose();
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> didPopRoute() async {
|
||||||
|
// Intercept back button at app level - runs BEFORE Navigator processes it
|
||||||
|
// If player is expanded, collapse it and consume the back gesture
|
||||||
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
|
return true; // We handled it, don't let Navigator process it
|
||||||
|
}
|
||||||
|
// Let Navigator handle the back gesture normally
|
||||||
|
return super.didPopRoute();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
super.didChangeAppLifecycleState(state);
|
super.didChangeAppLifecycleState(state);
|
||||||
|
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
// App came back to foreground - check connection and reconnect if needed
|
// App came back to foreground - check connection and reconnect if needed
|
||||||
print('📱 App resumed - checking WebSocket connection...');
|
_logger.log('📱 App resumed - checking WebSocket connection...');
|
||||||
_musicProvider.checkAndReconnect();
|
_musicProvider.checkAndReconnect();
|
||||||
} else if (state == AppLifecycleState.paused) {
|
} else if (state == AppLifecycleState.paused) {
|
||||||
print('📱 App paused (backgrounded)');
|
_logger.log('📱 App paused (backgrounded)');
|
||||||
} else if (state == AppLifecycleState.detached) {
|
} else if (state == AppLifecycleState.detached) {
|
||||||
print('📱 App detached (being destroyed)');
|
_logger.log('📱 App detached (being destroyed)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,29 +206,23 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
darkColorScheme = brandDarkColorScheme;
|
darkColorScheme = brandDarkColorScheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update system UI overlay style based on theme mode
|
return SystemUIWrapper(
|
||||||
final isDark = themeProvider.themeMode == ThemeMode.dark ||
|
|
||||||
(themeProvider.themeMode == ThemeMode.system &&
|
|
||||||
MediaQuery.platformBrightnessOf(context) == Brightness.dark);
|
|
||||||
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
|
||||||
SystemUiOverlayStyle(
|
|
||||||
statusBarColor: Colors.transparent,
|
|
||||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
|
||||||
systemNavigationBarColor: isDark
|
|
||||||
? darkColorScheme.background
|
|
||||||
: lightColorScheme.background,
|
|
||||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return MaterialApp(
|
|
||||||
title: 'ATTM', // Updated title
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
themeMode: themeProvider.themeMode,
|
themeMode: themeProvider.themeMode,
|
||||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
lightColorScheme: lightColorScheme,
|
||||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
darkColorScheme: darkColorScheme,
|
||||||
home: const AppStartup(),
|
child: MaterialApp(
|
||||||
|
navigatorKey: navigationProvider.navigatorKey,
|
||||||
|
title: 'Ensemble',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
themeMode: themeProvider.themeMode,
|
||||||
|
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||||
|
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||||
|
builder: (context, child) {
|
||||||
|
// Wrap entire app with global player overlay
|
||||||
|
return GlobalPlayerOverlay(child: child ?? const SizedBox.shrink());
|
||||||
|
},
|
||||||
|
home: const AppStartup(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -132,30 +232,185 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Startup widget that checks if user is logged in and shows appropriate screen
|
/// Widget that manages system UI overlay style updates efficiently
|
||||||
class AppStartup extends StatelessWidget {
|
/// Only updates when theme properties actually change
|
||||||
const AppStartup({super.key});
|
class SystemUIWrapper extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final ThemeMode themeMode;
|
||||||
|
final ColorScheme lightColorScheme;
|
||||||
|
final ColorScheme darkColorScheme;
|
||||||
|
|
||||||
|
const SystemUIWrapper({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
required this.themeMode,
|
||||||
|
required this.lightColorScheme,
|
||||||
|
required this.darkColorScheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SystemUIWrapper> createState() => _SystemUIWrapperState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SystemUIWrapperState extends State<SystemUIWrapper> {
|
||||||
|
ThemeMode? _previousThemeMode;
|
||||||
|
Brightness? _previousPlatformBrightness;
|
||||||
|
ColorScheme? _previousLightColorScheme;
|
||||||
|
ColorScheme? _previousDarkColorScheme;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_updateSystemUIIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SystemUIWrapper oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
_updateSystemUIIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateSystemUIIfNeeded() {
|
||||||
|
final platformBrightness = MediaQuery.platformBrightnessOf(context);
|
||||||
|
|
||||||
|
// Check if any relevant properties have changed
|
||||||
|
final themeChanged = _previousThemeMode != widget.themeMode;
|
||||||
|
final platformBrightnessChanged = _previousPlatformBrightness != platformBrightness;
|
||||||
|
final lightColorSchemeChanged = _previousLightColorScheme != widget.lightColorScheme;
|
||||||
|
final darkColorSchemeChanged = _previousDarkColorScheme != widget.darkColorScheme;
|
||||||
|
|
||||||
|
if (themeChanged || platformBrightnessChanged || lightColorSchemeChanged || darkColorSchemeChanged) {
|
||||||
|
// Determine if we should use dark theme
|
||||||
|
final isDark = widget.themeMode == ThemeMode.dark ||
|
||||||
|
(widget.themeMode == ThemeMode.system && platformBrightness == Brightness.dark);
|
||||||
|
|
||||||
|
// Update system UI overlay style
|
||||||
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
|
SystemUiOverlayStyle(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||||
|
systemNavigationBarColor: isDark
|
||||||
|
? widget.darkColorScheme.background
|
||||||
|
: widget.lightColorScheme.background,
|
||||||
|
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update cached values
|
||||||
|
_previousThemeMode = widget.themeMode;
|
||||||
|
_previousPlatformBrightness = platformBrightness;
|
||||||
|
_previousLightColorScheme = widget.lightColorScheme;
|
||||||
|
_previousDarkColorScheme = widget.darkColorScheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FutureBuilder<String?>(
|
return widget.child;
|
||||||
future: SettingsService.getServerUrl(),
|
}
|
||||||
builder: (context, snapshot) {
|
}
|
||||||
// Show loading while checking settings
|
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
/// Startup widget that checks if user is logged in and auto-connects
|
||||||
return const Scaffold(
|
class AppStartup extends StatefulWidget {
|
||||||
backgroundColor: Color(0xFF1a1a1a),
|
const AppStartup({super.key});
|
||||||
body: Center(
|
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
@override
|
||||||
),
|
State<AppStartup> createState() => _AppStartupState();
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
class _AppStartupState extends State<AppStartup> {
|
||||||
// If server URL is saved, go to home screen
|
bool _isConnecting = false;
|
||||||
// Otherwise, show login screen
|
String? _savedServerUrl;
|
||||||
final hasServerUrl = snapshot.data != null && snapshot.data!.isNotEmpty;
|
bool _connectionAttempted = false;
|
||||||
return hasServerUrl ? const HomeScreen() : const LoginScreen();
|
|
||||||
},
|
@override
|
||||||
);
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkAndConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkAndConnect() async {
|
||||||
|
final serverUrl = await SettingsService.getServerUrl();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_savedServerUrl = serverUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we have a saved server URL, attempt auto-connection
|
||||||
|
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_isConnecting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Connection is handled by MusicAssistantProvider._initialize()
|
||||||
|
// Just wait for it to complete or timeout
|
||||||
|
_logger.log('🚀 AppStartup: Waiting for provider auto-connection to $serverUrl');
|
||||||
|
|
||||||
|
// Give the provider time to connect (it restores credentials and connects in _initialize)
|
||||||
|
// Check connection state periodically
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 250));
|
||||||
|
if (provider.isConnected) {
|
||||||
|
_logger.log('🚀 AppStartup: Connection established');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (provider.connectionState == MAConnectionState.error) {
|
||||||
|
_logger.log('🚀 AppStartup: Connection failed with error');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider.isConnected) {
|
||||||
|
_logger.log('🚀 AppStartup: Connection still pending or failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isConnecting = false;
|
||||||
|
_connectionAttempted = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_connectionAttempted = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Show loading while checking settings or connecting
|
||||||
|
if (!_connectionAttempted || _isConnecting) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF1a1a1a),
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(color: Colors.white),
|
||||||
|
if (_isConnecting) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Connecting...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.7),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server URL is saved, go to home screen
|
||||||
|
// Otherwise, show login screen
|
||||||
|
final hasServerUrl = _savedServerUrl != null && _savedServerUrl!.isNotEmpty;
|
||||||
|
return hasServerUrl ? const HomeScreen() : const LoginScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'media_item.dart';
|
|||||||
class Player {
|
class Player {
|
||||||
final String playerId;
|
final String playerId;
|
||||||
final String name;
|
final String name;
|
||||||
|
final String? provider; // e.g., 'builtin_player', 'chromecast', etc.
|
||||||
final bool available;
|
final bool available;
|
||||||
final bool powered;
|
final bool powered;
|
||||||
final String state; // 'idle', 'playing', 'paused'
|
final String state; // 'idle', 'playing', 'paused'
|
||||||
@@ -15,6 +16,7 @@ class Player {
|
|||||||
Player({
|
Player({
|
||||||
required this.playerId,
|
required this.playerId,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
this.provider,
|
||||||
required this.available,
|
required this.available,
|
||||||
required this.powered,
|
required this.powered,
|
||||||
required this.state,
|
required this.state,
|
||||||
@@ -30,42 +32,65 @@ class Player {
|
|||||||
bool get isMuted => volumeMuted ?? false;
|
bool get isMuted => volumeMuted ?? false;
|
||||||
int get volume => volumeLevel ?? 0;
|
int get volume => volumeLevel ?? 0;
|
||||||
|
|
||||||
|
// Track when this Player object was created (for local interpolation fallback)
|
||||||
|
static final Map<String, double> _playerCreationTimes = {};
|
||||||
|
|
||||||
// Calculate current elapsed time (interpolated if playing)
|
// Calculate current elapsed time (interpolated if playing)
|
||||||
double get currentElapsedTime {
|
double get currentElapsedTime {
|
||||||
if (elapsedTime == null) {
|
if (elapsedTime == null) {
|
||||||
print('⚠️ elapsedTime is null, returning 0');
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPlaying || elapsedTimeLastUpdated == null) {
|
if (!isPlaying) {
|
||||||
print('🕐 Not playing or no last updated time, returning base: $elapsedTime');
|
|
||||||
return elapsedTime!;
|
return elapsedTime!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If playing, interpolate based on time since last update
|
|
||||||
final now = DateTime.now().millisecondsSinceEpoch / 1000.0;
|
final now = DateTime.now().millisecondsSinceEpoch / 1000.0;
|
||||||
final timeSinceUpdate = now - elapsedTimeLastUpdated!;
|
|
||||||
|
|
||||||
print('🕐 Time calculation:');
|
// Try server-provided timestamp first, but only if it's recent
|
||||||
print(' Base elapsed: $elapsedTime seconds');
|
if (elapsedTimeLastUpdated != null) {
|
||||||
print(' Last updated: $elapsedTimeLastUpdated (Unix timestamp)');
|
final timeSinceUpdate = now - elapsedTimeLastUpdated!;
|
||||||
print(' Now: $now (Unix timestamp)');
|
|
||||||
print(' Delta: $timeSinceUpdate seconds');
|
|
||||||
|
|
||||||
// Safety check: if time since update is negative or too large, just return elapsed time
|
// Only use server timestamp if it's within valid range (0-10 seconds)
|
||||||
if (timeSinceUpdate < 0) {
|
// - Negative means clock skew (client ahead of server)
|
||||||
print('⚠️ Negative time delta! Clock skew detected.');
|
// - > 10 seconds means stale data (e.g., after player switch)
|
||||||
return elapsedTime!;
|
// If outside this range, fall through to local fallback for smooth interpolation
|
||||||
|
if (timeSinceUpdate >= 0 && timeSinceUpdate <= 10.0) {
|
||||||
|
return elapsedTime! + timeSinceUpdate;
|
||||||
|
}
|
||||||
|
// Server timestamp is stale or invalid - fall through to local fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timeSinceUpdate > 30) {
|
// Fallback: use local creation time for interpolation
|
||||||
print('⚠️ Time delta too large (${timeSinceUpdate}s), using base elapsed time');
|
// This handles:
|
||||||
return elapsedTime!;
|
// 1. Server doesn't send elapsed_time_last_updated
|
||||||
|
// 2. Server timestamp is stale (e.g., after switching to a remote player)
|
||||||
|
// 3. Clock skew between client and server
|
||||||
|
//
|
||||||
|
// Key insight: when server sends a NEW elapsed_time, we get a NEW key,
|
||||||
|
// so interpolation naturally starts fresh. We should NEVER reset an existing
|
||||||
|
// key's creation time, as that causes progress to jump backward.
|
||||||
|
final creationKey = '$playerId:$elapsedTime';
|
||||||
|
|
||||||
|
if (!_playerCreationTimes.containsKey(creationKey)) {
|
||||||
|
// First time seeing this elapsed_time - record when we saw it
|
||||||
|
_playerCreationTimes[creationKey] = now;
|
||||||
|
// Clean up old entries to prevent memory leak
|
||||||
|
if (_playerCreationTimes.length > 100) {
|
||||||
|
final keysToRemove = _playerCreationTimes.keys.take(50).toList();
|
||||||
|
for (final key in keysToRemove) {
|
||||||
|
_playerCreationTimes.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final calculatedTime = elapsedTime! + timeSinceUpdate;
|
final creationTime = _playerCreationTimes[creationKey]!;
|
||||||
print(' Result: $calculatedTime seconds');
|
final timeSinceCreation = now - creationTime;
|
||||||
return calculatedTime;
|
|
||||||
|
// Server sends updates every ~5 seconds with new elapsed_time values,
|
||||||
|
// which creates new keys and corrects any drift. Allow unlimited
|
||||||
|
// interpolation since new server data will naturally correct it.
|
||||||
|
return elapsedTime! + timeSinceCreation;
|
||||||
}
|
}
|
||||||
|
|
||||||
factory Player.fromJson(Map<String, dynamic> json) {
|
factory Player.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -74,38 +99,49 @@ class Player {
|
|||||||
double? elapsedTime;
|
double? elapsedTime;
|
||||||
double? elapsedTimeLastUpdated;
|
double? elapsedTimeLastUpdated;
|
||||||
|
|
||||||
// IMPORTANT: Check top-level fields FIRST - they have the most current data
|
// Get top-level elapsed time values
|
||||||
elapsedTime = (json['elapsed_time'] as num?)?.toDouble();
|
// Try multiple field names as different player types may report position differently
|
||||||
elapsedTimeLastUpdated = (json['elapsed_time_last_updated'] as num?)?.toDouble();
|
final topLevelElapsedTime = (json['elapsed_time'] as num?)?.toDouble()
|
||||||
|
?? (json['position'] as num?)?.toDouble()
|
||||||
|
?? (json['media_position'] as num?)?.toDouble()
|
||||||
|
?? (json['current_position'] as num?)?.toDouble();
|
||||||
|
final topLevelLastUpdated = (json['elapsed_time_last_updated'] as num?)?.toDouble()
|
||||||
|
?? (json['position_updated_at'] as num?)?.toDouble();
|
||||||
|
|
||||||
// Extract current_item_id from current_media
|
// Extract current_item_id and elapsed time from current_media
|
||||||
|
// current_media often has the more accurate position after seeks
|
||||||
if (json.containsKey('current_media')) {
|
if (json.containsKey('current_media')) {
|
||||||
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
final currentMedia = json['current_media'] as Map<String, dynamic>?;
|
||||||
if (currentMedia != null) {
|
if (currentMedia != null) {
|
||||||
currentItemId ??= currentMedia['queue_item_id'] as String?;
|
currentItemId ??= currentMedia['queue_item_id'] as String?;
|
||||||
|
|
||||||
final currentMediaElapsedTime = (currentMedia['elapsed_time'] as num?)?.toDouble();
|
// Try multiple field names for position
|
||||||
final currentMediaLastUpdated = (currentMedia['elapsed_time_last_updated'] as num?)?.toDouble();
|
final currentMediaElapsedTime = (currentMedia['elapsed_time'] as num?)?.toDouble()
|
||||||
|
?? (currentMedia['position'] as num?)?.toDouble()
|
||||||
|
?? (currentMedia['media_position'] as num?)?.toDouble();
|
||||||
|
final currentMediaLastUpdated = (currentMedia['elapsed_time_last_updated'] as num?)?.toDouble()
|
||||||
|
?? (currentMedia['position_updated_at'] as num?)?.toDouble();
|
||||||
|
|
||||||
// Use current_media elapsed_time if:
|
// Prefer current_media elapsed_time when available - it reflects actual playback position
|
||||||
// 1. We don't have top-level elapsed_time, OR
|
// after seek operations, while top-level elapsed_time may lag behind
|
||||||
// 2. Top-level is 0 but current_media has a real value
|
if (currentMediaElapsedTime != null) {
|
||||||
if (elapsedTime == null || (elapsedTime == 0 && currentMediaElapsedTime != null && currentMediaElapsedTime > 0)) {
|
|
||||||
print('⚠️ Player ${json['name']}: Using current_media elapsed_time: $currentMediaElapsedTime (top-level was: $elapsedTime)');
|
|
||||||
elapsedTime = currentMediaElapsedTime;
|
elapsedTime = currentMediaElapsedTime;
|
||||||
elapsedTimeLastUpdated = currentMediaLastUpdated;
|
elapsedTimeLastUpdated = currentMediaLastUpdated ?? topLevelLastUpdated;
|
||||||
} else if (elapsedTime != null) {
|
|
||||||
print('✅ Player ${json['name']}: Using top-level elapsed_time: $elapsedTime seconds (updated: $elapsedTimeLastUpdated)');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to top-level values if current_media didn't have elapsed time
|
||||||
|
elapsedTime ??= topLevelElapsedTime;
|
||||||
|
elapsedTimeLastUpdated ??= topLevelLastUpdated;
|
||||||
|
|
||||||
return Player(
|
return Player(
|
||||||
playerId: json['player_id'] as String,
|
playerId: json['player_id'] as String,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
|
provider: json['provider'] as String?,
|
||||||
available: json['available'] as bool? ?? false,
|
available: json['available'] as bool? ?? false,
|
||||||
powered: json['powered'] as bool? ?? false,
|
powered: json['powered'] as bool? ?? false,
|
||||||
state: json['state'] as String? ?? 'idle',
|
state: json['playback_state'] as String? ?? json['state'] as String? ?? 'idle',
|
||||||
currentItemId: currentItemId,
|
currentItemId: currentItemId,
|
||||||
volumeLevel: json['volume_level'] as int?,
|
volumeLevel: json['volume_level'] as int?,
|
||||||
volumeMuted: json['volume_muted'] as bool?,
|
volumeMuted: json['volume_muted'] as bool?,
|
||||||
@@ -118,6 +154,7 @@ class Player {
|
|||||||
return {
|
return {
|
||||||
'player_id': playerId,
|
'player_id': playerId,
|
||||||
'name': name,
|
'name': name,
|
||||||
|
if (provider != null) 'provider': provider,
|
||||||
'available': available,
|
'available': available,
|
||||||
'powered': powered,
|
'powered': powered,
|
||||||
'state': state,
|
'state': state,
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../services/music_assistant_api.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/error_handler.dart';
|
||||||
|
import '../services/auth/auth_manager.dart';
|
||||||
|
import '../services/cache_service.dart';
|
||||||
|
|
||||||
|
/// Provider for managing connection state to Music Assistant server
|
||||||
|
/// Handles WebSocket connection, authentication, and reconnection
|
||||||
|
class ConnectionProvider with ChangeNotifier {
|
||||||
|
MusicAssistantAPI? _api;
|
||||||
|
final AuthManager _authManager = AuthManager();
|
||||||
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
final CacheService _cacheService = CacheService();
|
||||||
|
|
||||||
|
MAConnectionState _connectionState = MAConnectionState.disconnected;
|
||||||
|
String? _serverUrl;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
// Callbacks for post-connection initialization
|
||||||
|
VoidCallback? onConnected;
|
||||||
|
VoidCallback? onAuthenticated;
|
||||||
|
VoidCallback? onDisconnected;
|
||||||
|
|
||||||
|
MAConnectionState get connectionState => _connectionState;
|
||||||
|
String? get serverUrl => _serverUrl;
|
||||||
|
String? get error => _error;
|
||||||
|
bool get isConnected => _connectionState == MAConnectionState.connected ||
|
||||||
|
_connectionState == MAConnectionState.authenticated;
|
||||||
|
|
||||||
|
MusicAssistantAPI? get api => _api;
|
||||||
|
AuthManager get authManager => _authManager;
|
||||||
|
CacheService get cacheService => _cacheService;
|
||||||
|
|
||||||
|
ConnectionProvider() {
|
||||||
|
_initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initialize() async {
|
||||||
|
_serverUrl = await SettingsService.getServerUrl();
|
||||||
|
if (_serverUrl != null && _serverUrl!.isNotEmpty) {
|
||||||
|
await _restoreAuthCredentials();
|
||||||
|
await connectToServer(_serverUrl!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore auth credentials from persistent storage
|
||||||
|
Future<void> _restoreAuthCredentials() async {
|
||||||
|
final savedCredentials = await SettingsService.getAuthCredentials();
|
||||||
|
if (savedCredentials != null) {
|
||||||
|
_logger.log('🔐 Restoring saved auth credentials...');
|
||||||
|
_authManager.deserializeCredentials(savedCredentials);
|
||||||
|
_logger.log('🔐 Auth credentials restored: ${_authManager.currentStrategy?.name ?? "none"}');
|
||||||
|
} else {
|
||||||
|
_logger.log('🔐 No saved auth credentials found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle Music Assistant native authentication after WebSocket connection
|
||||||
|
Future<bool> handleMaAuthentication() async {
|
||||||
|
if (_api == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, try stored MA token
|
||||||
|
final storedToken = await SettingsService.getMaAuthToken();
|
||||||
|
if (storedToken != null) {
|
||||||
|
_logger.log('🔐 Trying stored MA token...');
|
||||||
|
final success = await _api!.authenticateWithToken(storedToken);
|
||||||
|
if (success) {
|
||||||
|
_logger.log('✅ MA authentication with stored token successful');
|
||||||
|
await _fetchAndSetUserProfileName();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_logger.log('⚠️ Stored MA token invalid, clearing...');
|
||||||
|
await SettingsService.clearMaAuthToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid token - try stored credentials
|
||||||
|
final username = await SettingsService.getUsername();
|
||||||
|
final password = await SettingsService.getPassword();
|
||||||
|
|
||||||
|
if (username != null && password != null && username.isNotEmpty && password.isNotEmpty) {
|
||||||
|
_logger.log('🔐 Trying stored credentials...');
|
||||||
|
final accessToken = await _api!.loginWithCredentials(username, password);
|
||||||
|
|
||||||
|
if (accessToken != null) {
|
||||||
|
_logger.log('✅ MA login with stored credentials successful');
|
||||||
|
|
||||||
|
final longLivedToken = await _api!.createLongLivedToken();
|
||||||
|
if (longLivedToken != null) {
|
||||||
|
await SettingsService.setMaAuthToken(longLivedToken);
|
||||||
|
_logger.log('✅ Saved new long-lived MA token');
|
||||||
|
} else {
|
||||||
|
await SettingsService.setMaAuthToken(accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _fetchAndSetUserProfileName();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('❌ MA authentication failed - no valid token or credentials');
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ MA authentication error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch user profile from MA and set owner name
|
||||||
|
Future<void> _fetchAndSetUserProfileName() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final userInfo = await _api!.getCurrentUserInfo();
|
||||||
|
if (userInfo == null) {
|
||||||
|
_logger.log('⚠️ Could not fetch user profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final displayName = userInfo['display_name'] as String?;
|
||||||
|
final username = userInfo['username'] as String?;
|
||||||
|
|
||||||
|
_logger.log('🔍 Profile data: display_name="$displayName", username="$username"');
|
||||||
|
|
||||||
|
final profileName = (displayName != null && displayName.isNotEmpty)
|
||||||
|
? displayName
|
||||||
|
: username;
|
||||||
|
|
||||||
|
if (profileName != null && profileName.isNotEmpty) {
|
||||||
|
await SettingsService.setOwnerName(profileName);
|
||||||
|
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||||
|
} else {
|
||||||
|
_logger.log('⚠️ No valid name in profile');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Could not fetch user profile (non-fatal): $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connectToServer(String serverUrl) async {
|
||||||
|
try {
|
||||||
|
_error = null;
|
||||||
|
_serverUrl = serverUrl;
|
||||||
|
await SettingsService.setServerUrl(serverUrl);
|
||||||
|
|
||||||
|
// Disconnect existing connection
|
||||||
|
await _api?.disconnect();
|
||||||
|
|
||||||
|
_api = MusicAssistantAPI(serverUrl, _authManager);
|
||||||
|
|
||||||
|
// Listen to connection state changes
|
||||||
|
_api!.connectionState.listen(
|
||||||
|
(state) async {
|
||||||
|
_connectionState = state;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
if (state == MAConnectionState.connected) {
|
||||||
|
_logger.log('🔗 WebSocket connected to MA server');
|
||||||
|
|
||||||
|
if (_api!.authRequired && !_api!.isAuthenticated) {
|
||||||
|
_logger.log('🔐 MA auth required, attempting authentication...');
|
||||||
|
final authenticated = await handleMaAuthentication();
|
||||||
|
if (!authenticated) {
|
||||||
|
_logger.log('❌ MA authentication failed - stopping connection flow');
|
||||||
|
_error = 'Authentication required. Please log in again.';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No auth required - notify connected
|
||||||
|
onConnected?.call();
|
||||||
|
} else if (state == MAConnectionState.authenticated) {
|
||||||
|
_logger.log('✅ MA authentication successful');
|
||||||
|
onAuthenticated?.call();
|
||||||
|
} else if (state == MAConnectionState.disconnected) {
|
||||||
|
_cacheService.clearAll();
|
||||||
|
onDisconnected?.call();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
_logger.log('Connection state stream error: $error');
|
||||||
|
_connectionState = MAConnectionState.error;
|
||||||
|
notifyListeners();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await _api!.connect();
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
final errorInfo = ErrorHandler.handleError(e, context: 'Connect to server');
|
||||||
|
_error = errorInfo.userMessage;
|
||||||
|
_connectionState = MAConnectionState.error;
|
||||||
|
_logger.log('Connection error: ${errorInfo.technicalMessage}');
|
||||||
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
await _api?.disconnect();
|
||||||
|
_connectionState = MAConnectionState.disconnected;
|
||||||
|
_cacheService.clearAll();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check connection and reconnect if needed (called when app resumes)
|
||||||
|
Future<void> checkAndReconnect() async {
|
||||||
|
_logger.log('🔄 checkAndReconnect called - state: $_connectionState');
|
||||||
|
|
||||||
|
if (_serverUrl == null) {
|
||||||
|
_logger.log('🔄 No server URL saved, skipping reconnect');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_connectionState != MAConnectionState.connected &&
|
||||||
|
_connectionState != MAConnectionState.authenticated) {
|
||||||
|
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
|
||||||
|
try {
|
||||||
|
await connectToServer(_serverUrl!);
|
||||||
|
_logger.log('🔄 Reconnection successful');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('🔄 Reconnection failed: $e');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_logger.log('🔄 Already connected, connection verified');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_api?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../services/music_assistant_api.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/error_handler.dart';
|
||||||
|
import '../services/cache_service.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
|
|
||||||
|
/// Provider for managing library data (artists, albums, tracks, playlists)
|
||||||
|
class LibraryProvider with ChangeNotifier {
|
||||||
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
final CacheService _cacheService;
|
||||||
|
|
||||||
|
MusicAssistantAPI? _api;
|
||||||
|
|
||||||
|
List<Artist> _artists = [];
|
||||||
|
List<Album> _albums = [];
|
||||||
|
List<Track> _tracks = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
// Search state persistence
|
||||||
|
String _lastSearchQuery = '';
|
||||||
|
Map<String, List<MediaItem>> _lastSearchResults = {
|
||||||
|
'artists': [],
|
||||||
|
'albums': [],
|
||||||
|
'tracks': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
List<Artist> get artists => _artists;
|
||||||
|
List<Album> get albums => _albums;
|
||||||
|
List<Track> get tracks => _tracks;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get error => _error;
|
||||||
|
String get lastSearchQuery => _lastSearchQuery;
|
||||||
|
Map<String, List<MediaItem>> get lastSearchResults => _lastSearchResults;
|
||||||
|
|
||||||
|
LibraryProvider(this._cacheService);
|
||||||
|
|
||||||
|
/// Initialize with API reference after connection
|
||||||
|
void initialize(MusicAssistantAPI api) {
|
||||||
|
_api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveSearchState(String query, Map<String, List<MediaItem>> results) {
|
||||||
|
_lastSearchQuery = query;
|
||||||
|
_lastSearchResults = results;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSearchState() {
|
||||||
|
_lastSearchQuery = '';
|
||||||
|
_lastSearchResults = {
|
||||||
|
'artists': [],
|
||||||
|
'albums': [],
|
||||||
|
'tracks': [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HOME SCREEN DATA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Get recently played albums with caching
|
||||||
|
Future<List<Album>> getRecentAlbumsWithCache({bool forceRefresh = false}) async {
|
||||||
|
if (_cacheService.isRecentAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached recent albums');
|
||||||
|
return _cacheService.getCachedRecentAlbums()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedRecentAlbums() ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching fresh recent albums...');
|
||||||
|
final albums = await _api!.getRecentAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||||
|
_cacheService.setCachedRecentAlbums(albums);
|
||||||
|
return albums;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch recent albums: $e');
|
||||||
|
return _cacheService.getCachedRecentAlbums() ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get discover artists (random) with caching
|
||||||
|
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
|
||||||
|
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached discover artists');
|
||||||
|
return _cacheService.getCachedDiscoverArtists()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching fresh discover artists...');
|
||||||
|
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
|
||||||
|
_cacheService.setCachedDiscoverArtists(artists);
|
||||||
|
return artists;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch discover artists: $e');
|
||||||
|
return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get discover albums (random) with caching
|
||||||
|
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
|
||||||
|
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached discover albums');
|
||||||
|
return _cacheService.getCachedDiscoverAlbums()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching fresh discover albums...');
|
||||||
|
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||||
|
_cacheService.setCachedDiscoverAlbums(albums);
|
||||||
|
return albums;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch discover albums: $e');
|
||||||
|
return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||||
|
void invalidateHomeCache() {
|
||||||
|
_cacheService.invalidateHomeCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LIBRARY DATA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
Future<void> loadLibrary() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final results = await Future.wait([
|
||||||
|
_api!.getArtists(limit: LibraryConstants.maxLibraryItems),
|
||||||
|
_api!.getAlbums(limit: LibraryConstants.maxLibraryItems),
|
||||||
|
_api!.getTracks(limit: LibraryConstants.maxLibraryItems),
|
||||||
|
]);
|
||||||
|
|
||||||
|
_artists = results[0] as List<Artist>;
|
||||||
|
_albums = results[1] as List<Album>;
|
||||||
|
_tracks = results[2] as List<Track>;
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
final errorInfo = ErrorHandler.handleError(e, context: 'Load library');
|
||||||
|
_error = errorInfo.userMessage;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
_artists = await _api!.getArtists(
|
||||||
|
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||||
|
offset: offset,
|
||||||
|
search: search,
|
||||||
|
);
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
final errorInfo = ErrorHandler.handleError(e, context: 'Load artists');
|
||||||
|
_error = errorInfo.userMessage;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadAlbums({int? limit, int? offset, String? search, String? artistId}) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
_albums = await _api!.getAlbums(
|
||||||
|
limit: limit ?? LibraryConstants.maxLibraryItems,
|
||||||
|
offset: offset,
|
||||||
|
search: search,
|
||||||
|
artistId: artistId,
|
||||||
|
);
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
final errorInfo = ErrorHandler.handleError(e, context: 'Load albums');
|
||||||
|
_error = errorInfo.userMessage;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DETAIL SCREEN DATA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Get album tracks with caching
|
||||||
|
Future<List<Track>> getAlbumTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
||||||
|
final cacheKey = '${provider}_$itemId';
|
||||||
|
|
||||||
|
if (_cacheService.isAlbumTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached album tracks for $cacheKey');
|
||||||
|
return _cacheService.getCachedAlbumTracks(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching fresh album tracks for $cacheKey...');
|
||||||
|
final tracks = await _api!.getAlbumTracks(provider, itemId);
|
||||||
|
_cacheService.setCachedAlbumTracks(cacheKey, tracks);
|
||||||
|
return tracks;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch album tracks: $e');
|
||||||
|
return _cacheService.getCachedAlbumTracks(cacheKey) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get playlist tracks with caching
|
||||||
|
Future<List<Track>> getPlaylistTracksWithCache(String provider, String itemId, {bool forceRefresh = false}) async {
|
||||||
|
final cacheKey = '${provider}_$itemId';
|
||||||
|
|
||||||
|
if (_cacheService.isPlaylistTracksCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached playlist tracks for $cacheKey');
|
||||||
|
return _cacheService.getCachedPlaylistTracks(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching fresh playlist tracks for $cacheKey...');
|
||||||
|
final tracks = await _api!.getPlaylistTracks(provider, itemId);
|
||||||
|
_cacheService.setCachedPlaylistTracks(cacheKey, tracks);
|
||||||
|
return tracks;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch playlist tracks: $e');
|
||||||
|
return _cacheService.getCachedPlaylistTracks(cacheKey) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get artist albums with caching
|
||||||
|
Future<List<Album>> getArtistAlbumsWithCache(String artistName, {bool forceRefresh = false}) async {
|
||||||
|
final cacheKey = artistName.toLowerCase();
|
||||||
|
|
||||||
|
if (_cacheService.isArtistAlbumsCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached artist albums for "$artistName"');
|
||||||
|
return _cacheService.getCachedArtistAlbums(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Fetching albums for artist "$artistName"...');
|
||||||
|
|
||||||
|
final libraryAlbums = await _api!.getAlbums();
|
||||||
|
final artistAlbums = libraryAlbums.where((album) {
|
||||||
|
final albumArtists = album.artists;
|
||||||
|
if (albumArtists == null || albumArtists.isEmpty) return false;
|
||||||
|
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final searchResults = await _api!.search(artistName);
|
||||||
|
final searchAlbums = searchResults['albums'] as List<MediaItem>? ?? [];
|
||||||
|
final providerAlbums = searchAlbums.whereType<Album>().where((album) {
|
||||||
|
final albumArtists = album.artists;
|
||||||
|
if (albumArtists == null || albumArtists.isEmpty) return false;
|
||||||
|
return albumArtists.any((a) => a.name.toLowerCase() == artistName.toLowerCase());
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final allAlbums = <Album>[];
|
||||||
|
final seenNames = <String>{};
|
||||||
|
|
||||||
|
for (final album in [...artistAlbums, ...providerAlbums]) {
|
||||||
|
final key = album.name.toLowerCase();
|
||||||
|
if (!seenNames.contains(key)) {
|
||||||
|
seenNames.add(key);
|
||||||
|
allAlbums.add(album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cacheService.setCachedArtistAlbums(cacheKey, allAlbums);
|
||||||
|
return allAlbums;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to fetch artist albums: $e');
|
||||||
|
return _cacheService.getCachedArtistAlbums(cacheKey) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate album tracks cache
|
||||||
|
void invalidateAlbumTracksCache(String albumId) {
|
||||||
|
_cacheService.invalidateAlbumTracksCache(albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate playlist tracks cache
|
||||||
|
void invalidatePlaylistTracksCache(String playlistId) {
|
||||||
|
_cacheService.invalidatePlaylistTracksCache(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SEARCH
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Search with caching
|
||||||
|
Future<Map<String, List<MediaItem>>> searchWithCache(String query, {bool forceRefresh = false}) async {
|
||||||
|
final cacheKey = query.toLowerCase().trim();
|
||||||
|
if (cacheKey.isEmpty) return {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
|
||||||
|
if (_cacheService.isSearchCacheValid(cacheKey, forceRefresh: forceRefresh)) {
|
||||||
|
_logger.log('📦 Using cached search results for "$query"');
|
||||||
|
return _cacheService.getCachedSearchResults(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_api == null) {
|
||||||
|
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Searching for "$query"...');
|
||||||
|
final results = await _api!.search(query);
|
||||||
|
|
||||||
|
final cachedResults = <String, List<MediaItem>>{
|
||||||
|
'artists': results['artists'] ?? [],
|
||||||
|
'albums': results['albums'] ?? [],
|
||||||
|
'tracks': results['tracks'] ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
_cacheService.setCachedSearchResults(cacheKey, cachedResults);
|
||||||
|
return cachedResults;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Search failed: $e');
|
||||||
|
return _cacheService.getCachedSearchResults(cacheKey) ?? {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<MediaItem>>> search(String query, {bool libraryOnly = false}) async {
|
||||||
|
if (_api == null) return {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await _api!.search(query, libraryOnly: libraryOnly);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Search', e);
|
||||||
|
return {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Track>> getAlbumTracks(String provider, String itemId) async {
|
||||||
|
if (_api == null) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await _api!.getAlbumTracks(provider, itemId);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Get album tracks', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearState() {
|
||||||
|
_artists = [];
|
||||||
|
_albums = [];
|
||||||
|
_tracks = [];
|
||||||
|
_isLoading = false;
|
||||||
|
_error = null;
|
||||||
|
clearSearchState();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Global navigation state for bottom navigation bar
|
||||||
|
class NavigationProvider extends ChangeNotifier {
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
|
/// Global navigator key for pushing routes from anywhere
|
||||||
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
/// Callback to focus search when search tab is selected
|
||||||
|
VoidCallback? onSearchTabSelected;
|
||||||
|
|
||||||
|
int get selectedIndex => _selectedIndex;
|
||||||
|
|
||||||
|
void setSelectedIndex(int index) {
|
||||||
|
final previousIndex = _selectedIndex;
|
||||||
|
if (previousIndex != index) {
|
||||||
|
_selectedIndex = index;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// Trigger search focus callback when switching to search tab
|
||||||
|
if (index == 2 && onSearchTabSelected != null) {
|
||||||
|
// Use post-frame callback to ensure SearchScreen is built first
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
onSearchTabSelected?.call();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop all routes and return to the home tab
|
||||||
|
void popToHome() {
|
||||||
|
// Pop all routes above the root
|
||||||
|
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global instance for easy access
|
||||||
|
final navigationProvider = NavigationProvider();
|
||||||
@@ -0,0 +1,956 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../models/player.dart';
|
||||||
|
import '../services/music_assistant_api.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/error_handler.dart';
|
||||||
|
import '../services/local_player_service.dart';
|
||||||
|
import '../services/auth/auth_manager.dart';
|
||||||
|
import '../services/device_id_service.dart';
|
||||||
|
import '../services/cache_service.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
|
import '../main.dart' show audioHandler;
|
||||||
|
|
||||||
|
/// Provider for managing player state, selection, and controls
|
||||||
|
class PlayerProvider with ChangeNotifier {
|
||||||
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
late final LocalPlayerService _localPlayer;
|
||||||
|
final CacheService _cacheService;
|
||||||
|
|
||||||
|
// Dependencies injected from parent provider
|
||||||
|
MusicAssistantAPI? _api;
|
||||||
|
AuthManager? _authManager;
|
||||||
|
String? _serverUrl;
|
||||||
|
|
||||||
|
// Player selection
|
||||||
|
Player? _selectedPlayer;
|
||||||
|
List<Player> _availablePlayers = [];
|
||||||
|
Track? _currentTrack;
|
||||||
|
Timer? _playerStateTimer;
|
||||||
|
Timer? _adjacentPlayerCacheTimer;
|
||||||
|
|
||||||
|
// Local Playback
|
||||||
|
bool _isLocalPlayerPowered = true;
|
||||||
|
StreamSubscription? _localPlayerEventSubscription;
|
||||||
|
StreamSubscription? _playerUpdatedEventSubscription;
|
||||||
|
Timer? _localPlayerStateReportTimer;
|
||||||
|
|
||||||
|
// Pending metadata from player_updated events
|
||||||
|
TrackMetadata? _pendingTrackMetadata;
|
||||||
|
TrackMetadata? _currentNotificationMetadata;
|
||||||
|
|
||||||
|
// Registration guard
|
||||||
|
Completer<void>? _registrationInProgress;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
Player? get selectedPlayer => _selectedPlayer;
|
||||||
|
List<Player> get availablePlayers => _availablePlayers;
|
||||||
|
Track? get currentTrack => _currentTrack;
|
||||||
|
bool get isLocalPlayerPowered => _isLocalPlayerPowered;
|
||||||
|
|
||||||
|
/// Get cached track for a player (used for smooth swipe transitions)
|
||||||
|
Track? getCachedTrackForPlayer(String playerId) => _cacheService.getCachedTrackForPlayer(playerId);
|
||||||
|
|
||||||
|
PlayerProvider(this._cacheService, AuthManager authManager) {
|
||||||
|
_authManager = authManager;
|
||||||
|
_localPlayer = LocalPlayerService(authManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize with API reference after connection
|
||||||
|
void initialize(MusicAssistantAPI api, String serverUrl) {
|
||||||
|
_api = api;
|
||||||
|
_serverUrl = serverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up event listeners after connection
|
||||||
|
Future<void> setupEventListeners() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
// Listen to built-in player events
|
||||||
|
_localPlayerEventSubscription?.cancel();
|
||||||
|
_localPlayerEventSubscription = _api!.builtinPlayerEvents.listen(
|
||||||
|
_handleLocalPlayerEvent,
|
||||||
|
onError: (error) {
|
||||||
|
_logger.log('Builtin player event stream error: $error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen to player_updated events
|
||||||
|
_playerUpdatedEventSubscription?.cancel();
|
||||||
|
_playerUpdatedEventSubscription = _api!.playerUpdatedEvents.listen(
|
||||||
|
_handlePlayerUpdatedEvent,
|
||||||
|
onError: (error) {
|
||||||
|
_logger.log('Player updated event stream error: $error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize local playback
|
||||||
|
Future<void> initializeLocalPlayback() async {
|
||||||
|
await _localPlayer.initialize();
|
||||||
|
_isLocalPlayerPowered = true;
|
||||||
|
|
||||||
|
// Wire up skip button callbacks
|
||||||
|
audioHandler.onSkipToNext = () {
|
||||||
|
_logger.log('🎵 Notification: Skip to next pressed');
|
||||||
|
nextTrackSelectedPlayer();
|
||||||
|
};
|
||||||
|
audioHandler.onSkipToPrevious = () {
|
||||||
|
_logger.log('🎵 Notification: Skip to previous pressed');
|
||||||
|
previousTrackSelectedPlayer();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_api != null) {
|
||||||
|
await registerLocalPlayer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to adopt an existing ghost player
|
||||||
|
Future<bool> tryAdoptGhostPlayer() async {
|
||||||
|
if (_api == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final isFresh = await DeviceIdService.isFreshInstallation();
|
||||||
|
if (!isFresh) {
|
||||||
|
_logger.log('👻 Not a fresh install, skipping ghost adoption');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ownerName = await SettingsService.getOwnerName();
|
||||||
|
if (ownerName == null || ownerName.isEmpty) {
|
||||||
|
_logger.log('👻 No owner name set, cannot adopt ghost player');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('👻 Fresh install detected, searching for adoptable ghost for "$ownerName"...');
|
||||||
|
final adoptableId = await _api!.findAdoptableGhostPlayer(ownerName);
|
||||||
|
if (adoptableId == null) {
|
||||||
|
_logger.log('👻 No matching ghost player found - will generate new ID');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('👻 Found adoptable ghost: $adoptableId');
|
||||||
|
await DeviceIdService.adoptPlayerId(adoptableId);
|
||||||
|
|
||||||
|
_logger.log('✅ Successfully adopted ghost player');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Ghost adoption failed (non-fatal): $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register local player with MA server
|
||||||
|
Future<void> registerLocalPlayer() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
if (_registrationInProgress != null) {
|
||||||
|
_logger.log('⏳ Registration already in progress, waiting...');
|
||||||
|
return _registrationInProgress!.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
_registrationInProgress = Completer<void>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||||
|
_logger.log('🆔 Using player ID: $playerId');
|
||||||
|
|
||||||
|
await SettingsService.setBuiltinPlayerId(playerId);
|
||||||
|
final name = await SettingsService.getLocalPlayerName();
|
||||||
|
|
||||||
|
final existingPlayers = await _api!.getPlayers();
|
||||||
|
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
|
||||||
|
|
||||||
|
if (existingPlayer != null && existingPlayer.available) {
|
||||||
|
_logger.log('✅ Player already registered and available: $playerId');
|
||||||
|
_startReportingLocalPlayerState();
|
||||||
|
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||||
|
_registrationInProgress!.complete();
|
||||||
|
}
|
||||||
|
_registrationInProgress = null;
|
||||||
|
return;
|
||||||
|
} else if (existingPlayer != null && !existingPlayer.available) {
|
||||||
|
_logger.log('⚠️ Player exists but unavailable (stale), re-registering: $playerId');
|
||||||
|
} else {
|
||||||
|
_logger.log('🆔 Player not found in MA, registering as new');
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('🎵 Registering player with MA: id=$playerId, name=$name');
|
||||||
|
await _api!.registerBuiltinPlayer(playerId, name);
|
||||||
|
|
||||||
|
_logger.log('✅ Player registration complete');
|
||||||
|
_startReportingLocalPlayerState();
|
||||||
|
|
||||||
|
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||||
|
_registrationInProgress!.complete();
|
||||||
|
}
|
||||||
|
_registrationInProgress = null;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ CRITICAL: Player registration failed: $e');
|
||||||
|
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
|
||||||
|
_registrationInProgress!.completeError(e);
|
||||||
|
}
|
||||||
|
_registrationInProgress = null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startReportingLocalPlayerState() {
|
||||||
|
_localPlayerStateReportTimer?.cancel();
|
||||||
|
_localPlayerStateReportTimer = Timer.periodic(Timings.localPlayerReportInterval, (_) async {
|
||||||
|
try {
|
||||||
|
await _reportLocalPlayerState();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error reporting local player state (will retry): $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reportLocalPlayerState() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
// Don't try to report state if not authenticated - avoids spamming errors
|
||||||
|
if (_api!.currentConnectionState != MAConnectionState.authenticated) return;
|
||||||
|
|
||||||
|
final playerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
if (playerId == null) return;
|
||||||
|
|
||||||
|
final isPlaying = _localPlayer.isPlaying;
|
||||||
|
final position = _localPlayer.position.inSeconds;
|
||||||
|
final volume = (_localPlayer.volume * 100).round();
|
||||||
|
final isPaused = !isPlaying && position > 0;
|
||||||
|
|
||||||
|
await _api!.updateBuiltinPlayerState(
|
||||||
|
playerId,
|
||||||
|
powered: _isLocalPlayerPowered,
|
||||||
|
playing: isPlaying,
|
||||||
|
paused: isPaused,
|
||||||
|
position: position,
|
||||||
|
volume: volume,
|
||||||
|
muted: _localPlayer.volume == 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLocalPlayerEvent(Map<String, dynamic> event) async {
|
||||||
|
_logger.log('📥 Local player event received: ${event['type'] ?? event['command']}');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final eventPlayerId = event['player_id'] as String?;
|
||||||
|
final myPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
|
||||||
|
if (eventPlayerId != null && myPlayerId != null && eventPlayerId != myPlayerId) {
|
||||||
|
_logger.log('🚫 Ignoring event for different player: $eventPlayerId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final command = (event['type'] as String?) ?? (event['command'] as String?);
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'play_media':
|
||||||
|
final urlPath = event['media_url'] as String? ?? event['url'] as String?;
|
||||||
|
|
||||||
|
if (urlPath != null && _serverUrl != null) {
|
||||||
|
String fullUrl;
|
||||||
|
if (urlPath.startsWith('http')) {
|
||||||
|
fullUrl = urlPath;
|
||||||
|
} else {
|
||||||
|
var baseUrl = _serverUrl!;
|
||||||
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
baseUrl = 'https://$baseUrl';
|
||||||
|
}
|
||||||
|
baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
||||||
|
final path = urlPath.startsWith('/') ? urlPath : '/$urlPath';
|
||||||
|
fullUrl = '$baseUrl$path';
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackMetadata metadata;
|
||||||
|
if (_pendingTrackMetadata != null) {
|
||||||
|
metadata = _pendingTrackMetadata!;
|
||||||
|
} else {
|
||||||
|
final trackName = event['track_name'] as String? ?? event['name'] as String? ?? 'Unknown Track';
|
||||||
|
final artistName = event['artist_name'] as String? ?? event['artist'] as String? ?? 'Unknown Artist';
|
||||||
|
final albumName = event['album_name'] as String? ?? event['album'] as String?;
|
||||||
|
var artworkUrl = event['image_url'] as String? ?? event['artwork_url'] as String?;
|
||||||
|
final durationSecs = event['duration'] as int?;
|
||||||
|
|
||||||
|
if (artworkUrl != null && artworkUrl.startsWith('http://')) {
|
||||||
|
artworkUrl = artworkUrl.replaceFirst('http://', 'https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = TrackMetadata(
|
||||||
|
title: trackName,
|
||||||
|
artist: artistName,
|
||||||
|
album: albumName,
|
||||||
|
artworkUrl: artworkUrl,
|
||||||
|
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_localPlayer.setCurrentTrackMetadata(metadata);
|
||||||
|
_currentNotificationMetadata = metadata;
|
||||||
|
await _localPlayer.playUrl(fullUrl);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stop':
|
||||||
|
await _localPlayer.stop();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'pause':
|
||||||
|
await _localPlayer.pause();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'play':
|
||||||
|
await _localPlayer.play();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'seek':
|
||||||
|
final position = event['position'] as int?;
|
||||||
|
if (position != null) {
|
||||||
|
await _localPlayer.seek(Duration(seconds: position));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'volume_set':
|
||||||
|
final volume = event['volume_level'] as int?;
|
||||||
|
if (volume != null) {
|
||||||
|
await _localPlayer.setVolume(volume / 100.0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'power_on':
|
||||||
|
case 'power_off':
|
||||||
|
case 'power':
|
||||||
|
bool? newPowerState;
|
||||||
|
if (command == 'power_on') {
|
||||||
|
newPowerState = true;
|
||||||
|
} else if (command == 'power_off') {
|
||||||
|
newPowerState = false;
|
||||||
|
} else {
|
||||||
|
newPowerState = event['powered'] as bool?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPowerState != null) {
|
||||||
|
_isLocalPlayerPowered = newPowerState;
|
||||||
|
if (!_isLocalPlayerPowered) {
|
||||||
|
await _localPlayer.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _reportLocalPlayerState();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error handling local player event: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handlePlayerUpdatedEvent(Map<String, dynamic> event) async {
|
||||||
|
try {
|
||||||
|
final playerId = event['player_id'] as String?;
|
||||||
|
if (playerId == null) return;
|
||||||
|
|
||||||
|
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||||
|
_updatePlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentMedia = event['current_media'] as Map<String, dynamic>?;
|
||||||
|
final playerName = event['name'] as String? ?? playerId;
|
||||||
|
|
||||||
|
if (currentMedia != null) {
|
||||||
|
final mediaType = currentMedia['media_type'] as String?;
|
||||||
|
if (mediaType != 'flow_stream') {
|
||||||
|
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||||
|
final albumName = currentMedia['album'] as String?;
|
||||||
|
final imageUrl = currentMedia['image_url'] as String?;
|
||||||
|
|
||||||
|
Map<String, dynamic>? metadata;
|
||||||
|
if (imageUrl != null && _serverUrl != null) {
|
||||||
|
var finalImageUrl = imageUrl;
|
||||||
|
try {
|
||||||
|
final imgUri = Uri.parse(imageUrl);
|
||||||
|
final queryString = imgUri.query;
|
||||||
|
var baseUrl = _serverUrl!;
|
||||||
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
baseUrl = 'https://$baseUrl';
|
||||||
|
}
|
||||||
|
if (baseUrl.endsWith('/')) {
|
||||||
|
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
finalImageUrl = '$baseUrl/imageproxy?$queryString';
|
||||||
|
} catch (e) {
|
||||||
|
// Use original URL
|
||||||
|
}
|
||||||
|
metadata = {
|
||||||
|
'images': [{'path': finalImageUrl, 'provider': 'direct'}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
final trackFromEvent = Track(
|
||||||
|
itemId: currentMedia['queue_item_id'] as String? ?? '',
|
||||||
|
provider: 'library',
|
||||||
|
name: currentMedia['title'] as String? ?? 'Unknown Track',
|
||||||
|
uri: currentMedia['uri'] as String?,
|
||||||
|
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||||
|
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
|
||||||
|
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||||
|
|
||||||
|
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||||
|
_currentTrack = trackFromEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📋 Cached track for $playerName: ${trackFromEvent.name}');
|
||||||
|
|
||||||
|
if (imageUrl != null) {
|
||||||
|
final images = metadata?['images'] as List?;
|
||||||
|
if (images != null && images.isNotEmpty) {
|
||||||
|
final imagePath = images[0]['path'] as String?;
|
||||||
|
if (imagePath != null) {
|
||||||
|
_precacheImage(imagePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle notification metadata for local player
|
||||||
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
if (builtinPlayerId == null || playerId != builtinPlayerId) return;
|
||||||
|
|
||||||
|
if (currentMedia == null) return;
|
||||||
|
|
||||||
|
final title = currentMedia['title'] as String? ?? 'Unknown Track';
|
||||||
|
final artist = currentMedia['artist'] as String? ?? 'Unknown Artist';
|
||||||
|
final album = currentMedia['album'] as String?;
|
||||||
|
var imageUrl = currentMedia['image_url'] as String?;
|
||||||
|
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||||
|
|
||||||
|
if (imageUrl != null && _serverUrl != null) {
|
||||||
|
try {
|
||||||
|
final imgUri = Uri.parse(imageUrl);
|
||||||
|
final queryString = imgUri.query;
|
||||||
|
var baseUrl = _serverUrl!;
|
||||||
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
baseUrl = 'https://$baseUrl';
|
||||||
|
}
|
||||||
|
if (baseUrl.endsWith('/')) {
|
||||||
|
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
|
||||||
|
}
|
||||||
|
imageUrl = '$baseUrl/imageproxy?$queryString';
|
||||||
|
} catch (e) {
|
||||||
|
if (imageUrl != null && imageUrl.startsWith('http://')) {
|
||||||
|
imageUrl = imageUrl.replaceFirst('http://', 'https://');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final newMetadata = TrackMetadata(
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
album: album,
|
||||||
|
artworkUrl: imageUrl,
|
||||||
|
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mediaType = currentMedia['media_type'] as String?;
|
||||||
|
if (mediaType == 'flow_stream') return;
|
||||||
|
|
||||||
|
_pendingTrackMetadata = newMetadata;
|
||||||
|
|
||||||
|
final notificationNeedsUpdate = _currentNotificationMetadata != null &&
|
||||||
|
(_currentNotificationMetadata!.title != title ||
|
||||||
|
_currentNotificationMetadata!.artist != artist);
|
||||||
|
|
||||||
|
if (_localPlayer.isPlaying && notificationNeedsUpdate) {
|
||||||
|
await _localPlayer.updateNotificationWhilePlaying(newMetadata);
|
||||||
|
_currentNotificationMetadata = newMetadata;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error handling player_updated event: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PLAYER SELECTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
Future<void> loadAndSelectPlayers({bool forceRefresh = false}) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!forceRefresh && _cacheService.isPlayersCacheValid() && _availablePlayers.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final allPlayers = await _api!.getPlayers();
|
||||||
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
|
||||||
|
_logger.log('🎛️ getPlayers returned ${allPlayers.length} players');
|
||||||
|
|
||||||
|
int filteredCount = 0;
|
||||||
|
|
||||||
|
_availablePlayers = allPlayers.where((player) {
|
||||||
|
final nameLower = player.name.toLowerCase();
|
||||||
|
|
||||||
|
if (nameLower.contains('music assistant mobile')) {
|
||||||
|
filteredCount++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameLower == 'this device' || player.playerId.startsWith('ma_')) {
|
||||||
|
filteredCount++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.playerId.startsWith('ensemble_')) {
|
||||||
|
if (builtinPlayerId == null || player.playerId != builtinPlayerId) {
|
||||||
|
filteredCount++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!player.available) {
|
||||||
|
if (builtinPlayerId != null && player.playerId == builtinPlayerId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
filteredCount++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
_cacheService.updatePlayersLastFetched();
|
||||||
|
|
||||||
|
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
|
||||||
|
|
||||||
|
if (_availablePlayers.isNotEmpty) {
|
||||||
|
Player? playerToSelect;
|
||||||
|
|
||||||
|
if (_selectedPlayer != null) {
|
||||||
|
final stillAvailable = _availablePlayers.any(
|
||||||
|
(p) => p.playerId == _selectedPlayer!.playerId && p.available,
|
||||||
|
);
|
||||||
|
if (stillAvailable) {
|
||||||
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerToSelect == null) {
|
||||||
|
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
|
||||||
|
|
||||||
|
if (lastSelectedPlayerId != null) {
|
||||||
|
try {
|
||||||
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == lastSelectedPlayerId && p.available,
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerToSelect == null && builtinPlayerId != null) {
|
||||||
|
try {
|
||||||
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == builtinPlayerId && p.available,
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerToSelect == null) {
|
||||||
|
try {
|
||||||
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.state == 'playing' && p.available,
|
||||||
|
);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerToSelect == null) {
|
||||||
|
playerToSelect = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.available,
|
||||||
|
orElse: () => _availablePlayers.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPlayer(playerToSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
_preloadAdjacentPlayers(preloadAll: true);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Load and select players', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void selectPlayer(Player player, {bool skipNotify = false}) {
|
||||||
|
_selectedPlayer = player;
|
||||||
|
SettingsService.setLastSelectedPlayerId(player.playerId);
|
||||||
|
_startPlayerStatePolling();
|
||||||
|
_preloadAdjacentPlayers();
|
||||||
|
|
||||||
|
if (!skipNotify) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _preloadAdjacentPlayers({bool preloadAll = false}) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
final players = _availablePlayers.where((p) => p.available).toList();
|
||||||
|
if (players.isEmpty) return;
|
||||||
|
|
||||||
|
if (preloadAll) {
|
||||||
|
_logger.log('🖼️ Preloading track info for all ${players.length} players...');
|
||||||
|
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
||||||
|
_logger.log('🖼️ Preloading complete');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedPlayer == null || players.length <= 1) return;
|
||||||
|
|
||||||
|
final currentIndex = players.indexWhere((p) => p.playerId == _selectedPlayer!.playerId);
|
||||||
|
if (currentIndex == -1) return;
|
||||||
|
|
||||||
|
final prevIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
|
||||||
|
final nextIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
|
||||||
|
|
||||||
|
final playersToPreload = <Player>{};
|
||||||
|
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
|
||||||
|
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
|
||||||
|
|
||||||
|
for (final player in playersToPreload) {
|
||||||
|
_preloadPlayerTrack(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _preloadPlayerTrack(Player player) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!player.available || !player.powered) {
|
||||||
|
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queue = await _api!.getQueue(player.playerId);
|
||||||
|
|
||||||
|
if (queue != null && queue.currentItem != null) {
|
||||||
|
final track = queue.currentItem!.track;
|
||||||
|
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||||
|
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||||
|
final newHasImage = track.metadata?['images'] != null;
|
||||||
|
|
||||||
|
if (!existingHasImage || newHasImage) {
|
||||||
|
_cacheService.setCachedTrackForPlayer(player.playerId, track);
|
||||||
|
}
|
||||||
|
|
||||||
|
final artworkUrl512 = _api!.getImageUrl(existingHasImage ? existingTrack! : track, size: 512);
|
||||||
|
if (artworkUrl512 != null) {
|
||||||
|
await _precacheImage(artworkUrl512);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final existingTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||||
|
if (existingTrack?.metadata?['images'] == null) {
|
||||||
|
_cacheService.setCachedTrackForPlayer(player.playerId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error preloading player track for ${player.name}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _precacheImage(String url) async {
|
||||||
|
try {
|
||||||
|
final imageProvider = NetworkImage(url);
|
||||||
|
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
||||||
|
final completer = Completer<void>();
|
||||||
|
late ImageStreamListener listener;
|
||||||
|
|
||||||
|
listener = ImageStreamListener(
|
||||||
|
(ImageInfo info, bool synchronousCall) {
|
||||||
|
if (!completer.isCompleted) completer.complete();
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
},
|
||||||
|
onError: (exception, stackTrace) {
|
||||||
|
if (!completer.isCompleted) completer.completeError(exception);
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
imageStream.addListener(listener);
|
||||||
|
await completer.future.timeout(const Duration(seconds: 10), onTimeout: () {
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Image precache failed for $url: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> preloadAllPlayerTracks() async {
|
||||||
|
if (_api == null) return;
|
||||||
|
final players = _availablePlayers.where((p) => p.available).toList();
|
||||||
|
await Future.wait(players.map((player) => _preloadPlayerTrack(player)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPlayerStatePolling() {
|
||||||
|
_playerStateTimer?.cancel();
|
||||||
|
if (_selectedPlayer == null) return;
|
||||||
|
|
||||||
|
_playerStateTimer = Timer.periodic(Timings.playerPollingInterval, (_) async {
|
||||||
|
try {
|
||||||
|
await _updatePlayerState();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error updating player state (will retry): $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_updatePlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updatePlayerState() async {
|
||||||
|
if (_selectedPlayer == null || _api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
bool stateChanged = false;
|
||||||
|
|
||||||
|
final allPlayers = await _api!.getPlayers();
|
||||||
|
final updatedPlayer = allPlayers.firstWhere(
|
||||||
|
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||||
|
orElse: () => _selectedPlayer!,
|
||||||
|
);
|
||||||
|
|
||||||
|
_selectedPlayer = updatedPlayer;
|
||||||
|
stateChanged = true;
|
||||||
|
|
||||||
|
final isPlayingOrPaused = _selectedPlayer!.state == 'playing' || _selectedPlayer!.state == 'paused';
|
||||||
|
final isIdleWithContent = _selectedPlayer!.state == 'idle' && _selectedPlayer!.powered;
|
||||||
|
final shouldShowTrack = _selectedPlayer!.available && (isPlayingOrPaused || isIdleWithContent);
|
||||||
|
|
||||||
|
if (!shouldShowTrack) {
|
||||||
|
if (_currentTrack != null) {
|
||||||
|
_currentTrack = null;
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
if (stateChanged) notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queue = await _api!.getQueue(_selectedPlayer!.playerId);
|
||||||
|
|
||||||
|
if (queue != null && queue.currentItem != null) {
|
||||||
|
if (_currentTrack == null ||
|
||||||
|
_currentTrack!.uri != queue.currentItem!.track.uri ||
|
||||||
|
_currentTrack!.name != queue.currentItem!.track.name) {
|
||||||
|
_currentTrack = queue.currentItem!.track;
|
||||||
|
stateChanged = true;
|
||||||
|
|
||||||
|
final builtinPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
if (builtinPlayerId != null && _selectedPlayer!.playerId == builtinPlayerId) {
|
||||||
|
final track = _currentTrack!;
|
||||||
|
final artworkUrl = _api?.getImageUrl(track, size: 512);
|
||||||
|
_localPlayer.updateNotification(
|
||||||
|
id: track.uri ?? track.itemId,
|
||||||
|
title: track.name,
|
||||||
|
artist: track.artistsString,
|
||||||
|
album: track.album?.name,
|
||||||
|
artworkUrl: artworkUrl,
|
||||||
|
duration: track.duration,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_currentTrack != null) {
|
||||||
|
_currentTrack = null;
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateChanged) notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Error updating player state: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PLAYER CONTROLS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
Future<void> playPauseSelectedPlayer() async {
|
||||||
|
if (_selectedPlayer == null || _api == null) return;
|
||||||
|
|
||||||
|
if (_selectedPlayer!.isPlaying) {
|
||||||
|
await _api!.pausePlayer(_selectedPlayer!.playerId);
|
||||||
|
} else {
|
||||||
|
await _api!.resumePlayer(_selectedPlayer!.playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> nextTrackSelectedPlayer() async {
|
||||||
|
if (_selectedPlayer == null || _api == null) return;
|
||||||
|
await _api!.nextTrack(_selectedPlayer!.playerId);
|
||||||
|
await Future.delayed(Timings.trackChangeDelay);
|
||||||
|
await _updatePlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> previousTrackSelectedPlayer() async {
|
||||||
|
if (_selectedPlayer == null || _api == null) return;
|
||||||
|
await _api!.previousTrack(_selectedPlayer!.playerId);
|
||||||
|
await Future.delayed(Timings.trackChangeDelay);
|
||||||
|
await _updatePlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshPlayers() async {
|
||||||
|
final previousState = _selectedPlayer?.state;
|
||||||
|
final previousVolume = _selectedPlayer?.volumeLevel;
|
||||||
|
|
||||||
|
await loadAndSelectPlayers(forceRefresh: true);
|
||||||
|
|
||||||
|
bool stateChanged = false;
|
||||||
|
if (_selectedPlayer != null && _availablePlayers.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final updatedPlayer = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == _selectedPlayer!.playerId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updatedPlayer.state != previousState || updatedPlayer.volumeLevel != previousVolume) {
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedPlayer = updatedPlayer;
|
||||||
|
} catch (e) {
|
||||||
|
stateChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateChanged) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> togglePower(String playerId) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final localPlayerId = await SettingsService.getBuiltinPlayerId();
|
||||||
|
final isLocalPlayer = localPlayerId != null && playerId == localPlayerId;
|
||||||
|
|
||||||
|
if (isLocalPlayer) {
|
||||||
|
_isLocalPlayerPowered = !_isLocalPlayerPowered;
|
||||||
|
if (!_isLocalPlayerPowered) {
|
||||||
|
await _localPlayer.stop();
|
||||||
|
}
|
||||||
|
await _reportLocalPlayerState();
|
||||||
|
await refreshPlayers();
|
||||||
|
} else {
|
||||||
|
final player = _availablePlayers.firstWhere(
|
||||||
|
(p) => p.playerId == playerId,
|
||||||
|
orElse: () => _selectedPlayer != null && _selectedPlayer!.playerId == playerId
|
||||||
|
? _selectedPlayer!
|
||||||
|
: throw Exception("Player not found"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _api!.setPower(playerId, !player.powered);
|
||||||
|
await refreshPlayers();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Toggle power', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setVolume(String playerId, int volumeLevel) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
try {
|
||||||
|
await _api!.setVolume(playerId, volumeLevel);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Set volume', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMute(String playerId, bool muted) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
try {
|
||||||
|
await _api!.setMute(playerId, muted);
|
||||||
|
await refreshPlayers();
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Set mute', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> seek(String playerId, int position) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
try {
|
||||||
|
await _api!.seek(playerId, position);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Seek', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleShuffle(String queueId) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
try {
|
||||||
|
await _api!.toggleShuffle(queueId);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Toggle shuffle', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setRepeatMode(String queueId, String mode) async {
|
||||||
|
if (_api == null) return;
|
||||||
|
try {
|
||||||
|
await _api!.setRepeatMode(queueId, mode);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler.logError('Set repeat mode', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cycleRepeatMode(String queueId, String? currentMode) async {
|
||||||
|
String nextMode;
|
||||||
|
switch (currentMode) {
|
||||||
|
case 'off':
|
||||||
|
case null:
|
||||||
|
nextMode = 'all';
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
nextMode = 'one';
|
||||||
|
break;
|
||||||
|
case 'one':
|
||||||
|
nextMode = 'off';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
nextMode = 'off';
|
||||||
|
}
|
||||||
|
await setRepeatMode(queueId, nextMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearState() {
|
||||||
|
_playerStateTimer?.cancel();
|
||||||
|
_playerStateTimer = null;
|
||||||
|
_localPlayerStateReportTimer?.cancel();
|
||||||
|
_localPlayerEventSubscription?.cancel();
|
||||||
|
_playerUpdatedEventSubscription?.cancel();
|
||||||
|
_availablePlayers = [];
|
||||||
|
_selectedPlayer = null;
|
||||||
|
_currentTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
clearState();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +1,332 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../widgets/global_player_overlay.dart';
|
||||||
import 'album_details_screen.dart';
|
import 'album_details_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../theme/palette_helper.dart';
|
import '../theme/palette_helper.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../services/metadata_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
|
||||||
class ArtistDetailsScreen extends StatefulWidget {
|
class ArtistDetailsScreen extends StatefulWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final String? initialImageUrl;
|
||||||
|
|
||||||
const ArtistDetailsScreen({super.key, required this.artist});
|
const ArtistDetailsScreen({
|
||||||
|
super.key,
|
||||||
|
required this.artist,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.initialImageUrl,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState();
|
State<ArtistDetailsScreen> createState() => _ArtistDetailsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||||
|
final _logger = DebugLogger();
|
||||||
List<Album> _albums = [];
|
List<Album> _albums = [];
|
||||||
|
List<Album> _providerAlbums = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isFavorite = false;
|
||||||
ColorScheme? _lightColorScheme;
|
ColorScheme? _lightColorScheme;
|
||||||
ColorScheme? _darkColorScheme;
|
ColorScheme? _darkColorScheme;
|
||||||
|
bool _isDescriptionExpanded = false;
|
||||||
|
String? _artistDescription;
|
||||||
|
String? _artistImageUrl;
|
||||||
|
|
||||||
|
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_isFavorite = widget.artist.favorite ?? false;
|
||||||
|
// Use initial image URL immediately for smooth hero animation
|
||||||
|
_artistImageUrl = widget.initialImageUrl;
|
||||||
_loadArtistAlbums();
|
_loadArtistAlbums();
|
||||||
_extractColors();
|
_loadArtistDescription();
|
||||||
|
_refreshFavoriteStatus();
|
||||||
|
|
||||||
|
// Defer higher-res image loading and color extraction until after transition
|
||||||
|
// But hero animation now works because we have initialImageUrl
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 350), () {
|
||||||
|
if (mounted) {
|
||||||
|
_loadArtistImage();
|
||||||
|
// Note: _extractColors is called by _loadArtistImage after image loads
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractColors() async {
|
Future<void> _refreshFavoriteStatus() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
|
if (maProvider.api == null) return;
|
||||||
|
|
||||||
if (imageUrl == null) return;
|
final artistUri = widget.artist.uri;
|
||||||
|
if (artistUri == null || artistUri.isEmpty) {
|
||||||
|
_logger.log('Cannot refresh favorite status: artist has no URI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final freshArtist = await maProvider.api!.getArtistByUri(artistUri);
|
||||||
|
if (freshArtist != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isFavorite = freshArtist.favorite ?? false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error refreshing artist favorite status: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFavorite() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
if (maProvider.api == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final newState = !_isFavorite;
|
||||||
|
|
||||||
|
if (newState) {
|
||||||
|
String actualProvider = widget.artist.provider;
|
||||||
|
String actualItemId = widget.artist.itemId;
|
||||||
|
|
||||||
|
if (widget.artist.providerMappings != null && widget.artist.providerMappings!.isNotEmpty) {
|
||||||
|
final mapping = widget.artist.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available && m.providerInstance != 'library',
|
||||||
|
orElse: () => widget.artist.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available,
|
||||||
|
orElse: () => widget.artist.providerMappings!.first,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
actualProvider = mapping.providerInstance;
|
||||||
|
actualItemId = mapping.itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
|
||||||
|
await maProvider.api!.addToFavorites('artist', actualItemId, actualProvider);
|
||||||
|
} else {
|
||||||
|
int? libraryItemId;
|
||||||
|
|
||||||
|
if (widget.artist.provider == 'library') {
|
||||||
|
libraryItemId = int.tryParse(widget.artist.itemId);
|
||||||
|
} else if (widget.artist.providerMappings != null) {
|
||||||
|
final libraryMapping = widget.artist.providerMappings!.firstWhere(
|
||||||
|
(m) => m.providerInstance == 'library',
|
||||||
|
orElse: () => widget.artist.providerMappings!.first,
|
||||||
|
);
|
||||||
|
if (libraryMapping.providerInstance == 'library') {
|
||||||
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryItemId == null) {
|
||||||
|
_logger.log('Error: Could not determine library_item_id for removal');
|
||||||
|
throw Exception('Could not determine library ID for this artist');
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
|
||||||
|
await maProvider.api!.removeFromFavorites('artist', libraryItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isFavorite = newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
maProvider.invalidateHomeCache();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isFavorite ? 'Added to favorites' : 'Removed from favorites',
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error toggling artist favorite: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to update favorite: $e'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startArtistRadio() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
|
||||||
|
if (selectedPlayer == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No player selected')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await maProvider.playArtistRadio(selectedPlayer.playerId, widget.artist);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Starting ${widget.artist.name} radio'),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error starting artist radio: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to start radio: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPlayOnMenu(BuildContext context) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final players = maProvider.availablePlayers;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (context) => Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Start ${widget.artist.name} radio on...',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (players.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: Text('No players available'),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Flexible(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: players.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final player = players[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.speaker,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
title: Text(player.name),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
try {
|
||||||
|
await maProvider.playArtistRadio(player.playerId, widget.artist);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Starting ${widget.artist.name} radio on ${player.name}'),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error starting artist radio on player: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to start radio: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addArtistRadioToQueue() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
|
||||||
|
if (selectedPlayer == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No player selected')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For adding to queue with radio mode, we use radio_mode but with 'add' option
|
||||||
|
await maProvider.api?.playArtistRadioToQueue(selectedPlayer.playerId, widget.artist);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Added ${widget.artist.name} radio to queue'),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error adding artist radio to queue: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to add to queue: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadArtistImage() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Get image URL with fallback to Deezer/Fanart.tv
|
||||||
|
final imageUrl = await maProvider.getArtistImageUrlWithFallback(widget.artist, size: 512);
|
||||||
|
|
||||||
|
if (mounted && imageUrl != null) {
|
||||||
|
setState(() {
|
||||||
|
_artistImageUrl = imageUrl;
|
||||||
|
});
|
||||||
|
// Extract colors after we have the image
|
||||||
|
_extractColors(imageUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _extractColors(String imageUrl) async {
|
||||||
try {
|
try {
|
||||||
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
||||||
NetworkImage(imageUrl),
|
CachedNetworkImageProvider(imageUrl),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (colorSchemes != null && mounted) {
|
if (colorSchemes != null && mounted) {
|
||||||
@@ -47,127 +336,123 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('⚠️ Failed to extract colors for artist: $e');
|
_logger.log('Failed to extract colors for artist: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadArtistDescription() async {
|
||||||
|
final artistName = widget.artist.name;
|
||||||
|
|
||||||
|
if (artistName.isEmpty) return;
|
||||||
|
|
||||||
|
final description = await MetadataService.getArtistDescription(
|
||||||
|
artistName,
|
||||||
|
widget.artist.metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_artistDescription = description;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadArtistAlbums() async {
|
Future<void> _loadArtistAlbums() async {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
// Load albums for this specific artist by filtering
|
// Use cached artist albums method
|
||||||
if (provider.api != null) {
|
final allAlbums = await provider.getArtistAlbumsWithCache(widget.artist.name);
|
||||||
print('🎵 Loading albums for artist: ${widget.artist.name}');
|
|
||||||
print(' Provider: ${widget.artist.provider}');
|
|
||||||
print(' ItemId: ${widget.artist.itemId}');
|
|
||||||
|
|
||||||
// Get all albums and filter locally (API filtering not reliable yet)
|
if (mounted) {
|
||||||
final allAlbums = await provider.api!.getAlbums();
|
setState(() {
|
||||||
|
_albums = allAlbums;
|
||||||
// Filter albums that include this artist
|
_providerAlbums = []; // All albums are merged in the cache method
|
||||||
var artistAlbums = allAlbums.where((album) {
|
_isLoading = false;
|
||||||
if (album.artists == null) return false;
|
});
|
||||||
return album.artists!.any((artist) =>
|
|
||||||
artist.itemId == widget.artist.itemId ||
|
|
||||||
artist.name == widget.artist.name // Fallback to name match
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
print(' Got ${artistAlbums.length} library albums for this artist');
|
|
||||||
|
|
||||||
// Fallback: If no library albums, try searching the provider
|
|
||||||
if (artistAlbums.isEmpty && widget.artist.name.isNotEmpty) {
|
|
||||||
print('🔍 No library albums found. Searching provider for "${widget.artist.name}"...');
|
|
||||||
try {
|
|
||||||
final searchResults = await provider.search(widget.artist.name);
|
|
||||||
final searchAlbums = searchResults['albums'] ?? [];
|
|
||||||
|
|
||||||
// Filter search results to ensure they are actually for this artist
|
|
||||||
final validSearchAlbums = searchAlbums.where((item) {
|
|
||||||
// Ensure item is an Album and check artist match
|
|
||||||
if (item is! Album) return false;
|
|
||||||
return item.artists?.any((a) =>
|
|
||||||
a.name.toLowerCase() == widget.artist.name.toLowerCase()
|
|
||||||
) ?? false;
|
|
||||||
}).cast<Album>().toList();
|
|
||||||
|
|
||||||
if (validSearchAlbums.isNotEmpty) {
|
|
||||||
print('✅ Found ${validSearchAlbums.length} albums via search fallback');
|
|
||||||
artistAlbums = validSearchAlbums;
|
|
||||||
} else {
|
|
||||||
print('⚠️ Search returned no valid albums for this artist');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('❌ Error searching for artist albums: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_albums = artistAlbums;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
||||||
final themeProvider = context.watch<ThemeProvider>();
|
// Only rebuild when specific properties change, not on every provider update
|
||||||
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
|
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||||
|
(provider) => provider.adaptiveTheme,
|
||||||
|
);
|
||||||
|
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||||
|
(provider) => provider.adaptiveLightScheme,
|
||||||
|
);
|
||||||
|
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||||
|
(provider) => provider.adaptiveDarkScheme,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the loaded image URL (with fallback) instead of directly from MA
|
||||||
|
final imageUrl = _artistImageUrl;
|
||||||
|
|
||||||
// Determine if we should use adaptive theme colors
|
|
||||||
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
// Get the color scheme to use
|
// Get the color scheme to use - prefer local state over provider
|
||||||
ColorScheme? adaptiveScheme;
|
ColorScheme? adaptiveScheme;
|
||||||
if (useAdaptiveTheme) {
|
if (adaptiveTheme) {
|
||||||
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
|
// Use local state first (from _extractColors), fallback to provider
|
||||||
|
adaptiveScheme = isDark
|
||||||
|
? (_darkColorScheme ?? adaptiveDarkScheme)
|
||||||
|
: (_lightColorScheme ?? adaptiveLightScheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use adaptive scheme if available, otherwise use global theme
|
|
||||||
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
backgroundColor: colorScheme.background,
|
canPop: true,
|
||||||
body: CustomScrollView(
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
slivers: [
|
if (didPop) {
|
||||||
SliverAppBar(
|
clearAdaptiveColorsOnBack(context);
|
||||||
expandedHeight: 300,
|
}
|
||||||
pinned: true,
|
},
|
||||||
backgroundColor: colorScheme.background,
|
child: Scaffold(
|
||||||
leading: IconButton(
|
backgroundColor: colorScheme.background,
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
body: CustomScrollView(
|
||||||
onPressed: () => Navigator.pop(context),
|
slivers: [
|
||||||
color: colorScheme.onBackground,
|
SliverAppBar(
|
||||||
),
|
expandedHeight: 300,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.background,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
clearAdaptiveColorsOnBack(context);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Column(
|
background: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 60),
|
const SizedBox(height: 60),
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId),
|
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
||||||
child: CircleAvatar(
|
child: ClipOval(
|
||||||
radius: 100,
|
child: Container(
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
width: 200,
|
||||||
backgroundImage:
|
height: 200,
|
||||||
imageUrl != null ? NetworkImage(imageUrl) : null,
|
color: colorScheme.surfaceVariant,
|
||||||
child: imageUrl == null
|
child: imageUrl != null
|
||||||
? Icon(
|
? Image.network(
|
||||||
Icons.person_rounded,
|
imageUrl,
|
||||||
size: 100,
|
fit: BoxFit.cover,
|
||||||
color: colorScheme.onSurfaceVariant,
|
errorBuilder: (_, __, ___) => Icon(
|
||||||
)
|
Icons.person_rounded,
|
||||||
: null,
|
size: 100,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
size: 100,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -181,7 +466,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId),
|
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + _heroTagSuffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -193,15 +478,112 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 16),
|
||||||
Text(
|
if (_artistDescription != null && _artistDescription!.isNotEmpty) ...[
|
||||||
'Albums',
|
InkWell(
|
||||||
style: textTheme.titleLarge?.copyWith(
|
onTap: () {
|
||||||
color: colorScheme.onBackground,
|
setState(() {
|
||||||
fontWeight: FontWeight.bold,
|
_isDescriptionExpanded = !_isDescriptionExpanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Text(
|
||||||
|
_artistDescription!,
|
||||||
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
maxLines: _isDescriptionExpanded ? null : 2,
|
||||||
|
overflow: _isDescriptionExpanded ? null : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
// Action Buttons Row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Main Radio Button
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _startArtistRadio,
|
||||||
|
icon: const Icon(Icons.radio),
|
||||||
|
label: const Text('Radio'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primary,
|
||||||
|
foregroundColor: colorScheme.onPrimary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// "Play on..." Button (Square)
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
width: 50,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => _showPlayOnMenu(context),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.speaker_group_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// "Add to Queue" Button (Square)
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
width: 50,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: _addArtistRadioToQueue,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.playlist_add),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Favorite Button
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
width: 50,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: _toggleFavorite,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: _isFavorite
|
||||||
|
? colorScheme.error
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -212,7 +594,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_albums.isEmpty)
|
else if (_albums.isEmpty && _providerAlbums.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -224,41 +606,101 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else ...[
|
||||||
SliverPadding(
|
// Library Albums Section
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
if (_albums.isNotEmpty) ...[
|
||||||
sliver: SliverGrid(
|
SliverToBoxAdapter(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
child: Padding(
|
||||||
crossAxisCount: 2,
|
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||||
childAspectRatio: 0.75,
|
child: Text(
|
||||||
crossAxisSpacing: 12,
|
'In Library',
|
||||||
mainAxisSpacing: 12,
|
style: textTheme.titleLarge?.copyWith(
|
||||||
),
|
color: colorScheme.onBackground,
|
||||||
delegate: SliverChildBuilderDelegate(
|
fontWeight: FontWeight.bold,
|
||||||
(context, index) {
|
),
|
||||||
final album = _albums[index];
|
),
|
||||||
return _buildAlbumCard(album, maProvider);
|
|
||||||
},
|
|
||||||
childCount: _albums.length,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
childAspectRatio: 0.75,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final album = _albums[index];
|
||||||
|
return _buildAlbumCard(album);
|
||||||
|
},
|
||||||
|
childCount: _albums.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Provider Albums Section
|
||||||
|
if (_providerAlbums.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24.0, 32.0, 24.0, 12.0),
|
||||||
|
child: Text(
|
||||||
|
'From Providers',
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
childAspectRatio: 0.75,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final album = _providerAlbums[index];
|
||||||
|
return _buildAlbumCard(album);
|
||||||
|
},
|
||||||
|
childCount: _providerAlbums.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
Widget _buildAlbumCard(Album album) {
|
||||||
final imageUrl = provider.getImageUrl(album, size: 256);
|
// Use read instead of passing provider to avoid rebuild dependencies
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.getImageUrl(album, size: 256);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
const String heroSuffix = 'artist_albums';
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
// Update adaptive colors immediately on tap
|
||||||
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
FadeSlidePageRoute(
|
||||||
builder: (context) => AlbumDetailsScreen(album: album),
|
child: AlbumDetailsScreen(
|
||||||
|
album: album,
|
||||||
|
heroTagSuffix: heroSuffix,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -266,45 +708,66 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Hero(
|
||||||
decoration: BoxDecoration(
|
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||||
color: colorScheme.surfaceVariant,
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
image: imageUrl != null
|
child: Container(
|
||||||
? DecorationImage(
|
color: colorScheme.surfaceVariant,
|
||||||
image: NetworkImage(imageUrl),
|
child: imageUrl != null
|
||||||
fit: BoxFit.cover,
|
? Image.network(
|
||||||
)
|
imageUrl,
|
||||||
: null,
|
fit: BoxFit.cover,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
errorBuilder: (_, __, ___) => Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.album_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.album_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
|
||||||
? Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.album_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Hero(
|
||||||
album.name,
|
tag: HeroTags.albumTitle + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||||
style: textTheme.titleSmall?.copyWith(
|
child: Material(
|
||||||
color: colorScheme.onSurface,
|
color: Colors.transparent,
|
||||||
fontWeight: FontWeight.w500,
|
child: Text(
|
||||||
|
album.name,
|
||||||
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
Text(
|
Hero(
|
||||||
album.artistsString,
|
tag: HeroTags.artistName + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||||
style: textTheme.bodySmall?.copyWith(
|
child: Material(
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
album.artistsString,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/player.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
|
||||||
class DebugLogScreen extends StatefulWidget {
|
class DebugLogScreen extends StatefulWidget {
|
||||||
const DebugLogScreen({super.key});
|
const DebugLogScreen({super.key});
|
||||||
@@ -15,6 +16,8 @@ class DebugLogScreen extends StatefulWidget {
|
|||||||
class _DebugLogScreenState extends State<DebugLogScreen> {
|
class _DebugLogScreenState extends State<DebugLogScreen> {
|
||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
|
LogLevel _filterLevel = LogLevel.debug; // Show all by default
|
||||||
|
bool _isGeneratingReport = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -33,17 +36,37 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<LogEntry> get _filteredEntries =>
|
||||||
|
_logger.entries.where((e) => e.level.index >= _filterLevel.index).toList();
|
||||||
|
|
||||||
void _copyLogs() {
|
void _copyLogs() {
|
||||||
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
|
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Logs copied to clipboard!'),
|
content: Text('Logs copied to clipboard'),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: Duration(seconds: 2),
|
duration: Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _shareBugReport() async {
|
||||||
|
setState(() => _isGeneratingReport = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final report = await _logger.generateBugReport();
|
||||||
|
|
||||||
|
await Share.share(
|
||||||
|
report,
|
||||||
|
subject: 'Ensemble Bug Report',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isGeneratingReport = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _clearLogs() {
|
void _clearLogs() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_logger.clear();
|
_logger.clear();
|
||||||
@@ -61,6 +84,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final allPlayers = await maProvider.getAllPlayersUnfiltered();
|
final allPlayers = await maProvider.getAllPlayersUnfiltered();
|
||||||
|
final currentPlayerId = await maProvider.getCurrentPlayerId();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -68,9 +92,9 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
backgroundColor: const Color(0xFF2a2a2a),
|
backgroundColor: const Color(0xFF2a2a2a),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'All Players (Including Hidden)',
|
'All Players (${allPlayers.length})',
|
||||||
style: TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: double.maxFinite,
|
width: double.maxFinite,
|
||||||
@@ -79,17 +103,50 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
itemCount: allPlayers.length,
|
itemCount: allPlayers.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final player = allPlayers[index];
|
final player = allPlayers[index];
|
||||||
final isGhost = player.name.toLowerCase().contains('music assistant mobile');
|
final id = player.playerId.toLowerCase();
|
||||||
|
|
||||||
|
// Detect ghost players: ensemble_*, massiv_*, ma_* prefixes
|
||||||
|
final isAppPlayer = id.startsWith('ensemble_') ||
|
||||||
|
id.startsWith('massiv_') ||
|
||||||
|
id.startsWith('ma_');
|
||||||
|
final isCurrentPlayer = player.playerId == currentPlayerId;
|
||||||
|
final isGhost = isAppPlayer && !isCurrentPlayer;
|
||||||
|
final isCorrupt = isAppPlayer && !player.available;
|
||||||
|
|
||||||
|
Color cardColor;
|
||||||
|
Color textColor;
|
||||||
|
IconData? trailingIcon;
|
||||||
|
Color? iconColor;
|
||||||
|
|
||||||
|
if (isCurrentPlayer) {
|
||||||
|
cardColor = Colors.green.withOpacity(0.2);
|
||||||
|
textColor = Colors.green[300]!;
|
||||||
|
trailingIcon = Icons.check_circle;
|
||||||
|
iconColor = Colors.green;
|
||||||
|
} else if (isCorrupt) {
|
||||||
|
cardColor = Colors.orange.withOpacity(0.2);
|
||||||
|
textColor = Colors.orange[300]!;
|
||||||
|
trailingIcon = Icons.error;
|
||||||
|
iconColor = Colors.orange;
|
||||||
|
} else if (isGhost) {
|
||||||
|
cardColor = Colors.red.withOpacity(0.2);
|
||||||
|
textColor = Colors.red[300]!;
|
||||||
|
trailingIcon = Icons.warning;
|
||||||
|
iconColor = Colors.red;
|
||||||
|
} else {
|
||||||
|
cardColor = Colors.white.withOpacity(0.1);
|
||||||
|
textColor = Colors.white;
|
||||||
|
}
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: isGhost ? Colors.red.withOpacity(0.2) : Colors.white.withOpacity(0.1),
|
color: cardColor,
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
player.name,
|
player.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isGhost ? Colors.red[300] : Colors.white,
|
color: textColor,
|
||||||
fontWeight: isGhost ? FontWeight.bold : FontWeight.normal,
|
fontWeight: (isGhost || isCurrentPlayer) ? FontWeight.bold : FontWeight.normal,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
@@ -100,13 +157,28 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Available: ${player.available} | State: ${player.state}',
|
'Available: ${player.available} | Provider: ${player.provider}',
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||||
),
|
),
|
||||||
|
if (isCurrentPlayer)
|
||||||
|
const Text(
|
||||||
|
'← This device',
|
||||||
|
style: TextStyle(color: Colors.green, fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
if (isGhost && !isCorrupt)
|
||||||
|
const Text(
|
||||||
|
'⚠️ Ghost player (duplicate)',
|
||||||
|
style: TextStyle(color: Colors.red, fontSize: 11),
|
||||||
|
),
|
||||||
|
if (isCorrupt)
|
||||||
|
const Text(
|
||||||
|
'⚠️ Unavailable/Corrupt',
|
||||||
|
style: TextStyle(color: Colors.orange, fontSize: 11),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: isGhost
|
trailing: trailingIcon != null
|
||||||
? const Icon(Icons.warning, color: Colors.red, size: 20)
|
? Icon(trailingIcon, color: iconColor, size: 20)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -117,7 +189,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final text = allPlayers.map((p) =>
|
final text = allPlayers.map((p) =>
|
||||||
'Name: ${p.name}\nID: ${p.playerId}\nAvailable: ${p.available}\nState: ${p.state}\n---'
|
'Name: ${p.name}\nID: ${p.playerId}\nAvailable: ${p.available}\nProvider: ${p.provider}\nState: ${p.state}\n---'
|
||||||
).join('\n');
|
).join('\n');
|
||||||
await Clipboard.setData(ClipboardData(text: text));
|
await Clipboard.setData(ClipboardData(text: text));
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -167,53 +239,105 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
// Share bug report button
|
||||||
icon: const Icon(Icons.speaker_group_rounded),
|
_isGeneratingReport
|
||||||
onPressed: _showAllPlayers,
|
? const Padding(
|
||||||
color: Colors.white,
|
padding: EdgeInsets.all(12),
|
||||||
tooltip: 'View all players',
|
child: SizedBox(
|
||||||
),
|
width: 24,
|
||||||
IconButton(
|
height: 24,
|
||||||
icon: const Icon(Icons.copy_rounded),
|
child: CircularProgressIndicator(
|
||||||
onPressed: _copyLogs,
|
strokeWidth: 2,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
tooltip: 'Copy logs',
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
)
|
||||||
icon: const Icon(Icons.delete_rounded),
|
: IconButton(
|
||||||
onPressed: _clearLogs,
|
icon: const Icon(Icons.share_rounded),
|
||||||
color: Colors.white,
|
onPressed: _shareBugReport,
|
||||||
tooltip: 'Clear logs',
|
color: Colors.white,
|
||||||
|
tooltip: 'Share bug report',
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||||
|
tooltip: 'More options',
|
||||||
|
onSelected: (value) async {
|
||||||
|
switch (value) {
|
||||||
|
case 'show_players':
|
||||||
|
_showAllPlayers();
|
||||||
|
break;
|
||||||
|
case 'copy_logs':
|
||||||
|
_copyLogs();
|
||||||
|
break;
|
||||||
|
case 'clear_logs':
|
||||||
|
_clearLogs();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'show_players',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.speaker_group_rounded),
|
||||||
|
title: Text('View All Players'),
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'copy_logs',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.copy_rounded),
|
||||||
|
title: Text('Copy Logs'),
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'clear_logs',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.delete_rounded),
|
||||||
|
title: Text('Clear Logs'),
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// Filter chips and stats
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
color: Colors.white.withOpacity(0.05),
|
color: Colors.white.withOpacity(0.05),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
// Level filter chips
|
||||||
Icons.info_outline_rounded,
|
SingleChildScrollView(
|
||||||
color: Colors.white70,
|
scrollDirection: Axis.horizontal,
|
||||||
size: 16,
|
child: Row(
|
||||||
),
|
children: [
|
||||||
const SizedBox(width: 8),
|
_buildFilterChip('All', LogLevel.debug),
|
||||||
Expanded(
|
const SizedBox(width: 8),
|
||||||
child: Text(
|
_buildFilterChip('Info+', LogLevel.info),
|
||||||
'Last ${_logger.logs.length} log entries',
|
const SizedBox(width: 8),
|
||||||
style: const TextStyle(
|
_buildFilterChip('Warnings', LogLevel.warning),
|
||||||
color: Colors.white70,
|
const SizedBox(width: 8),
|
||||||
fontSize: 12,
|
_buildFilterChip('Errors', LogLevel.error),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Stats
|
||||||
|
Text(
|
||||||
|
'Showing ${_filteredEntries.length} of ${_logger.entries.length} entries',
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _logger.logs.isEmpty
|
child: _filteredEntries.isEmpty
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No logs yet',
|
'No logs yet',
|
||||||
@@ -225,63 +349,100 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: EdgeInsets.fromLTRB(8, 8, 8, BottomSpacing.navBarOnly + 56),
|
||||||
itemCount: _logger.logs.length,
|
itemCount: _filteredEntries.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final log = _logger.logs[index];
|
final entry = _filteredEntries[index];
|
||||||
final isError = log.contains('Error') ||
|
return _buildLogEntry(entry);
|
||||||
log.contains('error') ||
|
|
||||||
log.contains('ERROR') ||
|
|
||||||
log.contains('failed') ||
|
|
||||||
log.contains('Failed');
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 4),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isError
|
|
||||||
? Colors.red.withOpacity(0.1)
|
|
||||||
: Colors.white.withOpacity(0.03),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
border: Border.all(
|
|
||||||
color: isError
|
|
||||||
? Colors.red.withOpacity(0.3)
|
|
||||||
: Colors.white.withOpacity(0.1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
log,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isError ? Colors.red[300] : Colors.white70,
|
|
||||||
fontSize: 11,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: Padding(
|
||||||
onPressed: () {
|
padding: EdgeInsets.only(bottom: BottomSpacing.navBarOnly),
|
||||||
setState(() {});
|
child: FloatingActionButton(
|
||||||
if (_scrollController.hasClients) {
|
onPressed: () {
|
||||||
_scrollController.animateTo(
|
setState(() {});
|
||||||
_scrollController.position.maxScrollExtent,
|
if (_scrollController.hasClients) {
|
||||||
duration: const Duration(milliseconds: 300),
|
_scrollController.animateTo(
|
||||||
curve: Curves.easeOut,
|
_scrollController.position.maxScrollExtent,
|
||||||
);
|
duration: const Duration(milliseconds: 300),
|
||||||
}
|
curve: Curves.easeOut,
|
||||||
},
|
);
|
||||||
backgroundColor: Colors.white,
|
}
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
},
|
||||||
child: const Icon(Icons.refresh_rounded),
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: const Color(0xFF1a1a1a),
|
||||||
|
child: const Icon(Icons.refresh_rounded),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterChip(String label, LogLevel level) {
|
||||||
|
final isSelected = _filterLevel == level;
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: isSelected,
|
||||||
|
showCheckmark: false,
|
||||||
|
onSelected: (_) {
|
||||||
|
setState(() {
|
||||||
|
_filterLevel = level;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
backgroundColor: Colors.white.withOpacity(0.1),
|
||||||
|
selectedColor: _getLevelColor(level).withOpacity(0.3),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? _getLevelColor(level) : Colors.white70,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
side: BorderSide(
|
||||||
|
color: isSelected ? _getLevelColor(level) : Colors.transparent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLogEntry(LogEntry entry) {
|
||||||
|
final color = _getLevelColor(entry.level);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(color: color.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
entry.formatted,
|
||||||
|
style: TextStyle(
|
||||||
|
color: entry.level == LogLevel.error
|
||||||
|
? Colors.red[300]
|
||||||
|
: entry.level == LogLevel.warning
|
||||||
|
? Colors.orange[300]
|
||||||
|
: Colors.white70,
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getLevelColor(LogLevel level) {
|
||||||
|
switch (level) {
|
||||||
|
case LogLevel.perf:
|
||||||
|
return Colors.purple;
|
||||||
|
case LogLevel.debug:
|
||||||
|
return Colors.grey;
|
||||||
|
case LogLevel.info:
|
||||||
|
return Colors.blue;
|
||||||
|
case LogLevel.warning:
|
||||||
|
return Colors.orange;
|
||||||
|
case LogLevel.error:
|
||||||
|
return Colors.red;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../widgets/mini_player.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/navigation_provider.dart';
|
||||||
|
import '../widgets/global_player_overlay.dart';
|
||||||
import 'new_home_screen.dart';
|
import 'new_home_screen.dart';
|
||||||
import 'new_library_screen.dart';
|
import 'new_library_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
@@ -13,90 +16,106 @@ class HomeScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
class _HomeScreenState extends State<HomeScreen> {
|
||||||
int _selectedIndex = 0;
|
final GlobalKey<SearchScreenState> _searchScreenKey = GlobalKey<SearchScreenState>();
|
||||||
|
DateTime? _lastBackPress;
|
||||||
final List<Widget> _screens = [
|
|
||||||
const NewHomeScreen(),
|
|
||||||
const NewLibraryScreen(),
|
|
||||||
const SearchScreen(),
|
|
||||||
const SettingsScreen(),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
void initState() {
|
||||||
final theme = Theme.of(context);
|
super.initState();
|
||||||
final colorScheme = theme.colorScheme;
|
// Register search focus callback with navigation provider
|
||||||
|
navigationProvider.onSearchTabSelected = () {
|
||||||
|
_searchScreenKey.currentState?.requestFocus();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return PopScope(
|
@override
|
||||||
canPop: _selectedIndex == 0,
|
void dispose() {
|
||||||
onPopInvoked: (didPop) {
|
// Clean up callback
|
||||||
if (didPop) return;
|
navigationProvider.onSearchTabSelected = null;
|
||||||
setState(() {
|
super.dispose();
|
||||||
_selectedIndex = 0;
|
}
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: colorScheme.background,
|
|
||||||
body: _screens[_selectedIndex],
|
|
||||||
bottomNavigationBar: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Mini player
|
|
||||||
const MiniPlayer(),
|
|
||||||
|
|
||||||
// Bottom navigation bar
|
void _showExitSnackBar() {
|
||||||
Container(
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
decoration: BoxDecoration(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
color: colorScheme.surface,
|
SnackBar(
|
||||||
boxShadow: [
|
content: const Text('Press back again to minimize'),
|
||||||
BoxShadow(
|
duration: const Duration(seconds: 2),
|
||||||
color: Colors.black.withOpacity(0.1),
|
behavior: SnackBarBehavior.floating,
|
||||||
blurRadius: 10,
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
offset: const Offset(0, -2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: BottomNavigationBar(
|
|
||||||
currentIndex: _selectedIndex,
|
|
||||||
onTap: (index) {
|
|
||||||
setState(() {
|
|
||||||
_selectedIndex = index;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
selectedItemColor: colorScheme.primary,
|
|
||||||
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
elevation: 0,
|
|
||||||
type: BottomNavigationBarType.fixed,
|
|
||||||
selectedFontSize: 12,
|
|
||||||
unselectedFontSize: 12,
|
|
||||||
items: const [
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.home_outlined),
|
|
||||||
activeIcon: Icon(Icons.home_rounded),
|
|
||||||
label: 'Home',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.library_music_outlined),
|
|
||||||
activeIcon: Icon(Icons.library_music_rounded),
|
|
||||||
label: 'Library',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.search_rounded),
|
|
||||||
activeIcon: Icon(Icons.search_rounded),
|
|
||||||
label: 'Search',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.settings_outlined),
|
|
||||||
activeIcon: Icon(Icons.settings_rounded),
|
|
||||||
label: 'Settings',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: navigationProvider,
|
||||||
|
builder: (context, _) {
|
||||||
|
final selectedIndex = navigationProvider.selectedIndex;
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (didPop) return;
|
||||||
|
|
||||||
|
// If global player is expanded, collapse it first
|
||||||
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not on home tab, navigate to home
|
||||||
|
if (selectedIndex != 0) {
|
||||||
|
// If leaving Settings, show player again
|
||||||
|
if (selectedIndex == 3) {
|
||||||
|
GlobalPlayerOverlay.showPlayer();
|
||||||
|
}
|
||||||
|
navigationProvider.setSelectedIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On home tab - check for double press to minimize
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastBackPress != null &&
|
||||||
|
now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
||||||
|
// Double press detected - minimize app (move to background)
|
||||||
|
// This keeps the app running and connection alive
|
||||||
|
SystemNavigator.pop();
|
||||||
|
} else {
|
||||||
|
// First press, show message
|
||||||
|
_lastBackPress = now;
|
||||||
|
_showExitSnackBar();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
// No bottomNavigationBar here - it's now in GlobalPlayerOverlay
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
// Home and Library use IndexedStack for state preservation
|
||||||
|
Offstage(
|
||||||
|
offstage: selectedIndex > 1,
|
||||||
|
child: IndexedStack(
|
||||||
|
index: selectedIndex.clamp(0, 1),
|
||||||
|
children: const [
|
||||||
|
NewHomeScreen(),
|
||||||
|
NewLibraryScreen(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Search and Settings are conditionally rendered (removed from tree when not visible)
|
||||||
|
if (selectedIndex == 2)
|
||||||
|
SearchScreen(key: _searchScreenKey),
|
||||||
|
if (selectedIndex == 3)
|
||||||
|
const SettingsScreen(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import 'package:provider/provider.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../widgets/album_card.dart';
|
import '../widgets/album_card.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
|
|
||||||
class LibraryAlbumsScreen extends StatelessWidget {
|
class LibraryAlbumsScreen extends StatelessWidget {
|
||||||
const LibraryAlbumsScreen({super.key});
|
const LibraryAlbumsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
// Use Selector for targeted rebuilds - only rebuild when albums or loading state changes
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
@@ -32,49 +33,28 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: _buildAlbumsList(context, provider),
|
body: Selector<MusicAssistantProvider, (List<Album>, bool)>(
|
||||||
|
selector: (_, provider) => (provider.albums, provider.isLoading),
|
||||||
|
builder: (context, data, _) {
|
||||||
|
final (albums, isLoading) = data;
|
||||||
|
return _buildAlbumsList(context, albums, isLoading);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAlbumsList(BuildContext context, MusicAssistantProvider provider) {
|
Widget _buildAlbumsList(BuildContext context, List<Album> albums, bool isLoading) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (provider.isLoading) {
|
if (isLoading) {
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
return Center(
|
return EmptyState.albums(
|
||||||
child: Column(
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.album_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No albums found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: provider.loadLibrary,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +62,13 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await provider.loadLibrary();
|
await context.read<MusicAssistantProvider>().loadLibrary();
|
||||||
},
|
},
|
||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
|
key: const PageStorageKey<String>('library_albums_full_grid'),
|
||||||
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Cards have their own keep-alive
|
||||||
|
addRepaintBoundaries: false, // Cards have RepaintBoundary
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
childAspectRatio: 0.75,
|
childAspectRatio: 0.75,
|
||||||
@@ -92,10 +76,14 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 12,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
itemCount: provider.albums.length,
|
itemCount: albums.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final album = provider.albums[index];
|
final album = albums[index];
|
||||||
return AlbumCard(album: album);
|
return AlbumCard(
|
||||||
|
key: ValueKey(album.uri ?? album.itemId),
|
||||||
|
album: album,
|
||||||
|
heroTagSuffix: 'library',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
|
import '../widgets/artist_avatar.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
|
||||||
|
|
||||||
class LibraryArtistsScreen extends StatelessWidget {
|
class LibraryArtistsScreen extends StatelessWidget {
|
||||||
const LibraryArtistsScreen({super.key});
|
const LibraryArtistsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
@@ -33,49 +35,28 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: _buildArtistsList(context, provider),
|
body: Selector<MusicAssistantProvider, (List<Artist>, bool)>(
|
||||||
|
selector: (_, provider) => (provider.artists, provider.isLoading),
|
||||||
|
builder: (context, data, _) {
|
||||||
|
final (artists, isLoading) = data;
|
||||||
|
return _buildArtistsList(context, artists, isLoading);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistsList(BuildContext context, MusicAssistantProvider provider) {
|
Widget _buildArtistsList(BuildContext context, List<Artist> artists, bool isLoading) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (provider.isLoading) {
|
if (isLoading) {
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.artists.isEmpty) {
|
if (artists.isEmpty) {
|
||||||
return Center(
|
return EmptyState.artists(
|
||||||
child: Column(
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.person_outline_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No artists found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: provider.loadLibrary,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,57 +64,65 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await provider.loadLibrary();
|
await context.read<MusicAssistantProvider>().loadLibrary();
|
||||||
},
|
},
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: provider.artists.length,
|
key: const PageStorageKey<String>('library_artists_full_list'),
|
||||||
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles have their own keep-alive
|
||||||
|
addRepaintBoundaries: false, // Tiles have RepaintBoundary
|
||||||
|
itemCount: artists.length,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final artist = provider.artists[index];
|
final artist = artists[index];
|
||||||
return _buildArtistTile(context, artist, provider);
|
return _buildArtistTile(
|
||||||
|
context,
|
||||||
|
artist,
|
||||||
|
key: ValueKey(artist.uri ?? artist.itemId),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistTile(BuildContext context, Artist artist, MusicAssistantProvider provider) {
|
Widget _buildArtistTile(
|
||||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
BuildContext context,
|
||||||
|
Artist artist, {
|
||||||
|
Key? key,
|
||||||
|
}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
// Get image URL for hero animation
|
||||||
|
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||||
|
|
||||||
return Builder(
|
// RepaintBoundary isolates repaints to individual tiles
|
||||||
builder: (context) => ListTile(
|
return RepaintBoundary(
|
||||||
leading: Hero(
|
child: ListTile(
|
||||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
|
key: key,
|
||||||
child: CircleAvatar(
|
leading: ArtistAvatar(
|
||||||
radius: 24,
|
artist: artist,
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
radius: 24,
|
||||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
imageSize: 128,
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
title: Hero(
|
title: Text(
|
||||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId),
|
artist.name,
|
||||||
child: Material(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: Colors.transparent,
|
color: colorScheme.onSurface,
|
||||||
child: Text(
|
fontWeight: FontWeight.w500,
|
||||||
artist.name,
|
|
||||||
style: textTheme.titleMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
FadeSlidePageRoute(
|
||||||
builder: (context) => ArtistDetailsScreen(artist: artist),
|
child: ArtistDetailsScreen(
|
||||||
|
artist: artist,
|
||||||
|
heroTagSuffix: 'library',
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
import 'playlist_details_screen.dart';
|
import 'playlist_details_screen.dart';
|
||||||
|
|
||||||
class LibraryPlaylistsScreen extends StatefulWidget {
|
class LibraryPlaylistsScreen extends StatefulWidget {
|
||||||
@@ -79,36 +81,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_playlists.isEmpty) {
|
if (_playlists.isEmpty) {
|
||||||
return Center(
|
return EmptyState.playlists(onRefresh: _loadPlaylists);
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.playlist_play_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No playlists found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: _loadPlaylists,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
@@ -118,6 +91,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: _playlists.length,
|
itemCount: _playlists.length,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
||||||
|
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final playlist = _playlists[index];
|
final playlist = _playlists[index];
|
||||||
return _buildPlaylistTile(context, playlist, provider);
|
return _buildPlaylistTile(context, playlist, provider);
|
||||||
@@ -131,8 +107,9 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return Builder(
|
return RepaintBoundary(
|
||||||
builder: (context) => ListTile(
|
child: ListTile(
|
||||||
|
key: ValueKey(playlist.itemId),
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
@@ -141,7 +118,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: imageUrl != null
|
image: imageUrl != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
image: NetworkImage(imageUrl),
|
image: CachedNetworkImageProvider(imageUrl),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -1,455 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../providers/music_assistant_provider.dart';
|
|
||||||
import '../models/media_item.dart';
|
|
||||||
import 'album_details_screen.dart';
|
|
||||||
import 'settings_screen.dart';
|
|
||||||
|
|
||||||
class LibraryScreen extends StatefulWidget {
|
|
||||||
const LibraryScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<LibraryScreen> createState() => _LibraryScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LibraryScreenState extends State<LibraryScreen>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late TabController _tabController;
|
|
||||||
final _searchController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_tabController.dispose();
|
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: colorScheme.background,
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
title: Text(
|
|
||||||
'Library',
|
|
||||||
style: textTheme.titleLarge?.copyWith(
|
|
||||||
color: colorScheme.onBackground,
|
|
||||||
fontWeight: FontWeight.w300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.settings_rounded),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const SettingsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
color: colorScheme.onBackground,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
bottom: TabBar(
|
|
||||||
controller: _tabController,
|
|
||||||
indicatorColor: colorScheme.primary,
|
|
||||||
labelColor: colorScheme.primary,
|
|
||||||
unselectedLabelColor: colorScheme.onSurfaceVariant,
|
|
||||||
tabs: const [
|
|
||||||
Tab(text: 'Artists'),
|
|
||||||
Tab(text: 'Albums'),
|
|
||||||
Tab(text: 'Tracks'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: !provider.isConnected
|
|
||||||
? _buildDisconnectedView(provider)
|
|
||||||
: TabBarView(
|
|
||||||
controller: _tabController,
|
|
||||||
children: [
|
|
||||||
_buildArtistsList(provider),
|
|
||||||
_buildAlbumsList(provider),
|
|
||||||
_buildTracksList(provider),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDisconnectedView(MusicAssistantProvider provider) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.cloud_off_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Not connected to Music Assistant',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const SettingsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.settings_rounded),
|
|
||||||
label: const Text('Configure Server'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.primary,
|
|
||||||
foregroundColor: colorScheme.onPrimary,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 24,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildArtistsList(MusicAssistantProvider provider) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
if (provider.isLoading) {
|
|
||||||
return Center(
|
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider.artists.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.person_outline_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No artists found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: provider.loadLibrary,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: provider.artists.length,
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final artist = provider.artists[index];
|
|
||||||
return _buildArtistTile(artist, provider);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildArtistTile(Artist artist, MusicAssistantProvider provider) {
|
|
||||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
radius: 24,
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
artist.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
// Load albums for this artist
|
|
||||||
await provider.loadAlbums(artistId: '${artist.provider}/${artist.itemId}');
|
|
||||||
if (context.mounted) {
|
|
||||||
_tabController.animateTo(1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAlbumsList(MusicAssistantProvider provider) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
if (provider.isLoading) {
|
|
||||||
return Center(
|
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider.albums.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.album_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No albums found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: provider.loadLibrary,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return GridView.builder(
|
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: 2,
|
|
||||||
childAspectRatio: 0.75,
|
|
||||||
crossAxisSpacing: 12,
|
|
||||||
mainAxisSpacing: 12,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
itemCount: provider.albums.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final album = provider.albums[index];
|
|
||||||
return _buildAlbumCard(album, provider);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAlbumCard(Album album, MusicAssistantProvider provider) {
|
|
||||||
final imageUrl = provider.getImageUrl(album, size: 256);
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return InkWell(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => AlbumDetailsScreen(album: album),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceVariant,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
image: imageUrl != null
|
|
||||||
? DecorationImage(
|
|
||||||
image: NetworkImage(imageUrl),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: imageUrl == null
|
|
||||||
? Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.album_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
album.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
album.artistsString,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTracksList(MusicAssistantProvider provider) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
if (provider.isLoading) {
|
|
||||||
return Center(
|
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider.tracks.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.music_note_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No tracks found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: provider.loadLibrary,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: provider.tracks.length,
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final track = provider.tracks[index];
|
|
||||||
return _buildTrackTile(track, provider);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTrackTile(Track track, MusicAssistantProvider provider) {
|
|
||||||
final imageUrl = track.album != null
|
|
||||||
? provider.getImageUrl(track.album!, size: 128)
|
|
||||||
: null;
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceVariant,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
image: imageUrl != null
|
|
||||||
? DecorationImage(
|
|
||||||
image: NetworkImage(imageUrl),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
track.name,
|
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
track.artistsString,
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.7), fontSize: 12),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
trailing: track.duration != null
|
|
||||||
? Text(
|
|
||||||
_formatDuration(track.duration!),
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54), fontSize: 12),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
// TODO: Play track
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDuration(Duration duration) {
|
|
||||||
final minutes = duration.inMinutes;
|
|
||||||
final seconds = duration.inSeconds % 60;
|
|
||||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
|
|
||||||
class LibraryTracksScreen extends StatelessWidget {
|
class LibraryTracksScreen extends StatelessWidget {
|
||||||
const LibraryTracksScreen({super.key});
|
const LibraryTracksScreen({super.key});
|
||||||
@@ -46,41 +48,15 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider.tracks.isEmpty) {
|
if (provider.tracks.isEmpty) {
|
||||||
return Center(
|
return EmptyState.tracks(onRefresh: provider.loadLibrary);
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.music_note_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No tracks found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: provider.loadLibrary,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: provider.tracks.length,
|
itemCount: provider.tracks.length,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
||||||
|
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final track = provider.tracks[index];
|
final track = provider.tracks[index];
|
||||||
return _buildTrackTile(context, track, provider, index);
|
return _buildTrackTile(context, track, provider, index);
|
||||||
@@ -100,43 +76,46 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return ListTile(
|
return RepaintBoundary(
|
||||||
leading: Container(
|
child: ListTile(
|
||||||
width: 48,
|
key: ValueKey(track.uri ?? track.itemId),
|
||||||
height: 48,
|
leading: Container(
|
||||||
decoration: BoxDecoration(
|
width: 48,
|
||||||
color: colorScheme.surfaceVariant,
|
height: 48,
|
||||||
borderRadius: BorderRadius.circular(8),
|
decoration: BoxDecoration(
|
||||||
image: imageUrl != null
|
color: colorScheme.surfaceVariant,
|
||||||
? DecorationImage(
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: NetworkImage(imageUrl),
|
image: imageUrl != null
|
||||||
fit: BoxFit.cover,
|
? DecorationImage(
|
||||||
)
|
image: CachedNetworkImageProvider(imageUrl),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: imageUrl == null
|
||||||
|
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
title: Text(
|
||||||
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
track.name,
|
||||||
|
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
track.artistsString,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: track.duration != null
|
||||||
|
? Text(
|
||||||
|
_formatDuration(track.duration!),
|
||||||
|
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
|
onTap: () => _playTrack(context, maProvider, index),
|
||||||
),
|
),
|
||||||
title: Text(
|
|
||||||
track.name,
|
|
||||||
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
track.artistsString,
|
|
||||||
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
trailing: track.duration != null
|
|
||||||
? Text(
|
|
||||||
_formatDuration(track.duration!),
|
|
||||||
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: () => _playTrack(context, maProvider, index),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth/auth_strategy.dart';
|
||||||
import '../widgets/logo_text.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../widgets/debug/debug_console.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
@@ -15,41 +16,62 @@ class LoginScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _LoginScreenState extends State<LoginScreen> {
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
final TextEditingController _serverUrlController = TextEditingController();
|
final TextEditingController _serverUrlController = TextEditingController();
|
||||||
final TextEditingController _portController = TextEditingController(text: '8095');
|
final TextEditingController _ownerNameController = TextEditingController();
|
||||||
|
final TextEditingController _portController = TextEditingController();
|
||||||
|
final TextEditingController _authServerUrlController = TextEditingController(); // For separate auth server (Authelia)
|
||||||
final TextEditingController _usernameController = TextEditingController();
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
final AuthService _authService = AuthService();
|
final FocusNode _usernameFocusNode = FocusNode();
|
||||||
|
|
||||||
bool _isConnecting = false;
|
bool _isConnecting = false;
|
||||||
bool _showAdvanced = false;
|
bool _isDetectingAuth = false;
|
||||||
bool _requiresAuth = false;
|
AuthStrategy? _detectedAuthStrategy;
|
||||||
|
String? _detectedAuthType;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
bool _showDebug = false;
|
||||||
|
List<String> _debugLogs = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadSavedPort();
|
_loadSavedSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSavedPort() async {
|
Future<void> _loadSavedSettings() async {
|
||||||
final savedPort = await SettingsService.getWebSocketPort();
|
final savedPort = await SettingsService.getWebSocketPort();
|
||||||
|
final savedAuthServerUrl = await SettingsService.getAuthServerUrl();
|
||||||
|
final savedOwnerName = await SettingsService.getOwnerName();
|
||||||
|
|
||||||
if (savedPort != null) {
|
if (savedPort != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_portController.text = savedPort.toString();
|
_portController.text = savedPort.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (savedAuthServerUrl != null) {
|
||||||
|
setState(() {
|
||||||
|
_authServerUrlController.text = savedAuthServerUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedOwnerName != null) {
|
||||||
|
setState(() {
|
||||||
|
_ownerNameController.text = savedOwnerName;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_serverUrlController.dispose();
|
_serverUrlController.dispose();
|
||||||
|
_ownerNameController.dispose();
|
||||||
_portController.dispose();
|
_portController.dispose();
|
||||||
|
_authServerUrlController.dispose();
|
||||||
_usernameController.dispose();
|
_usernameController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_usernameFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... existing code ...
|
|
||||||
|
|
||||||
String _normalizeServerUrl(String url) {
|
String _normalizeServerUrl(String url) {
|
||||||
// Remove any trailing slashes
|
// Remove any trailing slashes
|
||||||
@@ -76,6 +98,134 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
return 'https://$url';
|
return 'https://$url';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _buildServerUrl() {
|
||||||
|
// Normalize the base URL
|
||||||
|
var url = _normalizeServerUrl(_serverUrlController.text.trim());
|
||||||
|
|
||||||
|
// Parse to check if port is already included
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
|
||||||
|
// Get port from field
|
||||||
|
final portText = _portController.text.trim();
|
||||||
|
if (portText.isEmpty) {
|
||||||
|
return url; // No port specified, use URL as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
final port = int.tryParse(portText);
|
||||||
|
if (port == null) {
|
||||||
|
return url; // Invalid port, use URL as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip adding port if it's the default for the scheme
|
||||||
|
final isDefaultPort = (uri.scheme == 'https' && port == 443) ||
|
||||||
|
(uri.scheme == 'http' && port == 80);
|
||||||
|
|
||||||
|
if (isDefaultPort) {
|
||||||
|
return url; // Don't add default ports
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build URL with custom port
|
||||||
|
return '${uri.scheme}://${uri.host}:$port';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addDebugLog(String message) {
|
||||||
|
setState(() {
|
||||||
|
_debugLogs.add('[${DateTime.now().toString().substring(11, 19)}] $message');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _detectAuthRequirements() async {
|
||||||
|
if (_serverUrlController.text.trim().isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Please enter your Music Assistant server address';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isDetectingAuth = true;
|
||||||
|
_error = null;
|
||||||
|
_detectedAuthStrategy = null;
|
||||||
|
_detectedAuthType = null;
|
||||||
|
_debugLogs.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final serverUrl = _buildServerUrl();
|
||||||
|
_addDebugLog('Built server URL: $serverUrl');
|
||||||
|
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
_addDebugLog('Starting auth detection...');
|
||||||
|
// Auto-detect authentication requirements
|
||||||
|
final strategy = await provider.authManager.detectAuthStrategy(serverUrl);
|
||||||
|
|
||||||
|
_addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
|
||||||
|
|
||||||
|
if (strategy == null) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Cannot determine authentication requirements. Please check server URL.';
|
||||||
|
_isDetectingAuth = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_detectedAuthStrategy = strategy;
|
||||||
|
_detectedAuthType = _getAuthTypeName(strategy.name);
|
||||||
|
_isDetectingAuth = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no auth required, connect immediately
|
||||||
|
if (strategy.name == 'none') {
|
||||||
|
await _connect();
|
||||||
|
} else {
|
||||||
|
// Auth required - focus username field after a brief delay
|
||||||
|
// to allow the new fields to build
|
||||||
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
|
if (mounted) {
|
||||||
|
_usernameFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Auth detection failed: ${e.toString()}';
|
||||||
|
_isDetectingAuth = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getAuthTypeName(String strategyName) {
|
||||||
|
switch (strategyName) {
|
||||||
|
case 'none':
|
||||||
|
return 'No Authentication';
|
||||||
|
case 'basic':
|
||||||
|
return 'HTTP Basic Auth';
|
||||||
|
case 'authelia':
|
||||||
|
return 'Authelia';
|
||||||
|
case 'music_assistant':
|
||||||
|
return 'Music Assistant Login';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getAuthIcon(String strategyName) {
|
||||||
|
switch (strategyName) {
|
||||||
|
case 'none':
|
||||||
|
return Icons.lock_open_rounded;
|
||||||
|
case 'basic':
|
||||||
|
return Icons.vpn_key_rounded;
|
||||||
|
case 'authelia':
|
||||||
|
return Icons.shield_rounded;
|
||||||
|
case 'music_assistant':
|
||||||
|
return Icons.music_note_rounded;
|
||||||
|
default:
|
||||||
|
return Icons.security_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _connect() async {
|
Future<void> _connect() async {
|
||||||
if (_serverUrlController.text.trim().isEmpty) {
|
if (_serverUrlController.text.trim().isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -84,40 +234,52 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate owner name - only required if NOT using MA auth
|
||||||
|
// (MA auth will get display_name from user profile after login)
|
||||||
|
final isMaAuth = _detectedAuthStrategy?.name == 'music_assistant';
|
||||||
|
if (!isMaAuth && _ownerNameController.text.trim().isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Please enter your name';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isConnecting = true;
|
_isConnecting = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final serverUrl = _normalizeServerUrl(_serverUrlController.text.trim());
|
final serverUrl = _buildServerUrl();
|
||||||
final port = _portController.text.trim();
|
final port = _portController.text.trim();
|
||||||
|
|
||||||
// Validate port
|
// Validate port (if provided)
|
||||||
if (port.isEmpty) {
|
int? portNum;
|
||||||
setState(() {
|
if (port.isNotEmpty) {
|
||||||
_error = 'Please enter a port number';
|
portNum = int.tryParse(port);
|
||||||
_isConnecting = false;
|
if (portNum == null || portNum < 1 || portNum > 65535) {
|
||||||
});
|
setState(() {
|
||||||
return;
|
_error = 'Please enter a valid port number (1-65535)';
|
||||||
|
_isConnecting = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final portNum = int.tryParse(port);
|
// Save owner name (if provided) and port to settings
|
||||||
if (portNum == null || portNum < 1 || portNum > 65535) {
|
// For MA auth, the owner name will be set from user profile after login
|
||||||
setState(() {
|
if (!isMaAuth && _ownerNameController.text.trim().isNotEmpty) {
|
||||||
_error = 'Please enter a valid port number (1-65535)';
|
await SettingsService.setOwnerName(_ownerNameController.text.trim());
|
||||||
_isConnecting = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save port to settings
|
|
||||||
await SettingsService.setWebSocketPort(portNum);
|
await SettingsService.setWebSocketPort(portNum);
|
||||||
|
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
// Handle authentication if needed
|
// Handle authentication based on detected strategy
|
||||||
if (_requiresAuth) {
|
// Note: isMaAuth is already defined earlier in this method
|
||||||
|
|
||||||
|
if (_detectedAuthStrategy != null && _detectedAuthStrategy!.name != 'none' && !isMaAuth) {
|
||||||
|
// Pre-connect auth (Authelia, Basic Auth)
|
||||||
final username = _usernameController.text.trim();
|
final username = _usernameController.text.trim();
|
||||||
final password = _passwordController.text.trim();
|
final password = _passwordController.text.trim();
|
||||||
|
|
||||||
@@ -129,9 +291,25 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt login
|
// Get auth server URL (if provided for Authelia)
|
||||||
final token = await _authService.login(serverUrl, username, password);
|
String? authServerUrl;
|
||||||
if (token == null) {
|
if (_detectedAuthStrategy!.name == 'authelia') {
|
||||||
|
final authUrl = _authServerUrlController.text.trim();
|
||||||
|
if (authUrl.isNotEmpty) {
|
||||||
|
authServerUrl = _normalizeServerUrl(authUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt login with detected strategy
|
||||||
|
final success = await provider.authManager.login(
|
||||||
|
serverUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
_detectedAuthStrategy!,
|
||||||
|
authServerUrl: authServerUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Authentication failed. Please check your credentials.';
|
_error = 'Authentication failed. Please check your credentials.';
|
||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
@@ -139,20 +317,96 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save credentials
|
// Save credentials for future auto-login
|
||||||
await SettingsService.setUsername(username);
|
await SettingsService.setUsername(username);
|
||||||
await SettingsService.setPassword(password);
|
await SettingsService.setPassword(password);
|
||||||
|
|
||||||
|
// Save auth server URL if provided
|
||||||
|
if (authServerUrl != null && authServerUrl.isNotEmpty) {
|
||||||
|
await SettingsService.setAuthServerUrl(authServerUrl);
|
||||||
|
} else {
|
||||||
|
await SettingsService.setAuthServerUrl(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save auth credentials to settings
|
||||||
|
final serialized = provider.authManager.serializeCredentials();
|
||||||
|
if (serialized != null) {
|
||||||
|
await SettingsService.setAuthCredentials(serialized);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to server
|
// Connect to server
|
||||||
await provider.connectToServer(serverUrl);
|
await provider.connectToServer(serverUrl);
|
||||||
|
|
||||||
// Wait a moment for connection to establish
|
// Post-connect auth (Music Assistant native auth)
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
if (isMaAuth) {
|
||||||
|
final username = _usernameController.text.trim();
|
||||||
|
final password = _passwordController.text.trim();
|
||||||
|
|
||||||
|
if (username.isEmpty || password.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Please enter username and password';
|
||||||
|
_isConnecting = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_addDebugLog('Authenticating with Music Assistant...');
|
||||||
|
|
||||||
|
// Check if we have a stored MA token first
|
||||||
|
final storedToken = await SettingsService.getMaAuthToken();
|
||||||
|
bool authSuccess = false;
|
||||||
|
|
||||||
|
if (storedToken != null && provider.api != null) {
|
||||||
|
_addDebugLog('Trying stored MA token...');
|
||||||
|
authSuccess = await provider.api!.authenticateWithToken(storedToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authSuccess) {
|
||||||
|
_addDebugLog('Logging in with credentials...');
|
||||||
|
// Login with credentials over WebSocket
|
||||||
|
final accessToken = await provider.api?.loginWithCredentials(username, password);
|
||||||
|
|
||||||
|
if (accessToken == null) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Music Assistant login failed. Please check your credentials.';
|
||||||
|
_isConnecting = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create a long-lived token for future use
|
||||||
|
final longLivedToken = await provider.api?.createLongLivedToken();
|
||||||
|
final tokenToStore = longLivedToken ?? accessToken;
|
||||||
|
|
||||||
|
// Save the token for future auto-login
|
||||||
|
await SettingsService.setMaAuthToken(tokenToStore);
|
||||||
|
_addDebugLog('MA token saved for future logins');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save credentials for future auto-login
|
||||||
|
await SettingsService.setUsername(username);
|
||||||
|
await SettingsService.setPassword(password);
|
||||||
|
|
||||||
|
// Save auth strategy info
|
||||||
|
await SettingsService.setAuthCredentials({
|
||||||
|
'strategy': 'music_assistant',
|
||||||
|
'data': {'username': username},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for connection and authentication to complete
|
||||||
|
// MA auth happens asynchronously after connect, so we need to wait longer
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
if (provider.isConnected) break;
|
||||||
|
}
|
||||||
|
|
||||||
if (provider.isConnected) {
|
if (provider.isConnected) {
|
||||||
// Navigate to home screen
|
// Navigate to home screen
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
// Ensure keyboard is closed before navigating
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute(builder: (context) => const HomeScreen()),
|
MaterialPageRoute(builder: (context) => const HomeScreen()),
|
||||||
);
|
);
|
||||||
@@ -176,24 +430,31 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
// Determine if we need auth fields
|
||||||
|
final needsAuth = _detectedAuthStrategy != null && _detectedAuthStrategy!.name != 'none';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 78.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 60),
|
const SizedBox(height: 60),
|
||||||
|
|
||||||
// Logo
|
// Logo
|
||||||
Center(
|
Builder(
|
||||||
child: Image.asset(
|
builder: (context) {
|
||||||
'assets/images/attm_long_logo.png',
|
final width = MediaQuery.of(context).size.width * 0.5;
|
||||||
height: 160,
|
return Center(
|
||||||
fit: BoxFit.contain,
|
child: Image.asset(
|
||||||
color: colorScheme.onBackground, // Tints the logo to match theme
|
'assets/images/ensemble_icon_transparent.png',
|
||||||
),
|
width: width,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
@@ -225,31 +486,76 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
enabled: !_isConnecting,
|
enabled: !_isConnecting && !_isDetectingAuth,
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
),
|
),
|
||||||
|
|
||||||
// ... rest of UI ...
|
|
||||||
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Your Name - only show AFTER auth detection AND only for non-MA auth
|
||||||
|
// (MA auth gets display_name from user profile automatically)
|
||||||
|
// Show for: 'none' (no auth), 'authelia', 'basic_auth', etc.
|
||||||
|
// Hide for: 'music_assistant' (gets name from profile)
|
||||||
|
if (_detectedAuthStrategy != null &&
|
||||||
|
_detectedAuthStrategy!.name != 'music_assistant') ...[
|
||||||
|
Text(
|
||||||
|
'Your Name',
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
controller: _ownerNameController,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Your first name',
|
||||||
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabled: !_isConnecting && !_isDetectingAuth,
|
||||||
|
keyboardType: TextInputType.name,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
|
||||||
// Port
|
// Port
|
||||||
Text(
|
Text(
|
||||||
'Port',
|
'Port (Optional)',
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _portController,
|
controller: _portController,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: '8095',
|
hintText: 'e.g., 8095 or leave blank',
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
@@ -262,17 +568,109 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
enabled: !_isConnecting,
|
enabled: !_isConnecting && !_isDetectingAuth,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
textInputAction: _requiresAuth ? TextInputAction.next : TextInputAction.done,
|
textInputAction: needsAuth ? TextInputAction.next : TextInputAction.done,
|
||||||
onSubmitted: (_) => _requiresAuth ? null : _connect(),
|
onSubmitted: (_) => needsAuth ? null : _detectedAuthStrategy == null ? _detectAuthRequirements() : _connect(),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Authentication fields
|
// Detected auth method indicator
|
||||||
if (_requiresAuth) ...[
|
if (_detectedAuthType != null)
|
||||||
const SizedBox(height: 16),
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: needsAuth
|
||||||
|
? colorScheme.primaryContainer.withOpacity(0.3)
|
||||||
|
: colorScheme.tertiaryContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getAuthIcon(_detectedAuthStrategy!.name),
|
||||||
|
color: needsAuth ? colorScheme.primary : colorScheme.tertiary,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Detected: $_detectedAuthType',
|
||||||
|
style: TextStyle(
|
||||||
|
color: needsAuth
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onTertiaryContainer,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (needsAuth)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
'This server requires authentication',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimaryContainer.withOpacity(0.7),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Authentication fields (shown only if auth required)
|
||||||
|
if (needsAuth) ...[
|
||||||
|
// Auth Server URL (only for Authelia)
|
||||||
|
if (_detectedAuthStrategy?.name == 'authelia') ...[
|
||||||
|
Text(
|
||||||
|
'Auth Server URL (Optional)',
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Leave empty if authentication is on the same server',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
controller: _authServerUrlController,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'e.g., auth.example.com (if different from server)',
|
||||||
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.shield_rounded,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabled: !_isConnecting,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'Username',
|
'Username',
|
||||||
@@ -285,6 +683,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
|
focusNode: _usernameFocusNode,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Username',
|
hintText: 'Username',
|
||||||
@@ -337,9 +736,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (_) => _connect(),
|
onSubmitted: (_) => _connect(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Error message
|
// Error message
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
@@ -365,9 +766,26 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Connect button
|
// Debug toggle button
|
||||||
|
DebugToggleButton(
|
||||||
|
isVisible: _showDebug,
|
||||||
|
onToggle: () => setState(() => _showDebug = !_showDebug),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Debug panel
|
||||||
|
if (_showDebug) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DebugConsole(
|
||||||
|
onClear: () => setState(() => _debugLogs.clear()),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Connect button (changes based on state)
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _isConnecting ? null : _connect,
|
onPressed: (_isConnecting || _isDetectingAuth)
|
||||||
|
? null
|
||||||
|
: (_detectedAuthStrategy == null ? _detectAuthRequirements : _connect),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colorScheme.primary,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: colorScheme.onPrimary,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
@@ -377,22 +795,44 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
child: _isConnecting
|
child: _isDetectingAuth
|
||||||
? SizedBox(
|
? Row(
|
||||||
height: 20,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
width: 20,
|
children: [
|
||||||
child: CircularProgressIndicator(
|
SizedBox(
|
||||||
strokeWidth: 2,
|
height: 20,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
|
width: 20,
|
||||||
),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'Detecting authentication...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
: const Text(
|
: _isConnecting
|
||||||
'Connect',
|
? SizedBox(
|
||||||
style: TextStyle(
|
height: 20,
|
||||||
fontSize: 16,
|
width: 20,
|
||||||
fontWeight: FontWeight.bold,
|
child: CircularProgressIndicator(
|
||||||
),
|
strokeWidth: 2,
|
||||||
),
|
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_detectedAuthStrategy == null ? 'Detect & Connect' : 'Connect',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,18 +1,103 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/player_selector.dart';
|
import '../widgets/player_selector.dart';
|
||||||
import '../widgets/album_row.dart';
|
import '../widgets/album_row.dart';
|
||||||
import '../widgets/artist_row.dart';
|
import '../widgets/artist_row.dart';
|
||||||
import '../widgets/logo_text.dart';
|
import '../widgets/track_row.dart';
|
||||||
|
import '../widgets/common/disconnected_state.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
|
|
||||||
class NewHomeScreen extends StatelessWidget {
|
class NewHomeScreen extends StatefulWidget {
|
||||||
const NewHomeScreen({super.key});
|
const NewHomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NewHomeScreen> createState() => _NewHomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
Key _refreshKey = UniqueKey();
|
||||||
|
// Main rows (default on)
|
||||||
|
bool _showRecentAlbums = true;
|
||||||
|
bool _showDiscoverArtists = true;
|
||||||
|
bool _showDiscoverAlbums = true;
|
||||||
|
// Favorites rows (default off)
|
||||||
|
bool _showFavoriteAlbums = false;
|
||||||
|
bool _showFavoriteArtists = false;
|
||||||
|
bool _showFavoriteTracks = false;
|
||||||
|
// Random order for favorites (generated once per session)
|
||||||
|
late List<int> _favoritesOrder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
// Generate random order for favorites rows (0, 1, 2 shuffled)
|
||||||
|
_favoritesOrder = [0, 1, 2]..shuffle();
|
||||||
|
_loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// Reload settings when app resumes (coming back from settings)
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_loadSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSettings() async {
|
||||||
|
final showRecent = await SettingsService.getShowRecentAlbums();
|
||||||
|
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
|
||||||
|
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
|
||||||
|
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||||
|
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||||
|
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showRecentAlbums = showRecent;
|
||||||
|
_showDiscoverArtists = showDiscArtists;
|
||||||
|
_showDiscoverAlbums = showDiscAlbums;
|
||||||
|
_showFavoriteAlbums = showFavAlbums;
|
||||||
|
_showFavoriteArtists = showFavArtists;
|
||||||
|
_showFavoriteTracks = showFavTracks;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRefresh() async {
|
||||||
|
// Invalidate cache to force fresh data on pull-to-refresh
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
provider.invalidateHomeCache();
|
||||||
|
|
||||||
|
// Reload settings in case they changed
|
||||||
|
await _loadSettings();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_refreshKey = UniqueKey();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
// Settings are loaded in initState and didChangeAppLifecycleState - no need to reload on every build
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -21,147 +106,189 @@ class NewHomeScreen extends StatelessWidget {
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Padding(
|
title: Padding(
|
||||||
padding: const EdgeInsets.only(left: 8.0, top: 8.0, bottom: 8.0),
|
padding: const EdgeInsets.only(left: 16.0),
|
||||||
child: Image.asset(
|
child: ColorFiltered(
|
||||||
'assets/images/attm_short_logo.png',
|
colorFilter: Theme.of(context).brightness == Brightness.light
|
||||||
height: 32,
|
? const ColorFilter.matrix(<double>[
|
||||||
fit: BoxFit.contain,
|
-1, 0, 0, 0, 255,
|
||||||
alignment: Alignment.centerLeft,
|
0, -1, 0, 0, 255,
|
||||||
color: colorScheme.onBackground,
|
0, 0, -1, 0, 255,
|
||||||
|
0, 0, 0, 1, 0,
|
||||||
|
])
|
||||||
|
: const ColorFilter.mode(Colors.transparent, BlendMode.multiply),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/ensemble_logo.png',
|
||||||
|
height: 40,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
titleSpacing: 0,
|
titleSpacing: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.search),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const SearchScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const PlayerSelector(),
|
const PlayerSelector(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Consumer<MusicAssistantProvider>(
|
child: Selector<MusicAssistantProvider, bool>(
|
||||||
builder: (context, maProvider, child) {
|
selector: (_, p) => p.isConnected,
|
||||||
return !maProvider.isConnected
|
builder: (context, isConnected, child) {
|
||||||
? _buildDisconnectedView(context, maProvider)
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
: _buildConnectedView(context, maProvider);
|
return !isConnected
|
||||||
|
? DisconnectedState.full(
|
||||||
|
onSettings: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _onRefresh,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
child: _buildConnectedView(context, maProvider),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDisconnectedView(
|
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.cloud_off_rounded,
|
|
||||||
size: 80,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
'Not Connected',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'Connect to your Music Assistant server to start listening',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const SettingsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.settings_rounded),
|
|
||||||
label: const Text('Configure Server'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.primary,
|
|
||||||
foregroundColor: colorScheme.onPrimary,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 32,
|
|
||||||
vertical: 16,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildConnectedView(
|
Widget _buildConnectedView(
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
BuildContext context, MusicAssistantProvider provider) {
|
||||||
return SingleChildScrollView(
|
// Use LayoutBuilder to get available screen height
|
||||||
physics: const BouncingScrollPhysics(),
|
return LayoutBuilder(
|
||||||
child: Column(
|
builder: (context, constraints) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
// Simple calculation: available height divided by 3 rows
|
||||||
children: [
|
// Each row includes its title, artwork, and info
|
||||||
const SizedBox(height: 16),
|
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
||||||
|
final rowHeight = availableHeight / 3;
|
||||||
|
|
||||||
// Recently played albums
|
// Build favorites rows in random order
|
||||||
AlbumRow(
|
final favoritesWidgets = _buildFavoritesRows(provider, rowHeight);
|
||||||
title: 'Recently Played',
|
|
||||||
loadAlbums: () async {
|
|
||||||
if (provider.api == null) return [];
|
|
||||||
return await provider.api!.getRecentAlbums(limit: 10);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Discover Artists
|
// Use Android 12+ stretch overscroll effect
|
||||||
ArtistRow(
|
return NotificationListener<ScrollNotification>(
|
||||||
title: 'Discover Artists',
|
onNotification: (notification) {
|
||||||
loadArtists: () async {
|
if (notification is ScrollStartNotification) {
|
||||||
if (provider.api == null) return [];
|
_logger.resetFrameStats();
|
||||||
return await provider.api!.getRandomArtists(limit: 10);
|
_logger.perf('SCROLL START', context: 'HomeScreen');
|
||||||
},
|
} else if (notification is ScrollUpdateNotification) {
|
||||||
),
|
_logger.startFrame();
|
||||||
const SizedBox(height: 16),
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_logger.endFrame();
|
||||||
|
});
|
||||||
|
} else if (notification is ScrollEndNotification) {
|
||||||
|
_logger.perf('SCROLL END', context: 'HomeScreen');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: EdgeInsets.only(bottom: BottomSpacing.withMiniPlayer),
|
||||||
|
child: Column(
|
||||||
|
key: _refreshKey,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Recently played albums (optional)
|
||||||
|
if (_showRecentAlbums)
|
||||||
|
AlbumRow(
|
||||||
|
key: const ValueKey('recent-albums'),
|
||||||
|
title: 'Recently Played',
|
||||||
|
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
|
||||||
// Discover Albums
|
// Discover Artists (optional)
|
||||||
AlbumRow(
|
if (_showDiscoverArtists)
|
||||||
title: 'Discover Albums',
|
ArtistRow(
|
||||||
loadAlbums: () async {
|
key: const ValueKey('discover-artists'),
|
||||||
if (provider.api == null) return [];
|
title: 'Discover Artists',
|
||||||
return await provider.api!.getRandomAlbums(limit: 10);
|
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
||||||
},
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Discover Albums (optional)
|
||||||
|
if (_showDiscoverAlbums)
|
||||||
|
AlbumRow(
|
||||||
|
key: const ValueKey('discover-albums'),
|
||||||
|
title: 'Discover Albums',
|
||||||
|
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Favorites rows in random order
|
||||||
|
...favoritesWidgets,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 80), // Space for mini player
|
);
|
||||||
],
|
},
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build favorites rows in random order (order set once per session)
|
||||||
|
List<Widget> _buildFavoritesRows(MusicAssistantProvider provider, double rowHeight) {
|
||||||
|
// Create list of enabled favorites with their order index
|
||||||
|
final enabledFavorites = <MapEntry<int, Widget>>[];
|
||||||
|
|
||||||
|
if (_showFavoriteAlbums) {
|
||||||
|
enabledFavorites.add(MapEntry(
|
||||||
|
_favoritesOrder[0],
|
||||||
|
AlbumRow(
|
||||||
|
key: const ValueKey('favorite-albums'),
|
||||||
|
title: 'Favorite Albums',
|
||||||
|
loadAlbums: () => provider.getFavoriteAlbums(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_showFavoriteArtists) {
|
||||||
|
enabledFavorites.add(MapEntry(
|
||||||
|
_favoritesOrder[1],
|
||||||
|
ArtistRow(
|
||||||
|
key: const ValueKey('favorite-artists'),
|
||||||
|
title: 'Favorite Artists',
|
||||||
|
loadArtists: () => provider.getFavoriteArtists(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_showFavoriteTracks) {
|
||||||
|
enabledFavorites.add(MapEntry(
|
||||||
|
_favoritesOrder[2],
|
||||||
|
TrackRow(
|
||||||
|
key: const ValueKey('favorite-tracks'),
|
||||||
|
title: 'Favorite Tracks',
|
||||||
|
loadTracks: () => provider.getFavoriteTracks(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by the random order index
|
||||||
|
enabledFavorites.sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
|
||||||
|
return enabledFavorites.map((e) => e.value).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||||
|
class _StretchScrollBehavior extends ScrollBehavior {
|
||||||
|
const _StretchScrollBehavior();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildOverscrollIndicator(
|
||||||
|
BuildContext context, Widget child, ScrollableDetails details) {
|
||||||
|
return StretchingOverscrollIndicator(
|
||||||
|
axisDirection: details.direction,
|
||||||
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,243 +1,637 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/player_selector.dart';
|
import '../widgets/player_selector.dart';
|
||||||
import 'library_artists_screen.dart';
|
import '../widgets/album_card.dart';
|
||||||
import 'library_albums_screen.dart';
|
import '../widgets/artist_avatar.dart';
|
||||||
import 'library_tracks_screen.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import 'library_playlists_screen.dart';
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
|
import '../widgets/common/disconnected_state.dart';
|
||||||
|
import 'artist_details_screen.dart';
|
||||||
|
import 'playlist_details_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'search_screen.dart';
|
|
||||||
|
|
||||||
class NewLibraryScreen extends StatelessWidget {
|
class NewLibraryScreen extends StatefulWidget {
|
||||||
const NewLibraryScreen({super.key});
|
const NewLibraryScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<NewLibraryScreen> createState() => _NewLibraryScreenState();
|
||||||
final provider = context.watch<MusicAssistantProvider>();
|
}
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
class _NewLibraryScreenState extends State<NewLibraryScreen>
|
||||||
backgroundColor: colorScheme.background,
|
with SingleTickerProviderStateMixin, RestorationMixin {
|
||||||
appBar: AppBar(
|
late TabController _tabController;
|
||||||
backgroundColor: Colors.transparent,
|
List<Playlist> _playlists = [];
|
||||||
elevation: 0,
|
List<Track> _favoriteTracks = [];
|
||||||
title: Text(
|
bool _isLoadingPlaylists = true;
|
||||||
'Library',
|
bool _isLoadingTracks = false;
|
||||||
style: textTheme.titleLarge?.copyWith(
|
bool _showFavoritesOnly = false;
|
||||||
color: colorScheme.onBackground,
|
|
||||||
fontWeight: FontWeight.w300,
|
// Restoration: Remember selected tab across app restarts
|
||||||
),
|
final RestorableInt _selectedTabIndex = RestorableInt(0);
|
||||||
),
|
|
||||||
centerTitle: true,
|
int get _tabCount => _showFavoritesOnly ? 4 : 3;
|
||||||
actions: [
|
|
||||||
IconButton(
|
@override
|
||||||
icon: const Icon(Icons.search),
|
String? get restorationId => 'new_library_screen';
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
@override
|
||||||
context,
|
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
|
||||||
MaterialPageRoute(
|
registerForRestoration(_selectedTabIndex, 'selected_tab_index');
|
||||||
builder: (context) => const SearchScreen(),
|
// Apply restored tab index after TabController is created
|
||||||
|
if (_tabController.index != _selectedTabIndex.value &&
|
||||||
|
_selectedTabIndex.value < _tabController.length) {
|
||||||
|
_tabController.index = _selectedTabIndex.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: _tabCount, vsync: this);
|
||||||
|
// Listen to tab changes to persist selection
|
||||||
|
_tabController.addListener(_onTabChanged);
|
||||||
|
_loadPlaylists();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recreateTabController() {
|
||||||
|
final oldIndex = _tabController.index;
|
||||||
|
_tabController.removeListener(_onTabChanged);
|
||||||
|
_tabController.dispose();
|
||||||
|
_tabController = TabController(length: _tabCount, vsync: this);
|
||||||
|
_tabController.addListener(_onTabChanged);
|
||||||
|
// Restore to previous index if valid, otherwise default to 0
|
||||||
|
if (oldIndex < _tabCount) {
|
||||||
|
_tabController.index = oldIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTabChanged() {
|
||||||
|
if (!_tabController.indexIsChanging) {
|
||||||
|
_selectedTabIndex.value = _tabController.index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.removeListener(_onTabChanged);
|
||||||
|
_tabController.dispose();
|
||||||
|
_selectedTabIndex.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPlaylists({bool? favoriteOnly}) async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
if (maProvider.api != null) {
|
||||||
|
final playlists = await maProvider.api!.getPlaylists(
|
||||||
|
limit: 100,
|
||||||
|
favoriteOnly: favoriteOnly,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_playlists = playlists;
|
||||||
|
_isLoadingPlaylists = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingPlaylists = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFavoriteTracks() async {
|
||||||
|
if (_isLoadingTracks) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoadingTracks = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
if (maProvider.api != null) {
|
||||||
|
final tracks = await maProvider.api!.getTracks(
|
||||||
|
limit: 500,
|
||||||
|
favoriteOnly: true,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_favoriteTracks = tracks;
|
||||||
|
_isLoadingTracks = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingTracks = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFavoritesMode(bool value) {
|
||||||
|
setState(() {
|
||||||
|
_showFavoritesOnly = value;
|
||||||
|
_recreateTabController();
|
||||||
|
});
|
||||||
|
if (value) {
|
||||||
|
_loadPlaylists(favoriteOnly: true);
|
||||||
|
_loadFavoriteTracks();
|
||||||
|
} else {
|
||||||
|
_loadPlaylists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Use Selector for targeted rebuilds - only rebuild when connection state changes
|
||||||
|
return Selector<MusicAssistantProvider, bool>(
|
||||||
|
selector: (_, provider) => provider.isConnected,
|
||||||
|
builder: (context, isConnected, _) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text(
|
||||||
|
'Library',
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: const [PlayerSelector()],
|
||||||
|
),
|
||||||
|
body: DisconnectedState.withSettingsAction(
|
||||||
|
onSettings: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
titleSpacing: 16,
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
// Favorites toggle with label
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _toggleFavoritesMode(!_showFavoritesOnly),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Switch(
|
||||||
|
value: _showFavoritesOnly,
|
||||||
|
onChanged: _toggleFavoritesMode,
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Favourites',
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: _showFavoritesOnly
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontWeight: _showFavoritesOnly ? FontWeight.w500 : FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
centerTitle: false,
|
||||||
|
actions: const [
|
||||||
|
PlayerSelector(),
|
||||||
|
],
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
labelColor: colorScheme.primary,
|
||||||
|
unselectedLabelColor: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
indicatorColor: colorScheme.primary,
|
||||||
|
indicatorWeight: 3,
|
||||||
|
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
|
||||||
|
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14),
|
||||||
|
tabs: [
|
||||||
|
const Tab(text: 'Artists'),
|
||||||
|
const Tab(text: 'Albums'),
|
||||||
|
if (_showFavoritesOnly) const Tab(text: 'Tracks'),
|
||||||
|
const Tab(text: 'Playlists'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
_buildArtistsTab(context),
|
||||||
|
_buildAlbumsTab(context),
|
||||||
|
if (_showFavoritesOnly) _buildTracksTab(context),
|
||||||
|
_buildPlaylistsTab(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ ARTISTS TAB ============
|
||||||
|
Widget _buildArtistsTab(BuildContext context) {
|
||||||
|
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
|
||||||
|
return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
|
||||||
|
selector: (_, provider) => (provider.artists, provider.isLoading),
|
||||||
|
builder: (context, data, _) {
|
||||||
|
final (allArtists, isLoading) = data;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by favorites if enabled
|
||||||
|
final artists = _showFavoritesOnly
|
||||||
|
? allArtists.where((a) => a.favorite == true).toList()
|
||||||
|
: allArtists;
|
||||||
|
|
||||||
|
if (artists.isEmpty) {
|
||||||
|
if (_showFavoritesOnly) {
|
||||||
|
return EmptyState.custom(
|
||||||
|
icon: Icons.favorite_border,
|
||||||
|
title: 'No favorite artists',
|
||||||
|
subtitle: 'Tap the heart on an artist to add them to favorites',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return EmptyState.artists(
|
||||||
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
|
child: ListView.builder(
|
||||||
|
key: PageStorageKey<String>('library_artists_list_${_showFavoritesOnly ? 'fav' : 'all'}'),
|
||||||
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles have their own keep-alive
|
||||||
|
addRepaintBoundaries: false, // Tiles have RepaintBoundary
|
||||||
|
itemCount: artists.length,
|
||||||
|
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final artist = artists[index];
|
||||||
|
return _buildArtistTile(
|
||||||
|
context,
|
||||||
|
artist,
|
||||||
|
key: ValueKey(artist.uri ?? artist.itemId),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
color: colorScheme.onBackground,
|
|
||||||
),
|
),
|
||||||
const PlayerSelector(),
|
);
|
||||||
],
|
},
|
||||||
),
|
|
||||||
body: !provider.isConnected
|
|
||||||
? _buildDisconnectedView(context, provider)
|
|
||||||
: _buildLibraryMenu(context, provider),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDisconnectedView(
|
Widget _buildArtistTile(
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
BuildContext context,
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
Artist artist, {
|
||||||
|
Key? key,
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.cloud_off_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Not connected to Music Assistant',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const SettingsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.settings_rounded),
|
|
||||||
label: const Text('Configure Server'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.primary,
|
|
||||||
foregroundColor: colorScheme.onPrimary,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 24,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildLibraryMenu(
|
|
||||||
BuildContext context, MusicAssistantProvider provider) {
|
|
||||||
return ListView(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
children: [
|
|
||||||
_buildMenuTile(
|
|
||||||
context,
|
|
||||||
icon: Icons.person_outline_rounded,
|
|
||||||
title: 'Artists',
|
|
||||||
subtitle: '${provider.artists.length} artists',
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const LibraryArtistsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_buildMenuTile(
|
|
||||||
context,
|
|
||||||
icon: Icons.album_outlined,
|
|
||||||
title: 'Albums',
|
|
||||||
subtitle: '${provider.albums.length} albums',
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const LibraryAlbumsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_buildMenuTile(
|
|
||||||
context,
|
|
||||||
icon: Icons.music_note_outlined,
|
|
||||||
title: 'Tracks',
|
|
||||||
subtitle: '${provider.tracks.length} tracks',
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const LibraryTracksScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_buildMenuTile(
|
|
||||||
context,
|
|
||||||
icon: Icons.playlist_play_rounded,
|
|
||||||
title: 'Playlists',
|
|
||||||
subtitle: 'Your playlists',
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const LibraryPlaylistsScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_buildMenuTile(
|
|
||||||
context,
|
|
||||||
icon: Icons.category_outlined,
|
|
||||||
title: 'Genres',
|
|
||||||
subtitle: 'Coming soon',
|
|
||||||
onTap: null,
|
|
||||||
),
|
|
||||||
_buildMenuTile(
|
|
||||||
context,
|
|
||||||
icon: Icons.calendar_today_outlined,
|
|
||||||
title: 'Years',
|
|
||||||
subtitle: 'Coming soon',
|
|
||||||
onTap: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildMenuTile(
|
|
||||||
BuildContext context, {
|
|
||||||
required IconData icon,
|
|
||||||
required String title,
|
|
||||||
required String subtitle,
|
|
||||||
required VoidCallback? onTap,
|
|
||||||
}) {
|
}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final suffix = '_library';
|
||||||
|
// Get image URL for hero animation
|
||||||
|
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||||
|
|
||||||
return Card(
|
return RepaintBoundary(
|
||||||
elevation: 0, // Remove shadow
|
|
||||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
key: key,
|
||||||
|
leading: ArtistAvatar(
|
||||||
|
artist: artist,
|
||||||
|
radius: 24,
|
||||||
|
imageSize: 128,
|
||||||
|
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
||||||
|
),
|
||||||
|
title: Hero(
|
||||||
|
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
artist.name,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
FadeSlidePageRoute(
|
||||||
|
child: ArtistDetailsScreen(
|
||||||
|
artist: artist,
|
||||||
|
heroTagSuffix: 'library',
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ ALBUMS TAB ============
|
||||||
|
Widget _buildAlbumsTab(BuildContext context) {
|
||||||
|
// Use Selector for targeted rebuilds - only rebuild when albums or loading state changes
|
||||||
|
return Selector<MusicAssistantProvider, (List<Album>, bool)>(
|
||||||
|
selector: (_, provider) => (provider.albums, provider.isLoading),
|
||||||
|
builder: (context, data, _) {
|
||||||
|
final (allAlbums, isLoading) = data;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by favorites if enabled
|
||||||
|
final albums = _showFavoritesOnly
|
||||||
|
? allAlbums.where((a) => a.favorite == true).toList()
|
||||||
|
: allAlbums;
|
||||||
|
|
||||||
|
if (albums.isEmpty) {
|
||||||
|
if (_showFavoritesOnly) {
|
||||||
|
return EmptyState.custom(
|
||||||
|
icon: Icons.favorite_border,
|
||||||
|
title: 'No favorite albums',
|
||||||
|
subtitle: 'Tap the heart on an album to add it to favorites',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return EmptyState.albums(
|
||||||
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
onRefresh: () async => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
|
child: GridView.builder(
|
||||||
|
key: PageStorageKey<String>('library_albums_grid_${_showFavoritesOnly ? 'fav' : 'all'}'),
|
||||||
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Cards have their own keep-alive
|
||||||
|
addRepaintBoundaries: false, // Cards have RepaintBoundary
|
||||||
|
padding: EdgeInsets.only(left: 16, right: 16, top: 16, bottom: BottomSpacing.navBarOnly),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
childAspectRatio: 0.75,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
),
|
||||||
|
itemCount: albums.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final album = albums[index];
|
||||||
|
return AlbumCard(
|
||||||
|
key: ValueKey(album.uri ?? album.itemId),
|
||||||
|
album: album,
|
||||||
|
heroTagSuffix: 'library_grid',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ PLAYLISTS TAB ============
|
||||||
|
Widget _buildPlaylistsTab(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
if (_isLoadingPlaylists) {
|
||||||
|
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_playlists.isEmpty) {
|
||||||
|
if (_showFavoritesOnly) {
|
||||||
|
return EmptyState.custom(
|
||||||
|
icon: Icons.favorite_border,
|
||||||
|
title: 'No favorite playlists',
|
||||||
|
subtitle: 'Tap the heart on a playlist to add it to favorites',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return EmptyState.playlists(onRefresh: () => _loadPlaylists());
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
onRefresh: () => _loadPlaylists(favoriteOnly: _showFavoritesOnly ? true : null),
|
||||||
|
child: ListView.builder(
|
||||||
|
key: PageStorageKey<String>('library_playlists_list_${_showFavoritesOnly ? 'fav' : 'all'}'),
|
||||||
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
||||||
|
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
||||||
|
itemCount: _playlists.length,
|
||||||
|
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = _playlists[index];
|
||||||
|
return _buildPlaylistTile(context, playlist);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlaylistTile(BuildContext context, Playlist playlist) {
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = provider.api?.getImageUrl(playlist, size: 128);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: ListTile(
|
||||||
|
key: ValueKey(playlist.itemId),
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
image: imageUrl != null
|
||||||
child: Icon(
|
? DecorationImage(image: CachedNetworkImageProvider(imageUrl), fit: BoxFit.cover)
|
||||||
icon,
|
: null,
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
title: Text(
|
child: imageUrl == null
|
||||||
title,
|
? Icon(Icons.playlist_play_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
style: textTheme.titleMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
subtitle,
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: onTap != null
|
|
||||||
? Icon(
|
|
||||||
Icons.chevron_right_rounded,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.5),
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
onTap: onTap,
|
),
|
||||||
enabled: onTap != null,
|
title: Text(
|
||||||
|
playlist.name,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
playlist.trackCount != null
|
||||||
|
? '${playlist.trackCount} tracks'
|
||||||
|
: playlist.owner ?? 'Playlist',
|
||||||
|
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: playlist.favorite == true
|
||||||
|
? const Icon(Icons.favorite, color: Colors.red, size: 20)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => PlaylistDetailsScreen(
|
||||||
|
playlist: playlist,
|
||||||
|
provider: playlist.provider,
|
||||||
|
itemId: playlist.itemId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ TRACKS TAB (favorites only) ============
|
||||||
|
Widget _buildTracksTab(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
if (_isLoadingTracks) {
|
||||||
|
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_favoriteTracks.isEmpty) {
|
||||||
|
return EmptyState.custom(
|
||||||
|
icon: Icons.favorite_border,
|
||||||
|
title: 'No favorite tracks',
|
||||||
|
subtitle: 'Long-press a track and tap the heart to add it to favorites',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort tracks by artist name, then track name
|
||||||
|
final sortedTracks = List<Track>.from(_favoriteTracks)
|
||||||
|
..sort((a, b) {
|
||||||
|
final artistCompare = a.artistsString.compareTo(b.artistsString);
|
||||||
|
if (artistCompare != 0) return artistCompare;
|
||||||
|
return a.name.compareTo(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
onRefresh: _loadFavoriteTracks,
|
||||||
|
child: ListView.builder(
|
||||||
|
key: const PageStorageKey<String>('library_tracks_list'),
|
||||||
|
cacheExtent: 500,
|
||||||
|
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
||||||
|
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
||||||
|
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
|
||||||
|
itemCount: sortedTracks.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final track = sortedTracks[index];
|
||||||
|
return _buildTrackTile(context, track);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackTile(BuildContext context, Track track) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
// Get image URL from track itself
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(track, size: 128);
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: ListTile(
|
||||||
|
key: ValueKey(track.uri ?? track.itemId),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
leading: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
image: imageUrl != null
|
||||||
|
? DecorationImage(
|
||||||
|
image: CachedNetworkImageProvider(imageUrl),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: imageUrl == null
|
||||||
|
? Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
track.artistsString,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
track.name,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.favorite,
|
||||||
|
color: Colors.red,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final player = maProvider.selectedPlayer;
|
||||||
|
if (player == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No player selected')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start radio from this track
|
||||||
|
await maProvider.api?.playRadio(player.playerId, track);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to start radio: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,584 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../providers/music_assistant_provider.dart';
|
|
||||||
import '../models/player.dart';
|
|
||||||
import '../widgets/volume_control.dart';
|
|
||||||
import 'queue_screen.dart';
|
|
||||||
import '../constants/hero_tags.dart';
|
|
||||||
import '../widgets/animated_icon_button.dart';
|
|
||||||
import '../theme/palette_helper.dart';
|
|
||||||
import '../theme/theme_provider.dart';
|
|
||||||
|
|
||||||
class NowPlayingScreen extends StatefulWidget {
|
|
||||||
const NowPlayingScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<NowPlayingScreen> createState() => _NowPlayingScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NowPlayingScreenState extends State<NowPlayingScreen> {
|
|
||||||
PlayerQueue? _queue;
|
|
||||||
bool _isLoadingQueue = true;
|
|
||||||
Timer? _progressTimer;
|
|
||||||
double? _seekPosition; // Track seek position while dragging
|
|
||||||
ColorScheme? _lightColorScheme;
|
|
||||||
ColorScheme? _darkColorScheme;
|
|
||||||
String? _lastImageUrl; // Track last image URL to avoid re-extracting
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadQueue();
|
|
||||||
_startProgressTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _extractColors(String imageUrl) async {
|
|
||||||
if (_lastImageUrl == imageUrl) return; // Skip if same image
|
|
||||||
_lastImageUrl = imageUrl;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
|
||||||
NetworkImage(imageUrl),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (colorSchemes != null && mounted) {
|
|
||||||
setState(() {
|
|
||||||
_lightColorScheme = colorSchemes.$1;
|
|
||||||
_darkColorScheme = colorSchemes.$2;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('⚠️ Failed to extract colors for now playing: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_progressTimer?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startProgressTimer() {
|
|
||||||
// Update UI every second when playing to show progress
|
|
||||||
_progressTimer?.cancel();
|
|
||||||
_progressTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
// Just trigger rebuild to update elapsed time
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadQueue() async {
|
|
||||||
setState(() {
|
|
||||||
_isLoadingQueue = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
|
||||||
final player = maProvider.selectedPlayer;
|
|
||||||
|
|
||||||
if (player != null && maProvider.api != null) {
|
|
||||||
final queue = await maProvider.api!.getQueue(player.playerId);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_queue = queue;
|
|
||||||
_isLoadingQueue = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isLoadingQueue = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleShuffle() async {
|
|
||||||
if (_queue == null) return;
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
|
||||||
await maProvider.toggleShuffle(_queue!.playerId);
|
|
||||||
await _loadQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _cycleRepeat() async {
|
|
||||||
if (_queue == null) return;
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
|
||||||
await maProvider.cycleRepeatMode(_queue!.playerId, _queue!.repeatMode);
|
|
||||||
await _loadQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final themeProvider = context.watch<ThemeProvider>();
|
|
||||||
|
|
||||||
return Consumer<MusicAssistantProvider>(
|
|
||||||
builder: (context, maProvider, child) {
|
|
||||||
final selectedPlayer = maProvider.selectedPlayer;
|
|
||||||
final currentTrack = maProvider.currentTrack;
|
|
||||||
|
|
||||||
if (currentTrack == null || selectedPlayer == null) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.music_note, size: 64, color: Colors.grey[700]),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No track playing',
|
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 18),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final imageUrl = maProvider.getImageUrl(currentTrack, size: 512);
|
|
||||||
|
|
||||||
// Extract colors if adaptive theme is enabled and we have an image URL
|
|
||||||
if (themeProvider.adaptiveTheme && imageUrl != null) {
|
|
||||||
_extractColors(imageUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if we should use adaptive theme colors
|
|
||||||
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
|
|
||||||
// Get the color scheme to use
|
|
||||||
ColorScheme? adaptiveScheme;
|
|
||||||
if (useAdaptiveTheme) {
|
|
||||||
adaptiveScheme = isDark ? _darkColorScheme : _lightColorScheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine colors to use
|
|
||||||
final backgroundColor = useAdaptiveTheme && adaptiveScheme != null
|
|
||||||
? adaptiveScheme.background
|
|
||||||
: const Color(0xFF1a1a1a);
|
|
||||||
|
|
||||||
final surfaceColor = useAdaptiveTheme && adaptiveScheme != null
|
|
||||||
? adaptiveScheme.surface
|
|
||||||
: const Color(0xFF2a2a2a);
|
|
||||||
|
|
||||||
final primaryColor = useAdaptiveTheme && adaptiveScheme != null
|
|
||||||
? adaptiveScheme.primary
|
|
||||||
: Colors.white;
|
|
||||||
|
|
||||||
final textColor = useAdaptiveTheme && adaptiveScheme != null
|
|
||||||
? adaptiveScheme.onSurface
|
|
||||||
: Colors.white;
|
|
||||||
|
|
||||||
return Hero(
|
|
||||||
tag: HeroTags.nowPlayingBackground,
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: backgroundColor,
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
title: Text(
|
|
||||||
selectedPlayer.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor.withOpacity(0.7),
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.queue_music, color: textColor),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const QueueScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Spacer(),
|
|
||||||
// Album Art with Hero animation
|
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final size = constraints.maxWidth.clamp(200.0, 400.0);
|
|
||||||
return Center(
|
|
||||||
child: Hero(
|
|
||||||
tag: HeroTags.nowPlayingArt,
|
|
||||||
child: Container(
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: imageUrl != null
|
|
||||||
? Image.network(
|
|
||||||
imageUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
cacheWidth: 1024,
|
|
||||||
cacheHeight: 1024,
|
|
||||||
filterQuality: FilterQuality.medium,
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
return Container(
|
|
||||||
color: const Color(0xFF2a2a2a),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.music_note_rounded,
|
|
||||||
color: Colors.white24,
|
|
||||||
size: 120,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
color: const Color(0xFF2a2a2a),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.music_note_rounded,
|
|
||||||
color: Colors.white24,
|
|
||||||
size: 120,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
|
|
||||||
// Track Info
|
|
||||||
Text(
|
|
||||||
currentTrack.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
currentTrack.artistsString,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (currentTrack.album != null) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
currentTrack.album!.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor.withOpacity(0.54),
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// Progress Bar (showing elapsed time)
|
|
||||||
if (currentTrack.duration != null) ...[
|
|
||||||
SliderTheme(
|
|
||||||
data: SliderThemeData(
|
|
||||||
trackHeight: 3,
|
|
||||||
thumbShape: const RoundSliderThumbShape(
|
|
||||||
enabledThumbRadius: 6,
|
|
||||||
),
|
|
||||||
overlayShape: const RoundSliderOverlayShape(
|
|
||||||
overlayRadius: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Slider(
|
|
||||||
value: (_seekPosition ?? selectedPlayer.currentElapsedTime).clamp(0, currentTrack.duration!.inSeconds.toDouble()),
|
|
||||||
max: currentTrack.duration!.inSeconds.toDouble(),
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_seekPosition = value;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onChangeStart: (value) {
|
|
||||||
setState(() {
|
|
||||||
_seekPosition = value;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onChangeEnd: (value) async {
|
|
||||||
final targetPosition = value.round();
|
|
||||||
print('🎯 Seek slider released at $targetPosition seconds');
|
|
||||||
try {
|
|
||||||
await maProvider.seek(selectedPlayer.playerId, targetPosition);
|
|
||||||
print('✅ Seek command completed');
|
|
||||||
|
|
||||||
// Wait briefly for server to update position
|
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
|
||||||
} catch (e) {
|
|
||||||
print('❌ Error seeking: $e');
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error seeking: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_seekPosition = null;
|
|
||||||
print('🎯 Cleared seek position');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
activeColor: primaryColor,
|
|
||||||
inactiveColor: primaryColor.withOpacity(0.24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_formatDuration((_seekPosition ?? selectedPlayer.currentElapsedTime).toInt()),
|
|
||||||
style: TextStyle(color: textColor.withOpacity(0.54), fontSize: 12),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_formatDuration(currentTrack.duration!.inSeconds),
|
|
||||||
style: TextStyle(color: textColor.withOpacity(0.54), fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
// Show indeterminate progress if no duration available
|
|
||||||
const LinearProgressIndicator(
|
|
||||||
backgroundColor: Colors.white24,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Playback Controls
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
// Shuffle
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.shuffle,
|
|
||||||
color: _queue?.shuffle == true ? primaryColor : textColor.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
iconSize: 24,
|
|
||||||
onPressed: _isLoadingQueue ? null : _toggleShuffle,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
// Previous
|
|
||||||
Hero(
|
|
||||||
tag: HeroTags.nowPlayingPreviousButton,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: AnimatedIconButton(
|
|
||||||
icon: Icons.skip_previous_rounded,
|
|
||||||
color: textColor,
|
|
||||||
iconSize: 42,
|
|
||||||
onPressed: () async {
|
|
||||||
try {
|
|
||||||
await maProvider.previousTrackSelectedPlayer();
|
|
||||||
} catch (e) {
|
|
||||||
print('❌ Error in previous track: $e');
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
// Play/Pause
|
|
||||||
Hero(
|
|
||||||
tag: HeroTags.nowPlayingPlayButton,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: _AnimatedPlayButton(
|
|
||||||
isPlaying: selectedPlayer.isPlaying,
|
|
||||||
primaryColor: primaryColor,
|
|
||||||
backgroundColor: backgroundColor,
|
|
||||||
onPressed: () async {
|
|
||||||
try {
|
|
||||||
await maProvider.playPauseSelectedPlayer();
|
|
||||||
} catch (e) {
|
|
||||||
print('❌ Error in play/pause: $e');
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
// Next
|
|
||||||
Hero(
|
|
||||||
tag: HeroTags.nowPlayingNextButton,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: AnimatedIconButton(
|
|
||||||
icon: Icons.skip_next_rounded,
|
|
||||||
color: textColor,
|
|
||||||
iconSize: 42,
|
|
||||||
onPressed: () async {
|
|
||||||
try {
|
|
||||||
await maProvider.nextTrackSelectedPlayer();
|
|
||||||
} catch (e) {
|
|
||||||
print('❌ Error in next track: $e');
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
// Repeat
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
_queue?.repeatMode == 'one'
|
|
||||||
? Icons.repeat_one
|
|
||||||
: Icons.repeat,
|
|
||||||
color: _queue?.repeatMode != null && _queue!.repeatMode != 'off'
|
|
||||||
? primaryColor
|
|
||||||
: textColor.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
iconSize: 24,
|
|
||||||
onPressed: _isLoadingQueue ? null : _cycleRepeat,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// Volume Control
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: VolumeControl(compact: false),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDuration(int seconds) {
|
|
||||||
final duration = Duration(seconds: seconds);
|
|
||||||
final minutes = duration.inMinutes;
|
|
||||||
final secs = duration.inSeconds % 60;
|
|
||||||
return '${minutes.toString().padLeft(1, '0')}:${secs.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Animated play/pause button with scale animation
|
|
||||||
class _AnimatedPlayButton extends StatefulWidget {
|
|
||||||
final bool isPlaying;
|
|
||||||
final Color primaryColor;
|
|
||||||
final Color backgroundColor;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
|
|
||||||
const _AnimatedPlayButton({
|
|
||||||
required this.isPlaying,
|
|
||||||
required this.primaryColor,
|
|
||||||
required this.backgroundColor,
|
|
||||||
required this.onPressed,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_AnimatedPlayButton> createState() => _AnimatedPlayButtonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AnimatedPlayButtonState extends State<_AnimatedPlayButton>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late AnimationController _controller;
|
|
||||||
late Animation<double> _scaleAnimation;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 100),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.85).animate(
|
|
||||||
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleTap() async {
|
|
||||||
// Animate press
|
|
||||||
await _controller.forward();
|
|
||||||
await _controller.reverse();
|
|
||||||
|
|
||||||
// Call the callback
|
|
||||||
widget.onPressed();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTapDown: (_) => _controller.forward(),
|
|
||||||
onTapUp: (_) => _controller.reverse(),
|
|
||||||
onTapCancel: () => _controller.reverse(),
|
|
||||||
child: ScaleTransition(
|
|
||||||
scale: _scaleAnimation,
|
|
||||||
child: Container(
|
|
||||||
width: 72,
|
|
||||||
height: 72,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: widget.primaryColor,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
widget.isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
|
||||||
),
|
|
||||||
color: widget.backgroundColor,
|
|
||||||
iconSize: 42,
|
|
||||||
onPressed: _handleTap,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
|
||||||
|
final _playlistLogger = DebugLogger();
|
||||||
|
|
||||||
class PlaylistDetailsScreen extends StatefulWidget {
|
class PlaylistDetailsScreen extends StatefulWidget {
|
||||||
final Playlist playlist;
|
final Playlist playlist;
|
||||||
@@ -35,19 +38,16 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api != null) {
|
final tracks = await maProvider.getPlaylistTracksWithCache(
|
||||||
final tracks = await maProvider.api!.getPlaylistTracks(
|
widget.provider,
|
||||||
widget.provider,
|
widget.itemId,
|
||||||
widget.itemId,
|
);
|
||||||
);
|
|
||||||
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_tracks = tracks;
|
_tracks = tracks;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,11 +64,11 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
print('🎵 Queueing playlist on ${player.name}: ${player.playerId}');
|
_playlistLogger.info('Queueing playlist on ${player.name}', context: 'Playlist');
|
||||||
|
|
||||||
// Queue all tracks via Music Assistant
|
// Queue all tracks via Music Assistant
|
||||||
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
|
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
|
||||||
print('✓ Playlist queued on ${player.name}');
|
_playlistLogger.info('Playlist queued successfully', context: 'Playlist');
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -79,7 +79,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error playing playlist: $e');
|
_playlistLogger.error('Error playing playlist', context: 'Playlist', error: e);
|
||||||
_showError('Error playing playlist: $e');
|
_showError('Error playing playlist: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,8 +101,8 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
final imageUrl = maProvider.api?.getImageUrl(widget.playlist, size: 400);
|
final imageUrl = maProvider.api?.getImageUrl(widget.playlist, size: 400);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1a1a1a),
|
backgroundColor: const Color(0xFF1a1a1a),
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 300,
|
expandedHeight: 300,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
|
|
||||||
class QueueScreen extends StatefulWidget {
|
class QueueScreen extends StatefulWidget {
|
||||||
const QueueScreen({super.key});
|
const QueueScreen({super.key});
|
||||||
@@ -11,33 +13,56 @@ class QueueScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _QueueScreenState extends State<QueueScreen> {
|
class _QueueScreenState extends State<QueueScreen> {
|
||||||
|
final _logger = DebugLogger();
|
||||||
PlayerQueue? _queue;
|
PlayerQueue? _queue;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadQueue();
|
// Use addPostFrameCallback to safely access context after build
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_loadQueue();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadQueue() async {
|
Future<void> _loadQueue() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
try {
|
||||||
final player = maProvider.selectedPlayer;
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final player = maProvider.selectedPlayer;
|
||||||
|
|
||||||
if (player != null && maProvider.api != null) {
|
if (player != null && maProvider.api != null) {
|
||||||
final queue = await maProvider.api!.getQueue(player.playerId);
|
final queue = await maProvider.api!.getQueue(player.playerId);
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_queue = queue;
|
setState(() {
|
||||||
_isLoading = false;
|
_queue = queue;
|
||||||
});
|
_isLoading = false;
|
||||||
} else {
|
});
|
||||||
setState(() {
|
}
|
||||||
_isLoading = false;
|
} else {
|
||||||
});
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = 'No player selected';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('QueueScreen error loading queue: $e');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_error = 'Failed to load queue: $e';
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +101,28 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 64, color: Colors.red[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(color: Colors.red[300], fontSize: 16),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loadQueue,
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (maProvider.selectedPlayer == null) {
|
if (maProvider.selectedPlayer == null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -93,19 +140,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_queue == null || _queue!.items.isEmpty) {
|
if (_queue == null || _queue!.items.isEmpty) {
|
||||||
return Center(
|
return EmptyState.queue();
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.queue_music, size: 64, color: Colors.grey[700]),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Queue is empty',
|
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 18),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to show only current and upcoming items (not history)
|
// Filter to show only current and upcoming items (not history)
|
||||||
@@ -113,18 +148,9 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
final upcomingItems = _queue!.items.sublist(currentIndex);
|
final upcomingItems = _queue!.items.sublist(currentIndex);
|
||||||
|
|
||||||
if (upcomingItems.isEmpty) {
|
if (upcomingItems.isEmpty) {
|
||||||
return Center(
|
return const EmptyState(
|
||||||
child: Column(
|
icon: Icons.queue_music,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
message: 'No upcoming tracks',
|
||||||
children: [
|
|
||||||
Icon(Icons.queue_music, size: 64, color: Colors.grey[700]),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No upcoming tracks',
|
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 18),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +211,9 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
),
|
),
|
||||||
onDismissed: (direction) {
|
onDismissed: (direction) {
|
||||||
// TODO: Implement queue item removal via API
|
// TODO: Implement queue item removal via API
|
||||||
|
// Currently only removes locally from UI state, not from actual Music Assistant queue
|
||||||
|
// Need to investigate if Music Assistant API supports player_queues/remove_item or similar endpoint
|
||||||
|
// Requires: API method like removeQueueItem(queueId, queueItemId) that calls player_queues/remove_item
|
||||||
setState(() {
|
setState(() {
|
||||||
_queue!.items.removeAt(index);
|
_queue!.items.removeAt(index);
|
||||||
});
|
});
|
||||||
@@ -209,13 +238,10 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
leading: Row(
|
leading: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Drag handle
|
// Drag handle (visual only for now - reordering not implemented)
|
||||||
ReorderableDragStartListener(
|
Icon(
|
||||||
index: index,
|
Icons.drag_handle,
|
||||||
child: Icon(
|
color: Colors.grey[600],
|
||||||
Icons.drag_handle,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Album art
|
// Album art
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
import '../widgets/common/empty_state.dart';
|
||||||
|
import '../widgets/common/disconnected_state.dart';
|
||||||
|
import '../widgets/artist_avatar.dart';
|
||||||
import 'album_details_screen.dart';
|
import 'album_details_screen.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
|
|
||||||
@@ -9,12 +17,49 @@ class SearchScreen extends StatefulWidget {
|
|||||||
const SearchScreen({super.key});
|
const SearchScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SearchScreen> createState() => _SearchScreenState();
|
State<SearchScreen> createState() => SearchScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SearchScreenState extends State<SearchScreen> {
|
// Helper enum and class for ListView.builder item types
|
||||||
|
enum _ListItemType { header, artist, album, track, spacer }
|
||||||
|
|
||||||
|
class _ListItem {
|
||||||
|
final _ListItemType type;
|
||||||
|
final MediaItem? mediaItem;
|
||||||
|
final String? headerTitle;
|
||||||
|
final int? headerCount;
|
||||||
|
|
||||||
|
_ListItem.header(this.headerTitle, this.headerCount)
|
||||||
|
: type = _ListItemType.header,
|
||||||
|
mediaItem = null;
|
||||||
|
|
||||||
|
_ListItem.artist(this.mediaItem)
|
||||||
|
: type = _ListItemType.artist,
|
||||||
|
headerTitle = null,
|
||||||
|
headerCount = null;
|
||||||
|
|
||||||
|
_ListItem.album(this.mediaItem)
|
||||||
|
: type = _ListItemType.album,
|
||||||
|
headerTitle = null,
|
||||||
|
headerCount = null;
|
||||||
|
|
||||||
|
_ListItem.track(this.mediaItem)
|
||||||
|
: type = _ListItemType.track,
|
||||||
|
headerTitle = null,
|
||||||
|
headerCount = null;
|
||||||
|
|
||||||
|
_ListItem.spacer()
|
||||||
|
: type = _ListItemType.spacer,
|
||||||
|
mediaItem = null,
|
||||||
|
headerTitle = null,
|
||||||
|
headerCount = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchScreenState extends State<SearchScreen> {
|
||||||
|
final _logger = DebugLogger();
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
Timer? _debounceTimer;
|
||||||
Map<String, List<MediaItem>> _searchResults = {
|
Map<String, List<MediaItem>> _searchResults = {
|
||||||
'artists': [],
|
'artists': [],
|
||||||
'albums': [],
|
'albums': [],
|
||||||
@@ -22,51 +67,92 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
};
|
};
|
||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
bool _hasSearched = false;
|
bool _hasSearched = false;
|
||||||
|
String _activeFilter = 'all'; // 'all', 'artists', 'albums', 'tracks'
|
||||||
|
String? _searchError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Auto-focus the search field when screen opens
|
// Don't auto-focus - let user tap to focus
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
// This prevents keyboard popup bug when SearchScreen is in widget tree but not visible
|
||||||
|
}
|
||||||
|
|
||||||
|
void requestFocus() {
|
||||||
|
if (mounted) {
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeFocus() {
|
||||||
|
if (mounted) {
|
||||||
|
_focusNode.unfocus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performSearch(String query) async {
|
void _onSearchChanged(String query) {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_debounceTimer = Timer(Timings.searchDebounce, () {
|
||||||
|
_performSearch(query, keepFocus: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performSearch(String query, {bool keepFocus = false}) async {
|
||||||
if (query.isEmpty) {
|
if (query.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
|
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
|
||||||
_hasSearched = false;
|
_hasSearched = false;
|
||||||
|
_searchError = null;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSearching = true;
|
_isSearching = true;
|
||||||
|
_searchError = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
try {
|
||||||
final results = await provider.search(query);
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
final results = await provider.searchWithCache(query);
|
||||||
|
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_searchResults = results;
|
setState(() {
|
||||||
_isSearching = false;
|
_searchResults = results;
|
||||||
_hasSearched = true;
|
_isSearching = false;
|
||||||
});
|
_hasSearched = true;
|
||||||
|
});
|
||||||
|
// Keep keyboard open if user is still typing
|
||||||
|
if (keepFocus && _focusNode.hasFocus) {
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Search error: $e');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSearching = false;
|
||||||
|
_hasSearched = true;
|
||||||
|
_searchError = 'Search failed. Please check your connection.';
|
||||||
|
});
|
||||||
|
if (keepFocus && _focusNode.hasFocus) {
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
final maProvider = context.watch<MusicAssistantProvider>();
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.background,
|
backgroundColor: colorScheme.background,
|
||||||
@@ -78,6 +164,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
cursorColor: colorScheme.primary,
|
cursorColor: colorScheme.primary,
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search music...',
|
hintText: 'Search music...',
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
@@ -87,53 +174,28 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
icon: Icon(Icons.clear_rounded, color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
_performSearch('');
|
setState(() {
|
||||||
|
_searchResults = {'artists': [], 'albums': [], 'tracks': []};
|
||||||
|
_hasSearched = false;
|
||||||
|
_searchError = null;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {});
|
setState(() {}); // Update clear button visibility
|
||||||
_performSearch(value);
|
_onSearchChanged(value);
|
||||||
},
|
},
|
||||||
onSubmitted: _performSearch,
|
onSubmitted: (query) => _performSearch(query),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: !maProvider.isConnected
|
body: !maProvider.isConnected
|
||||||
? _buildDisconnectedView()
|
? DisconnectedState.simple()
|
||||||
: _buildSearchContent(),
|
: _buildSearchContent(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDisconnectedView() {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.cloud_off_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onBackground.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Not connected to Music Assistant',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onBackground.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSearchContent() {
|
Widget _buildSearchContent() {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
@@ -166,6 +228,35 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_searchError != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.error.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_searchError!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () => _performSearch(_searchController.text),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final artists = _searchResults['artists'] as List<MediaItem>? ?? [];
|
final artists = _searchResults['artists'] as List<MediaItem>? ?? [];
|
||||||
final albums = _searchResults['albums'] as List<MediaItem>? ?? [];
|
final albums = _searchResults['albums'] as List<MediaItem>? ?? [];
|
||||||
final tracks = _searchResults['tracks'] as List<MediaItem>? ?? [];
|
final tracks = _searchResults['tracks'] as List<MediaItem>? ?? [];
|
||||||
@@ -173,52 +264,133 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty;
|
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty;
|
||||||
|
|
||||||
if (!hasResults) {
|
if (!hasResults) {
|
||||||
return Center(
|
return EmptyState.search();
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.search_off_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onBackground.withOpacity(0.3),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No results found',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onBackground.withOpacity(0.5),
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView(
|
return Column(
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
children: [
|
||||||
if (artists.isNotEmpty) ...[
|
// Filters
|
||||||
_buildSectionHeader('Artists', artists.length),
|
SingleChildScrollView(
|
||||||
const SizedBox(height: 8),
|
scrollDirection: Axis.horizontal,
|
||||||
...artists.map((item) => _buildArtistTile(item as Artist)),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
const SizedBox(height: 24),
|
child: Row(
|
||||||
],
|
children: [
|
||||||
if (albums.isNotEmpty) ...[
|
_buildFilterChip('All', 'all'),
|
||||||
_buildSectionHeader('Albums', albums.length),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(height: 8),
|
if (artists.isNotEmpty) ...[
|
||||||
...albums.map((item) => _buildAlbumTile(item as Album)),
|
_buildFilterChip('Artists', 'artists'),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
if (tracks.isNotEmpty) ...[
|
if (albums.isNotEmpty) ...[
|
||||||
_buildSectionHeader('Tracks', tracks.length),
|
_buildFilterChip('Albums', 'albums'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(width: 8),
|
||||||
...tracks.map((item) => _buildTrackTile(item as Track)),
|
],
|
||||||
],
|
if (tracks.isNotEmpty) ...[
|
||||||
|
_buildFilterChip('Tracks', 'tracks'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Results
|
||||||
|
Expanded(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final listItems = _buildListItems(artists, albums, tracks);
|
||||||
|
return ListView.builder(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 8, 16, BottomSpacing.navBarOnly),
|
||||||
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
||||||
|
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
||||||
|
itemCount: listItems.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = listItems[index];
|
||||||
|
switch (item.type) {
|
||||||
|
case _ListItemType.header:
|
||||||
|
return _buildSectionHeader(item.headerTitle!, item.headerCount!);
|
||||||
|
case _ListItemType.artist:
|
||||||
|
return _buildArtistTile(item.mediaItem! as Artist);
|
||||||
|
case _ListItemType.album:
|
||||||
|
return _buildAlbumTile(item.mediaItem! as Album);
|
||||||
|
case _ListItemType.track:
|
||||||
|
return _buildTrackTile(item.mediaItem! as Track);
|
||||||
|
case _ListItemType.spacer:
|
||||||
|
return const SizedBox(height: 24);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<_ListItem> _buildListItems(
|
||||||
|
List<MediaItem> artists,
|
||||||
|
List<MediaItem> albums,
|
||||||
|
List<MediaItem> tracks,
|
||||||
|
) {
|
||||||
|
final items = <_ListItem>[];
|
||||||
|
|
||||||
|
// Add artists section
|
||||||
|
if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) {
|
||||||
|
if (_activeFilter == 'all') {
|
||||||
|
items.add(_ListItem.header('Artists', artists.length));
|
||||||
|
}
|
||||||
|
for (final artist in artists) {
|
||||||
|
items.add(_ListItem.artist(artist));
|
||||||
|
}
|
||||||
|
items.add(_ListItem.spacer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add albums section
|
||||||
|
if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) {
|
||||||
|
if (_activeFilter == 'all') {
|
||||||
|
items.add(_ListItem.header('Albums', albums.length));
|
||||||
|
}
|
||||||
|
for (final album in albums) {
|
||||||
|
items.add(_ListItem.album(album));
|
||||||
|
}
|
||||||
|
items.add(_ListItem.spacer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tracks section
|
||||||
|
if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) {
|
||||||
|
if (_activeFilter == 'all') {
|
||||||
|
items.add(_ListItem.header('Tracks', tracks.length));
|
||||||
|
}
|
||||||
|
for (final track in tracks) {
|
||||||
|
items.add(_ListItem.track(track));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFilterChip(String label, String value) {
|
||||||
|
final isSelected = _activeFilter == value;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: isSelected,
|
||||||
|
showCheckmark: false,
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
_activeFilter = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
selectedColor: colorScheme.primaryContainer,
|
||||||
|
side: BorderSide.none,
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSectionHeader(String title, int count) {
|
Widget _buildSectionHeader(String title, int count) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||||
@@ -234,18 +406,17 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistTile(Artist artist) {
|
Widget _buildArtistTile(Artist artist) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
|
||||||
final imageUrl = maProvider.getImageUrl(artist, size: 128);
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.getImageUrl(artist, size: 256);
|
||||||
|
|
||||||
return ListTile(
|
return RepaintBoundary(
|
||||||
leading: CircleAvatar(
|
child: ListTile(
|
||||||
|
key: ValueKey(artist.uri ?? artist.itemId),
|
||||||
|
leading: ArtistAvatar(
|
||||||
|
artist: artist,
|
||||||
radius: 24,
|
radius: 24,
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
imageSize: 128,
|
||||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
artist.name,
|
artist.name,
|
||||||
@@ -260,14 +431,18 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
'Artist',
|
'Artist',
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ArtistDetailsScreen(artist: artist),
|
builder: (context) => ArtistDetailsScreen(
|
||||||
),
|
artist: artist,
|
||||||
);
|
initialImageUrl: imageUrl,
|
||||||
},
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,8 +451,10 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
final imageUrl = maProvider.getImageUrl(album, size: 128);
|
final imageUrl = maProvider.getImageUrl(album, size: 128);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListTile(
|
return RepaintBoundary(
|
||||||
leading: Container(
|
child: ListTile(
|
||||||
|
key: ValueKey(album.uri ?? album.itemId),
|
||||||
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -285,7 +462,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: imageUrl != null
|
image: imageUrl != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
image: NetworkImage(imageUrl),
|
image: CachedNetworkImageProvider(imageUrl),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -309,14 +486,15 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => AlbumDetailsScreen(album: album),
|
builder: (context) => AlbumDetailsScreen(album: album),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,8 +505,10 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
: null;
|
: null;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListTile(
|
return RepaintBoundary(
|
||||||
leading: Container(
|
child: ListTile(
|
||||||
|
key: ValueKey(track.uri ?? track.itemId),
|
||||||
|
leading: Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -336,7 +516,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: imageUrl != null
|
image: imageUrl != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
image: NetworkImage(imageUrl),
|
image: CachedNetworkImageProvider(imageUrl),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -360,13 +540,14 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
trailing: track.duration != null
|
trailing: track.duration != null
|
||||||
? Text(
|
? Text(
|
||||||
_formatDuration(track.duration!),
|
_formatDuration(track.duration!),
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5), fontSize: 12),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () => _playTrack(track),
|
onTap: () => _playTrack(track),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import 'package:provider/provider.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/music_assistant_api.dart';
|
import '../services/music_assistant_api.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/auth_service.dart';
|
|
||||||
import '../services/debug_logger.dart';
|
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../widgets/global_player_overlay.dart';
|
||||||
import 'debug_log_screen.dart';
|
import 'debug_log_screen.dart';
|
||||||
|
import 'login_screen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -16,13 +16,16 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
final _serverUrlController = TextEditingController();
|
final _lastFmApiKeyController = TextEditingController();
|
||||||
final _portController = TextEditingController(text: '8095');
|
final _audioDbApiKeyController = TextEditingController();
|
||||||
final _usernameController = TextEditingController();
|
// Main rows (default on)
|
||||||
final _passwordController = TextEditingController();
|
bool _showRecentAlbums = true;
|
||||||
final _authService = AuthService();
|
bool _showDiscoverArtists = true;
|
||||||
final _logger = DebugLogger();
|
bool _showDiscoverAlbums = true;
|
||||||
bool _isConnecting = false;
|
// Favorites rows (default off)
|
||||||
|
bool _showFavoriteAlbums = false;
|
||||||
|
bool _showFavoriteArtists = false;
|
||||||
|
bool _showFavoriteTracks = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -31,120 +34,56 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final lastFmKey = await SettingsService.getLastFmApiKey();
|
||||||
_serverUrlController.text = provider.serverUrl ?? '';
|
if (lastFmKey != null) {
|
||||||
|
_lastFmApiKeyController.text = lastFmKey;
|
||||||
final port = await SettingsService.getWebSocketPort();
|
|
||||||
if (port != null) {
|
|
||||||
_portController.text = port.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final username = await SettingsService.getUsername();
|
final audioDbKey = await SettingsService.getTheAudioDbApiKey();
|
||||||
if (username != null) {
|
if (audioDbKey != null) {
|
||||||
_usernameController.text = username;
|
_audioDbApiKeyController.text = audioDbKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
final password = await SettingsService.getPassword();
|
// Load home screen settings
|
||||||
if (password != null) {
|
final showRecent = await SettingsService.getShowRecentAlbums();
|
||||||
_passwordController.text = password;
|
final showDiscArtists = await SettingsService.getShowDiscoverArtists();
|
||||||
|
final showDiscAlbums = await SettingsService.getShowDiscoverAlbums();
|
||||||
|
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||||
|
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||||
|
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showRecentAlbums = showRecent;
|
||||||
|
_showDiscoverArtists = showDiscArtists;
|
||||||
|
_showDiscoverAlbums = showDiscAlbums;
|
||||||
|
_showFavoriteAlbums = showFavAlbums;
|
||||||
|
_showFavoriteArtists = showFavArtists;
|
||||||
|
_showFavoriteTracks = showFavTracks;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_serverUrlController.dispose();
|
_lastFmApiKeyController.dispose();
|
||||||
_portController.dispose();
|
_audioDbApiKeyController.dispose();
|
||||||
_usernameController.dispose();
|
|
||||||
_passwordController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _connect() async {
|
Future<void> _disconnect() async {
|
||||||
if (_serverUrlController.text.isEmpty) {
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
_showError('Please enter a server URL');
|
await provider.disconnect();
|
||||||
return;
|
|
||||||
|
// Clear saved server URL so login screen shows
|
||||||
|
await SettingsService.clearServerUrl();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
// Navigate to login screen and clear the navigation stack
|
||||||
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(builder: (context) => const LoginScreen()),
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final port = _portController.text.trim();
|
|
||||||
if (port.isEmpty) {
|
|
||||||
_showError('Please enter a port number');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final portNum = int.tryParse(port);
|
|
||||||
if (portNum == null || portNum < 1 || portNum > 65535) {
|
|
||||||
_showError('Please enter a valid port number (1-65535)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await SettingsService.setWebSocketPort(portNum);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isConnecting = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (_usernameController.text.trim().isNotEmpty &&
|
|
||||||
_passwordController.text.trim().isNotEmpty) {
|
|
||||||
_logger.log('🔐 Attempting login with credentials...');
|
|
||||||
|
|
||||||
final token = await _authService.login(
|
|
||||||
_serverUrlController.text,
|
|
||||||
_usernameController.text.trim(),
|
|
||||||
_passwordController.text.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (token != null) {
|
|
||||||
await SettingsService.setUsername(_usernameController.text.trim());
|
|
||||||
await SettingsService.setPassword(_passwordController.text.trim());
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('✓ Authentication successful!'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_showError('Authentication failed. Please check your credentials.');
|
|
||||||
setState(() {
|
|
||||||
_isConnecting = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
|
||||||
await provider.connectToServer(_serverUrlController.text);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Connected successfully!'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_showError('Connection failed: $e');
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isConnecting = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showError(String message) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(message),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -190,194 +129,75 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24.0),
|
padding: const EdgeInsets.all(24.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
// Logo - same size as login screen (50% of screen width)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0, bottom: 48.0),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/ensemble_icon_transparent.png',
|
||||||
|
width: MediaQuery.of(context).size.width * 0.5,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Connection status box - centered with border radius like theme boxes
|
||||||
Container(
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
_getStatusIcon(provider.connectionState),
|
_getStatusIcon(provider.connectionState),
|
||||||
color: _getStatusColor(provider.connectionState, colorScheme),
|
color: _getStatusColor(provider.connectionState, colorScheme),
|
||||||
size: 24,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Text(
|
||||||
child: Column(
|
_getStatusText(provider.connectionState),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
style: textTheme.titleMedium?.copyWith(
|
||||||
children: [
|
color: _getStatusColor(provider.connectionState, colorScheme),
|
||||||
Text(
|
fontWeight: FontWeight.bold,
|
||||||
'Connection Status',
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_getStatusText(provider.connectionState),
|
|
||||||
style: textTheme.titleMedium?.copyWith(
|
|
||||||
color: _getStatusColor(provider.connectionState, colorScheme),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
Text(
|
|
||||||
'Music Assistant Server',
|
|
||||||
style: textTheme.titleMedium?.copyWith(
|
|
||||||
color: colorScheme.onBackground,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Enter your Music Assistant server URL or IP address',
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onBackground.withOpacity(0.6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
TextField(
|
// Disconnect button
|
||||||
controller: _serverUrlController,
|
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'e.g., music.example.com or 192.168.1.100',
|
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
|
||||||
filled: true,
|
|
||||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
prefixIcon: Icon(
|
|
||||||
Icons.dns_rounded,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
enabled: !_isConnecting,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
Text(
|
|
||||||
'Port',
|
|
||||||
style: textTheme.titleMedium?.copyWith(
|
|
||||||
color: colorScheme.onBackground,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Music Assistant WebSocket port (usually 8095)',
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onBackground.withOpacity(0.6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
TextField(
|
|
||||||
controller: _portController,
|
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: '8095',
|
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
|
||||||
filled: true,
|
|
||||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
prefixIcon: Icon(
|
|
||||||
Icons.settings_ethernet_rounded,
|
|
||||||
color: colorScheme.onSurface.withOpacity(0.54),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
enabled: !_isConnecting,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
child: ElevatedButton(
|
child: FilledButton.tonalIcon(
|
||||||
onPressed: _isConnecting ? null : _connect,
|
onPressed: _disconnect,
|
||||||
style: ElevatedButton.styleFrom(
|
icon: const Icon(Icons.logout_rounded),
|
||||||
backgroundColor: colorScheme.primary,
|
label: const Text(
|
||||||
foregroundColor: colorScheme.onPrimary,
|
'Disconnect',
|
||||||
disabledBackgroundColor: colorScheme.primary.withOpacity(0.38),
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.errorContainer.withOpacity(0.4),
|
||||||
|
foregroundColor: colorScheme.error,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _isConnecting
|
|
||||||
? SizedBox(
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text(
|
|
||||||
'Connect',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (provider.isConnected) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 50,
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
await provider.disconnect();
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Disconnected from server'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: colorScheme.error,
|
|
||||||
side: BorderSide(color: colorScheme.error.withOpacity(0.5)),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'Disconnect',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Theme section
|
||||||
Text(
|
Text(
|
||||||
'Theme',
|
'Theme',
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
@@ -405,41 +225,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Consumer<ThemeProvider>(
|
Consumer<ThemeProvider>(
|
||||||
builder: (context, themeProvider, _) {
|
builder: (context, themeProvider, _) {
|
||||||
return SegmentedButton<ThemeMode>(
|
return SizedBox(
|
||||||
segments: const [
|
width: double.infinity,
|
||||||
ButtonSegment<ThemeMode>(
|
child: SegmentedButton<ThemeMode>(
|
||||||
value: ThemeMode.light,
|
segments: const [
|
||||||
label: Text('Light'),
|
ButtonSegment<ThemeMode>(
|
||||||
icon: Icon(Icons.light_mode_rounded),
|
value: ThemeMode.light,
|
||||||
|
label: Text('Light'),
|
||||||
|
icon: Icon(Icons.light_mode_rounded),
|
||||||
|
),
|
||||||
|
ButtonSegment<ThemeMode>(
|
||||||
|
value: ThemeMode.dark,
|
||||||
|
label: Text('Dark'),
|
||||||
|
icon: Icon(Icons.dark_mode_rounded),
|
||||||
|
),
|
||||||
|
ButtonSegment<ThemeMode>(
|
||||||
|
value: ThemeMode.system,
|
||||||
|
label: Text('System'),
|
||||||
|
icon: Icon(Icons.auto_mode_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {themeProvider.themeMode},
|
||||||
|
onSelectionChanged: (Set<ThemeMode> newSelection) {
|
||||||
|
themeProvider.setThemeMode(newSelection.first);
|
||||||
|
},
|
||||||
|
style: ButtonStyle(
|
||||||
|
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return colorScheme.primaryContainer;
|
||||||
|
}
|
||||||
|
return colorScheme.surfaceVariant.withOpacity(0.3);
|
||||||
|
}),
|
||||||
|
foregroundColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return colorScheme.onPrimaryContainer;
|
||||||
|
}
|
||||||
|
return colorScheme.onSurfaceVariant;
|
||||||
|
}),
|
||||||
|
side: WidgetStateProperty.all(BorderSide.none),
|
||||||
),
|
),
|
||||||
ButtonSegment<ThemeMode>(
|
|
||||||
value: ThemeMode.dark,
|
|
||||||
label: Text('Dark'),
|
|
||||||
icon: Icon(Icons.dark_mode_rounded),
|
|
||||||
),
|
|
||||||
ButtonSegment<ThemeMode>(
|
|
||||||
value: ThemeMode.system,
|
|
||||||
label: Text('System'),
|
|
||||||
icon: Icon(Icons.auto_mode_rounded),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {themeProvider.themeMode},
|
|
||||||
onSelectionChanged: (Set<ThemeMode> newSelection) {
|
|
||||||
themeProvider.setThemeMode(newSelection.first);
|
|
||||||
},
|
|
||||||
style: ButtonStyle(
|
|
||||||
backgroundColor: MaterialStateProperty.resolveWith((states) {
|
|
||||||
if (states.contains(MaterialState.selected)) {
|
|
||||||
return colorScheme.primaryContainer;
|
|
||||||
}
|
|
||||||
return Colors.transparent;
|
|
||||||
}),
|
|
||||||
foregroundColor: MaterialStateProperty.resolveWith((states) {
|
|
||||||
if (states.contains(MaterialState.selected)) {
|
|
||||||
return colorScheme.onPrimaryContainer;
|
|
||||||
}
|
|
||||||
return colorScheme.onSurfaceVariant;
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -510,10 +334,261 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Home Screen section
|
||||||
|
Text(
|
||||||
|
'Home Screen',
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Choose which rows to display on the home screen',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Main rows section
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Recently Played',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show recently played albums',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showRecentAlbums,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showRecentAlbums = value);
|
||||||
|
SettingsService.setShowRecentAlbums(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Discover Artists',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show random artists to discover',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showDiscoverArtists,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showDiscoverArtists = value);
|
||||||
|
SettingsService.setShowDiscoverArtists(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Discover Albums',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show random albums to discover',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showDiscoverAlbums,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showDiscoverAlbums = value);
|
||||||
|
SettingsService.setShowDiscoverAlbums(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
'Favorites',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Favorites rows section
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Favorite Albums',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show a row of your favorite albums',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showFavoriteAlbums,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showFavoriteAlbums = value);
|
||||||
|
SettingsService.setShowFavoriteAlbums(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Favorite Artists',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show a row of your favorite artists',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showFavoriteArtists,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showFavoriteArtists = value);
|
||||||
|
SettingsService.setShowFavoriteArtists(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
'Favorite Tracks',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Show a row of your favorite tracks',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _showFavoriteTracks,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _showFavoriteTracks = value);
|
||||||
|
SettingsService.setShowFavoriteTracks(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
'Metadata APIs (Optional)',
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
controller: _lastFmApiKeyController,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Last.fm API Key',
|
||||||
|
hintText: 'Get free key at last.fm/api',
|
||||||
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.music_note_rounded,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
|
),
|
||||||
|
suffixIcon: _lastFmApiKeyController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_lastFmApiKeyController.clear();
|
||||||
|
});
|
||||||
|
SettingsService.setLastFmApiKey(null);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
SettingsService.setLastFmApiKey(value.trim().isEmpty ? null : value.trim());
|
||||||
|
setState(() {}); // Update UI to show/hide clear button
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
controller: _audioDbApiKeyController,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'TheAudioDB API Key',
|
||||||
|
hintText: 'Use "2" for free tier or premium key',
|
||||||
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.audiotrack_rounded,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
|
),
|
||||||
|
suffixIcon: _audioDbApiKeyController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_audioDbApiKeyController.clear();
|
||||||
|
});
|
||||||
|
SettingsService.setTheAudioDbApiKey(null);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
SettingsService.setTheAudioDbApiKey(value.trim().isEmpty ? null : value.trim());
|
||||||
|
setState(() {}); // Update UI to show/hide clear button
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
child: OutlinedButton.icon(
|
child: FilledButton.tonalIcon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -524,9 +599,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
icon: const Icon(Icons.bug_report_rounded),
|
icon: const Icon(Icons.bug_report_rounded),
|
||||||
label: const Text('View Debug Logs'),
|
label: const Text('View Debug Logs'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
foregroundColor: colorScheme.onBackground,
|
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.5),
|
||||||
side: BorderSide(color: colorScheme.outline),
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -534,53 +609,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
SizedBox(height: BottomSpacing.navBarOnly), // Space for bottom nav bar
|
||||||
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceVariant.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: colorScheme.outline.withOpacity(0.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.info_outline_rounded,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Connection Info',
|
|
||||||
style: textTheme.titleSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'• Default ports: 443 for HTTPS, 8095 for HTTP\n'
|
|
||||||
'• You can override the port in the WebSocket Port field\n'
|
|
||||||
'• Use domain name or IP address for server\n'
|
|
||||||
'• Make sure your device can reach the server\n'
|
|
||||||
'• Check debug logs if connection fails',
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -590,8 +619,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
IconData _getStatusIcon(MAConnectionState state) {
|
IconData _getStatusIcon(MAConnectionState state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case MAConnectionState.connected:
|
case MAConnectionState.connected:
|
||||||
|
case MAConnectionState.authenticated:
|
||||||
return Icons.check_circle_rounded;
|
return Icons.check_circle_rounded;
|
||||||
case MAConnectionState.connecting:
|
case MAConnectionState.connecting:
|
||||||
|
case MAConnectionState.authenticating:
|
||||||
return Icons.sync_rounded;
|
return Icons.sync_rounded;
|
||||||
case MAConnectionState.error:
|
case MAConnectionState.error:
|
||||||
return Icons.error_rounded;
|
return Icons.error_rounded;
|
||||||
@@ -603,8 +634,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Color _getStatusColor(MAConnectionState state, ColorScheme colorScheme) {
|
Color _getStatusColor(MAConnectionState state, ColorScheme colorScheme) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case MAConnectionState.connected:
|
case MAConnectionState.connected:
|
||||||
|
case MAConnectionState.authenticated:
|
||||||
return Colors.green;
|
return Colors.green;
|
||||||
case MAConnectionState.connecting:
|
case MAConnectionState.connecting:
|
||||||
|
case MAConnectionState.authenticating:
|
||||||
return Colors.orange;
|
return Colors.orange;
|
||||||
case MAConnectionState.error:
|
case MAConnectionState.error:
|
||||||
return colorScheme.error;
|
return colorScheme.error;
|
||||||
@@ -616,8 +649,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
String _getStatusText(MAConnectionState state) {
|
String _getStatusText(MAConnectionState state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case MAConnectionState.connected:
|
case MAConnectionState.connected:
|
||||||
|
case MAConnectionState.authenticated:
|
||||||
return 'Connected';
|
return 'Connected';
|
||||||
case MAConnectionState.connecting:
|
case MAConnectionState.connecting:
|
||||||
|
case MAConnectionState.authenticating:
|
||||||
return 'Connecting...';
|
return 'Connecting...';
|
||||||
case MAConnectionState.error:
|
case MAConnectionState.error:
|
||||||
return 'Connection Error';
|
return 'Connection Error';
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
|
||||||
|
/// Comprehensive animation debugging utility for diagnosing jank and performance issues.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// 1. Call AnimationDebugger.startSession('playerExpand') at animation start
|
||||||
|
/// 2. Call AnimationDebugger.recordFrame() in animation listener (every frame)
|
||||||
|
/// 3. Call AnimationDebugger.endSession() when animation completes
|
||||||
|
/// 4. Logs will show frame timing, jank detection, and summary stats
|
||||||
|
class AnimationDebugger {
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
static bool _enabled = false; // Disabled for release - enable for debugging animation jank
|
||||||
|
|
||||||
|
// Session tracking
|
||||||
|
static String? _currentSession;
|
||||||
|
static int _frameCount = 0;
|
||||||
|
static final List<_FrameData> _frames = [];
|
||||||
|
static DateTime? _sessionStart;
|
||||||
|
static double? _lastAnimationValue;
|
||||||
|
|
||||||
|
// Scheduler binding for frame timing
|
||||||
|
static Duration? _lastFrameTime;
|
||||||
|
|
||||||
|
/// Enable or disable animation debugging
|
||||||
|
static void setEnabled(bool enabled) {
|
||||||
|
_enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a new animation debugging session
|
||||||
|
static void startSession(String name) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
_currentSession = name;
|
||||||
|
_frameCount = 0;
|
||||||
|
_frames.clear();
|
||||||
|
_sessionStart = DateTime.now();
|
||||||
|
_lastFrameTime = null;
|
||||||
|
_lastAnimationValue = null;
|
||||||
|
|
||||||
|
// Listen to frame timing
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback(_onFirstFrame);
|
||||||
|
|
||||||
|
_logger.log('🎬 ANIM[$name] START');
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _onFirstFrame(Duration timestamp) {
|
||||||
|
_lastFrameTime = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a frame during animation
|
||||||
|
/// [animationValue] - current animation progress (0.0 to 1.0)
|
||||||
|
/// [context] - optional context string for this frame
|
||||||
|
static void recordFrame(double animationValue, {String? context}) {
|
||||||
|
if (!_enabled || _currentSession == null) return;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final frameTime = SchedulerBinding.instance.currentFrameTimeStamp;
|
||||||
|
|
||||||
|
Duration? frameDuration;
|
||||||
|
if (_lastFrameTime != null) {
|
||||||
|
frameDuration = frameTime - _lastFrameTime!;
|
||||||
|
}
|
||||||
|
_lastFrameTime = frameTime;
|
||||||
|
|
||||||
|
// Calculate animation delta
|
||||||
|
double? animationDelta;
|
||||||
|
if (_lastAnimationValue != null) {
|
||||||
|
animationDelta = (animationValue - _lastAnimationValue!).abs();
|
||||||
|
}
|
||||||
|
_lastAnimationValue = animationValue;
|
||||||
|
|
||||||
|
final frame = _FrameData(
|
||||||
|
frameNumber: _frameCount,
|
||||||
|
timestamp: now,
|
||||||
|
frameDuration: frameDuration,
|
||||||
|
animationValue: animationValue,
|
||||||
|
animationDelta: animationDelta,
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
_frames.add(frame);
|
||||||
|
_frameCount++;
|
||||||
|
|
||||||
|
// Log jank immediately (frames > 16.67ms for 60fps)
|
||||||
|
if (frameDuration != null && frameDuration.inMicroseconds > 16667) {
|
||||||
|
final ms = frameDuration.inMicroseconds / 1000;
|
||||||
|
_logger.log('🔴 ANIM[$_currentSession] JANK frame $_frameCount: ${ms.toStringAsFixed(2)}ms (>${(ms/16.67).toStringAsFixed(1)}x target)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a build/layout event during animation
|
||||||
|
static void recordBuild(String widgetName) {
|
||||||
|
if (!_enabled || _currentSession == null) return;
|
||||||
|
_logger.log('🔨 ANIM[$_currentSession] BUILD: $widgetName at frame $_frameCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the current session and log summary
|
||||||
|
static void endSession() {
|
||||||
|
if (!_enabled || _currentSession == null) return;
|
||||||
|
|
||||||
|
final sessionName = _currentSession!;
|
||||||
|
final totalDuration = DateTime.now().difference(_sessionStart!);
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
int jankFrames = 0;
|
||||||
|
int severeJankFrames = 0;
|
||||||
|
double maxFrameMs = 0;
|
||||||
|
double totalFrameMs = 0;
|
||||||
|
int measuredFrames = 0;
|
||||||
|
|
||||||
|
for (final frame in _frames) {
|
||||||
|
if (frame.frameDuration != null) {
|
||||||
|
final ms = frame.frameDuration!.inMicroseconds / 1000;
|
||||||
|
totalFrameMs += ms;
|
||||||
|
measuredFrames++;
|
||||||
|
|
||||||
|
if (ms > maxFrameMs) maxFrameMs = ms;
|
||||||
|
if (ms > 16.67) jankFrames++;
|
||||||
|
if (ms > 33.33) severeJankFrames++; // Dropped more than 1 frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final avgFrameMs = measuredFrames > 0 ? totalFrameMs / measuredFrames : 0;
|
||||||
|
final effectiveFps = avgFrameMs > 0 ? 1000 / avgFrameMs : 0;
|
||||||
|
|
||||||
|
// Build summary
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
buffer.writeln('');
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln('🎬 ANIMATION SUMMARY: $sessionName');
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln('Duration: ${totalDuration.inMilliseconds}ms');
|
||||||
|
buffer.writeln('Total frames: $_frameCount');
|
||||||
|
buffer.writeln('Measured frames: $measuredFrames');
|
||||||
|
buffer.writeln('Average frame: ${avgFrameMs.toStringAsFixed(2)}ms');
|
||||||
|
buffer.writeln('Max frame: ${maxFrameMs.toStringAsFixed(2)}ms');
|
||||||
|
buffer.writeln('Effective FPS: ${effectiveFps.toStringAsFixed(1)}');
|
||||||
|
buffer.writeln('Jank frames (>16.67ms): $jankFrames (${measuredFrames > 0 ? (jankFrames * 100 / measuredFrames).toStringAsFixed(1) : 0}%)');
|
||||||
|
buffer.writeln('Severe jank (>33.33ms): $severeJankFrames');
|
||||||
|
|
||||||
|
// List worst frames
|
||||||
|
if (_frames.isNotEmpty) {
|
||||||
|
final sortedFrames = List<_FrameData>.from(_frames)
|
||||||
|
..sort((a, b) {
|
||||||
|
final aMs = a.frameDuration?.inMicroseconds ?? 0;
|
||||||
|
final bMs = b.frameDuration?.inMicroseconds ?? 0;
|
||||||
|
return bMs.compareTo(aMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
final worstFrames = sortedFrames.take(5).where((f) => f.frameDuration != null && f.frameDuration!.inMicroseconds > 16667).toList();
|
||||||
|
|
||||||
|
if (worstFrames.isNotEmpty) {
|
||||||
|
buffer.writeln('');
|
||||||
|
buffer.writeln('Worst frames:');
|
||||||
|
for (final frame in worstFrames) {
|
||||||
|
final ms = frame.frameDuration!.inMicroseconds / 1000;
|
||||||
|
buffer.writeln(' Frame ${frame.frameNumber}: ${ms.toStringAsFixed(2)}ms (anim=${frame.animationValue.toStringAsFixed(3)})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
|
||||||
|
_logger.log(buffer.toString());
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
_currentSession = null;
|
||||||
|
_frames.clear();
|
||||||
|
_sessionStart = null;
|
||||||
|
_lastAnimationValue = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log a one-off animation event (for Hero transitions, page transitions, etc.)
|
||||||
|
static void logEvent(String event, {String? details}) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
final detailStr = details != null ? ' - $details' : '';
|
||||||
|
_logger.log('🎬 ANIM_EVENT: $event$detailStr');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log frame callback timing (call this from addPostFrameCallback)
|
||||||
|
static void logPostFrame(String context) {
|
||||||
|
if (!_enabled) return;
|
||||||
|
final frameTime = SchedulerBinding.instance.currentFrameTimeStamp;
|
||||||
|
_logger.log('📐 POST_FRAME[$context]: ${frameTime.inMilliseconds}ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a HeroFlightShuttleBuilder that logs Hero animation progress
|
||||||
|
static HeroFlightShuttleBuilder createHeroDebugShuttle(String heroTag) {
|
||||||
|
return (
|
||||||
|
BuildContext flightContext,
|
||||||
|
Animation<double> animation,
|
||||||
|
HeroFlightDirection flightDirection,
|
||||||
|
BuildContext fromHeroContext,
|
||||||
|
BuildContext toHeroContext,
|
||||||
|
) {
|
||||||
|
// Log Hero flight start
|
||||||
|
if (animation.value < 0.1) {
|
||||||
|
logEvent('Hero flight START', details: 'tag=$heroTag, direction=${flightDirection.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add listener for completion (one-shot)
|
||||||
|
void onComplete(AnimationStatus status) {
|
||||||
|
if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
|
||||||
|
logEvent('Hero flight END', details: 'tag=$heroTag, status=${status.name}');
|
||||||
|
animation.removeStatusListener(onComplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animation.addStatusListener(onComplete);
|
||||||
|
|
||||||
|
// Return default shuttle (the toHero's child)
|
||||||
|
final Hero toHero = toHeroContext.widget as Hero;
|
||||||
|
return toHero.child;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FrameData {
|
||||||
|
final int frameNumber;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final Duration? frameDuration;
|
||||||
|
final double animationValue;
|
||||||
|
final double? animationDelta;
|
||||||
|
final String? context;
|
||||||
|
|
||||||
|
_FrameData({
|
||||||
|
required this.frameNumber,
|
||||||
|
required this.timestamp,
|
||||||
|
this.frameDuration,
|
||||||
|
required this.animationValue,
|
||||||
|
this.animationDelta,
|
||||||
|
this.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:audio_session/audio_session.dart';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
import '../debug_logger.dart';
|
||||||
|
import '../auth/auth_manager.dart';
|
||||||
|
|
||||||
|
/// Custom AudioHandler for Ensemble that provides full control over
|
||||||
|
/// notification actions and metadata updates.
|
||||||
|
class MassivAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||||
|
final AudioPlayer _player = AudioPlayer();
|
||||||
|
final AuthManager authManager;
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
|
// Track current metadata separately from what's in the notification
|
||||||
|
// This allows us to update the notification when metadata arrives late
|
||||||
|
MediaItem? _currentMediaItem;
|
||||||
|
|
||||||
|
// Callbacks for actions (wired up by MusicAssistantProvider)
|
||||||
|
Function()? onSkipToNext;
|
||||||
|
Function()? onSkipToPrevious;
|
||||||
|
Function()? onPlay;
|
||||||
|
Function()? onPause;
|
||||||
|
Function()? onSwitchPlayer;
|
||||||
|
|
||||||
|
// Track whether we're in remote control mode (controlling MA player, not playing locally)
|
||||||
|
bool _isRemoteMode = false;
|
||||||
|
|
||||||
|
// Custom control for switching players (uses stop action with custom icon)
|
||||||
|
static const _switchPlayerControl = MediaControl(
|
||||||
|
androidIcon: 'drawable/ic_switch_player',
|
||||||
|
label: 'Switch Player',
|
||||||
|
action: MediaAction.stop,
|
||||||
|
);
|
||||||
|
|
||||||
|
MassivAudioHandler({required this.authManager}) {
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
// Configure audio session
|
||||||
|
final session = await AudioSession.instance;
|
||||||
|
await session.configure(const AudioSessionConfiguration.music());
|
||||||
|
|
||||||
|
// Handle audio interruptions
|
||||||
|
session.interruptionEventStream.listen((event) {
|
||||||
|
if (event.begin) {
|
||||||
|
switch (event.type) {
|
||||||
|
case AudioInterruptionType.duck:
|
||||||
|
_player.setVolume(0.5);
|
||||||
|
break;
|
||||||
|
case AudioInterruptionType.pause:
|
||||||
|
case AudioInterruptionType.unknown:
|
||||||
|
pause();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (event.type) {
|
||||||
|
case AudioInterruptionType.duck:
|
||||||
|
_player.setVolume(1.0);
|
||||||
|
break;
|
||||||
|
case AudioInterruptionType.pause:
|
||||||
|
play();
|
||||||
|
break;
|
||||||
|
case AudioInterruptionType.unknown:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle becoming noisy (headphones unplugged)
|
||||||
|
session.becomingNoisyEventStream.listen((_) {
|
||||||
|
pause();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast playback state changes
|
||||||
|
_player.playbackEventStream.listen(_broadcastState);
|
||||||
|
|
||||||
|
// Broadcast current media item changes
|
||||||
|
_player.currentIndexStream.listen((_) {
|
||||||
|
if (_currentMediaItem != null) {
|
||||||
|
mediaItem.add(_currentMediaItem);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast the current playback state to the system
|
||||||
|
void _broadcastState(PlaybackEvent event) {
|
||||||
|
final playing = _player.playing;
|
||||||
|
|
||||||
|
playbackState.add(playbackState.value.copyWith(
|
||||||
|
// Configure notification action buttons
|
||||||
|
controls: [
|
||||||
|
MediaControl.skipToPrevious,
|
||||||
|
if (playing) MediaControl.pause else MediaControl.play,
|
||||||
|
MediaControl.skipToNext,
|
||||||
|
_switchPlayerControl, // Switch player button
|
||||||
|
],
|
||||||
|
// System-level actions (for headphones, car stereos, etc.)
|
||||||
|
systemActions: const {
|
||||||
|
MediaAction.seek,
|
||||||
|
MediaAction.seekForward,
|
||||||
|
MediaAction.seekBackward,
|
||||||
|
MediaAction.play,
|
||||||
|
MediaAction.pause,
|
||||||
|
MediaAction.stop,
|
||||||
|
MediaAction.skipToNext,
|
||||||
|
MediaAction.skipToPrevious,
|
||||||
|
},
|
||||||
|
// Which buttons to show in compact notification (max 3)
|
||||||
|
// Show: play/pause (1), skip-next (2), switch-player (3)
|
||||||
|
androidCompactActionIndices: const [1, 2, 3],
|
||||||
|
processingState: {
|
||||||
|
ProcessingState.idle: AudioProcessingState.idle,
|
||||||
|
ProcessingState.loading: AudioProcessingState.loading,
|
||||||
|
ProcessingState.buffering: AudioProcessingState.buffering,
|
||||||
|
ProcessingState.ready: AudioProcessingState.ready,
|
||||||
|
ProcessingState.completed: AudioProcessingState.completed,
|
||||||
|
}[_player.processingState]!,
|
||||||
|
playing: playing,
|
||||||
|
updatePosition: _player.position,
|
||||||
|
bufferedPosition: _player.bufferedPosition,
|
||||||
|
speed: _player.speed,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Playback control methods ---
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> play() async {
|
||||||
|
if (_isRemoteMode) {
|
||||||
|
onPlay?.call();
|
||||||
|
} else {
|
||||||
|
await _player.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> pause() async {
|
||||||
|
if (_isRemoteMode) {
|
||||||
|
onPause?.call();
|
||||||
|
} else {
|
||||||
|
await _player.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stop() async {
|
||||||
|
// Stop action is used for switching players (both local and remote modes)
|
||||||
|
onSwitchPlayer?.call();
|
||||||
|
// Note: We don't actually stop playback - this button is repurposed for player switching
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> seek(Duration position) => _player.seek(position);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> skipToNext() async {
|
||||||
|
onSkipToNext?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> skipToPrevious() async {
|
||||||
|
onSkipToPrevious?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Custom methods for Ensemble ---
|
||||||
|
|
||||||
|
/// Play a URL with the given metadata
|
||||||
|
Future<void> playUrl(String url, MediaItem item, {Map<String, String>? headers}) async {
|
||||||
|
_currentMediaItem = item;
|
||||||
|
mediaItem.add(item);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final source = AudioSource.uri(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: headers,
|
||||||
|
tag: item,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _player.setAudioSource(source);
|
||||||
|
await _player.play();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('MassivAudioHandler: Error playing URL: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the current media item (for notification display)
|
||||||
|
/// This can be called when metadata arrives after playback starts
|
||||||
|
@override
|
||||||
|
Future<void> updateMediaItem(MediaItem item) async {
|
||||||
|
_currentMediaItem = item;
|
||||||
|
mediaItem.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set volume (0.0 to 1.0)
|
||||||
|
Future<void> setVolume(double volume) async {
|
||||||
|
await _player.setVolume(volume.clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set remote playback state (for controlling MA players without local playback)
|
||||||
|
/// This shows the notification and responds to media controls without playing audio locally.
|
||||||
|
void setRemotePlaybackState({
|
||||||
|
required MediaItem item,
|
||||||
|
required bool playing,
|
||||||
|
Duration position = Duration.zero,
|
||||||
|
Duration? duration,
|
||||||
|
}) {
|
||||||
|
_isRemoteMode = true;
|
||||||
|
_currentMediaItem = item;
|
||||||
|
mediaItem.add(item);
|
||||||
|
|
||||||
|
playbackState.add(playbackState.value.copyWith(
|
||||||
|
controls: [
|
||||||
|
MediaControl.skipToPrevious,
|
||||||
|
if (playing) MediaControl.pause else MediaControl.play,
|
||||||
|
MediaControl.skipToNext,
|
||||||
|
_switchPlayerControl,
|
||||||
|
],
|
||||||
|
systemActions: const {
|
||||||
|
MediaAction.play,
|
||||||
|
MediaAction.pause,
|
||||||
|
MediaAction.skipToNext,
|
||||||
|
MediaAction.skipToPrevious,
|
||||||
|
MediaAction.stop,
|
||||||
|
},
|
||||||
|
// Which buttons to show in compact notification (max 3)
|
||||||
|
// Show: play/pause (1), skip-next (2), switch-player (3)
|
||||||
|
androidCompactActionIndices: const [1, 2, 3],
|
||||||
|
processingState: AudioProcessingState.ready,
|
||||||
|
playing: playing,
|
||||||
|
updatePosition: position,
|
||||||
|
bufferedPosition: duration ?? Duration.zero,
|
||||||
|
speed: 1.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear remote playback state and hide notification
|
||||||
|
void clearRemotePlaybackState() {
|
||||||
|
_isRemoteMode = false;
|
||||||
|
_currentMediaItem = null;
|
||||||
|
|
||||||
|
playbackState.add(playbackState.value.copyWith(
|
||||||
|
controls: [],
|
||||||
|
processingState: AudioProcessingState.idle,
|
||||||
|
playing: false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to local playback mode (when builtin player is selected)
|
||||||
|
void setLocalMode() {
|
||||||
|
_isRemoteMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update notification for local mode (builtin player) without switching to remote mode
|
||||||
|
/// This allows the notification to show the correct player/track info while keeping
|
||||||
|
/// pause working for local audio playback.
|
||||||
|
void updateLocalModeNotification({
|
||||||
|
required MediaItem item,
|
||||||
|
required bool playing,
|
||||||
|
Duration? duration,
|
||||||
|
}) {
|
||||||
|
// Keep local mode - DON'T set _isRemoteMode = true
|
||||||
|
// Only update mediaItem if it changed - avoid unnecessary notification refreshes
|
||||||
|
// that cause blinking. The playbackState is managed by _broadcastState which
|
||||||
|
// responds to actual player events.
|
||||||
|
if (_currentMediaItem?.id != item.id ||
|
||||||
|
_currentMediaItem?.title != item.title ||
|
||||||
|
_currentMediaItem?.artist != item.artist) {
|
||||||
|
_currentMediaItem = item;
|
||||||
|
mediaItem.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isRemoteMode => _isRemoteMode;
|
||||||
|
|
||||||
|
// --- Expose player state for provider ---
|
||||||
|
|
||||||
|
bool get isPlaying => _player.playing;
|
||||||
|
|
||||||
|
Duration get position => _player.position;
|
||||||
|
|
||||||
|
Duration get duration => _player.duration ?? Duration.zero;
|
||||||
|
|
||||||
|
double get volume => _player.volume;
|
||||||
|
|
||||||
|
PlayerState get playerState => _player.playerState;
|
||||||
|
|
||||||
|
Stream<PlayerState> get playerStateStream => _player.playerStateStream;
|
||||||
|
|
||||||
|
Stream<Duration> get positionStream => _player.positionStream;
|
||||||
|
|
||||||
|
Stream<Duration?> get durationStream => _player.durationStream;
|
||||||
|
|
||||||
|
MediaItem? get currentMediaItem => _currentMediaItem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'auth_strategy.dart';
|
||||||
|
import 'no_auth_strategy.dart';
|
||||||
|
import 'basic_auth_strategy.dart';
|
||||||
|
import 'authelia_strategy.dart';
|
||||||
|
import 'ma_auth_strategy.dart';
|
||||||
|
import '../debug_logger.dart';
|
||||||
|
|
||||||
|
/// Central authentication manager
|
||||||
|
/// Manages auth strategy selection, auto-detection, and credential lifecycle
|
||||||
|
class AuthManager {
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
|
// Available auth strategies
|
||||||
|
final List<AuthStrategy> _strategies = [
|
||||||
|
NoAuthStrategy(),
|
||||||
|
BasicAuthStrategy(),
|
||||||
|
AutheliaStrategy(),
|
||||||
|
MusicAssistantAuthStrategy(),
|
||||||
|
];
|
||||||
|
|
||||||
|
AuthStrategy? _currentStrategy;
|
||||||
|
AuthCredentials? _currentCredentials;
|
||||||
|
|
||||||
|
/// Get current auth strategy (null if none selected)
|
||||||
|
AuthStrategy? get currentStrategy => _currentStrategy;
|
||||||
|
|
||||||
|
/// Get current credentials (null if none stored)
|
||||||
|
AuthCredentials? get currentCredentials => _currentCredentials;
|
||||||
|
|
||||||
|
/// Auto-detect authentication requirements for a server
|
||||||
|
/// Returns null if server is unreachable or auth method unknown
|
||||||
|
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
|
||||||
|
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
|
||||||
|
|
||||||
|
// Normalize URL
|
||||||
|
var baseUrl = serverUrl;
|
||||||
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
// Use http for local IPs, https for domains
|
||||||
|
if (baseUrl.startsWith('192.') ||
|
||||||
|
baseUrl.startsWith('10.') ||
|
||||||
|
baseUrl.startsWith('172.') ||
|
||||||
|
baseUrl == 'localhost' ||
|
||||||
|
baseUrl.startsWith('127.')) {
|
||||||
|
baseUrl = 'http://$baseUrl';
|
||||||
|
} else {
|
||||||
|
baseUrl = 'https://$baseUrl';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, check if this is a Music Assistant server with native auth
|
||||||
|
_logger.log('Checking for Music Assistant native auth...');
|
||||||
|
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
|
||||||
|
if (maAuthResult != null) {
|
||||||
|
if (maAuthResult == 'required') {
|
||||||
|
_logger.log('✓ Detected Music Assistant native authentication');
|
||||||
|
return _strategies.firstWhere((s) => s.name == 'music_assistant');
|
||||||
|
} else if (maAuthResult == 'none') {
|
||||||
|
_logger.log('✓ Music Assistant server - no auth required');
|
||||||
|
return _strategies.firstWhere((s) => s.name == 'none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try no-auth (direct connection without any auth)
|
||||||
|
_logger.log('Testing no-auth strategy...');
|
||||||
|
final canConnect = await _canConnectWithoutAuth(baseUrl);
|
||||||
|
_logger.log('No-auth result: $canConnect');
|
||||||
|
|
||||||
|
if (canConnect) {
|
||||||
|
_logger.log('✓ Server does not require authentication');
|
||||||
|
return _strategies.firstWhere((s) => s.name == 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe server for auth requirements (reverse proxy scenarios)
|
||||||
|
try {
|
||||||
|
_logger.log('Probing server for auth headers...');
|
||||||
|
// Test root endpoint
|
||||||
|
final probeUrl = baseUrl;
|
||||||
|
_logger.log('Probe URL: $probeUrl');
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(probeUrl),
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
_logger.log('Probe status: ${response.statusCode}');
|
||||||
|
|
||||||
|
// Check for redirect (common with reverse proxy auth)
|
||||||
|
if (response.statusCode == 302 || response.statusCode == 307) {
|
||||||
|
final location = response.headers['location'];
|
||||||
|
_logger.log('Redirect to: $location');
|
||||||
|
|
||||||
|
// If redirects to Authelia login, it's Authelia
|
||||||
|
if (location != null && location.contains('authelia')) {
|
||||||
|
_logger.log('✓ Detected Authelia (redirect)');
|
||||||
|
return _strategies.firstWhere((s) => s.name == 'authelia');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server returned 401 Unauthorized - check WWW-Authenticate header
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
final wwwAuth = response.headers['www-authenticate']?.toLowerCase();
|
||||||
|
_logger.log('401 with WWW-Authenticate: $wwwAuth');
|
||||||
|
|
||||||
|
// Check for Basic Auth
|
||||||
|
if (wwwAuth != null && wwwAuth.contains('basic')) {
|
||||||
|
_logger.log('✓ Detected Basic Authentication');
|
||||||
|
return _strategies.firstWhere((s) => s.name == 'basic');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Bearer token - could be MA native auth
|
||||||
|
if (wwwAuth != null && wwwAuth.contains('bearer')) {
|
||||||
|
_logger.log('✓ Detected Bearer auth - likely Music Assistant native');
|
||||||
|
return _strategies.firstWhere((s) => s.name == 'music_assistant');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got here and there's any auth challenge, assume Authelia
|
||||||
|
// (since we're behind a reverse proxy that requires auth)
|
||||||
|
if (response.statusCode >= 300) {
|
||||||
|
_logger.log('Server requires authentication (status ${response.statusCode})');
|
||||||
|
_logger.log('Assuming Authelia (default for authenticated reverse proxy)');
|
||||||
|
return _strategies.firstWhere((s) => s.name == 'authelia');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('✗ Auth detection error: $e');
|
||||||
|
return null; // Return null instead of rethrow to show generic error
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('⚠️ Could not determine auth method');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if server is a Music Assistant server with native auth
|
||||||
|
/// Returns 'required' if auth needed, 'none' if no auth, null if not MA or error
|
||||||
|
Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(baseUrl);
|
||||||
|
final apiUrl = Uri(
|
||||||
|
scheme: uri.scheme,
|
||||||
|
host: uri.host,
|
||||||
|
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
|
||||||
|
path: '/api',
|
||||||
|
);
|
||||||
|
|
||||||
|
_logger.log('Checking MA API at: $apiUrl');
|
||||||
|
|
||||||
|
// Try to get server info - this tells us if auth is required
|
||||||
|
// Note: The command is just 'info', not 'server/info'
|
||||||
|
final response = await http.post(
|
||||||
|
apiUrl,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'command': 'info',
|
||||||
|
}),
|
||||||
|
).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
_logger.log('MA API response: ${response.statusCode}');
|
||||||
|
_logger.log('MA API body: ${response.body}');
|
||||||
|
|
||||||
|
// 401/403 means auth required - check this first
|
||||||
|
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||||
|
_logger.log('MA API returned ${response.statusCode} - auth required');
|
||||||
|
return 'required';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for "Authentication required" text response
|
||||||
|
if (response.body.toLowerCase().contains('authentication required')) {
|
||||||
|
_logger.log('MA API returned auth required message');
|
||||||
|
return 'required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
// Try to parse as JSON
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Check if we got server info successfully (no auth required)
|
||||||
|
if (data.containsKey('result')) {
|
||||||
|
final result = data['result'] as Map<String, dynamic>?;
|
||||||
|
final serverVersion = result?['server_version'] as String?;
|
||||||
|
final schemaVersion = result?['schema_version'] as int?;
|
||||||
|
|
||||||
|
if (serverVersion != null) {
|
||||||
|
_logger.log('MA server version: $serverVersion, schema: $schemaVersion');
|
||||||
|
|
||||||
|
// Schema 28+ requires auth, but if we got here without auth, it's not enabled
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for auth error in JSON
|
||||||
|
if (data.containsKey('error_code')) {
|
||||||
|
final errorCode = data['error_code'] as int?;
|
||||||
|
// Error 50 = AuthenticationRequired
|
||||||
|
if (errorCode == 50) {
|
||||||
|
_logger.log('MA server requires authentication (error 50)');
|
||||||
|
return 'required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (jsonError) {
|
||||||
|
_logger.log('MA API response not JSON: $jsonError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('MA auth check error: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test if server is accessible without authentication
|
||||||
|
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
|
||||||
|
try {
|
||||||
|
// Test root endpoint - Music Assistant doesn't have /api/info
|
||||||
|
final testUrl = serverUrl;
|
||||||
|
_logger.log('No-auth test URL: $testUrl');
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(testUrl),
|
||||||
|
// Don't follow redirects - we want to see if server requires auth
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
_logger.log('No-auth status: ${response.statusCode}');
|
||||||
|
|
||||||
|
// 200 = no auth required
|
||||||
|
// 302/307 = redirect (probably to auth)
|
||||||
|
// 401 = auth required
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('No-auth error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt login with specified strategy
|
||||||
|
/// Returns true if login successful and credentials stored
|
||||||
|
/// authServerUrl is optional - only used for Authelia when auth is on a different domain
|
||||||
|
Future<bool> login(
|
||||||
|
String serverUrl,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
AuthStrategy strategy, {
|
||||||
|
String? authServerUrl,
|
||||||
|
}) async {
|
||||||
|
_logger.log('Attempting login with ${strategy.name} strategy');
|
||||||
|
|
||||||
|
// Use auth server URL if provided, otherwise use main server URL
|
||||||
|
final loginUrl = authServerUrl?.isNotEmpty == true ? authServerUrl! : serverUrl;
|
||||||
|
_logger.log('Login URL: $loginUrl');
|
||||||
|
|
||||||
|
final credentials = await strategy.login(loginUrl, username, password);
|
||||||
|
|
||||||
|
if (credentials != null) {
|
||||||
|
_currentStrategy = strategy;
|
||||||
|
_currentCredentials = credentials;
|
||||||
|
_logger.log('✓ Login successful with ${strategy.name}');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('✗ Login failed with ${strategy.name}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate current credentials are still valid
|
||||||
|
Future<bool> validateCurrentCredentials(String serverUrl) async {
|
||||||
|
if (_currentStrategy == null || _currentCredentials == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _currentStrategy!.validateCredentials(
|
||||||
|
serverUrl,
|
||||||
|
_currentCredentials!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get WebSocket connection headers for current credentials
|
||||||
|
Map<String, dynamic> getWebSocketHeaders() {
|
||||||
|
if (_currentStrategy == null || _currentCredentials == null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return _currentStrategy!.buildWebSocketHeaders(_currentCredentials!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get HTTP streaming headers for current credentials
|
||||||
|
Map<String, String> getStreamingHeaders() {
|
||||||
|
if (_currentStrategy == null || _currentCredentials == null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return _currentStrategy!.buildStreamingHeaders(_currentCredentials!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize current credentials for persistent storage
|
||||||
|
Map<String, dynamic>? serializeCredentials() {
|
||||||
|
if (_currentStrategy == null || _currentCredentials == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'strategy': _currentStrategy!.name,
|
||||||
|
'data': _currentStrategy!.serializeCredentials(_currentCredentials!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize and restore credentials from persistent storage
|
||||||
|
void deserializeCredentials(Map<String, dynamic> stored) {
|
||||||
|
final strategyName = stored['strategy'] as String?;
|
||||||
|
final data = stored['data'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
if (strategyName == null || data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching strategy
|
||||||
|
try {
|
||||||
|
final strategy = _strategies.firstWhere((s) => s.name == strategyName);
|
||||||
|
_currentStrategy = strategy;
|
||||||
|
_currentCredentials = strategy.deserializeCredentials(data);
|
||||||
|
_logger.log('✓ Restored ${strategy.name} credentials from storage');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('✗ Could not restore credentials: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear current authentication state
|
||||||
|
void logout() {
|
||||||
|
_currentStrategy = null;
|
||||||
|
_currentCredentials = null;
|
||||||
|
_logger.log('Logged out - cleared auth state');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get strategy by name (for manual selection)
|
||||||
|
AuthStrategy? getStrategyByName(String name) {
|
||||||
|
try {
|
||||||
|
return _strategies.firstWhere((s) => s.name == name);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/// Authentication credentials container
|
||||||
|
class AuthCredentials {
|
||||||
|
final String strategyName;
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
|
||||||
|
AuthCredentials(this.strategyName, this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abstract authentication strategy interface
|
||||||
|
/// Implementations handle different auth methods (none, basic, Authelia, HA, etc.)
|
||||||
|
abstract class AuthStrategy {
|
||||||
|
/// Strategy name identifier (e.g., 'none', 'basic', 'authelia')
|
||||||
|
String get name;
|
||||||
|
|
||||||
|
/// Attempt login and return auth credentials/token
|
||||||
|
/// Returns null if login fails
|
||||||
|
Future<AuthCredentials?> login(
|
||||||
|
String serverUrl,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Test if current credentials are still valid
|
||||||
|
Future<bool> validateCredentials(
|
||||||
|
String serverUrl,
|
||||||
|
AuthCredentials credentials,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Build headers for WebSocket connection
|
||||||
|
/// Example: {'Authorization': 'Basic xyz'} or {'Cookie': 'session=abc'}
|
||||||
|
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials);
|
||||||
|
|
||||||
|
/// Build headers for HTTP streaming requests
|
||||||
|
/// Example: {'Authorization': 'Basic xyz'}
|
||||||
|
Map<String, String> buildStreamingHeaders(AuthCredentials credentials);
|
||||||
|
|
||||||
|
/// Serialize credentials for persistent storage
|
||||||
|
Map<String, dynamic> serializeCredentials(AuthCredentials credentials);
|
||||||
|
|
||||||
|
/// Deserialize credentials from persistent storage
|
||||||
|
AuthCredentials deserializeCredentials(Map<String, dynamic> data);
|
||||||
|
}
|
||||||
@@ -1,27 +1,33 @@
|
|||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'debug_logger.dart';
|
import 'package:http/http.dart' as http;
|
||||||
import 'settings_service.dart';
|
import 'auth_strategy.dart';
|
||||||
|
import '../debug_logger.dart';
|
||||||
|
|
||||||
class AuthService {
|
/// Authelia authentication strategy
|
||||||
|
/// Used when Music Assistant is behind Authelia reverse proxy authentication
|
||||||
|
/// Authelia-specific endpoints: /api/firstfactor, /api/verify
|
||||||
|
class AutheliaStrategy implements AuthStrategy {
|
||||||
final _logger = DebugLogger();
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
/// Authenticate with the server and get session cookie
|
@override
|
||||||
Future<String?> login(String serverUrl, String username, String password) async {
|
String get name => 'authelia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthCredentials?> login(
|
||||||
|
String serverUrl,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
// Check if there's a separate auth server URL configured
|
// Normalize server URL
|
||||||
final authServerUrl = await SettingsService.getAuthServerUrl();
|
var baseUrl = serverUrl;
|
||||||
final targetUrl = authServerUrl ?? serverUrl;
|
|
||||||
|
|
||||||
_logger.log('🔐 Attempting login to $targetUrl');
|
|
||||||
|
|
||||||
// Determine base URL
|
|
||||||
var baseUrl = targetUrl;
|
|
||||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
baseUrl = 'https://$baseUrl';
|
baseUrl = 'https://$baseUrl';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse URL
|
_logger.log('🔐 Attempting Authelia login to $baseUrl');
|
||||||
|
|
||||||
|
// Parse URL and construct Authelia firstfactor endpoint
|
||||||
final uri = Uri.parse(baseUrl);
|
final uri = Uri.parse(baseUrl);
|
||||||
final authUrl = Uri(
|
final authUrl = Uri(
|
||||||
scheme: uri.scheme,
|
scheme: uri.scheme,
|
||||||
@@ -32,7 +38,7 @@ class AuthService {
|
|||||||
|
|
||||||
_logger.log('Auth URL: $authUrl');
|
_logger.log('Auth URL: $authUrl');
|
||||||
|
|
||||||
// Use Authelia's firstfactor endpoint with POST
|
// POST to Authelia's firstfactor endpoint
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
authUrl,
|
authUrl,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -47,26 +53,28 @@ class AuthService {
|
|||||||
|
|
||||||
_logger.log('Auth response status: ${response.statusCode}');
|
_logger.log('Auth response status: ${response.statusCode}');
|
||||||
|
|
||||||
// Check response status first - only proceed if successful
|
// Check for successful authentication
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
_logger.log('✓ Authentication successful');
|
_logger.log('✓ Authentication successful');
|
||||||
|
|
||||||
// Check for session cookies
|
// Extract session cookie from Set-Cookie header
|
||||||
final cookies = response.headers['set-cookie'];
|
final cookies = response.headers['set-cookie'];
|
||||||
if (cookies != null && cookies.isNotEmpty) {
|
if (cookies != null && cookies.isNotEmpty) {
|
||||||
_logger.log('✓ Received session cookie');
|
_logger.log('✓ Received session cookie');
|
||||||
|
|
||||||
// Extract authelia_session cookie if present
|
|
||||||
final sessionCookie = _extractSessionCookie(cookies);
|
final sessionCookie = _extractSessionCookie(cookies);
|
||||||
if (sessionCookie != null) {
|
if (sessionCookie != null) {
|
||||||
_logger.log('✓ Extracted session cookie');
|
_logger.log('✓ Extracted session cookie');
|
||||||
await SettingsService.setAuthToken(sessionCookie);
|
return AuthCredentials('authelia', {
|
||||||
return sessionCookie;
|
'session_cookie': sessionCookie,
|
||||||
|
'username': username,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If 200 but no cookie, might not need auth
|
// If 200 but no cookie, something is wrong
|
||||||
return 'authenticated';
|
_logger.log('✗ No session cookie in response');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('✗ Authentication failed: ${response.statusCode}');
|
_logger.log('✗ Authentication failed: ${response.statusCode}');
|
||||||
@@ -78,8 +86,73 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> validateCredentials(
|
||||||
|
String serverUrl,
|
||||||
|
AuthCredentials credentials,
|
||||||
|
) async {
|
||||||
|
final sessionCookie = credentials.data['session_cookie'] as String?;
|
||||||
|
if (sessionCookie == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Normalize server URL
|
||||||
|
var baseUrl = serverUrl;
|
||||||
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
baseUrl = 'https://$baseUrl';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse URL and construct Authelia verify endpoint
|
||||||
|
final uri = Uri.parse(baseUrl);
|
||||||
|
final verifyUrl = Uri(
|
||||||
|
scheme: uri.scheme,
|
||||||
|
host: uri.host,
|
||||||
|
port: uri.hasPort ? uri.port : null,
|
||||||
|
path: '/api/verify',
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
verifyUrl,
|
||||||
|
headers: {
|
||||||
|
'Cookie': 'authelia_session=$sessionCookie',
|
||||||
|
},
|
||||||
|
).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Auth validation failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
|
||||||
|
final sessionCookie = credentials.data['session_cookie'] as String;
|
||||||
|
return {
|
||||||
|
'Cookie': 'authelia_session=$sessionCookie',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
|
||||||
|
final sessionCookie = credentials.data['session_cookie'] as String;
|
||||||
|
return {
|
||||||
|
'Cookie': 'authelia_session=$sessionCookie',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
|
||||||
|
return credentials.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
|
||||||
|
return AuthCredentials('authelia', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract authelia_session cookie value from Set-Cookie header
|
||||||
|
/// Migrated from auth_service.dart:81-98
|
||||||
String? _extractSessionCookie(String setCookieHeader) {
|
String? _extractSessionCookie(String setCookieHeader) {
|
||||||
// Parse Set-Cookie header to extract authelia_session value
|
|
||||||
final cookies = setCookieHeader.split(',');
|
final cookies = setCookieHeader.split(',');
|
||||||
|
|
||||||
for (final cookie in cookies) {
|
for (final cookie in cookies) {
|
||||||
@@ -96,39 +169,4 @@ class AuthService {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test if current credentials/token work
|
|
||||||
Future<bool> testAuthentication(String serverUrl) async {
|
|
||||||
try {
|
|
||||||
var baseUrl = serverUrl;
|
|
||||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
|
||||||
baseUrl = 'https://$baseUrl';
|
|
||||||
}
|
|
||||||
|
|
||||||
final uri = Uri.parse(baseUrl);
|
|
||||||
final testUrl = Uri(
|
|
||||||
scheme: uri.scheme,
|
|
||||||
host: uri.host,
|
|
||||||
port: uri.hasPort ? uri.port : null,
|
|
||||||
path: '/api/verify',
|
|
||||||
);
|
|
||||||
|
|
||||||
final token = await SettingsService.getAuthToken();
|
|
||||||
final headers = <String, String>{};
|
|
||||||
|
|
||||||
if (token != null && token != 'authenticated') {
|
|
||||||
headers['Cookie'] = 'authelia_session=$token';
|
|
||||||
}
|
|
||||||
|
|
||||||
final response = await http.get(
|
|
||||||
testUrl,
|
|
||||||
headers: headers,
|
|
||||||
).timeout(const Duration(seconds: 5));
|
|
||||||
|
|
||||||
return response.statusCode == 200;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Auth test failed: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'auth_strategy.dart';
|
||||||
|
|
||||||
|
/// Basic HTTP Authentication strategy
|
||||||
|
/// Used by reverse proxies (nginx, Apache, Caddy, etc.) configured with basic auth
|
||||||
|
/// Example: Authorization: Basic base64(username:password)
|
||||||
|
class BasicAuthStrategy implements AuthStrategy {
|
||||||
|
@override
|
||||||
|
String get name => 'basic';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthCredentials?> login(
|
||||||
|
String serverUrl,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
) async {
|
||||||
|
// Encode credentials to base64
|
||||||
|
final credentials = base64Encode(utf8.encode('$username:$password'));
|
||||||
|
|
||||||
|
// Test credentials against server root endpoint
|
||||||
|
// (Music Assistant doesn't have /api/info)
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(serverUrl),
|
||||||
|
headers: {'Authorization': 'Basic $credentials'},
|
||||||
|
).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return AuthCredentials('basic', {
|
||||||
|
'credentials': credentials,
|
||||||
|
'username': username, // Store for re-login if needed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> validateCredentials(
|
||||||
|
String serverUrl,
|
||||||
|
AuthCredentials credentials,
|
||||||
|
) async {
|
||||||
|
final base64Creds = credentials.data['credentials'] as String?;
|
||||||
|
if (base64Creds == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(serverUrl),
|
||||||
|
headers: {'Authorization': 'Basic $base64Creds'},
|
||||||
|
).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
|
||||||
|
final base64Creds = credentials.data['credentials'] as String;
|
||||||
|
return {'Authorization': 'Basic $base64Creds'};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
|
||||||
|
final base64Creds = credentials.data['credentials'] as String;
|
||||||
|
return {'Authorization': 'Basic $base64Creds'};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
|
||||||
|
return credentials.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
|
||||||
|
return AuthCredentials('basic', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'auth_strategy.dart';
|
||||||
|
import '../debug_logger.dart';
|
||||||
|
|
||||||
|
/// Music Assistant native authentication strategy
|
||||||
|
/// Used when MA server has built-in authentication enabled (schema 28+)
|
||||||
|
///
|
||||||
|
/// Flow:
|
||||||
|
/// 1. Connect WebSocket (receives server_info with auth requirements)
|
||||||
|
/// 2. If auth required, login with username/password to get access token
|
||||||
|
/// 3. Send 'auth' command with token to authenticate the session
|
||||||
|
/// 4. Optionally create long-lived token for persistent storage
|
||||||
|
class MusicAssistantAuthStrategy implements AuthStrategy {
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => 'music_assistant';
|
||||||
|
|
||||||
|
/// Login to Music Assistant and get an access token
|
||||||
|
/// This is called AFTER WebSocket connection is established
|
||||||
|
/// Returns credentials containing the access token
|
||||||
|
@override
|
||||||
|
Future<AuthCredentials?> login(
|
||||||
|
String serverUrl,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
// Normalize server URL
|
||||||
|
var baseUrl = serverUrl;
|
||||||
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
// Determine protocol based on URL pattern
|
||||||
|
if (baseUrl.startsWith('192.') ||
|
||||||
|
baseUrl.startsWith('10.') ||
|
||||||
|
baseUrl.startsWith('172.') ||
|
||||||
|
baseUrl == 'localhost' ||
|
||||||
|
baseUrl.startsWith('127.')) {
|
||||||
|
baseUrl = 'http://$baseUrl';
|
||||||
|
} else {
|
||||||
|
baseUrl = 'https://$baseUrl';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('🔐 Attempting Music Assistant login to $baseUrl');
|
||||||
|
|
||||||
|
// MA auth endpoint - POST /api with JSON-RPC style command
|
||||||
|
final uri = Uri.parse(baseUrl);
|
||||||
|
final apiUrl = Uri(
|
||||||
|
scheme: uri.scheme,
|
||||||
|
host: uri.host,
|
||||||
|
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
|
||||||
|
path: '/api',
|
||||||
|
);
|
||||||
|
|
||||||
|
_logger.log('Auth API URL: $apiUrl');
|
||||||
|
|
||||||
|
// Login request to get access token
|
||||||
|
final response = await http.post(
|
||||||
|
apiUrl,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'command': 'auth/login',
|
||||||
|
'args': {
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
_logger.log('Auth response status: ${response.statusCode}');
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Check for error in response
|
||||||
|
if (data.containsKey('error_code')) {
|
||||||
|
_logger.log('✗ Login failed: ${data['error_code']} - ${data['details']}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract access token from result
|
||||||
|
final result = data['result'] as Map<String, dynamic>?;
|
||||||
|
final accessToken = result?['access_token'] as String?;
|
||||||
|
|
||||||
|
if (accessToken != null) {
|
||||||
|
_logger.log('✓ Got access token from MA');
|
||||||
|
|
||||||
|
// Now try to create a long-lived token for persistent auth
|
||||||
|
final longLivedToken = await _createLongLivedToken(apiUrl, accessToken);
|
||||||
|
|
||||||
|
return AuthCredentials('music_assistant', {
|
||||||
|
'access_token': accessToken,
|
||||||
|
'long_lived_token': longLivedToken,
|
||||||
|
'username': username,
|
||||||
|
'server_url': baseUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('✗ No access token in response');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('✗ Authentication failed: ${response.statusCode}');
|
||||||
|
_logger.log('Response body: ${response.body}');
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('✗ Login error: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a long-lived token for persistent authentication
|
||||||
|
Future<String?> _createLongLivedToken(Uri apiUrl, String accessToken) async {
|
||||||
|
try {
|
||||||
|
_logger.log('Creating long-lived token...');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
apiUrl,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $accessToken',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'command': 'auth/create_token',
|
||||||
|
'args': {
|
||||||
|
'name': 'Ensemble Mobile App',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
if (!data.containsKey('error_code')) {
|
||||||
|
final result = data['result'] as Map<String, dynamic>?;
|
||||||
|
final token = result?['token'] as String?;
|
||||||
|
|
||||||
|
if (token != null) {
|
||||||
|
_logger.log('✓ Created long-lived token');
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('⚠️ Could not create long-lived token (non-fatal)');
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Long-lived token creation failed: $e (non-fatal)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> validateCredentials(
|
||||||
|
String serverUrl,
|
||||||
|
AuthCredentials credentials,
|
||||||
|
) async {
|
||||||
|
// Try long-lived token first, then access token
|
||||||
|
final longLivedToken = credentials.data['long_lived_token'] as String?;
|
||||||
|
final accessToken = credentials.data['access_token'] as String?;
|
||||||
|
|
||||||
|
final token = longLivedToken ?? accessToken;
|
||||||
|
if (token == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var baseUrl = serverUrl;
|
||||||
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
baseUrl = 'https://$baseUrl';
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.parse(baseUrl);
|
||||||
|
final apiUrl = Uri(
|
||||||
|
scheme: uri.scheme,
|
||||||
|
host: uri.host,
|
||||||
|
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
|
||||||
|
path: '/api',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test token by getting server info
|
||||||
|
final response = await http.post(
|
||||||
|
apiUrl,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'command': 'server/info',
|
||||||
|
}),
|
||||||
|
).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return !data.containsKey('error_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Token validation failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
|
||||||
|
// MA auth happens AFTER WebSocket connection, not via headers
|
||||||
|
// But we can still include token if available for initial handshake
|
||||||
|
final longLivedToken = credentials.data['long_lived_token'] as String?;
|
||||||
|
final accessToken = credentials.data['access_token'] as String?;
|
||||||
|
|
||||||
|
final token = longLivedToken ?? accessToken;
|
||||||
|
if (token != null) {
|
||||||
|
return {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
|
||||||
|
final longLivedToken = credentials.data['long_lived_token'] as String?;
|
||||||
|
final accessToken = credentials.data['access_token'] as String?;
|
||||||
|
|
||||||
|
final token = longLivedToken ?? accessToken;
|
||||||
|
if (token != null) {
|
||||||
|
return {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
|
||||||
|
return credentials.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
|
||||||
|
return AuthCredentials('music_assistant', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the best available token (prefer long-lived)
|
||||||
|
String? getToken(AuthCredentials credentials) {
|
||||||
|
final longLivedToken = credentials.data['long_lived_token'] as String?;
|
||||||
|
final accessToken = credentials.data['access_token'] as String?;
|
||||||
|
return longLivedToken ?? accessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'auth_strategy.dart';
|
||||||
|
|
||||||
|
/// No authentication strategy for open Music Assistant servers
|
||||||
|
/// Typical scenarios:
|
||||||
|
/// - Local network deployment (http://192.168.1.100:8095)
|
||||||
|
/// - Home network without reverse proxy
|
||||||
|
/// - Development servers
|
||||||
|
class NoAuthStrategy implements AuthStrategy {
|
||||||
|
@override
|
||||||
|
String get name => 'none';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AuthCredentials?> login(
|
||||||
|
String serverUrl,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
) async {
|
||||||
|
// No authentication required - just test server connectivity
|
||||||
|
try {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$serverUrl/api/info'),
|
||||||
|
).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return AuthCredentials('none', {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> validateCredentials(
|
||||||
|
String serverUrl,
|
||||||
|
AuthCredentials credentials,
|
||||||
|
) async {
|
||||||
|
// Always valid for no-auth
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> buildWebSocketHeaders(AuthCredentials credentials) {
|
||||||
|
// No auth headers needed
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, String> buildStreamingHeaders(AuthCredentials credentials) {
|
||||||
|
// No auth headers needed
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> serializeCredentials(AuthCredentials credentials) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AuthCredentials deserializeCredentials(Map<String, dynamic> data) {
|
||||||
|
return AuthCredentials('none', {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../constants/timings.dart';
|
||||||
|
|
||||||
|
/// Centralized cache service for all app data caching
|
||||||
|
/// This is a non-notifying service - it just stores and retrieves cached data
|
||||||
|
class CacheService {
|
||||||
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
|
||||||
|
// Home screen row caching
|
||||||
|
List<Album>? _cachedRecentAlbums;
|
||||||
|
List<Artist>? _cachedDiscoverArtists;
|
||||||
|
List<Album>? _cachedDiscoverAlbums;
|
||||||
|
DateTime? _recentAlbumsLastFetched;
|
||||||
|
DateTime? _discoverArtistsLastFetched;
|
||||||
|
DateTime? _discoverAlbumsLastFetched;
|
||||||
|
|
||||||
|
// Detail screen caching
|
||||||
|
final Map<String, List<Track>> _albumTracksCache = {};
|
||||||
|
final Map<String, DateTime> _albumTracksCacheTime = {};
|
||||||
|
final Map<String, List<Track>> _playlistTracksCache = {};
|
||||||
|
final Map<String, DateTime> _playlistTracksCacheTime = {};
|
||||||
|
final Map<String, List<Album>> _artistAlbumsCache = {};
|
||||||
|
final Map<String, DateTime> _artistAlbumsCacheTime = {};
|
||||||
|
|
||||||
|
// Search results caching
|
||||||
|
final Map<String, Map<String, List<MediaItem>>> _searchCache = {};
|
||||||
|
final Map<String, DateTime> _searchCacheTime = {};
|
||||||
|
|
||||||
|
// Player list caching
|
||||||
|
DateTime? _playersLastFetched;
|
||||||
|
|
||||||
|
// Player track cache (for smooth swipe transitions)
|
||||||
|
final Map<String, Track?> _playerTrackCache = {};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HOME SCREEN ROW CACHING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Check if recent albums cache is valid
|
||||||
|
bool isRecentAlbumsCacheValid({bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _cachedRecentAlbums != null &&
|
||||||
|
_recentAlbumsLastFetched != null &&
|
||||||
|
now.difference(_recentAlbumsLastFetched!) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached recent albums
|
||||||
|
List<Album>? getCachedRecentAlbums() => _cachedRecentAlbums;
|
||||||
|
|
||||||
|
/// Set cached recent albums
|
||||||
|
void setCachedRecentAlbums(List<Album> albums) {
|
||||||
|
_cachedRecentAlbums = albums;
|
||||||
|
_recentAlbumsLastFetched = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${albums.length} recent albums');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if discover artists cache is valid
|
||||||
|
bool isDiscoverArtistsCacheValid({bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _cachedDiscoverArtists != null &&
|
||||||
|
_discoverArtistsLastFetched != null &&
|
||||||
|
now.difference(_discoverArtistsLastFetched!) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached discover artists
|
||||||
|
List<Artist>? getCachedDiscoverArtists() => _cachedDiscoverArtists;
|
||||||
|
|
||||||
|
/// Set cached discover artists
|
||||||
|
void setCachedDiscoverArtists(List<Artist> artists) {
|
||||||
|
_cachedDiscoverArtists = artists;
|
||||||
|
_discoverArtistsLastFetched = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${artists.length} discover artists');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if discover albums cache is valid
|
||||||
|
bool isDiscoverAlbumsCacheValid({bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _cachedDiscoverAlbums != null &&
|
||||||
|
_discoverAlbumsLastFetched != null &&
|
||||||
|
now.difference(_discoverAlbumsLastFetched!) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached discover albums
|
||||||
|
List<Album>? getCachedDiscoverAlbums() => _cachedDiscoverAlbums;
|
||||||
|
|
||||||
|
/// Set cached discover albums
|
||||||
|
void setCachedDiscoverAlbums(List<Album> albums) {
|
||||||
|
_cachedDiscoverAlbums = albums;
|
||||||
|
_discoverAlbumsLastFetched = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${albums.length} discover albums');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||||
|
void invalidateHomeCache() {
|
||||||
|
_recentAlbumsLastFetched = null;
|
||||||
|
_discoverArtistsLastFetched = null;
|
||||||
|
_discoverAlbumsLastFetched = null;
|
||||||
|
_logger.log('🗑️ Home screen cache invalidated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DETAIL SCREEN CACHING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Check if album tracks cache is valid
|
||||||
|
bool isAlbumTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final cacheTime = _albumTracksCacheTime[cacheKey];
|
||||||
|
return _albumTracksCache.containsKey(cacheKey) &&
|
||||||
|
cacheTime != null &&
|
||||||
|
now.difference(cacheTime) < const Duration(minutes: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached album tracks
|
||||||
|
List<Track>? getCachedAlbumTracks(String cacheKey) => _albumTracksCache[cacheKey];
|
||||||
|
|
||||||
|
/// Set cached album tracks
|
||||||
|
void setCachedAlbumTracks(String cacheKey, List<Track> tracks) {
|
||||||
|
_albumTracksCache[cacheKey] = tracks;
|
||||||
|
_albumTracksCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${tracks.length} tracks for album $cacheKey');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate album tracks cache
|
||||||
|
void invalidateAlbumTracksCache(String albumId) {
|
||||||
|
_albumTracksCache.remove(albumId);
|
||||||
|
_albumTracksCacheTime.remove(albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if playlist tracks cache is valid
|
||||||
|
bool isPlaylistTracksCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final cacheTime = _playlistTracksCacheTime[cacheKey];
|
||||||
|
return _playlistTracksCache.containsKey(cacheKey) &&
|
||||||
|
cacheTime != null &&
|
||||||
|
now.difference(cacheTime) < const Duration(minutes: 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached playlist tracks
|
||||||
|
List<Track>? getCachedPlaylistTracks(String cacheKey) => _playlistTracksCache[cacheKey];
|
||||||
|
|
||||||
|
/// Set cached playlist tracks
|
||||||
|
void setCachedPlaylistTracks(String cacheKey, List<Track> tracks) {
|
||||||
|
_playlistTracksCache[cacheKey] = tracks;
|
||||||
|
_playlistTracksCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${tracks.length} tracks for playlist $cacheKey');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate playlist tracks cache
|
||||||
|
void invalidatePlaylistTracksCache(String playlistId) {
|
||||||
|
_playlistTracksCache.remove(playlistId);
|
||||||
|
_playlistTracksCacheTime.remove(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if artist albums cache is valid
|
||||||
|
bool isArtistAlbumsCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final cacheTime = _artistAlbumsCacheTime[cacheKey];
|
||||||
|
return _artistAlbumsCache.containsKey(cacheKey) &&
|
||||||
|
cacheTime != null &&
|
||||||
|
now.difference(cacheTime) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached artist albums
|
||||||
|
List<Album>? getCachedArtistAlbums(String cacheKey) => _artistAlbumsCache[cacheKey];
|
||||||
|
|
||||||
|
/// Set cached artist albums
|
||||||
|
void setCachedArtistAlbums(String cacheKey, List<Album> albums) {
|
||||||
|
_artistAlbumsCache[cacheKey] = albums;
|
||||||
|
_artistAlbumsCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_logger.log('✅ Cached ${albums.length} albums for artist $cacheKey');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SEARCH CACHING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Check if search cache is valid
|
||||||
|
bool isSearchCacheValid(String cacheKey, {bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final cacheTime = _searchCacheTime[cacheKey];
|
||||||
|
return _searchCache.containsKey(cacheKey) &&
|
||||||
|
cacheTime != null &&
|
||||||
|
now.difference(cacheTime) < Timings.homeRowCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached search results
|
||||||
|
Map<String, List<MediaItem>>? getCachedSearchResults(String cacheKey) => _searchCache[cacheKey];
|
||||||
|
|
||||||
|
/// Set cached search results
|
||||||
|
void setCachedSearchResults(String cacheKey, Map<String, List<MediaItem>> results) {
|
||||||
|
_searchCache[cacheKey] = results;
|
||||||
|
_searchCacheTime[cacheKey] = DateTime.now();
|
||||||
|
_logger.log('✅ Cached search results for "$cacheKey"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PLAYER CACHING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Check if players cache is valid
|
||||||
|
bool isPlayersCacheValid({bool forceRefresh = false}) {
|
||||||
|
if (forceRefresh) return false;
|
||||||
|
final now = DateTime.now();
|
||||||
|
return _playersLastFetched != null &&
|
||||||
|
now.difference(_playersLastFetched!) < Timings.playersCacheDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update players cache timestamp
|
||||||
|
void updatePlayersLastFetched() {
|
||||||
|
_playersLastFetched = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached track for a player (used for smooth swipe transitions)
|
||||||
|
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
|
||||||
|
|
||||||
|
/// Set cached track for a player
|
||||||
|
void setCachedTrackForPlayer(String playerId, Track? track) {
|
||||||
|
_playerTrackCache[playerId] = track;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CLEAR ALL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Clear all detail caches (call on disconnect/reconnect)
|
||||||
|
void clearAllDetailCaches() {
|
||||||
|
_albumTracksCache.clear();
|
||||||
|
_albumTracksCacheTime.clear();
|
||||||
|
_playlistTracksCache.clear();
|
||||||
|
_playlistTracksCacheTime.clear();
|
||||||
|
_artistAlbumsCache.clear();
|
||||||
|
_artistAlbumsCacheTime.clear();
|
||||||
|
_searchCache.clear();
|
||||||
|
_searchCacheTime.clear();
|
||||||
|
_playerTrackCache.clear();
|
||||||
|
_logger.log('🗑️ All detail caches cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all caches
|
||||||
|
void clearAll() {
|
||||||
|
invalidateHomeCache();
|
||||||
|
clearAllDetailCaches();
|
||||||
|
_playersLastFetched = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,243 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
|
||||||
|
/// Log levels for filtering and display
|
||||||
|
enum LogLevel {
|
||||||
|
perf, // Performance timing (for FPS diagnosis)
|
||||||
|
debug, // Verbose debugging info (hidden by default)
|
||||||
|
info, // General information
|
||||||
|
warning, // Potential issues
|
||||||
|
error, // Errors and failures
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogEntry {
|
||||||
|
final DateTime timestamp;
|
||||||
|
final LogLevel level;
|
||||||
|
final String message;
|
||||||
|
final String? context;
|
||||||
|
|
||||||
|
LogEntry({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.level,
|
||||||
|
required this.message,
|
||||||
|
this.context,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get formatted {
|
||||||
|
final time = timestamp.toIso8601String().substring(11, 23);
|
||||||
|
final levelStr = level.name.toUpperCase().padRight(5);
|
||||||
|
final contextStr = context != null ? '[$context] ' : '';
|
||||||
|
return '[$time] $levelStr $contextStr$message';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class DebugLogger {
|
class DebugLogger {
|
||||||
static final DebugLogger _instance = DebugLogger._internal();
|
static final DebugLogger _instance = DebugLogger._internal();
|
||||||
factory DebugLogger() => _instance;
|
factory DebugLogger() => _instance;
|
||||||
DebugLogger._internal();
|
DebugLogger._internal();
|
||||||
|
|
||||||
final List<String> _logs = [];
|
final List<LogEntry> _entries = [];
|
||||||
final _maxLogs = 500;
|
final _maxLogs = 1000;
|
||||||
|
|
||||||
List<String> get logs => List.unmodifiable(_logs);
|
// Minimum level to display in debug console (debug builds show all)
|
||||||
|
LogLevel _minConsoleLevel = kDebugMode ? LogLevel.debug : LogLevel.info;
|
||||||
|
|
||||||
void log(String message) {
|
List<LogEntry> get entries => List.unmodifiable(_entries);
|
||||||
final timestamp = DateTime.now().toIso8601String().substring(11, 23);
|
|
||||||
final logMessage = '[$timestamp] $message';
|
|
||||||
|
|
||||||
debugPrint(logMessage);
|
/// Legacy getter for backward compatibility
|
||||||
|
List<String> get logs => _entries.map((e) => e.formatted).toList();
|
||||||
|
|
||||||
_logs.add(logMessage);
|
// Set to true to only log perf entries (for FPS diagnosis)
|
||||||
if (_logs.length > _maxLogs) {
|
// Set to false for normal operation with full logging for user bug reports
|
||||||
_logs.removeAt(0);
|
static const bool _perfOnlyMode = false;
|
||||||
|
|
||||||
|
void _addEntry(LogLevel level, String message, {String? context}) {
|
||||||
|
// In perf-only mode, skip all non-perf logs
|
||||||
|
if (_perfOnlyMode && level != LogLevel.perf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final entry = LogEntry(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
level: level,
|
||||||
|
message: message,
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Always store in memory
|
||||||
|
_entries.add(entry);
|
||||||
|
if (_entries.length > _maxLogs) {
|
||||||
|
_entries.removeAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only print to console if above minimum level
|
||||||
|
if (level.index >= _minConsoleLevel.index) {
|
||||||
|
debugPrint(entry.formatted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Debug level - verbose info for development
|
||||||
|
void debug(String message, {String? context}) {
|
||||||
|
_addEntry(LogLevel.debug, message, context: context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Info level - general operational info
|
||||||
|
void info(String message, {String? context}) {
|
||||||
|
_addEntry(LogLevel.info, message, context: context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warning level - potential issues
|
||||||
|
void warning(String message, {String? context}) {
|
||||||
|
_addEntry(LogLevel.warning, message, context: context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error level - failures and errors
|
||||||
|
void error(String message, {String? context, Object? error, StackTrace? stackTrace}) {
|
||||||
|
var fullMessage = message;
|
||||||
|
if (error != null) {
|
||||||
|
fullMessage += ': $error';
|
||||||
|
}
|
||||||
|
_addEntry(LogLevel.error, fullMessage, context: context);
|
||||||
|
if (stackTrace != null && kDebugMode) {
|
||||||
|
debugPrint('Stack trace: $stackTrace');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy method for backward compatibility
|
||||||
|
void log(String message) {
|
||||||
|
_addEntry(LogLevel.info, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performance logging - always prints to console for real-time monitoring
|
||||||
|
void perf(String message, {String? context}) {
|
||||||
|
_addEntry(LogLevel.perf, message, context: context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track build time of a widget
|
||||||
|
final Map<String, Stopwatch> _buildTimers = {};
|
||||||
|
|
||||||
|
void startBuild(String widgetName) {
|
||||||
|
_buildTimers[widgetName] = Stopwatch()..start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void endBuild(String widgetName) {
|
||||||
|
final sw = _buildTimers.remove(widgetName);
|
||||||
|
if (sw != null) {
|
||||||
|
sw.stop();
|
||||||
|
if (sw.elapsedMilliseconds > 4) { // Only log if > 4ms (slow for 60fps)
|
||||||
|
perf('BUILD ${sw.elapsedMilliseconds}ms', context: widgetName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track frame times during scroll
|
||||||
|
Stopwatch? _frameTimer;
|
||||||
|
int _slowFrameCount = 0;
|
||||||
|
int _totalFrameCount = 0;
|
||||||
|
|
||||||
|
void startFrame() {
|
||||||
|
_frameTimer = Stopwatch()..start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void endFrame() {
|
||||||
|
if (_frameTimer != null) {
|
||||||
|
_frameTimer!.stop();
|
||||||
|
_totalFrameCount++;
|
||||||
|
final ms = _frameTimer!.elapsedMilliseconds;
|
||||||
|
if (ms > 16) { // > 16ms means dropped frame at 60fps
|
||||||
|
_slowFrameCount++;
|
||||||
|
perf('SLOW FRAME ${ms}ms (${_slowFrameCount}/${_totalFrameCount} slow)', context: 'Scroll');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetFrameStats() {
|
||||||
|
_slowFrameCount = 0;
|
||||||
|
_totalFrameCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
_logs.clear();
|
_entries.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
String getAllLogs() {
|
String getAllLogs() {
|
||||||
return _logs.join('\n');
|
return _entries.map((e) => e.formatted).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get logs filtered by minimum level
|
||||||
|
String getLogsFiltered({LogLevel minLevel = LogLevel.info}) {
|
||||||
|
return _entries
|
||||||
|
.where((e) => e.level.index >= minLevel.index)
|
||||||
|
.map((e) => e.formatted)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a formatted bug report with device info
|
||||||
|
Future<String> generateBugReport() async {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln('ENSEMBLE BUG REPORT');
|
||||||
|
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln();
|
||||||
|
|
||||||
|
// App info
|
||||||
|
try {
|
||||||
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
buffer.writeln('APP INFO:');
|
||||||
|
buffer.writeln(' Version: ${packageInfo.version} (${packageInfo.buildNumber})');
|
||||||
|
buffer.writeln(' Package: ${packageInfo.packageName}');
|
||||||
|
buffer.writeln();
|
||||||
|
} catch (e) {
|
||||||
|
buffer.writeln('APP INFO: Unable to retrieve');
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device info
|
||||||
|
try {
|
||||||
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final android = await deviceInfo.androidInfo;
|
||||||
|
buffer.writeln('DEVICE INFO:');
|
||||||
|
buffer.writeln(' Model: ${android.manufacturer} ${android.model}');
|
||||||
|
buffer.writeln(' Android: ${android.version.release} (SDK ${android.version.sdkInt})');
|
||||||
|
buffer.writeln(' Device: ${android.device}');
|
||||||
|
buffer.writeln();
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
final ios = await deviceInfo.iosInfo;
|
||||||
|
buffer.writeln('DEVICE INFO:');
|
||||||
|
buffer.writeln(' Model: ${ios.model}');
|
||||||
|
buffer.writeln(' iOS: ${ios.systemVersion}');
|
||||||
|
buffer.writeln(' Name: ${ios.name}');
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
buffer.writeln('DEVICE INFO: Unable to retrieve');
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log statistics
|
||||||
|
final errorCount = _entries.where((e) => e.level == LogLevel.error).length;
|
||||||
|
final warningCount = _entries.where((e) => e.level == LogLevel.warning).length;
|
||||||
|
buffer.writeln('LOG SUMMARY:');
|
||||||
|
buffer.writeln(' Total entries: ${_entries.length}');
|
||||||
|
buffer.writeln(' Errors: $errorCount');
|
||||||
|
buffer.writeln(' Warnings: $warningCount');
|
||||||
|
buffer.writeln();
|
||||||
|
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln('LOGS (newest first):');
|
||||||
|
buffer.writeln('═══════════════════════════════════════');
|
||||||
|
buffer.writeln();
|
||||||
|
|
||||||
|
// Add logs in reverse order (newest first) for easier reading
|
||||||
|
for (final entry in _entries.reversed) {
|
||||||
|
buffer.writeln(entry.formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
|
||||||
|
/// Service to generate unique per-installation player IDs
|
||||||
|
/// Simplified to match KMP client pattern: single storage key, lazy generation
|
||||||
|
/// This prevents the critical bug where multiple devices of the same model
|
||||||
|
/// would share the same player ID and trigger playback on all devices
|
||||||
|
class DeviceIdService {
|
||||||
|
static const String _keyLocalPlayerId = 'local_player_id';
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
/// Get or generate a unique player ID for this installation
|
||||||
|
/// ID is generated once and persisted across app restarts
|
||||||
|
/// Format: ensemble_<uuid>
|
||||||
|
///
|
||||||
|
/// This matches the KMP client pattern: generate on first access, store once
|
||||||
|
static Future<String> getOrCreateDevicePlayerId() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// Single source of truth - no complex migration logic
|
||||||
|
final existingId = prefs.getString(_keyLocalPlayerId);
|
||||||
|
if (existingId != null && existingId.startsWith('ensemble_')) {
|
||||||
|
_logger.log('Using existing player ID: $existingId');
|
||||||
|
return existingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new ID on first access
|
||||||
|
final playerId = 'ensemble_${_uuid.v4()}';
|
||||||
|
await prefs.setString(_keyLocalPlayerId, playerId);
|
||||||
|
_logger.log('Generated new player ID: $playerId');
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adopt an existing player ID (used when claiming a ghost player)
|
||||||
|
static Future<void> adoptPlayerId(String playerId) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyLocalPlayerId, playerId);
|
||||||
|
_logger.log('Adopted player ID: $playerId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a fresh installation (no player ID stored yet)
|
||||||
|
static Future<bool> isFreshInstallation() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLocalPlayerId) == null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
|
||||||
|
/// Service to intercept hardware volume button presses
|
||||||
|
/// and route them to the selected Music Assistant player.
|
||||||
|
///
|
||||||
|
/// For MA players: intercept buttons and send volume commands via API
|
||||||
|
/// For builtin player: let native volume work (don't intercept)
|
||||||
|
class HardwareVolumeService {
|
||||||
|
static final HardwareVolumeService _instance = HardwareVolumeService._internal();
|
||||||
|
factory HardwareVolumeService() => _instance;
|
||||||
|
HardwareVolumeService._internal();
|
||||||
|
|
||||||
|
static const _channel = MethodChannel('com.musicassistant.music_assistant/volume_buttons');
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
|
final _volumeUpController = StreamController<void>.broadcast();
|
||||||
|
final _volumeDownController = StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
Stream<void> get onVolumeUp => _volumeUpController.stream;
|
||||||
|
Stream<void> get onVolumeDown => _volumeDownController.stream;
|
||||||
|
|
||||||
|
bool _isListening = false;
|
||||||
|
bool get isListening => _isListening;
|
||||||
|
|
||||||
|
bool _isIntercepting = false;
|
||||||
|
bool get isIntercepting => _isIntercepting;
|
||||||
|
|
||||||
|
/// Initialize the service and start listening for volume button events
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_isListening) return;
|
||||||
|
|
||||||
|
_channel.setMethodCallHandler((call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'volumeUp':
|
||||||
|
_volumeUpController.add(null);
|
||||||
|
break;
|
||||||
|
case 'volumeDown':
|
||||||
|
_volumeDownController.add(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod('startListening');
|
||||||
|
_isListening = true;
|
||||||
|
_isIntercepting = true;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.error('Failed to start volume button listening', context: 'VolumeService', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable volume button interception.
|
||||||
|
/// When disabled, hardware volume buttons control device volume normally.
|
||||||
|
/// When enabled, volume button events are sent to Flutter for MA player control.
|
||||||
|
Future<void> setIntercepting(bool intercept) async {
|
||||||
|
if (!_isListening || _isIntercepting == intercept) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (intercept) {
|
||||||
|
await _channel.invokeMethod('startListening');
|
||||||
|
_isIntercepting = true;
|
||||||
|
} else {
|
||||||
|
await _channel.invokeMethod('stopListening');
|
||||||
|
_isIntercepting = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.error('Failed to set volume interception', context: 'VolumeService', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop listening for volume button events
|
||||||
|
Future<void> dispose() async {
|
||||||
|
if (!_isListening) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod('stopListening');
|
||||||
|
_isListening = false;
|
||||||
|
_isIntercepting = false;
|
||||||
|
_logger.info('Hardware volume button listening stopped', context: 'VolumeService');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.error('Failed to stop volume button listening', context: 'VolumeService', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
import 'auth/auth_manager.dart';
|
||||||
|
import '../main.dart' show audioHandler;
|
||||||
|
|
||||||
|
/// Metadata for the currently playing track
|
||||||
|
class TrackMetadata {
|
||||||
|
final String title;
|
||||||
|
final String artist;
|
||||||
|
final String? album;
|
||||||
|
final String? artworkUrl;
|
||||||
|
final Duration? duration;
|
||||||
|
|
||||||
|
TrackMetadata({
|
||||||
|
required this.title,
|
||||||
|
required this.artist,
|
||||||
|
this.album,
|
||||||
|
this.artworkUrl,
|
||||||
|
this.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Convert to MediaItem for audio_service
|
||||||
|
MediaItem toMediaItem(String id) {
|
||||||
|
return MediaItem(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
album: album ?? '',
|
||||||
|
duration: duration,
|
||||||
|
artUri: artworkUrl != null ? Uri.tryParse(artworkUrl!) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service that wraps the global MassivAudioHandler for local playback.
|
||||||
|
/// This maintains the same interface as before for compatibility with
|
||||||
|
/// MusicAssistantProvider.
|
||||||
|
class LocalPlayerService {
|
||||||
|
final AuthManager authManager;
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
// Current track metadata for notifications
|
||||||
|
TrackMetadata? _currentMetadata;
|
||||||
|
|
||||||
|
LocalPlayerService(this.authManager);
|
||||||
|
|
||||||
|
// Expose player state streams from the global handler
|
||||||
|
Stream<PlayerState> get playerStateStream {
|
||||||
|
return audioHandler.playerStateStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<Duration> get positionStream {
|
||||||
|
return audioHandler.positionStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<Duration?> get durationStream {
|
||||||
|
return audioHandler.durationStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current state getters
|
||||||
|
bool get isPlaying {
|
||||||
|
return audioHandler.isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
double get volume {
|
||||||
|
return audioHandler.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerState get playerState {
|
||||||
|
return audioHandler.playerState;
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration get position {
|
||||||
|
return audioHandler.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration get duration {
|
||||||
|
return audioHandler.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
// The audioHandler is already initialized in main.dart
|
||||||
|
// Just mark ourselves as initialized
|
||||||
|
_isInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set metadata for the current track (for notification display)
|
||||||
|
void setCurrentTrackMetadata(TrackMetadata metadata) {
|
||||||
|
_currentMetadata = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play a stream URL with authentication headers
|
||||||
|
Future<void> playUrl(String url) async {
|
||||||
|
// Ensure player is initialized before playing
|
||||||
|
if (!_isInitialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get auth headers from AuthManager
|
||||||
|
final headers = authManager.getStreamingHeaders();
|
||||||
|
|
||||||
|
// Create MediaItem from current metadata
|
||||||
|
final mediaItem = _currentMetadata?.toMediaItem(url) ?? MediaItem(
|
||||||
|
id: url,
|
||||||
|
title: 'Unknown Track',
|
||||||
|
artist: 'Unknown Artist',
|
||||||
|
);
|
||||||
|
|
||||||
|
await audioHandler.playUrl(
|
||||||
|
url,
|
||||||
|
mediaItem,
|
||||||
|
headers: headers.isNotEmpty ? headers : null,
|
||||||
|
);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_logger.log('LocalPlayerService: Error playing URL: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the notification with new track info (without reloading audio)
|
||||||
|
void updateNotification({
|
||||||
|
required String id,
|
||||||
|
required String title,
|
||||||
|
String? artist,
|
||||||
|
String? album,
|
||||||
|
String? artworkUrl,
|
||||||
|
Duration? duration,
|
||||||
|
}) {
|
||||||
|
_currentMetadata = TrackMetadata(
|
||||||
|
title: title,
|
||||||
|
artist: artist ?? 'Unknown Artist',
|
||||||
|
album: album,
|
||||||
|
artworkUrl: artworkUrl,
|
||||||
|
duration: duration,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the notification while audio is already playing.
|
||||||
|
/// With audio_service, we can update the MediaItem directly without
|
||||||
|
/// re-setting the audio source.
|
||||||
|
Future<void> updateNotificationWhilePlaying(TrackMetadata metadata) async {
|
||||||
|
final currentItem = audioHandler.currentMediaItem;
|
||||||
|
final id = currentItem?.id ?? 'unknown';
|
||||||
|
|
||||||
|
final newMediaItem = metadata.toMediaItem(id);
|
||||||
|
audioHandler.updateMediaItem(newMediaItem);
|
||||||
|
_currentMetadata = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> play() async {
|
||||||
|
await audioHandler.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pause() async {
|
||||||
|
await audioHandler.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
await audioHandler.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> seek(Duration position) async {
|
||||||
|
await audioHandler.seek(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set volume (0.0 to 1.0)
|
||||||
|
Future<void> setVolume(double volume) async {
|
||||||
|
await audioHandler.setVolume(volume.clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
// The global audioHandler is managed by audio_service
|
||||||
|
// Don't dispose it here
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
import 'settings_service.dart';
|
||||||
|
|
||||||
|
class MetadataService {
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
// Cache to avoid repeated API calls for the same artist/album
|
||||||
|
static final Map<String, String> _cache = {};
|
||||||
|
|
||||||
|
// Cache for artist images
|
||||||
|
static final Map<String, String?> _artistImageCache = {};
|
||||||
|
|
||||||
|
/// Fetches artist biography/description with fallback chain:
|
||||||
|
/// 1. Music Assistant metadata (passed in)
|
||||||
|
/// 2. Last.fm API (if key configured)
|
||||||
|
/// 3. TheAudioDB API (if key configured)
|
||||||
|
static Future<String?> getArtistDescription(
|
||||||
|
String artistName,
|
||||||
|
Map<String, dynamic>? musicAssistantMetadata,
|
||||||
|
) async {
|
||||||
|
// Try Music Assistant metadata first
|
||||||
|
if (musicAssistantMetadata != null) {
|
||||||
|
final maDescription = musicAssistantMetadata['description'] ??
|
||||||
|
musicAssistantMetadata['biography'] ??
|
||||||
|
musicAssistantMetadata['wiki'] ??
|
||||||
|
musicAssistantMetadata['bio'] ??
|
||||||
|
musicAssistantMetadata['summary'];
|
||||||
|
|
||||||
|
if (maDescription != null && (maDescription as String).trim().isNotEmpty) {
|
||||||
|
return maDescription;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
final cacheKey = 'artist:$artistName';
|
||||||
|
if (_cache.containsKey(cacheKey)) {
|
||||||
|
return _cache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Last.fm API
|
||||||
|
final lastFmKey = await SettingsService.getLastFmApiKey();
|
||||||
|
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||||
|
final lastFmDesc = await _fetchFromLastFm(artistName, null, lastFmKey);
|
||||||
|
if (lastFmDesc != null) {
|
||||||
|
_cache[cacheKey] = lastFmDesc;
|
||||||
|
return lastFmDesc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try TheAudioDB API
|
||||||
|
final audioDbKey = await SettingsService.getTheAudioDbApiKey();
|
||||||
|
if (audioDbKey != null && audioDbKey.isNotEmpty) {
|
||||||
|
final audioDbDesc = await _fetchFromTheAudioDb(artistName, audioDbKey);
|
||||||
|
if (audioDbDesc != null) {
|
||||||
|
_cache[cacheKey] = audioDbDesc;
|
||||||
|
return audioDbDesc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches album description with fallback chain:
|
||||||
|
/// 1. Music Assistant metadata (passed in)
|
||||||
|
/// 2. Last.fm API (if key configured)
|
||||||
|
static Future<String?> getAlbumDescription(
|
||||||
|
String artistName,
|
||||||
|
String albumName,
|
||||||
|
Map<String, dynamic>? musicAssistantMetadata,
|
||||||
|
) async {
|
||||||
|
// Try Music Assistant metadata first
|
||||||
|
if (musicAssistantMetadata != null) {
|
||||||
|
final maDescription = musicAssistantMetadata['description'] ??
|
||||||
|
musicAssistantMetadata['wiki'] ??
|
||||||
|
musicAssistantMetadata['biography'] ??
|
||||||
|
musicAssistantMetadata['summary'];
|
||||||
|
|
||||||
|
if (maDescription != null && (maDescription as String).trim().isNotEmpty) {
|
||||||
|
return maDescription;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
final cacheKey = 'album:$artistName:$albumName';
|
||||||
|
if (_cache.containsKey(cacheKey)) {
|
||||||
|
return _cache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Last.fm API (TheAudioDB doesn't have good album info)
|
||||||
|
final lastFmKey = await SettingsService.getLastFmApiKey();
|
||||||
|
if (lastFmKey != null && lastFmKey.isNotEmpty) {
|
||||||
|
final lastFmDesc = await _fetchFromLastFm(artistName, albumName, lastFmKey);
|
||||||
|
if (lastFmDesc != null) {
|
||||||
|
_cache[cacheKey] = lastFmDesc;
|
||||||
|
return lastFmDesc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _fetchFromLastFm(
|
||||||
|
String artistName,
|
||||||
|
String? albumName,
|
||||||
|
String apiKey,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final String method;
|
||||||
|
final Map<String, String> params = {
|
||||||
|
'api_key': apiKey,
|
||||||
|
'format': 'json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (albumName != null) {
|
||||||
|
// Album info
|
||||||
|
method = 'album.getinfo';
|
||||||
|
params['artist'] = artistName;
|
||||||
|
params['album'] = albumName;
|
||||||
|
} else {
|
||||||
|
// Artist info
|
||||||
|
method = 'artist.getinfo';
|
||||||
|
params['artist'] = artistName;
|
||||||
|
}
|
||||||
|
|
||||||
|
params['method'] = method;
|
||||||
|
|
||||||
|
final uri = Uri.https('ws.audioscrobbler.com', '/2.0/', params);
|
||||||
|
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
|
||||||
|
if (albumName != null) {
|
||||||
|
// Parse album response
|
||||||
|
final album = data['album'];
|
||||||
|
if (album != null) {
|
||||||
|
final wiki = album['wiki'];
|
||||||
|
if (wiki != null) {
|
||||||
|
// Prefer summary, fall back to content
|
||||||
|
return _cleanLastFmText(wiki['summary'] ?? wiki['content']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Parse artist response
|
||||||
|
final artist = data['artist'];
|
||||||
|
if (artist != null) {
|
||||||
|
final bio = artist['bio'];
|
||||||
|
if (bio != null) {
|
||||||
|
// Prefer summary, fall back to content
|
||||||
|
return _cleanLastFmText(bio['summary'] ?? bio['content']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.warning('Last.fm API error: $e', context: 'Metadata');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _fetchFromTheAudioDb(
|
||||||
|
String artistName,
|
||||||
|
String apiKey,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.https(
|
||||||
|
'theaudiodb.com',
|
||||||
|
'/api/v1/json/$apiKey/search.php',
|
||||||
|
{'s': artistName},
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final artists = data['artists'];
|
||||||
|
|
||||||
|
if (artists != null && artists.isNotEmpty) {
|
||||||
|
final artist = artists[0];
|
||||||
|
// Try multiple language fields
|
||||||
|
return artist['strBiographyEN'] ??
|
||||||
|
artist['strBiographyDE'] ??
|
||||||
|
artist['strBiographyFR'] ??
|
||||||
|
artist['strBiographyIT'] ??
|
||||||
|
artist['strBiographyES'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.warning('TheAudioDB API error: $e', context: 'Metadata');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes Last.fm HTML tags and links
|
||||||
|
static String? _cleanLastFmText(String? text) {
|
||||||
|
if (text == null) return null;
|
||||||
|
|
||||||
|
// Remove <a href...> tags
|
||||||
|
text = text.replaceAll(RegExp(r'<a[^>]*>'), '');
|
||||||
|
text = text.replaceAll('</a>', '');
|
||||||
|
|
||||||
|
// Remove "Read more on Last.fm" footer
|
||||||
|
text = text.replaceAll(RegExp(r'\s*<a[^>]*>.*?</a>.*$'), '');
|
||||||
|
|
||||||
|
// Clean up any remaining HTML
|
||||||
|
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
|
||||||
|
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches artist image URL from Deezer (free, no API key required)
|
||||||
|
/// Returns the image URL if found, null otherwise
|
||||||
|
static Future<String?> getArtistImageUrl(String artistName) async {
|
||||||
|
// Check cache first
|
||||||
|
final cacheKey = 'artistImage:$artistName';
|
||||||
|
if (_artistImageCache.containsKey(cacheKey)) {
|
||||||
|
return _artistImageCache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Deezer API (free, no key, excellent coverage)
|
||||||
|
try {
|
||||||
|
final uri = Uri.https(
|
||||||
|
'api.deezer.com',
|
||||||
|
'/search/artist',
|
||||||
|
{
|
||||||
|
'q': artistName,
|
||||||
|
'limit': '1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final artists = data['data'] as List?;
|
||||||
|
if (artists != null && artists.isNotEmpty) {
|
||||||
|
final artist = artists[0];
|
||||||
|
// Use picture_xl for high quality, fall back to picture_medium
|
||||||
|
final imageUrl = artist['picture_xl'] ?? artist['picture_medium'] ?? artist['picture'];
|
||||||
|
_artistImageCache[cacheKey] = imageUrl;
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.warning('Deezer artist image error: $e', context: 'Metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the null result to avoid repeated failed lookups
|
||||||
|
_artistImageCache[cacheKey] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for album images
|
||||||
|
static final Map<String, String?> _albumImageCache = {};
|
||||||
|
|
||||||
|
/// Fetches album cover URL from Deezer (free, no API key required)
|
||||||
|
/// Returns the image URL if found, null otherwise
|
||||||
|
static Future<String?> getAlbumImageUrl(String albumName, String? artistName) async {
|
||||||
|
// Build cache key
|
||||||
|
final cacheKey = 'albumImage:${artistName ?? ""}:$albumName';
|
||||||
|
if (_albumImageCache.containsKey(cacheKey)) {
|
||||||
|
return _albumImageCache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Deezer API (free, no key, excellent coverage)
|
||||||
|
try {
|
||||||
|
// Search with both album and artist if available for better matching
|
||||||
|
final query = artistName != null && artistName.isNotEmpty
|
||||||
|
? '$artistName $albumName'
|
||||||
|
: albumName;
|
||||||
|
|
||||||
|
final uri = Uri.https(
|
||||||
|
'api.deezer.com',
|
||||||
|
'/search/album',
|
||||||
|
{
|
||||||
|
'q': query,
|
||||||
|
'limit': '5', // Get a few results to find best match
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final albums = data['data'] as List?;
|
||||||
|
if (albums != null && albums.isNotEmpty) {
|
||||||
|
// Try to find exact match first
|
||||||
|
Map<String, dynamic>? bestMatch;
|
||||||
|
for (final album in albums) {
|
||||||
|
final deezerAlbumName = (album['title'] as String?)?.toLowerCase();
|
||||||
|
final deezerArtistName = (album['artist']?['name'] as String?)?.toLowerCase();
|
||||||
|
|
||||||
|
if (deezerAlbumName == albumName.toLowerCase()) {
|
||||||
|
// Album name matches
|
||||||
|
if (artistName == null || artistName.isEmpty ||
|
||||||
|
deezerArtistName == artistName.toLowerCase()) {
|
||||||
|
// And artist matches (or we don't have artist to match)
|
||||||
|
bestMatch = album;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Album matches but artist doesn't - keep as candidate
|
||||||
|
bestMatch ??= album;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to first result if no good match
|
||||||
|
final album = bestMatch ?? albums[0] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Use cover_xl for high quality, fall back to cover_medium
|
||||||
|
final imageUrl = album['cover_xl'] ?? album['cover_big'] ??
|
||||||
|
album['cover_medium'] ?? album['cover'];
|
||||||
|
_albumImageCache[cacheKey] = imageUrl;
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.warning('Deezer album image error: $e', context: 'Metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the null result to avoid repeated failed lookups
|
||||||
|
_albumImageCache[cacheKey] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the metadata cache
|
||||||
|
static void clearCache() {
|
||||||
|
_cache.clear();
|
||||||
|
_artistImageCache.clear();
|
||||||
|
_albumImageCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
|
||||||
|
/// Single source of truth for playback position tracking.
|
||||||
|
///
|
||||||
|
/// This service eliminates race conditions by:
|
||||||
|
/// 1. Being the ONLY place that calculates interpolated position
|
||||||
|
/// 2. Using consistent interpolation (no mode switching mid-stream)
|
||||||
|
/// 3. Capping position at track duration
|
||||||
|
/// 4. Broadcasting position updates via a single stream
|
||||||
|
///
|
||||||
|
/// All consumers (UI, notification) should listen to [positionStream]
|
||||||
|
/// instead of calculating position themselves.
|
||||||
|
class PositionTracker {
|
||||||
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
|
||||||
|
// Current state
|
||||||
|
String? _playerId;
|
||||||
|
bool _isPlaying = false;
|
||||||
|
Duration _duration = Duration.zero;
|
||||||
|
|
||||||
|
// Position tracking - we anchor to a known position and interpolate from there
|
||||||
|
double _anchorPosition = 0.0; // Position in seconds at anchor time
|
||||||
|
DateTime _anchorTime = DateTime.now(); // When we set the anchor
|
||||||
|
|
||||||
|
// Interpolation timer
|
||||||
|
Timer? _interpolationTimer;
|
||||||
|
|
||||||
|
// Stream controller for position updates
|
||||||
|
final _positionController = StreamController<Duration>.broadcast();
|
||||||
|
|
||||||
|
// Last emitted position (to avoid redundant updates)
|
||||||
|
int _lastEmittedSeconds = -1;
|
||||||
|
|
||||||
|
/// Stream of position updates (emits every second when playing)
|
||||||
|
Stream<Duration> get positionStream => _positionController.stream;
|
||||||
|
|
||||||
|
/// Current position (can be called synchronously)
|
||||||
|
Duration get currentPosition {
|
||||||
|
if (!_isPlaying) {
|
||||||
|
return Duration(seconds: _anchorPosition.round());
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final elapsed = now.difference(_anchorTime).inMilliseconds / 1000.0;
|
||||||
|
final interpolated = _anchorPosition + elapsed;
|
||||||
|
|
||||||
|
// Cap at duration to prevent overflow
|
||||||
|
final capped = _duration.inSeconds > 0
|
||||||
|
? interpolated.clamp(0.0, _duration.inSeconds.toDouble())
|
||||||
|
: interpolated.clamp(0.0, double.infinity);
|
||||||
|
|
||||||
|
return Duration(seconds: capped.round());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current position in seconds (for compatibility)
|
||||||
|
double get currentPositionSeconds {
|
||||||
|
if (!_isPlaying) {
|
||||||
|
return _anchorPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final elapsed = now.difference(_anchorTime).inMilliseconds / 1000.0;
|
||||||
|
final interpolated = _anchorPosition + elapsed;
|
||||||
|
|
||||||
|
// Cap at duration
|
||||||
|
if (_duration.inSeconds > 0) {
|
||||||
|
return interpolated.clamp(0.0, _duration.inSeconds.toDouble());
|
||||||
|
}
|
||||||
|
return interpolated.clamp(0.0, double.infinity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update with fresh data from server.
|
||||||
|
///
|
||||||
|
/// [playerId] - The player this update is for
|
||||||
|
/// [position] - Current position in seconds from server
|
||||||
|
/// [isPlaying] - Whether the player is currently playing
|
||||||
|
/// [duration] - Track duration (null if unknown)
|
||||||
|
/// [serverTimestamp] - Server's timestamp for when position was recorded (optional)
|
||||||
|
void updateFromServer({
|
||||||
|
required String playerId,
|
||||||
|
required double position,
|
||||||
|
required bool isPlaying,
|
||||||
|
Duration? duration,
|
||||||
|
double? serverTimestamp,
|
||||||
|
}) {
|
||||||
|
final bool playerChanged = _playerId != playerId;
|
||||||
|
final bool playStateChanged = _isPlaying != isPlaying;
|
||||||
|
final bool durationChanged = duration != null && _duration != duration;
|
||||||
|
|
||||||
|
_playerId = playerId;
|
||||||
|
_duration = duration ?? _duration;
|
||||||
|
|
||||||
|
// Calculate the best anchor position
|
||||||
|
double anchorPos = position;
|
||||||
|
bool hasStaleTimestamp = false;
|
||||||
|
|
||||||
|
// If server provides timestamp, use it to calculate current position more accurately
|
||||||
|
if (serverTimestamp != null && isPlaying) {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch / 1000.0;
|
||||||
|
final serverAge = now - serverTimestamp;
|
||||||
|
|
||||||
|
// Only use server timestamp if it's reasonably fresh (< 30 seconds)
|
||||||
|
// and not in the future (clock skew)
|
||||||
|
if (serverAge >= 0 && serverAge < 30) {
|
||||||
|
anchorPos = position + serverAge;
|
||||||
|
} else {
|
||||||
|
// Timestamp is stale - mark this so we don't blindly trust the position
|
||||||
|
hasStaleTimestamp = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at duration
|
||||||
|
if (_duration.inSeconds > 0) {
|
||||||
|
anchorPos = anchorPos.clamp(0.0, _duration.inSeconds.toDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if position jumped significantly (more than 3 seconds difference)
|
||||||
|
// This helps identify seeks or track changes
|
||||||
|
final currentInterpolated = currentPositionSeconds;
|
||||||
|
final positionDiff = (anchorPos - currentInterpolated).abs();
|
||||||
|
final isBackwardJump = anchorPos < currentInterpolated;
|
||||||
|
final isJumpToNearZero = anchorPos < 3.0;
|
||||||
|
|
||||||
|
// Detect suspicious backward jumps to 0 - likely bad server data
|
||||||
|
// This happens when some player types don't report elapsed_time correctly
|
||||||
|
final isSuspiciousReset = !playerChanged
|
||||||
|
&& !playStateChanged
|
||||||
|
&& !durationChanged
|
||||||
|
&& isPlaying
|
||||||
|
&& _isPlaying
|
||||||
|
&& isBackwardJump
|
||||||
|
&& isJumpToNearZero
|
||||||
|
&& currentInterpolated > 3.0;
|
||||||
|
|
||||||
|
// Always update anchor when:
|
||||||
|
// 1. Player changed
|
||||||
|
// 2. Play state changed
|
||||||
|
// 3. Position jumped significantly (seek or track change) - BUT NOT if it's a suspicious reset
|
||||||
|
// AND NOT if the timestamp is stale (stale data shouldn't override interpolated position)
|
||||||
|
// 4. We're not playing (paused state should reflect server position)
|
||||||
|
//
|
||||||
|
// Key insight: When timestamp is stale, the raw position from server is likely outdated.
|
||||||
|
// Our interpolated position is probably more accurate, so don't let stale data override it.
|
||||||
|
final shouldUpdateAnchor = playerChanged
|
||||||
|
|| playStateChanged
|
||||||
|
|| (positionDiff > 2 && !isSuspiciousReset && !hasStaleTimestamp)
|
||||||
|
|| !isPlaying;
|
||||||
|
|
||||||
|
if (shouldUpdateAnchor) {
|
||||||
|
_anchorPosition = anchorPos;
|
||||||
|
_anchorTime = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
_isPlaying = isPlaying;
|
||||||
|
|
||||||
|
// Manage interpolation timer
|
||||||
|
if (isPlaying) {
|
||||||
|
_startInterpolationTimer();
|
||||||
|
} else {
|
||||||
|
_stopInterpolationTimer();
|
||||||
|
// Emit current position for paused state
|
||||||
|
_emitPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle seek - immediately update position without waiting for server
|
||||||
|
void onSeek(double positionSeconds) {
|
||||||
|
_anchorPosition = positionSeconds;
|
||||||
|
_anchorTime = DateTime.now();
|
||||||
|
_lastEmittedSeconds = -1; // Force emit
|
||||||
|
_emitPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle track change - reset position to 0
|
||||||
|
void onTrackChange(String playerId, Duration? newDuration) {
|
||||||
|
_playerId = playerId;
|
||||||
|
_duration = newDuration ?? Duration.zero;
|
||||||
|
_anchorPosition = 0.0;
|
||||||
|
_anchorTime = DateTime.now();
|
||||||
|
_lastEmittedSeconds = -1;
|
||||||
|
_emitPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle player selection change
|
||||||
|
void onPlayerSelected(String playerId) {
|
||||||
|
if (_playerId != playerId) {
|
||||||
|
_playerId = playerId;
|
||||||
|
// Don't reset position - let updateFromServer handle it with actual data
|
||||||
|
_lastEmittedSeconds = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear tracker state (e.g., when disconnecting)
|
||||||
|
void clear() {
|
||||||
|
_stopInterpolationTimer();
|
||||||
|
_playerId = null;
|
||||||
|
_isPlaying = false;
|
||||||
|
_anchorPosition = 0.0;
|
||||||
|
_duration = Duration.zero;
|
||||||
|
_lastEmittedSeconds = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startInterpolationTimer() {
|
||||||
|
if (_interpolationTimer != null) return;
|
||||||
|
|
||||||
|
_interpolationTimer = Timer.periodic(const Duration(milliseconds: 250), (_) {
|
||||||
|
_emitPosition();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit immediately
|
||||||
|
_emitPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopInterpolationTimer() {
|
||||||
|
_interpolationTimer?.cancel();
|
||||||
|
_interpolationTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emitPosition() {
|
||||||
|
final pos = currentPosition;
|
||||||
|
final seconds = pos.inSeconds;
|
||||||
|
|
||||||
|
// Only emit if second changed (reduces unnecessary updates)
|
||||||
|
if (seconds != _lastEmittedSeconds) {
|
||||||
|
_lastEmittedSeconds = seconds;
|
||||||
|
_positionController.add(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if position has reached or exceeded duration (track ended)
|
||||||
|
bool get hasReachedEnd {
|
||||||
|
if (_duration.inSeconds <= 0) return false;
|
||||||
|
return currentPositionSeconds >= _duration.inSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_stopInterpolationTimer();
|
||||||
|
_positionController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,32 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
class SettingsService {
|
class SettingsService {
|
||||||
static const String _keyServerUrl = 'server_url';
|
static const String _keyServerUrl = 'server_url';
|
||||||
static const String _keyAuthServerUrl = 'auth_server_url';
|
static const String _keyAuthServerUrl = 'auth_server_url';
|
||||||
static const String _keyWebSocketPort = 'websocket_port';
|
static const String _keyWebSocketPort = 'websocket_port';
|
||||||
static const String _keyAuthToken = 'auth_token';
|
static const String _keyAuthToken = 'auth_token';
|
||||||
|
static const String _keyMaAuthToken = 'ma_auth_token'; // Music Assistant native auth token
|
||||||
|
static const String _keyAuthCredentials = 'auth_credentials'; // NEW: Serialized auth strategy credentials
|
||||||
static const String _keyUsername = 'username';
|
static const String _keyUsername = 'username';
|
||||||
static const String _keyPassword = 'password';
|
static const String _keyPassword = 'password';
|
||||||
static const String _keyBuiltinPlayerId = 'builtin_player_id';
|
static const String _keyBuiltinPlayerId = 'local_player_id'; // Unified with DeviceIdService
|
||||||
static const String _keyThemeMode = 'theme_mode';
|
static const String _keyThemeMode = 'theme_mode';
|
||||||
static const String _keyUseMaterialTheme = 'use_material_theme';
|
static const String _keyUseMaterialTheme = 'use_material_theme';
|
||||||
static const String _keyAdaptiveTheme = 'adaptive_theme';
|
static const String _keyAdaptiveTheme = 'adaptive_theme';
|
||||||
static const String _keyCustomColor = 'custom_color';
|
static const String _keyCustomColor = 'custom_color';
|
||||||
|
static const String _keyLastFmApiKey = 'lastfm_api_key';
|
||||||
|
static const String _keyTheAudioDbApiKey = 'theaudiodb_api_key';
|
||||||
|
static const String _keyEnableLocalPlayback = 'enable_local_playback';
|
||||||
|
static const String _keyLocalPlayerName = 'local_player_name';
|
||||||
|
static const String _keyOwnerName = 'owner_name';
|
||||||
|
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
||||||
|
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||||
|
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||||
|
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||||
|
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
|
||||||
|
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
|
||||||
|
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
|
||||||
|
|
||||||
static Future<String?> getServerUrl() async {
|
static Future<String?> getServerUrl() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -23,6 +38,11 @@ class SettingsService {
|
|||||||
await prefs.setString(_keyServerUrl, url);
|
await prefs.setString(_keyServerUrl, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> clearServerUrl() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_keyServerUrl);
|
||||||
|
}
|
||||||
|
|
||||||
// Get authentication server URL (returns null if not set, meaning use server URL)
|
// Get authentication server URL (returns null if not set, meaning use server URL)
|
||||||
static Future<String?> getAuthServerUrl() async {
|
static Future<String?> getAuthServerUrl() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -71,6 +91,52 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get Music Assistant native auth token (long-lived token)
|
||||||
|
static Future<String?> getMaAuthToken() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyMaAuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Music Assistant native auth token (long-lived token)
|
||||||
|
static Future<void> setMaAuthToken(String? token) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
await prefs.remove(_keyMaAuthToken);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_keyMaAuthToken, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear Music Assistant native auth token
|
||||||
|
static Future<void> clearMaAuthToken() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_keyMaAuthToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get authentication credentials (serialized auth strategy credentials)
|
||||||
|
static Future<Map<String, dynamic>?> getAuthCredentials() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final json = prefs.getString(_keyAuthCredentials);
|
||||||
|
if (json == null) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(json) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set authentication credentials (serialized auth strategy credentials)
|
||||||
|
static Future<void> setAuthCredentials(Map<String, dynamic> credentials) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyAuthCredentials, jsonEncode(credentials));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear authentication credentials
|
||||||
|
static Future<void> clearAuthCredentials() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_keyAuthCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
// Get username for authentication
|
// Get username for authentication
|
||||||
static Future<String?> getUsername() async {
|
static Future<String?> getUsername() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -156,8 +222,163 @@ class SettingsService {
|
|||||||
await prefs.setString(_keyCustomColor, color);
|
await prefs.setString(_keyCustomColor, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Metadata API Keys
|
||||||
|
static Future<String?> getLastFmApiKey() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLastFmApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLastFmApiKey(String? key) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
await prefs.remove(_keyLastFmApiKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_keyLastFmApiKey, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> getTheAudioDbApiKey() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyTheAudioDbApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setTheAudioDbApiKey(String? key) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (key == null || key.isEmpty) {
|
||||||
|
await prefs.remove(_keyTheAudioDbApiKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_keyTheAudioDbApiKey, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local Playback Settings
|
||||||
|
static Future<bool> getEnableLocalPlayback() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyEnableLocalPlayback) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setEnableLocalPlayback(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyEnableLocalPlayback, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> getLocalPlayerName() async {
|
||||||
|
// Derive player name from owner name
|
||||||
|
final ownerName = await getOwnerName();
|
||||||
|
if (ownerName != null && ownerName.isNotEmpty) {
|
||||||
|
return _makePlayerName(ownerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to stored local player name or default
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLocalPlayerName) ?? 'Ensemble';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLocalPlayerName(String name) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyLocalPlayerName, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner Name - used to derive player name
|
||||||
|
static Future<String?> getOwnerName() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyOwnerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setOwnerName(String name) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyOwnerName, name.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last selected player ID - persists user's player selection across sessions
|
||||||
|
static Future<String?> getLastSelectedPlayerId() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLastSelectedPlayerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLastSelectedPlayerId(String? playerId) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (playerId == null || playerId.isEmpty) {
|
||||||
|
await prefs.remove(_keyLastSelectedPlayerId);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_keyLastSelectedPlayerId, playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create player name with possessive apostrophe
|
||||||
|
static String _makePlayerName(String ownerName) {
|
||||||
|
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
|
||||||
|
if (ownerName.toLowerCase().endsWith('s')) {
|
||||||
|
return "$ownerName' Phone";
|
||||||
|
} else {
|
||||||
|
return "$ownerName's Phone";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> clearSettings() async {
|
static Future<void> clearSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.clear();
|
await prefs.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Home Screen Row Settings (Main rows - default on)
|
||||||
|
static Future<bool> getShowRecentAlbums() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowRecentAlbums) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowRecentAlbums(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowRecentAlbums, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowDiscoverArtists() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowDiscoverArtists) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowDiscoverArtists(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowDiscoverArtists, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowDiscoverAlbums() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowDiscoverAlbums) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowDiscoverAlbums(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowDiscoverAlbums, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home Screen Favorites Settings (default off)
|
||||||
|
static Future<bool> getShowFavoriteAlbums() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowFavoriteAlbums) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowFavoriteAlbums(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowFavoriteAlbums, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowFavoriteArtists() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowFavoriteArtists) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowFavoriteArtists(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowFavoriteArtists, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowFavoriteTracks() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowFavoriteTracks) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowFavoriteTracks(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowFavoriteTracks, show);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
// Brand colors for Amass
|
// Brand colors for Ensemble
|
||||||
const Color brandPrimaryColor = Color(0xFF1a1a1a);
|
const Color brandPrimaryColor = Color(0xFF1a1a1a);
|
||||||
const Color brandAccentColor = Color(0xFF604CEC);
|
const Color brandAccentColor = Color(0xFF604CEC);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Design tokens for consistent spacing, dimensions, and styling throughout the app.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// Padding(padding: Spacing.paddingAll16)
|
||||||
|
/// SizedBox(height: Spacing.md)
|
||||||
|
/// BorderRadius.circular(Radii.md)
|
||||||
|
/// ```
|
||||||
|
class Spacing {
|
||||||
|
Spacing._();
|
||||||
|
|
||||||
|
// Base spacing scale (follows 4px grid)
|
||||||
|
static const double xxs = 2.0;
|
||||||
|
static const double xs = 4.0;
|
||||||
|
static const double sm = 8.0;
|
||||||
|
static const double md = 12.0;
|
||||||
|
static const double lg = 16.0;
|
||||||
|
static const double xl = 24.0;
|
||||||
|
static const double xxl = 32.0;
|
||||||
|
static const double xxxl = 48.0;
|
||||||
|
|
||||||
|
// Pre-built EdgeInsets for common padding patterns
|
||||||
|
static const EdgeInsets paddingAll4 = EdgeInsets.all(xs);
|
||||||
|
static const EdgeInsets paddingAll8 = EdgeInsets.all(sm);
|
||||||
|
static const EdgeInsets paddingAll12 = EdgeInsets.all(md);
|
||||||
|
static const EdgeInsets paddingAll16 = EdgeInsets.all(lg);
|
||||||
|
static const EdgeInsets paddingAll24 = EdgeInsets.all(xl);
|
||||||
|
static const EdgeInsets paddingAll32 = EdgeInsets.all(xxl);
|
||||||
|
|
||||||
|
// Horizontal padding
|
||||||
|
static const EdgeInsets paddingH8 = EdgeInsets.symmetric(horizontal: sm);
|
||||||
|
static const EdgeInsets paddingH12 = EdgeInsets.symmetric(horizontal: md);
|
||||||
|
static const EdgeInsets paddingH16 = EdgeInsets.symmetric(horizontal: lg);
|
||||||
|
static const EdgeInsets paddingH24 = EdgeInsets.symmetric(horizontal: xl);
|
||||||
|
|
||||||
|
// Vertical padding
|
||||||
|
static const EdgeInsets paddingV8 = EdgeInsets.symmetric(vertical: sm);
|
||||||
|
static const EdgeInsets paddingV12 = EdgeInsets.symmetric(vertical: md);
|
||||||
|
static const EdgeInsets paddingV16 = EdgeInsets.symmetric(vertical: lg);
|
||||||
|
static const EdgeInsets paddingV24 = EdgeInsets.symmetric(vertical: xl);
|
||||||
|
|
||||||
|
// Pre-built SizedBox widgets for gaps
|
||||||
|
static const SizedBox vGap4 = SizedBox(height: xs);
|
||||||
|
static const SizedBox vGap8 = SizedBox(height: sm);
|
||||||
|
static const SizedBox vGap12 = SizedBox(height: md);
|
||||||
|
static const SizedBox vGap16 = SizedBox(height: lg);
|
||||||
|
static const SizedBox vGap24 = SizedBox(height: xl);
|
||||||
|
static const SizedBox vGap32 = SizedBox(height: xxl);
|
||||||
|
|
||||||
|
static const SizedBox hGap4 = SizedBox(width: xs);
|
||||||
|
static const SizedBox hGap8 = SizedBox(width: sm);
|
||||||
|
static const SizedBox hGap12 = SizedBox(width: md);
|
||||||
|
static const SizedBox hGap16 = SizedBox(width: lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border radius tokens
|
||||||
|
class Radii {
|
||||||
|
Radii._();
|
||||||
|
|
||||||
|
static const double xs = 2.0;
|
||||||
|
static const double sm = 4.0;
|
||||||
|
static const double md = 8.0;
|
||||||
|
static const double lg = 12.0;
|
||||||
|
static const double xl = 16.0;
|
||||||
|
static const double xxl = 24.0;
|
||||||
|
static const double pill = 999.0;
|
||||||
|
|
||||||
|
// Pre-built BorderRadius for common patterns
|
||||||
|
static final BorderRadius borderRadiusSm = BorderRadius.circular(sm);
|
||||||
|
static final BorderRadius borderRadiusMd = BorderRadius.circular(md);
|
||||||
|
static final BorderRadius borderRadiusLg = BorderRadius.circular(lg);
|
||||||
|
static final BorderRadius borderRadiusXl = BorderRadius.circular(xl);
|
||||||
|
static final BorderRadius borderRadiusXxl = BorderRadius.circular(xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Icon size tokens
|
||||||
|
class IconSizes {
|
||||||
|
IconSizes._();
|
||||||
|
|
||||||
|
static const double xs = 16.0;
|
||||||
|
static const double sm = 20.0;
|
||||||
|
static const double md = 24.0;
|
||||||
|
static const double lg = 32.0;
|
||||||
|
static const double xl = 48.0;
|
||||||
|
static const double xxl = 64.0;
|
||||||
|
static const double xxxl = 80.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common dimension tokens
|
||||||
|
class Dimensions {
|
||||||
|
Dimensions._();
|
||||||
|
|
||||||
|
// Album art / thumbnail sizes
|
||||||
|
static const double thumbnailSm = 48.0;
|
||||||
|
static const double thumbnailMd = 80.0;
|
||||||
|
static const double thumbnailLg = 128.0;
|
||||||
|
static const double thumbnailXl = 200.0;
|
||||||
|
|
||||||
|
// Avatar sizes
|
||||||
|
static const double avatarSm = 32.0;
|
||||||
|
static const double avatarMd = 48.0;
|
||||||
|
static const double avatarLg = 64.0;
|
||||||
|
|
||||||
|
// List tile heights
|
||||||
|
static const double listTileHeight = 56.0;
|
||||||
|
static const double listTileHeightDense = 48.0;
|
||||||
|
|
||||||
|
// Mini player height
|
||||||
|
static const double miniPlayerHeight = 64.0;
|
||||||
|
|
||||||
|
// App bar heights
|
||||||
|
static const double appBarHeight = 56.0;
|
||||||
|
static const double expandedAppBarHeight = 200.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration tokens for animations
|
||||||
|
class Durations {
|
||||||
|
Durations._();
|
||||||
|
|
||||||
|
static const Duration instant = Duration(milliseconds: 100);
|
||||||
|
static const Duration fast = Duration(milliseconds: 200);
|
||||||
|
static const Duration normal = Duration(milliseconds: 300);
|
||||||
|
static const Duration slow = Duration(milliseconds: 500);
|
||||||
|
}
|
||||||
@@ -1,43 +1,172 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
|
||||||
|
/// Extracted adaptive colors from album art
|
||||||
|
class AdaptiveColors {
|
||||||
|
final Color primary; // Main accent color (vibrant)
|
||||||
|
final Color surface; // Background color
|
||||||
|
final Color onSurface; // Text color on background
|
||||||
|
final Color miniPlayer; // Darker version for mini player
|
||||||
|
|
||||||
|
const AdaptiveColors({
|
||||||
|
required this.primary,
|
||||||
|
required this.surface,
|
||||||
|
required this.onSurface,
|
||||||
|
required this.miniPlayer,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const fallback = AdaptiveColors(
|
||||||
|
primary: Color(0xFF604CEC),
|
||||||
|
surface: Color(0xFF121212),
|
||||||
|
onSurface: Colors.white,
|
||||||
|
miniPlayer: Color(0xFF1a1a1a),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to get contrasting text color based on background luminance
|
||||||
|
Color getContrastingTextColor(Color backgroundColor) {
|
||||||
|
// Use relative luminance to determine if we need light or dark text
|
||||||
|
// Standard threshold is 0.179 based on WCAG guidelines
|
||||||
|
return backgroundColor.computeLuminance() > 0.4 ? Colors.black : Colors.white;
|
||||||
|
}
|
||||||
|
|
||||||
class PaletteHelper {
|
class PaletteHelper {
|
||||||
/// Extract a color palette from an image
|
/// Extract a color palette from an image with higher color count for better variety
|
||||||
static Future<PaletteGenerator?> extractPalette(ImageProvider imageProvider) async {
|
static Future<PaletteGenerator?> extractPalette(ImageProvider imageProvider) async {
|
||||||
try {
|
try {
|
||||||
final palette = await PaletteGenerator.fromImageProvider(
|
final palette = await PaletteGenerator.fromImageProvider(
|
||||||
imageProvider,
|
imageProvider,
|
||||||
maximumColorCount: 20,
|
maximumColorCount: 32, // Increased from 20 for more color variety
|
||||||
);
|
);
|
||||||
return palette;
|
return palette;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('⚠️ Failed to extract palette: $e');
|
_logger.warning('Failed to extract palette: $e', context: 'Palette');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate color schemes from a palette
|
/// Extract actual colors from the palette (not seed-based)
|
||||||
|
static AdaptiveColors? extractAdaptiveColors(PaletteGenerator? palette, {required bool isDark}) {
|
||||||
|
if (palette == null) return null;
|
||||||
|
|
||||||
|
// Get primary color - prefer vibrant, then dominant
|
||||||
|
final Color primary = palette.vibrantColor?.color ??
|
||||||
|
palette.lightVibrantColor?.color ??
|
||||||
|
palette.dominantColor?.color ??
|
||||||
|
const Color(0xFF604CEC);
|
||||||
|
|
||||||
|
// Ensure primary has enough saturation to be visually distinct
|
||||||
|
// AND enough lightness for white text to be readable on it
|
||||||
|
final HSLColor hslPrimary = HSLColor.fromColor(primary);
|
||||||
|
Color adjustedPrimary = primary;
|
||||||
|
|
||||||
|
// Adjust saturation if too low
|
||||||
|
if (hslPrimary.saturation < 0.3) {
|
||||||
|
adjustedPrimary = hslPrimary.withSaturation(0.5).toColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure minimum lightness for contrast with white text
|
||||||
|
// If primary is too dark (luminance < 0.15), lighten it
|
||||||
|
if (adjustedPrimary.computeLuminance() < 0.15) {
|
||||||
|
final hsl = HSLColor.fromColor(adjustedPrimary);
|
||||||
|
adjustedPrimary = hsl.withLightness((hsl.lightness + 0.25).clamp(0.3, 0.6)).toColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
// Dark mode: use dark muted colors for background
|
||||||
|
final Color surfaceBase = palette.darkMutedColor?.color ??
|
||||||
|
palette.mutedColor?.color ??
|
||||||
|
const Color(0xFF121212);
|
||||||
|
|
||||||
|
// Darken the surface color significantly for the expanded background
|
||||||
|
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
|
||||||
|
final Color surface = hslSurface
|
||||||
|
.withLightness((hslSurface.lightness * 0.3).clamp(0.05, 0.15))
|
||||||
|
.toColor();
|
||||||
|
|
||||||
|
// Mini player should be medium brightness - noticeably tinted but readable
|
||||||
|
// Use the muted color but at medium lightness (0.25-0.35 range)
|
||||||
|
final Color miniPlayer = hslSurface
|
||||||
|
.withLightness(0.3.clamp(0.25, 0.38))
|
||||||
|
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
|
||||||
|
.toColor();
|
||||||
|
|
||||||
|
return AdaptiveColors(
|
||||||
|
primary: adjustedPrimary,
|
||||||
|
surface: surface,
|
||||||
|
onSurface: Colors.white,
|
||||||
|
miniPlayer: miniPlayer,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Light mode: use light muted colors
|
||||||
|
final Color surfaceBase = palette.lightMutedColor?.color ??
|
||||||
|
palette.mutedColor?.color ??
|
||||||
|
Colors.white;
|
||||||
|
|
||||||
|
final HSLColor hslSurface = HSLColor.fromColor(surfaceBase);
|
||||||
|
final Color surface = hslSurface
|
||||||
|
.withLightness((hslSurface.lightness).clamp(0.92, 0.98))
|
||||||
|
.toColor();
|
||||||
|
|
||||||
|
// Mini player in light mode - medium tinted
|
||||||
|
final Color miniPlayer = hslSurface
|
||||||
|
.withLightness(0.75.clamp(0.65, 0.8))
|
||||||
|
.withSaturation((hslSurface.saturation * 1.2).clamp(0.15, 0.5))
|
||||||
|
.toColor();
|
||||||
|
|
||||||
|
return AdaptiveColors(
|
||||||
|
primary: adjustedPrimary,
|
||||||
|
surface: surface,
|
||||||
|
onSurface: Colors.black87,
|
||||||
|
miniPlayer: miniPlayer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate color schemes from a palette (kept for backward compatibility)
|
||||||
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
|
static (ColorScheme, ColorScheme)? generateColorSchemes(PaletteGenerator? palette) {
|
||||||
if (palette == null) return null;
|
if (palette == null) return null;
|
||||||
|
|
||||||
// Get the dominant color as the seed color
|
final lightColors = extractAdaptiveColors(palette, isDark: false);
|
||||||
Color seedColor = palette.dominantColor?.color ??
|
final darkColors = extractAdaptiveColors(palette, isDark: true);
|
||||||
palette.vibrantColor?.color ??
|
|
||||||
palette.mutedColor?.color ??
|
|
||||||
const Color(0xFF604CEC); // Fallback to brand color
|
|
||||||
|
|
||||||
// Generate light and dark color schemes from the seed color
|
if (lightColors == null || darkColors == null) return null;
|
||||||
final lightScheme = ColorScheme.fromSeed(
|
|
||||||
seedColor: seedColor,
|
// Determine contrasting text colors based on primary color luminance
|
||||||
|
final lightOnPrimary = getContrastingTextColor(lightColors.primary);
|
||||||
|
final darkOnPrimary = getContrastingTextColor(darkColors.primary);
|
||||||
|
final lightOnMiniPlayer = getContrastingTextColor(lightColors.miniPlayer);
|
||||||
|
final darkOnMiniPlayer = getContrastingTextColor(darkColors.miniPlayer);
|
||||||
|
|
||||||
|
final lightScheme = ColorScheme(
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
|
primary: lightColors.primary,
|
||||||
|
onPrimary: lightOnPrimary,
|
||||||
|
secondary: lightColors.primary.withOpacity(0.8),
|
||||||
|
onSecondary: lightOnPrimary,
|
||||||
|
error: Colors.red,
|
||||||
|
onError: Colors.white,
|
||||||
|
surface: lightColors.surface,
|
||||||
|
onSurface: lightColors.onSurface,
|
||||||
|
primaryContainer: lightColors.miniPlayer,
|
||||||
|
onPrimaryContainer: lightOnMiniPlayer,
|
||||||
);
|
);
|
||||||
|
|
||||||
final darkScheme = ColorScheme.fromSeed(
|
final darkScheme = ColorScheme(
|
||||||
seedColor: seedColor,
|
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
// Use darker background colors for better contrast
|
primary: darkColors.primary,
|
||||||
surface: const Color(0xFF2a2a2a),
|
onPrimary: darkOnPrimary,
|
||||||
background: const Color(0xFF1a1a1a),
|
secondary: darkColors.primary.withOpacity(0.8),
|
||||||
|
onSecondary: darkOnPrimary,
|
||||||
|
error: Colors.redAccent,
|
||||||
|
onError: Colors.black,
|
||||||
|
surface: darkColors.surface,
|
||||||
|
onSurface: darkColors.onSurface,
|
||||||
|
primaryContainer: darkColors.miniPlayer,
|
||||||
|
onPrimaryContainer: darkOnMiniPlayer,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (lightScheme, darkScheme);
|
return (lightScheme, darkScheme);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
|
||||||
|
final _systemThemeLogger = DebugLogger();
|
||||||
|
|
||||||
class SystemThemeHelper {
|
class SystemThemeHelper {
|
||||||
/// Attempts to get system color schemes from the device (Android 12+)
|
/// Attempts to get system color schemes from the device (Android 12+)
|
||||||
@@ -35,7 +38,7 @@ class SystemThemeHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('⚠️ Failed to get system colors: $e');
|
_systemThemeLogger.warning('Failed to get system colors: $e', context: 'Theme');
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import 'palette_helper.dart';
|
||||||
|
|
||||||
|
final _themeLogger = DebugLogger();
|
||||||
|
|
||||||
|
/// Global function to update adaptive colors from an image URL
|
||||||
|
/// This can be called from anywhere in the app (e.g., when tapping an album/artist)
|
||||||
|
Future<void> updateAdaptiveColorsFromImage(BuildContext context, String? imageUrl) async {
|
||||||
|
if (imageUrl == null || imageUrl.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final colorSchemes = await PaletteHelper.extractColorSchemes(CachedNetworkImageProvider(imageUrl));
|
||||||
|
if (colorSchemes != null && context.mounted) {
|
||||||
|
final themeProvider = context.read<ThemeProvider>();
|
||||||
|
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail - colors will update when track plays
|
||||||
|
_themeLogger.debug('Failed to extract colors on tap: $e', context: 'Theme');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear adaptive colors and return to default theme colors
|
||||||
|
/// Call this when navigating back from album/artist detail screens
|
||||||
|
void clearAdaptiveColorsOnBack(BuildContext context) {
|
||||||
|
final themeProvider = context.read<ThemeProvider>();
|
||||||
|
themeProvider.clearAdaptiveColors();
|
||||||
|
}
|
||||||
|
|
||||||
class ThemeProvider extends ChangeNotifier {
|
class ThemeProvider extends ChangeNotifier {
|
||||||
ThemeMode _themeMode = ThemeMode.system;
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
@@ -7,6 +37,11 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
bool _adaptiveTheme = true;
|
bool _adaptiveTheme = true;
|
||||||
Color _customColor = const Color(0xFF604CEC);
|
Color _customColor = const Color(0xFF604CEC);
|
||||||
|
|
||||||
|
// Adaptive colors extracted from current album art
|
||||||
|
AdaptiveColors? _adaptiveColors;
|
||||||
|
ColorScheme? _adaptiveLightScheme;
|
||||||
|
ColorScheme? _adaptiveDarkScheme;
|
||||||
|
|
||||||
ThemeProvider() {
|
ThemeProvider() {
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
}
|
}
|
||||||
@@ -16,6 +51,23 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
bool get adaptiveTheme => _adaptiveTheme;
|
bool get adaptiveTheme => _adaptiveTheme;
|
||||||
Color get customColor => _customColor;
|
Color get customColor => _customColor;
|
||||||
|
|
||||||
|
// Adaptive color getters
|
||||||
|
AdaptiveColors? get adaptiveColors => _adaptiveColors;
|
||||||
|
ColorScheme? get adaptiveLightScheme => _adaptiveLightScheme;
|
||||||
|
ColorScheme? get adaptiveDarkScheme => _adaptiveDarkScheme;
|
||||||
|
|
||||||
|
/// Get the current adaptive primary color (for bottom nav highlight, etc.)
|
||||||
|
Color get adaptivePrimaryColor => _adaptiveColors?.primary ?? _customColor;
|
||||||
|
|
||||||
|
/// Get the current adaptive surface color (for bottom nav background, etc.)
|
||||||
|
/// Returns a subtle tinted surface based on the adaptive colors
|
||||||
|
Color? get adaptiveSurfaceColor {
|
||||||
|
if (_adaptiveColors == null) return null;
|
||||||
|
// Use the miniPlayer color but darkened for a subtle tinted background
|
||||||
|
final hsl = HSLColor.fromColor(_adaptiveColors!.miniPlayer);
|
||||||
|
return hsl.withLightness((hsl.lightness * 0.4).clamp(0.08, 0.15)).toColor();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
final themeModeString = await SettingsService.getThemeMode();
|
final themeModeString = await SettingsService.getThemeMode();
|
||||||
_themeMode = _parseThemeMode(themeModeString);
|
_themeMode = _parseThemeMode(themeModeString);
|
||||||
@@ -93,4 +145,32 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
await SettingsService.saveCustomColor(_colorToString(color));
|
await SettingsService.saveCustomColor(_colorToString(color));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update adaptive colors from album art
|
||||||
|
void updateAdaptiveColors(ColorScheme? lightScheme, ColorScheme? darkScheme) {
|
||||||
|
_adaptiveLightScheme = lightScheme;
|
||||||
|
_adaptiveDarkScheme = darkScheme;
|
||||||
|
|
||||||
|
// Extract AdaptiveColors from the schemes
|
||||||
|
if (darkScheme != null) {
|
||||||
|
_adaptiveColors = AdaptiveColors(
|
||||||
|
primary: darkScheme.primary,
|
||||||
|
surface: darkScheme.surface,
|
||||||
|
onSurface: darkScheme.onSurface,
|
||||||
|
miniPlayer: darkScheme.primaryContainer,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_adaptiveColors = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear adaptive colors (when no track is playing)
|
||||||
|
void clearAdaptiveColors() {
|
||||||
|
_adaptiveColors = null;
|
||||||
|
_adaptiveLightScheme = null;
|
||||||
|
_adaptiveDarkScheme = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A page route optimized for hero animations.
|
||||||
|
///
|
||||||
|
/// On forward navigation: fade in + slight slide from right
|
||||||
|
/// On back navigation: quick fade out + slide down
|
||||||
|
class FadeSlidePageRoute<T> extends PageRouteBuilder<T> {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
FadeSlidePageRoute({
|
||||||
|
required this.child,
|
||||||
|
}) : super(
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => child,
|
||||||
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 200), // Quick back animation
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
// Fade transition - uses optimized cubic curves for smoother hero animations
|
||||||
|
final fadeAnimation = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check direction for slide
|
||||||
|
final isReverse = animation.status == AnimationStatus.reverse;
|
||||||
|
|
||||||
|
if (isReverse) {
|
||||||
|
// Back navigation: slide DOWN with easeInCubic for smooth deceleration
|
||||||
|
final slideAnimation = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.08), // Slide down 8%
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeInCubic,
|
||||||
|
));
|
||||||
|
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: fadeAnimation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: slideAnimation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Forward navigation: slide from RIGHT with easeOutCubic for natural motion
|
||||||
|
final slideAnimation = Tween<Offset>(
|
||||||
|
begin: const Offset(0.05, 0), // Slide from right 5%
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
));
|
||||||
|
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: fadeAnimation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: slideAnimation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,74 +1,159 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../screens/album_details_screen.dart';
|
import '../screens/album_details_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
import '../services/metadata_service.dart';
|
||||||
|
|
||||||
class AlbumCard extends StatelessWidget {
|
class AlbumCard extends StatefulWidget {
|
||||||
final Album album;
|
final Album album;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
|
||||||
const AlbumCard({
|
const AlbumCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.album,
|
required this.album,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.heroTagSuffix,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AlbumCard> createState() => _AlbumCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlbumCardState extends State<AlbumCard> {
|
||||||
|
String? _fallbackImageUrl;
|
||||||
|
bool _triedFallback = false;
|
||||||
|
bool _maImageFailed = false;
|
||||||
|
String? _cachedMaImageUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initFallbackImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initFallbackImage() {
|
||||||
|
// Check if MA has an image after first build, then fetch fallback if needed
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final maImageUrl = maProvider.api?.getImageUrl(widget.album, size: 256);
|
||||||
|
_cachedMaImageUrl = maImageUrl;
|
||||||
|
|
||||||
|
if (maImageUrl == null && !_triedFallback) {
|
||||||
|
_triedFallback = true;
|
||||||
|
_fetchFallbackImage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchFallbackImage() async {
|
||||||
|
final fallbackUrl = await MetadataService.getAlbumImageUrl(
|
||||||
|
widget.album.name,
|
||||||
|
widget.album.artistsString,
|
||||||
|
);
|
||||||
|
if (fallbackUrl != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_fallbackImageUrl = fallbackUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onImageError() {
|
||||||
|
// When MA image fails to load, try Deezer fallback
|
||||||
|
if (!_triedFallback && !_maImageFailed) {
|
||||||
|
_maImageFailed = true;
|
||||||
|
_triedFallback = true;
|
||||||
|
_fetchFallbackImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.api?.getImageUrl(album, size: 256);
|
// Use cached URL if available, otherwise get fresh
|
||||||
|
final maImageUrl = _cachedMaImageUrl ?? maProvider.api?.getImageUrl(widget.album, size: 256);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return GestureDetector(
|
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
onTap: onTap ?? () {
|
|
||||||
Navigator.push(
|
// Use fallback if MA image failed or wasn't available
|
||||||
context,
|
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => AlbumDetailsScreen(album: album),
|
return RepaintBoundary(
|
||||||
),
|
child: GestureDetector(
|
||||||
);
|
onTap: widget.onTap ?? () {
|
||||||
},
|
// Update adaptive colors immediately on tap
|
||||||
child: Column(
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Navigator.push(
|
||||||
children: [
|
context,
|
||||||
// Album artwork with Hero animation
|
FadeSlidePageRoute(
|
||||||
Expanded(
|
child: AlbumDetailsScreen(
|
||||||
child: Hero(
|
album: widget.album,
|
||||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId),
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
child: Container(
|
),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
);
|
||||||
color: colorScheme.surfaceVariant,
|
},
|
||||||
image: imageUrl != null
|
child: Column(
|
||||||
? DecorationImage(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
image: NetworkImage(imageUrl),
|
children: [
|
||||||
fit: BoxFit.cover,
|
// Album artwork
|
||||||
)
|
AspectRatio(
|
||||||
: null,
|
aspectRatio: 1.0,
|
||||||
),
|
child: Hero(
|
||||||
child: imageUrl == null
|
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||||
? Center(
|
child: ClipRRect(
|
||||||
child: Icon(
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
Icons.album_rounded,
|
child: Container(
|
||||||
size: 64,
|
color: colorScheme.surfaceVariant,
|
||||||
color: colorScheme.onSurfaceVariant,
|
child: imageUrl != null
|
||||||
),
|
? CachedNetworkImage(
|
||||||
)
|
imageUrl: imageUrl,
|
||||||
: null,
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 150),
|
||||||
|
placeholder: (context, url) => const SizedBox(),
|
||||||
|
errorWidget: (context, url, error) {
|
||||||
|
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||||
|
if (!_maImageFailed && url == maImageUrl) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_onImageError();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Icon(
|
||||||
|
Icons.album_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.album_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Album title with Hero animation
|
// Album title
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.albumTitle + (album.uri ?? album.itemId),
|
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
album.name,
|
widget.album.name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
style: textTheme.titleSmall?.copyWith(
|
||||||
@@ -78,13 +163,13 @@ class AlbumCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Artist name with Hero animation
|
// Artist name
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistName + (album.uri ?? album.itemId),
|
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
album.artistsString,
|
widget.album.artistsString,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
@@ -93,7 +178,8 @@ class AlbumCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,92 +2,161 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
import 'album_card.dart';
|
import 'album_card.dart';
|
||||||
|
|
||||||
class AlbumRow extends StatefulWidget {
|
class AlbumRow extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Future<List<Album>> Function() loadAlbums;
|
final Future<List<Album>> Function() loadAlbums;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final double? rowHeight;
|
||||||
|
|
||||||
const AlbumRow({
|
const AlbumRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.loadAlbums,
|
required this.loadAlbums,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.rowHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AlbumRow> createState() => _AlbumRowState();
|
State<AlbumRow> createState() => _AlbumRowState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AlbumRowState extends State<AlbumRow> {
|
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<List<Album>> _albumsFuture;
|
late Future<List<Album>> _albumsFuture;
|
||||||
|
List<Album>? _cachedAlbums; // Keep last loaded data to prevent flash
|
||||||
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_albumsFuture = widget.loadAlbums();
|
_loadAlbumsOnce();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _loadAlbumsOnce() {
|
||||||
|
if (!_hasLoaded) {
|
||||||
|
_albumsFuture = widget.loadAlbums().then((albums) {
|
||||||
|
_cachedAlbums = albums;
|
||||||
|
return albums;
|
||||||
|
});
|
||||||
|
_hasLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
_logger.startBuild('AlbumRow:${widget.title}');
|
||||||
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Column(
|
// Total row height includes title + content
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final totalHeight = widget.rowHeight ?? 237.0; // Default: 44 title + 193 content
|
||||||
children: [
|
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||||
Padding(
|
final contentHeight = totalHeight - titleHeight;
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
|
||||||
child: Text(
|
final result = RepaintBoundary(
|
||||||
widget.title,
|
child: SizedBox(
|
||||||
style: textTheme.titleLarge?.copyWith(
|
height: totalHeight,
|
||||||
fontWeight: FontWeight.bold,
|
child: Column(
|
||||||
color: colorScheme.onBackground,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
SizedBox(
|
child: FutureBuilder<List<Album>>(
|
||||||
height: 210, // Slightly increased for text
|
future: _albumsFuture,
|
||||||
child: FutureBuilder<List<Album>>(
|
builder: (context, snapshot) {
|
||||||
future: _albumsFuture,
|
// Use cached data immediately if available (prevents flash on rebuild)
|
||||||
builder: (context, snapshot) {
|
final albums = snapshot.data ?? _cachedAlbums;
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
// Only show loading if we have no data at all
|
||||||
return Center(
|
if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
|
||||||
child: Text('Error: ${snapshot.error}'),
|
return const Center(child: CircularProgressIndicator());
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
final albums = snapshot.data ?? [];
|
if (snapshot.hasError && albums == null) {
|
||||||
if (albums.isEmpty) {
|
return Center(
|
||||||
return Center(
|
child: Text('Error: ${snapshot.error}'),
|
||||||
child: Text(
|
);
|
||||||
'No albums found',
|
}
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
|
||||||
|
if (albums == null || albums.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No albums found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: square artwork + text below
|
||||||
|
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
|
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: albums.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final album = albums[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(album.uri ?? album.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: AlbumCard(
|
||||||
|
album: album,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
return ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
physics: const BouncingScrollPhysics(),
|
|
||||||
itemCount: albums.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final album = albums[index];
|
|
||||||
return Container(
|
|
||||||
width: 150,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
|
||||||
child: AlbumCard(album: album),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
);
|
||||||
|
_logger.endBuild('AlbumRow:${widget.title}');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||||
|
class _StretchScrollBehavior extends ScrollBehavior {
|
||||||
|
const _StretchScrollBehavior();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildOverscrollIndicator(
|
||||||
|
BuildContext context, Widget child, ScrollableDetails details) {
|
||||||
|
return StretchingOverscrollIndicator(
|
||||||
|
axisDirection: details.direction,
|
||||||
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/metadata_service.dart';
|
||||||
|
|
||||||
|
/// A CircleAvatar that shows artist image with automatic fallback to Deezer/Fanart.tv
|
||||||
|
class ArtistAvatar extends StatefulWidget {
|
||||||
|
final Artist artist;
|
||||||
|
final double radius;
|
||||||
|
final int imageSize;
|
||||||
|
final String? heroTag;
|
||||||
|
final ValueChanged<String?>? onImageLoaded;
|
||||||
|
|
||||||
|
const ArtistAvatar({
|
||||||
|
super.key,
|
||||||
|
required this.artist,
|
||||||
|
this.radius = 24,
|
||||||
|
this.imageSize = 128,
|
||||||
|
this.heroTag,
|
||||||
|
this.onImageLoaded,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ArtistAvatar> createState() => _ArtistAvatarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArtistAvatarState extends State<ArtistAvatar> {
|
||||||
|
String? _imageUrl;
|
||||||
|
String? _maImageUrl;
|
||||||
|
String? _fallbackImageUrl;
|
||||||
|
bool _triedFallback = false;
|
||||||
|
bool _maImageFailed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadImage() async {
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Try MA first
|
||||||
|
final maUrl = provider.getImageUrl(widget.artist, size: widget.imageSize);
|
||||||
|
_maImageUrl = maUrl;
|
||||||
|
|
||||||
|
if (maUrl != null) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_imageUrl = maUrl;
|
||||||
|
});
|
||||||
|
widget.onImageLoaded?.call(maUrl);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to external sources (MA returned null)
|
||||||
|
_fetchFallbackImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchFallbackImage() async {
|
||||||
|
if (_triedFallback) return;
|
||||||
|
_triedFallback = true;
|
||||||
|
|
||||||
|
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
||||||
|
_fallbackImageUrl = fallbackUrl;
|
||||||
|
|
||||||
|
if (fallbackUrl != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_imageUrl = fallbackUrl;
|
||||||
|
});
|
||||||
|
widget.onImageLoaded?.call(fallbackUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onImageError() {
|
||||||
|
// When MA image fails to load, try Deezer fallback
|
||||||
|
if (!_maImageFailed) {
|
||||||
|
_maImageFailed = true;
|
||||||
|
_fetchFallbackImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Use fallback if MA image failed
|
||||||
|
final displayUrl = (_maImageFailed && _fallbackImageUrl != null)
|
||||||
|
? _fallbackImageUrl
|
||||||
|
: _imageUrl;
|
||||||
|
|
||||||
|
Widget avatarContent;
|
||||||
|
if (displayUrl != null) {
|
||||||
|
avatarContent = ClipOval(
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: displayUrl,
|
||||||
|
width: widget.radius * 2,
|
||||||
|
height: widget.radius * 2,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => Container(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
child: Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) {
|
||||||
|
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||||
|
if (!_maImageFailed && url == _maImageUrl) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_onImageError();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
child: Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
avatarContent = CircleAvatar(
|
||||||
|
radius: widget.radius,
|
||||||
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
|
child: Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final avatar = SizedBox(
|
||||||
|
width: widget.radius * 2,
|
||||||
|
height: widget.radius * 2,
|
||||||
|
child: avatarContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.heroTag != null) {
|
||||||
|
return Hero(
|
||||||
|
tag: widget.heroTag!,
|
||||||
|
child: avatar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,75 +1,185 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../screens/artist_details_screen.dart';
|
import '../screens/artist_details_screen.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
import '../services/metadata_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
|
||||||
class ArtistCard extends StatelessWidget {
|
class ArtistCard extends StatefulWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final double? imageSize;
|
||||||
|
|
||||||
const ArtistCard({
|
const ArtistCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.artist,
|
required this.artist,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.imageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ArtistCard> createState() => _ArtistCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArtistCardState extends State<ArtistCard> {
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
String? _fallbackImageUrl;
|
||||||
|
bool _triedFallback = false;
|
||||||
|
bool _maImageFailed = false;
|
||||||
|
String? _cachedMaImageUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Fetch fallback image once in initState, not during build
|
||||||
|
_initFallbackImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initFallbackImage() {
|
||||||
|
// We'll check if MA has an image after first build, then fetch fallback if needed
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256);
|
||||||
|
_cachedMaImageUrl = maImageUrl;
|
||||||
|
|
||||||
|
if (maImageUrl == null && !_triedFallback) {
|
||||||
|
_triedFallback = true;
|
||||||
|
_logger.debug('No MA image for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
|
||||||
|
_fetchFallbackImage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onImageError() {
|
||||||
|
// When MA image fails to load, try Deezer fallback
|
||||||
|
if (!_triedFallback && !_maImageFailed) {
|
||||||
|
_maImageFailed = true;
|
||||||
|
_triedFallback = true;
|
||||||
|
_logger.debug('MA image failed for "${widget.artist.name}", trying fallback', context: 'ArtistCard');
|
||||||
|
_fetchFallbackImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
|
// Use cached URL if available, otherwise get fresh (but don't trigger fetches during build)
|
||||||
|
final maImageUrl = _cachedMaImageUrl ?? maProvider.api?.getImageUrl(widget.artist, size: 256);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
return GestureDetector(
|
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
onTap: onTap ?? () {
|
|
||||||
Navigator.push(
|
// Use fallback if MA image failed or wasn't available
|
||||||
context,
|
final imageUrl = (_maImageFailed || maImageUrl == null) ? _fallbackImageUrl : maImageUrl;
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => ArtistDetailsScreen(artist: artist),
|
return RepaintBoundary(
|
||||||
),
|
child: GestureDetector(
|
||||||
);
|
onTap: widget.onTap ?? () {
|
||||||
},
|
// Update adaptive colors immediately on tap
|
||||||
child: Column(
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
Navigator.push(
|
||||||
children: [
|
context,
|
||||||
// Artist image - circular with Hero animation
|
FadeSlidePageRoute(
|
||||||
Hero(
|
child: ArtistDetailsScreen(
|
||||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId),
|
artist: widget.artist,
|
||||||
child: CircleAvatar(
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
radius: 60, // Fixed size radius for row, or flexible? Row uses fixed width 120.
|
initialImageUrl: imageUrl,
|
||||||
// In Grid we might want flexible.
|
),
|
||||||
// Let's make it responsive to container width if possible, or just use AspectRatio.
|
|
||||||
// But for CircleAvatar, radius is explicit.
|
|
||||||
// Better to use Container with BoxShape.circle
|
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
|
||||||
backgroundImage: imageUrl != null ? NetworkImage(imageUrl) : null,
|
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(Icons.person_rounded, size: 60, color: colorScheme.onSurfaceVariant)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
const SizedBox(height: 12),
|
},
|
||||||
// Artist name with Hero animation
|
child: Column(
|
||||||
Hero(
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId),
|
children: [
|
||||||
child: Material(
|
// Artist image - circular, constrained to be a proper circle
|
||||||
color: Colors.transparent,
|
Expanded(
|
||||||
child: Text(
|
child: Center(
|
||||||
artist.name,
|
child: LayoutBuilder(
|
||||||
maxLines: 2,
|
builder: (context, constraints) {
|
||||||
overflow: TextOverflow.ellipsis,
|
// Use the smaller dimension to ensure a circle
|
||||||
textAlign: TextAlign.center,
|
final size = constraints.maxWidth < constraints.maxHeight
|
||||||
style: textTheme.titleSmall?.copyWith(
|
? constraints.maxWidth
|
||||||
color: colorScheme.onSurface,
|
: constraints.maxHeight;
|
||||||
fontWeight: FontWeight.w500,
|
return Hero(
|
||||||
|
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||||
|
child: ClipOval(
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 150),
|
||||||
|
placeholder: (context, url) => const SizedBox(),
|
||||||
|
errorWidget: (context, url, error) {
|
||||||
|
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||||
|
if (!_maImageFailed && url == maImageUrl) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_onImageError();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
size: size * 0.55,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Icon(Icons.person_rounded, size: size * 0.55, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Artist name - fixed height container so image size is consistent
|
||||||
|
SizedBox(
|
||||||
|
height: 36, // Fixed height for 2 lines of text
|
||||||
|
child: Hero(
|
||||||
|
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
widget.artist.name,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchFallbackImage() async {
|
||||||
|
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
||||||
|
if (fallbackUrl != null && mounted) {
|
||||||
|
_logger.debug('Found fallback image for "${widget.artist.name}"', context: 'ArtistCard');
|
||||||
|
setState(() {
|
||||||
|
_fallbackImageUrl = fallbackUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,92 +2,161 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
import 'artist_card.dart';
|
import 'artist_card.dart';
|
||||||
|
|
||||||
class ArtistRow extends StatefulWidget {
|
class ArtistRow extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Future<List<Artist>> Function() loadArtists;
|
final Future<List<Artist>> Function() loadArtists;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final double? rowHeight;
|
||||||
|
|
||||||
const ArtistRow({
|
const ArtistRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.loadArtists,
|
required this.loadArtists,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.rowHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ArtistRow> createState() => _ArtistRowState();
|
State<ArtistRow> createState() => _ArtistRowState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistRowState extends State<ArtistRow> {
|
class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<List<Artist>> _artistsFuture;
|
late Future<List<Artist>> _artistsFuture;
|
||||||
|
List<Artist>? _cachedArtists; // Keep last loaded data to prevent flash
|
||||||
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_artistsFuture = widget.loadArtists();
|
_loadArtistsOnce();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _loadArtistsOnce() {
|
||||||
|
if (!_hasLoaded) {
|
||||||
|
_artistsFuture = widget.loadArtists().then((artists) {
|
||||||
|
_cachedArtists = artists;
|
||||||
|
return artists;
|
||||||
|
});
|
||||||
|
_hasLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
_logger.startBuild('ArtistRow:${widget.title}');
|
||||||
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Column(
|
// Total row height includes title + content
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final totalHeight = widget.rowHeight ?? 207.0; // Default: 44 title + 163 content
|
||||||
children: [
|
const titleHeight = 44.0; // 12 top padding + ~24 text + 8 bottom padding
|
||||||
Padding(
|
final contentHeight = totalHeight - titleHeight;
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
|
||||||
child: Text(
|
final result = RepaintBoundary(
|
||||||
widget.title,
|
child: SizedBox(
|
||||||
style: textTheme.titleLarge?.copyWith(
|
height: totalHeight,
|
||||||
fontWeight: FontWeight.bold,
|
child: Column(
|
||||||
color: colorScheme.onBackground,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
SizedBox(
|
child: FutureBuilder<List<Artist>>(
|
||||||
height: 180,
|
future: _artistsFuture,
|
||||||
child: FutureBuilder<List<Artist>>(
|
builder: (context, snapshot) {
|
||||||
future: _artistsFuture,
|
// Use cached data immediately if available (prevents flash on rebuild)
|
||||||
builder: (context, snapshot) {
|
final artists = snapshot.data ?? _cachedArtists;
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
// Only show loading if we have no data at all
|
||||||
return Center(
|
if (artists == null && snapshot.connectionState == ConnectionState.waiting) {
|
||||||
child: Text('Error: ${snapshot.error}'),
|
return const Center(child: CircularProgressIndicator());
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
final artists = snapshot.data ?? [];
|
if (snapshot.hasError && artists == null) {
|
||||||
if (artists.isEmpty) {
|
return Center(
|
||||||
return Center(
|
child: Text('Error: ${snapshot.error}'),
|
||||||
child: Text(
|
);
|
||||||
'No artists found',
|
}
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
|
||||||
|
if (artists == null || artists.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No artists found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: circle image + name below
|
||||||
|
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final imageSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = imageSize; // Card width = image width (circle)
|
||||||
|
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: artists.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final artist = artists[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(artist.uri ?? artist.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: ArtistCard(
|
||||||
|
artist: artist,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
return ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
physics: const BouncingScrollPhysics(),
|
|
||||||
itemCount: artists.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final artist = artists[index];
|
|
||||||
return Container(
|
|
||||||
width: 120,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
||||||
child: ArtistCard(artist: artist),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
);
|
||||||
|
_logger.endBuild('ArtistRow:${widget.title}');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom scroll behavior that uses Android 12+ stretch overscroll effect
|
||||||
|
class _StretchScrollBehavior extends ScrollBehavior {
|
||||||
|
const _StretchScrollBehavior();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildOverscrollIndicator(
|
||||||
|
BuildContext context, Widget child, ScrollableDetails details) {
|
||||||
|
return StretchingOverscrollIndicator(
|
||||||
|
axisDirection: details.direction,
|
||||||
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
|
||||||
|
/// A wrapper around CachedNetworkImage for consistent image caching throughout the app.
|
||||||
|
///
|
||||||
|
/// Benefits over NetworkImage:
|
||||||
|
/// - Disk caching (persists across app restarts)
|
||||||
|
/// - Memory caching with configurable limits
|
||||||
|
/// - Placeholder and error widgets
|
||||||
|
/// - Fade-in animation
|
||||||
|
class CachedImage extends StatelessWidget {
|
||||||
|
final String imageUrl;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
final BoxFit fit;
|
||||||
|
final Widget? placeholder;
|
||||||
|
final Widget? errorWidget;
|
||||||
|
final int? cacheWidth;
|
||||||
|
final int? cacheHeight;
|
||||||
|
final BorderRadius? borderRadius;
|
||||||
|
|
||||||
|
const CachedImage({
|
||||||
|
super.key,
|
||||||
|
required this.imageUrl,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
this.placeholder,
|
||||||
|
this.errorWidget,
|
||||||
|
this.cacheWidth,
|
||||||
|
this.cacheHeight,
|
||||||
|
this.borderRadius,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
Widget image = CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
fit: fit,
|
||||||
|
memCacheWidth: cacheWidth,
|
||||||
|
memCacheHeight: cacheHeight,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 150),
|
||||||
|
fadeOutDuration: const Duration(milliseconds: 150),
|
||||||
|
placeholder: (context, url) => placeholder ?? Container(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => errorWidget ?? Container(
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
child: Icon(
|
||||||
|
Icons.broken_image_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (borderRadius != null) {
|
||||||
|
image = ClipRRect(
|
||||||
|
borderRadius: borderRadius!,
|
||||||
|
child: image,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A circular cached image, perfect for artist avatars
|
||||||
|
class CachedCircleAvatar extends StatelessWidget {
|
||||||
|
final String? imageUrl;
|
||||||
|
final double radius;
|
||||||
|
final Widget? fallbackIcon;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
|
||||||
|
const CachedCircleAvatar({
|
||||||
|
super.key,
|
||||||
|
this.imageUrl,
|
||||||
|
this.radius = 24,
|
||||||
|
this.fallbackIcon,
|
||||||
|
this.backgroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final bgColor = backgroundColor ?? colorScheme.surfaceVariant;
|
||||||
|
|
||||||
|
if (imageUrl == null || imageUrl!.isEmpty) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
child: fallbackIcon ?? Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: radius,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl!,
|
||||||
|
memCacheWidth: (radius * 4).toInt(),
|
||||||
|
memCacheHeight: (radius * 4).toInt(),
|
||||||
|
fadeInDuration: const Duration(milliseconds: 150),
|
||||||
|
fadeOutDuration: const Duration(milliseconds: 150),
|
||||||
|
imageBuilder: (context, imageProvider) => CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
backgroundImage: imageProvider,
|
||||||
|
),
|
||||||
|
placeholder: (context, url) => CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
child: SizedBox(
|
||||||
|
width: radius,
|
||||||
|
height: radius,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
child: fallbackIcon ?? Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: radius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a CachedNetworkImageProvider for use with DecorationImage, CircleAvatar, etc.
|
||||||
|
/// This is useful when you need an ImageProvider rather than a widget.
|
||||||
|
CachedNetworkImageProvider? cachedImageProvider(String? imageUrl) {
|
||||||
|
if (imageUrl == null || imageUrl.isEmpty) return null;
|
||||||
|
return CachedNetworkImageProvider(imageUrl);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
|
||||||
|
/// A reusable widget for displaying disconnected/offline state.
|
||||||
|
class DisconnectedState extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final String? actionLabel;
|
||||||
|
final VoidCallback? onAction;
|
||||||
|
final IconData? actionIcon;
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
|
const DisconnectedState({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.actionLabel,
|
||||||
|
this.onAction,
|
||||||
|
this.actionIcon,
|
||||||
|
this.iconSize = 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Simple disconnected state without action button
|
||||||
|
factory DisconnectedState.simple() => const DisconnectedState(
|
||||||
|
title: 'Not connected to Music Assistant',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Disconnected state with settings button for navigation
|
||||||
|
factory DisconnectedState.withSettingsAction({
|
||||||
|
required VoidCallback onSettings,
|
||||||
|
}) =>
|
||||||
|
DisconnectedState(
|
||||||
|
title: 'Not connected to Music Assistant',
|
||||||
|
actionLabel: 'Configure Server',
|
||||||
|
actionIcon: Icons.settings_rounded,
|
||||||
|
onAction: onSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Full disconnected state with title, subtitle, and action (for home screen)
|
||||||
|
factory DisconnectedState.full({
|
||||||
|
required VoidCallback onSettings,
|
||||||
|
}) =>
|
||||||
|
DisconnectedState(
|
||||||
|
title: 'Not Connected',
|
||||||
|
subtitle: 'Connect to your Music Assistant server to start listening',
|
||||||
|
actionLabel: 'Configure Server',
|
||||||
|
actionIcon: Icons.settings_rounded,
|
||||||
|
onAction: onSettings,
|
||||||
|
iconSize: 80,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: Spacing.paddingAll32,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_off_rounded,
|
||||||
|
size: iconSize,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.54),
|
||||||
|
),
|
||||||
|
SizedBox(height: iconSize > IconSizes.xxl ? Spacing.xl : Spacing.lg),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(subtitle != null ? 1.0 : 0.7),
|
||||||
|
fontSize: subtitle != null ? 24 : 16,
|
||||||
|
fontWeight: subtitle != null ? FontWeight.w300 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
Spacing.vGap12,
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (actionLabel != null && onAction != null) ...[
|
||||||
|
SizedBox(height: subtitle != null ? Spacing.xxl : Spacing.xl),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onAction,
|
||||||
|
icon: actionIcon != null ? Icon(actionIcon) : const SizedBox.shrink(),
|
||||||
|
label: Text(actionLabel!),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primary,
|
||||||
|
foregroundColor: colorScheme.onPrimary,
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: subtitle != null ? Spacing.xxl : Spacing.xl,
|
||||||
|
vertical: subtitle != null ? Spacing.lg : Spacing.md,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(Radii.xxl),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
|
||||||
|
/// A reusable empty state widget for displaying when no data is available.
|
||||||
|
///
|
||||||
|
/// Shows an icon, message, and optional action button.
|
||||||
|
class EmptyState extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String message;
|
||||||
|
final String? actionLabel;
|
||||||
|
final VoidCallback? onAction;
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
|
const EmptyState({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.message,
|
||||||
|
this.actionLabel,
|
||||||
|
this.onAction,
|
||||||
|
this.iconSize = 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates an empty state for artists
|
||||||
|
factory EmptyState.artists({VoidCallback? onRefresh}) => EmptyState(
|
||||||
|
icon: Icons.person_outline_rounded,
|
||||||
|
message: 'No artists found',
|
||||||
|
actionLabel: onRefresh != null ? 'Refresh' : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an empty state for albums
|
||||||
|
factory EmptyState.albums({VoidCallback? onRefresh}) => EmptyState(
|
||||||
|
icon: Icons.album_outlined,
|
||||||
|
message: 'No albums found',
|
||||||
|
actionLabel: onRefresh != null ? 'Refresh' : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an empty state for tracks
|
||||||
|
factory EmptyState.tracks({VoidCallback? onRefresh}) => EmptyState(
|
||||||
|
icon: Icons.music_note_outlined,
|
||||||
|
message: 'No tracks found',
|
||||||
|
actionLabel: onRefresh != null ? 'Refresh' : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an empty state for playlists
|
||||||
|
factory EmptyState.playlists({VoidCallback? onRefresh}) => EmptyState(
|
||||||
|
icon: Icons.playlist_play_rounded,
|
||||||
|
message: 'No playlists found',
|
||||||
|
actionLabel: onRefresh != null ? 'Refresh' : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an empty state for queue
|
||||||
|
factory EmptyState.queue() => const EmptyState(
|
||||||
|
icon: Icons.queue_music,
|
||||||
|
message: 'Queue is empty',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates an empty state for search results
|
||||||
|
factory EmptyState.search() => const EmptyState(
|
||||||
|
icon: Icons.search_off_rounded,
|
||||||
|
message: 'No results found',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates a custom empty state with title and subtitle
|
||||||
|
factory EmptyState.custom({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
|
VoidCallback? onRefresh,
|
||||||
|
}) =>
|
||||||
|
_CustomEmptyState(
|
||||||
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
onRefresh: onRefresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: Spacing.paddingAll24,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: iconSize,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.38),
|
||||||
|
),
|
||||||
|
Spacing.vGap16,
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (actionLabel != null && onAction != null) ...[
|
||||||
|
Spacing.vGap24,
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onAction,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
label: Text(actionLabel!),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A custom empty state with title and optional subtitle
|
||||||
|
class _CustomEmptyState extends EmptyState {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final VoidCallback? onRefresh;
|
||||||
|
|
||||||
|
const _CustomEmptyState({
|
||||||
|
required super.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.onRefresh,
|
||||||
|
}) : super(message: title);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: Spacing.paddingAll24,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: iconSize,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.38),
|
||||||
|
),
|
||||||
|
Spacing.vGap16,
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
Spacing.vGap8,
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.4),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (onRefresh != null) ...[
|
||||||
|
Spacing.vGap24,
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onRefresh,
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
label: const Text('Refresh'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../../services/debug_logger.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
|
||||||
|
/// A collapsible debug console for viewing application logs
|
||||||
|
class DebugConsole extends StatelessWidget {
|
||||||
|
final VoidCallback onClear;
|
||||||
|
final int maxLogs;
|
||||||
|
|
||||||
|
const DebugConsole({
|
||||||
|
super.key,
|
||||||
|
required this.onClear,
|
||||||
|
this.maxLogs = 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: Spacing.paddingAll12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.8),
|
||||||
|
borderRadius: BorderRadius.circular(Radii.md),
|
||||||
|
border: Border.all(color: Colors.green.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildHeader(context),
|
||||||
|
const Divider(color: Colors.green, height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _buildLogsList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.terminal, color: Colors.green, size: IconSizes.xs),
|
||||||
|
Spacing.hGap8,
|
||||||
|
const Text(
|
||||||
|
'Debug Console',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _copyLogs(context),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
minimumSize: const Size(50, 20),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.copy, size: 10, color: Colors.green),
|
||||||
|
label: const Text(
|
||||||
|
'Copy',
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.green),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
DebugLogger().clear();
|
||||||
|
onClear();
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
minimumSize: const Size(40, 20),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Clear',
|
||||||
|
style: TextStyle(fontSize: 10, color: Colors.green),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyLogs(BuildContext context) {
|
||||||
|
final logs = DebugLogger().getAllLogs();
|
||||||
|
if (logs.isNotEmpty) {
|
||||||
|
Clipboard.setData(ClipboardData(text: logs));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Logs copied to clipboard'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('No logs to copy'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLogsList() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
reverse: true,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final allLogs = DebugLogger().logs;
|
||||||
|
final recentLogs = allLogs.length > maxLogs
|
||||||
|
? allLogs.sublist(allLogs.length - maxLogs)
|
||||||
|
: allLogs;
|
||||||
|
|
||||||
|
if (recentLogs.isEmpty) {
|
||||||
|
return const Text(
|
||||||
|
'No debug logs yet. Try detecting auth.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontSize: 10,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: recentLogs
|
||||||
|
.map((log) => Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: Spacing.xxs),
|
||||||
|
child: Text(
|
||||||
|
log,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A toggle button for showing/hiding the debug console
|
||||||
|
class DebugToggleButton extends StatelessWidget {
|
||||||
|
final bool isVisible;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
|
||||||
|
const DebugToggleButton({
|
||||||
|
super.key,
|
||||||
|
required this.isVisible,
|
||||||
|
required this.onToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextButton.icon(
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
isVisible ? Icons.bug_report : Icons.bug_report_outlined,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
isVisible ? 'Hide Debug' : 'Show Debug',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../providers/navigation_provider.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
import 'expandable_player.dart';
|
||||||
|
|
||||||
|
/// Cached color with contrast adjustment
|
||||||
|
/// Avoids expensive HSL conversions during scroll
|
||||||
|
class _CachedNavColor {
|
||||||
|
Color? _sourceColor;
|
||||||
|
bool? _isDark;
|
||||||
|
Color? _adjustedColor;
|
||||||
|
|
||||||
|
Color getAdjustedColor(Color sourceColor, bool isDark) {
|
||||||
|
// Return cached value if inputs haven't changed
|
||||||
|
if (_sourceColor == sourceColor && _isDark == isDark && _adjustedColor != null) {
|
||||||
|
return _adjustedColor!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute new adjusted color
|
||||||
|
var navSelectedColor = sourceColor;
|
||||||
|
if (isDark && navSelectedColor.computeLuminance() < 0.2) {
|
||||||
|
final hsl = HSLColor.fromColor(navSelectedColor);
|
||||||
|
navSelectedColor = hsl.withLightness((hsl.lightness + 0.3).clamp(0.0, 0.8)).toColor();
|
||||||
|
} else if (!isDark && navSelectedColor.computeLuminance() > 0.8) {
|
||||||
|
final hsl = HSLColor.fromColor(navSelectedColor);
|
||||||
|
navSelectedColor = hsl.withLightness((hsl.lightness - 0.3).clamp(0.2, 1.0)).toColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
_sourceColor = sourceColor;
|
||||||
|
_isDark = isDark;
|
||||||
|
_adjustedColor = navSelectedColor;
|
||||||
|
|
||||||
|
return navSelectedColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _cachedNavColor = _CachedNavColor();
|
||||||
|
|
||||||
|
/// A global key to access the player state from anywhere in the app
|
||||||
|
final globalPlayerKey = GlobalKey<ExpandablePlayerState>();
|
||||||
|
|
||||||
|
/// Key for the overlay state to control visibility
|
||||||
|
final _overlayStateKey = GlobalKey<_GlobalPlayerOverlayState>();
|
||||||
|
|
||||||
|
/// Constants for bottom UI elements spacing
|
||||||
|
class BottomSpacing {
|
||||||
|
/// Height of the bottom navigation bar
|
||||||
|
static const double navBarHeight = 56.0;
|
||||||
|
|
||||||
|
/// Height of mini player when visible (64px height + 12px margin)
|
||||||
|
static const double miniPlayerHeight = 76.0;
|
||||||
|
|
||||||
|
/// Space needed when only nav bar is visible (with some extra padding)
|
||||||
|
static const double navBarOnly = navBarHeight + 16.0;
|
||||||
|
|
||||||
|
/// Space needed when mini player is also visible
|
||||||
|
static const double withMiniPlayer = navBarHeight + miniPlayerHeight + 22.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ValueNotifier for player expansion progress (0.0 to 1.0) and background color
|
||||||
|
class PlayerExpansionState {
|
||||||
|
final double progress;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
PlayerExpansionState(this.progress, this.backgroundColor);
|
||||||
|
}
|
||||||
|
final playerExpansionNotifier = ValueNotifier<PlayerExpansionState>(PlayerExpansionState(0.0, null));
|
||||||
|
|
||||||
|
/// Wrapper widget that provides a global player overlay above all navigation.
|
||||||
|
///
|
||||||
|
/// This ensures the mini player and expanded player are consistent across
|
||||||
|
/// all screens (home, library, album details, artist details, etc.) without
|
||||||
|
/// needing separate player instances in each screen.
|
||||||
|
class GlobalPlayerOverlay extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
GlobalPlayerOverlay({
|
||||||
|
required this.child,
|
||||||
|
}) : super(key: _overlayStateKey);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GlobalPlayerOverlay> createState() => _GlobalPlayerOverlayState();
|
||||||
|
|
||||||
|
/// Collapse the player if it's expanded
|
||||||
|
static void collapsePlayer() {
|
||||||
|
globalPlayerKey.currentState?.collapse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the player is currently expanded
|
||||||
|
static bool get isPlayerExpanded =>
|
||||||
|
globalPlayerKey.currentState?.isExpanded ?? false;
|
||||||
|
|
||||||
|
/// Get the current expansion progress (0.0 to 1.0)
|
||||||
|
static double get expansionProgress =>
|
||||||
|
globalPlayerKey.currentState?.expansionProgress ?? 0.0;
|
||||||
|
|
||||||
|
/// Get the current expanded background color
|
||||||
|
static Color? get expandedBackgroundColor =>
|
||||||
|
globalPlayerKey.currentState?.currentExpandedBgColor;
|
||||||
|
|
||||||
|
/// Hide the mini player temporarily (e.g., when showing device selector)
|
||||||
|
/// The player slides down off-screen with animation
|
||||||
|
static void hidePlayer() {
|
||||||
|
_overlayStateKey.currentState?._setHidden(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the mini player again (slides back up)
|
||||||
|
static void showPlayer() {
|
||||||
|
_overlayStateKey.currentState?._setHidden(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _slideController;
|
||||||
|
late Animation<double> _slideAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_slideController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_slideAnimation = CurvedAnimation(
|
||||||
|
parent: _slideController,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_slideController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setHidden(bool hidden) {
|
||||||
|
if (hidden) {
|
||||||
|
_slideController.forward();
|
||||||
|
} else {
|
||||||
|
_slideController.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// The main app content (Navigator, screens, etc.)
|
||||||
|
// Add bottom padding to account for bottom nav + mini player
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 0), // Content manages its own padding
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
// Global persistent bottom navigation bar - positioned at bottom
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: navigationProvider,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Consumer<ThemeProvider>(
|
||||||
|
builder: (context, themeProvider, _) {
|
||||||
|
// Use adaptive primary color for bottom nav when adaptive theme is enabled
|
||||||
|
final sourceColor = themeProvider.adaptiveTheme
|
||||||
|
? themeProvider.adaptivePrimaryColor
|
||||||
|
: colorScheme.primary;
|
||||||
|
|
||||||
|
// Use cached color computation to avoid expensive HSL operations during scroll
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||||
|
|
||||||
|
// Base background: use adaptive surface color if available, otherwise default surface
|
||||||
|
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
|
||||||
|
? themeProvider.adaptiveSurfaceColor!
|
||||||
|
: colorScheme.surface;
|
||||||
|
|
||||||
|
return ValueListenableBuilder<PlayerExpansionState>(
|
||||||
|
valueListenable: playerExpansionNotifier,
|
||||||
|
// Pass BottomNavigationBar as child to avoid rebuilding it every frame
|
||||||
|
child: BottomNavigationBar(
|
||||||
|
currentIndex: navigationProvider.selectedIndex,
|
||||||
|
onTap: (index) {
|
||||||
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
|
}
|
||||||
|
navigationProvider.navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||||
|
if (index == 3) {
|
||||||
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
|
} else if (navigationProvider.selectedIndex == 3) {
|
||||||
|
GlobalPlayerOverlay.showPlayer();
|
||||||
|
}
|
||||||
|
navigationProvider.setSelectedIndex(index);
|
||||||
|
},
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
selectedItemColor: navSelectedColor,
|
||||||
|
unselectedItemColor: colorScheme.onSurface.withOpacity(0.54),
|
||||||
|
elevation: 0,
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
selectedFontSize: 12,
|
||||||
|
unselectedFontSize: 12,
|
||||||
|
items: const [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.home_outlined),
|
||||||
|
activeIcon: Icon(Icons.home_rounded),
|
||||||
|
label: 'Home',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.library_music_outlined),
|
||||||
|
activeIcon: Icon(Icons.library_music_rounded),
|
||||||
|
label: 'Library',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.search_rounded),
|
||||||
|
activeIcon: Icon(Icons.search_rounded),
|
||||||
|
label: 'Search',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
activeIcon: Icon(Icons.settings_rounded),
|
||||||
|
label: 'Settings',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
builder: (context, expansionState, navBar) {
|
||||||
|
// Only compute colors during animation - this is the hot path
|
||||||
|
final navBgColor = expansionState.progress > 0 && expansionState.backgroundColor != null
|
||||||
|
? Color.lerp(baseBgColor, expansionState.backgroundColor, expansionState.progress)!
|
||||||
|
: baseBgColor;
|
||||||
|
|
||||||
|
// Use AnimatedContainer for smoother transitions instead of rebuilding
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: navBgColor,
|
||||||
|
boxShadow: expansionState.progress < 0.5
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: navBar, // Reuse pre-built navigation bar
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
|
||||||
|
// Use Selector instead of Consumer to avoid rebuilds during animation
|
||||||
|
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
|
||||||
|
selector: (_, provider) => (
|
||||||
|
isConnected: provider.isConnected,
|
||||||
|
hasPlayer: provider.selectedPlayer != null,
|
||||||
|
),
|
||||||
|
builder: (context, state, child) {
|
||||||
|
// Only show player if connected and has a selected player
|
||||||
|
if (!state.isConnected || !state.hasPlayer) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _slideAnimation,
|
||||||
|
child: ExpandablePlayer(key: globalPlayerKey, slideOffset: 0),
|
||||||
|
builder: (context, staticChild) {
|
||||||
|
// Only slideOffset changes during animation, player widget stays same
|
||||||
|
return ExpandablePlayer(
|
||||||
|
key: globalPlayerKey,
|
||||||
|
slideOffset: _slideAnimation.value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
|
|
||||||
class LogoText extends StatelessWidget {
|
|
||||||
final double fontSize;
|
|
||||||
final bool lightMode;
|
|
||||||
|
|
||||||
const LogoText({
|
|
||||||
super.key,
|
|
||||||
this.fontSize = 24,
|
|
||||||
this.lightMode = false
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textColor = lightMode ? Colors.white : colorScheme.onBackground;
|
|
||||||
|
|
||||||
return Text(
|
|
||||||
'Assistant To The Music',
|
|
||||||
style: GoogleFonts.specialElite(
|
|
||||||
textStyle: TextStyle(
|
|
||||||
color: textColor,
|
|
||||||
fontSize: fontSize,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
// Typewriter fonts often don't need extra letter spacing
|
|
||||||
),
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||