Compare commits
614 Commits
push-qsztx
...
version-0.
| Author | SHA1 | Date | |
|---|---|---|---|
| f5d055be6f | |||
| e42a7b1ef2 | |||
| 5320545d56 | |||
| 9c90e706f1 | |||
| a4301140d8 | |||
| 64450185c5 | |||
| 000d378789 | |||
| 02a44e9e75 | |||
| 2f77913caa | |||
| 2353cd736d | |||
| 572f6e4301 | |||
| 5cb8cc2f31 | |||
| 2b69350162 | |||
| 0c64ed1a9a | |||
| 876b1e0147 | |||
| b915481595 | |||
| 9fe4819640 | |||
| 7976ce096e | |||
| 0cbd0c9b30 | |||
| 0abe66f7fb | |||
| ed46ee8109 | |||
| dec48a3130 | |||
| 46625c20ec | |||
| 125b5bbc2f | |||
| 0a50ca38ac | |||
| 40cce9f894 | |||
| 6d9c50d454 | |||
| 6b5282903e | |||
| d6f8d94ca5 | |||
| 86711c1e48 | |||
| 18d12fa088 | |||
| 7c901ace41 | |||
| 3e980af484 | |||
| ea6ddf90c0 | |||
| d4917e159a | |||
| d35e17ae95 | |||
| 4124fe2083 | |||
| 0348397173 | |||
| bfb8f588b8 | |||
| 9574ccbe0b | |||
| 090ab9060b | |||
| eab891aec7 | |||
| 7084707a4a | |||
| c3588de340 | |||
| a753fbf910 | |||
| 4713b7b121 | |||
| 1a7e5dd790 | |||
| 5f7ffe1bbe | |||
| 3f9798be9b | |||
| 0264e0c2e1 | |||
| 7de0008cc8 | |||
| 1bf34fe949 | |||
| ca36a102e5 | |||
| d6920703a1 | |||
| 68e6f528e5 | |||
| d2d8111668 | |||
| 90399b408a | |||
| 4bc80dfd08 | |||
| d2f6abf4bb | |||
| 50693aaf7a | |||
| cd19d68703 | |||
| 7389c55970 | |||
| 76132086b6 | |||
| 31ee25c7f2 | |||
| 565e3f81e5 | |||
| 380e105873 | |||
| daef8d6150 | |||
| 4d79c6d883 | |||
| 27aa70d81f | |||
| 2ffd3b5fb5 | |||
| 9f62191830 | |||
| b38d2593b3 | |||
| 75878be564 | |||
| 1432f2507c | |||
| 23705c9c21 | |||
| 72bf73ef10 | |||
| 825254a2bb | |||
| f99ab2a41e | |||
| aeb9332387 | |||
| ea1774f216 | |||
| f434c521e8 | |||
| 7b1d35cebc | |||
| 14f6b0b379 | |||
| 0e70a91ff9 | |||
| f096a8cd8f | |||
| 2108e7ce39 | |||
| cf2636a992 | |||
| bed0eca7f6 | |||
| 3ec692ea6f | |||
| 6cbf4791cd | |||
| 0a03f72467 | |||
| cf3f0afde4 | |||
| 8f043a4d80 | |||
| 6ae06b38e1 | |||
| a559375176 | |||
| 503b8bc4ff | |||
| 7562801989 | |||
| 128fa823a9 | |||
| 4e979a2d79 | |||
| b04a90818f | |||
| 216fdfa744 | |||
| bd0544820a | |||
| 6589279bb3 | |||
| c972182b3b | |||
| f2dcd55549 | |||
| 996a2096d0 | |||
| 9e8491710c | |||
| 3d1673157b | |||
| 0b31d75021 | |||
| ccd2112305 | |||
| e21fa5948e | |||
| d55c22a267 | |||
| d1ca476c4f | |||
| aeddcc9c64 | |||
| 3957e17f64 | |||
| b7995a14a1 | |||
| 17edc082b1 | |||
| 792ac5cb95 | |||
| a51d7c551e | |||
| e00d0ce4d8 | |||
| f7f2364da6 | |||
| f7c89d8000 | |||
| 75e4ad28b7 | |||
| 7fd8d50b8b | |||
| 12389dd7c1 | |||
| ac93d09212 | |||
| a0f723e2d2 | |||
| 1ef9a8a932 | |||
| 77e5eb047e | |||
| cd47266fc8 | |||
| 9be6835ddc | |||
| 9bc0c2544d | |||
| cbe197da9c | |||
| 765070c4b0 | |||
| 66416dafa8 | |||
| c81a4651d6 | |||
| 97a383c079 | |||
| 58c4383023 | |||
| d9ad056933 | |||
| 4e27255305 | |||
| 1c2d30cbe9 | |||
|
|
de84cbafbb | ||
|
|
fd5cead8d4 | ||
|
|
9b36dfd5ab | ||
|
|
9a7e4d9f00 | ||
|
|
6eef3ab91c | ||
|
|
46dee1de7b | ||
|
|
ba3ef23e67 | ||
|
|
4a3cefa2fd | ||
|
|
0129fd49cc | ||
|
|
4cdc65dac1 | ||
|
|
3219a8beda | ||
|
|
ae5c65db4a | ||
|
|
1e8eedc253 | ||
|
|
4d03ce9f74 | ||
|
|
4e42ccbb03 | ||
|
|
290a55a25f | ||
|
|
f783244477 | ||
|
|
2ee08d4650 | ||
|
|
216d2b9205 | ||
|
|
8a8843bbf1 | ||
|
|
69cb3eb80a | ||
|
|
8eafff0f0c | ||
|
|
dbb809261a | ||
|
|
e44bef2021 | ||
|
|
ac2d5c9501 | ||
|
|
215b097f4b | ||
|
|
b3f22d1b61 | ||
|
|
dc50e5d12b | ||
|
|
8ba6c1365a | ||
|
|
4bae8b00dd | ||
|
|
81b990a828 | ||
|
|
a8d31353c3 | ||
|
|
fd421cfc80 | ||
|
|
7fb953c5a3 | ||
|
|
49630f4e54 | ||
|
|
684187b6ce | ||
|
|
3ad6f1ec61 | ||
|
|
d4508e603f | ||
|
|
acf504aaec | ||
|
|
8518740f3e | ||
|
|
79254ea4a3 | ||
|
|
d9bc1cfc03 | ||
|
|
1f5884627c | ||
|
|
5c29f55942 | ||
|
|
aceb9ab2a1 | ||
|
|
2b47f851b6 | ||
|
|
fe428301d0 | ||
|
|
98a07cf737 | ||
|
|
d595476aba | ||
|
|
55c66f0462 | ||
|
|
9043e54076 | ||
|
|
941fbbed71 | ||
|
|
4884fddf0e | ||
|
|
b25a4f9fb3 | ||
|
|
419bc35a46 | ||
|
|
07dcc5bef1 | ||
|
|
ea62d11f64 | ||
|
|
cdf24b0143 | ||
|
|
befdd90149 | ||
|
|
c9a71df250 | ||
|
|
96ee568840 | ||
|
|
56abb68c0d | ||
|
|
b6723e529d | ||
|
|
074f943a6a | ||
|
|
f1d74d1ca6 | ||
|
|
dfd71e4d37 | ||
|
|
25eb705f59 | ||
|
|
f1224516e0 | ||
|
|
413047cce5 | ||
|
|
483ec26dfe | ||
|
|
03bc9c9ae7 | ||
|
|
019c0124e0 | ||
|
|
9e1ab7198a | ||
|
|
175a280b78 | ||
|
|
018d689189 | ||
|
|
dba9f668f6 | ||
|
|
4ac81fecac | ||
|
|
7e81a8e777 | ||
|
|
58e6753a81 | ||
|
|
d8594e72ad | ||
|
|
0df18fc637 | ||
|
|
a8f9e19f4a | ||
|
|
ed0bbfbf69 | ||
|
|
d9b1f8cf59 | ||
|
|
ff859372cf | ||
|
|
a5a2ea1181 | ||
|
|
b0c08c3c8c | ||
|
|
78004b0327 | ||
|
|
a0dd3278ad | ||
|
|
64586721b9 | ||
|
|
3bd2a33497 | ||
|
|
c92ad696de | ||
|
|
7fbb2c418b | ||
|
|
f23e43b5ea | ||
|
|
f3d56f4150 | ||
|
|
427c527810 | ||
|
|
06f514e6c6 | ||
|
|
fdd07842df | ||
|
|
3b229eab28 | ||
|
|
46d99bd96c | ||
|
|
818d7ce31a | ||
|
|
ac99176ee6 | ||
|
|
05920b52f6 | ||
|
|
e4e3e91ecb | ||
|
|
ad3c92eadc | ||
|
|
15eb4da669 | ||
|
|
590cd655d6 | ||
|
|
b629d57c34 | ||
|
|
4803c6ab73 | ||
|
|
4e5b8a9148 | ||
|
|
e06a8d95df | ||
|
|
a6cb7ac5e1 | ||
|
|
9d1aa3ea3d | ||
|
|
123bac1fdf | ||
|
|
4e775e2119 | ||
|
|
a0b6463273 | ||
|
|
95b7a9015c | ||
|
|
df06bc74fd | ||
|
|
50fed84a1b | ||
|
|
5337615c6a | ||
|
|
c047e625d8 | ||
|
|
9871cfcb70 | ||
|
|
e60535ed3c | ||
|
|
282b4cfad6 | ||
|
|
06a2797479 | ||
|
|
30d30739bd | ||
|
|
8076ed5a48 | ||
|
|
bf1de53952 | ||
|
|
6b3851de19 | ||
|
|
ff4ebcdfa5 | ||
|
|
971f0ba9d6 | ||
|
|
83e9cca756 | ||
|
|
6a7ba01102 | ||
|
|
aa916fa1ac | ||
|
|
33fefa7d33 | ||
|
|
4110b3875a | ||
|
|
98bf45cd27 | ||
|
|
bac4a94cad | ||
|
|
0a54db5963 | ||
|
|
cc3e31dbd0 | ||
|
|
27f7b0a304 | ||
|
|
23af037f36 | ||
|
|
6dd3ef6c61 | ||
|
|
7cefc51020 | ||
|
|
8ff74da191 | ||
|
|
78cbded701 | ||
|
|
010d233920 | ||
|
|
69831a17df | ||
|
|
9b45be87d6 | ||
|
|
74d8788463 | ||
|
|
f80ebd9f3d | ||
|
|
23e07fb6cf | ||
|
|
f8e09939ba | ||
|
|
1817d1becc | ||
|
|
77469d5ee8 | ||
|
|
ce63cbffb3 | ||
|
|
28e33dc26f | ||
|
|
094c4af082 | ||
|
|
dbfa392429 | ||
|
|
fc5b0288b7 | ||
|
|
17dec3351e | ||
|
|
ec61af7b71 | ||
|
|
c2b78fa040 | ||
|
|
1d8bdfa30c | ||
|
|
e084e75faa | ||
|
|
8b0317f88d | ||
|
|
92c53ed3a5 | ||
|
|
5d88d3a25e | ||
|
|
701decfc55 | ||
|
|
3b1b81a893 | ||
| 4332aa9b89 | |||
| 134d769374 | |||
| 88349e7797 | |||
| 8389d1a78e | |||
| 83d7520840 | |||
| 0603da2998 | |||
| 40950164e9 | |||
| 157baadcfd | |||
| 1eff9a57a5 | |||
| 7d907e7429 | |||
| 6bce26c4fc | |||
| 5f29496d79 | |||
| ec0a0e675e | |||
| e743c8affe | |||
| a5f0859c43 | |||
| 0cd2b6a64a | |||
| 8fe110eeb7 | |||
| a14210345c | |||
| 579109374d | |||
| 56b3ec8285 | |||
| 27a5279378 | |||
| 0351d17fee | |||
| 785f8d52a5 | |||
| a6ed30e0c7 | |||
| 8245fa6f41 | |||
| b4a8925281 | |||
| eb59aacc23 | |||
| b004c8a8bf | |||
| d5aa26edff | |||
| a1170a8689 | |||
| a8a5c2db9f | |||
| f77376e07c | |||
| 56366ec678 | |||
| 61b15f290d | |||
| 28381bf19e | |||
| 5b60fdbe1d | |||
| 5a785a445a | |||
| e82979561a | |||
| 0313492978 | |||
| 7c1485ae72 | |||
| ddf4e6812f | |||
| 107e657666 | |||
| b7661b5767 | |||
| bc1d0ef275 | |||
| 6b52b6ffee | |||
| 162265c661 | |||
| 0e26ebea31 | |||
| da2d45c12d | |||
| ca908fbe69 | |||
| 36949a1da8 | |||
| a383980412 | |||
| dd5ed5e890 | |||
| 3cb90b72ac | |||
| fe984edb7b | |||
| 92c35116f8 | |||
| 93db9c25e7 | |||
| a0e5d92bf9 | |||
| 09cdd3f516 | |||
| fee4c706da | |||
| 24502ff53b | |||
| ea6dd03ac0 | |||
| dea7062038 | |||
| 078d6cb5f8 | |||
| aae941b7cb | |||
| 217ebb84c9 | |||
| c5569cdcf2 | |||
| bbecd8efb5 | |||
| 4d510431d3 | |||
| 79271514b4 | |||
| 1d0a1c22f4 | |||
| 80dd1aa2d6 | |||
| d49a1e54d6 | |||
| bfaddfce0f | |||
| 058db96ade | |||
| 6c9192b6ea | |||
| 55be74bd94 | |||
| 261ac885c1 | |||
| d2b9cbf93b | |||
| 1e0a0e2acb | |||
| e5fbf372c4 | |||
| 1268c24faf | |||
| de40b5b90c | |||
| 33761f1cef | |||
| fc093743c1 | |||
| 9d15d2a127 | |||
| e3c17bf131 | |||
| 998205b0db | |||
| df138565af | |||
| fbef57890d | |||
| 4b29d12859 | |||
| 9e4a8410e1 | |||
| 882b6e2886 | |||
| e17722a99c | |||
| a64a5f6a6a | |||
| d78acc254d | |||
| 2ab526464d | |||
| 2290ae3cd7 | |||
| 6c40b93089 | |||
| ecb362ae48 | |||
| 37c3d9daf6 | |||
| 180241a315 | |||
| ffaa56fac9 | |||
| 2bb2e7b7db | |||
| 1567beee2e | |||
| b95bebdb6a | |||
| f510e59b1a | |||
| 28b3888498 | |||
| f5f6bcf3ab | |||
| a04c79a1f8 | |||
| b6e439dcf6 | |||
| 79940e685c | |||
| 2632233dbd | |||
| 45599787ba | |||
| ec89a227f2 | |||
| a809fca1ac | |||
| f3e6c59143 | |||
| 455fc4ed21 | |||
| 1371f5d1d8 | |||
| 0c648a9765 | |||
| 31dba7d84a | |||
| 208fe8be76 | |||
| 776c535862 | |||
| 756ae7f82c | |||
| 90a7b8b6fa | |||
| d2fe0a1bf6 | |||
| da9684812a | |||
| 2c814bd7c0 | |||
| 749f96c168 | |||
| efa4855555 | |||
| 3ab0c58e4a | |||
| 34822bef1e | |||
| 8be1e50681 | |||
| 8d62e50a20 | |||
| 4d15fb6ea2 | |||
| 336329a4ed | |||
| 4c9233e249 | |||
| 9932007bba | |||
| e9109a8a0c | |||
| 23c6d8b7a7 | |||
| 7a8562fe8e | |||
| ee9dca75ba | |||
| 032b55a6f1 | |||
| 5fe1624f26 | |||
| f00260ac4d | |||
| 10724430ce | |||
| 3ae20d0e3a | |||
| 1d2e25368c | |||
| 5c36116092 | |||
| 8cd14da8ec | |||
| 203907dfaf | |||
| fb0394b427 | |||
| cf12567ae5 | |||
| f27d5d3483 | |||
| 4cd6b125a4 | |||
| ff515e22bd | |||
| ebc15b0518 | |||
| d86cfe46df | |||
| c1e0151a11 | |||
| 0559a210ea | |||
| d768ab7fd5 | |||
| 993ef18ac6 | |||
| 2e4b69e758 | |||
| 9bd0cd173b | |||
| fdd6cb6401 | |||
| 082914cf8c | |||
| 2f9abff6f1 | |||
| 2eec78ab4f | |||
| f94a600ff8 | |||
| e33f8a7d54 | |||
| 8b3cb719a9 | |||
| 823617c5f3 | |||
| e67bca60e1 | |||
| 1f263c2295 | |||
| c496dd86ed | |||
| 218e527e24 | |||
| 0ddf7d9637 | |||
| 622c1d952e | |||
| 4e5f86730d | |||
| 6a87046845 | |||
| f9dc2ecb12 | |||
| 552d8d90fb | |||
| d657ea2315 | |||
| d64b96cef4 | |||
| bd61fdba81 | |||
| ee1072a491 | |||
| fef3b6f645 | |||
| b22a82bb50 | |||
| 5c06a0f0e9 | |||
| 2149d1a797 | |||
| e0fbb11475 | |||
| 3f85067426 | |||
| 68b0c89130 | |||
| 015cc69a31 | |||
| 5eef699a60 | |||
| e7e6727123 | |||
| 8ccb3a26ec | |||
| 1c83416be4 | |||
| 447af737a6 | |||
| 664be97ea6 | |||
| ffecc219b5 | |||
| b8154a4837 | |||
| 11187db71b | |||
| 3ca6fa9884 | |||
| fb94886c53 | |||
| 24068b6eb0 | |||
| a17b77a840 | |||
| 6a98fcc2b9 | |||
| 3fa53c1ba8 | |||
| e70537ed1b | |||
| d29b6cc1fb | |||
| fa0206a3f9 | |||
| bdb4d72f50 | |||
| 1abe80f74a | |||
| 708cefd5ab | |||
| 777cc0b3fe | |||
| 4a8deae567 | |||
| aded870495 | |||
| 2c8a1325b4 | |||
| ff2e998794 | |||
| dca6fcb565 | |||
| 3c1b1a449d | |||
| 83fa3287f4 | |||
| 5d13255155 | |||
| 4431a3b50b | |||
| 05aa1634e9 | |||
| 982e5317fb | |||
| 913da8a5a3 | |||
| 5ace866ee0 | |||
| 22dc0c7374 | |||
| fd1a44b6fa | |||
| c7d7ed749a | |||
| e5c83d01c0 | |||
| cd4954111f | |||
| 2ec567ebf3 | |||
| 3154c11845 | |||
| 115658bf6a | |||
| 24be870e4a | |||
| c409c6eb1b | |||
| 9bf5bc7fe2 | |||
| ff2d6b23bd | |||
| e260330877 | |||
| adb6555aa6 | |||
| c57ef5df30 | |||
| adeb37ce83 | |||
| c4ace53ea0 | |||
| 428d54641e | |||
| dd5299f59a | |||
| adb316a6ac | |||
| 8a55dafe8d | |||
| abcf4ebaf9 | |||
| f87f467680 | |||
| 02c2788ecc | |||
| 05a9c7100a | |||
| 87859397df | |||
| a648931960 | |||
| aaab49274c | |||
| 4b65795972 | |||
| 73342ea3ec | |||
| 57239e2d1f | |||
| 2a46e4574f | |||
| bcb95c6be1 | |||
| 2776aac2a6 | |||
| 7e193fb230 | |||
| 24da9eb418 | |||
| f6503e4d22 | |||
| fffb26cd56 | |||
| ae3fed8217 | |||
| 1a6b52fd35 | |||
| c06f7d8028 | |||
| 7e5353abf8 | |||
| c316e7f1fe | |||
| 921a29fa9f | |||
| a4001ae691 | |||
| d10c61913a | |||
| 98bafd4e93 | |||
| 79cfc6d3c9 | |||
| f5eba06476 | |||
| e8b42aa284 | |||
| 26a57b1b50 | |||
| 3385c72d1a | |||
| 5f65b8c1ed | |||
| 96d2a95798 | |||
| 590df8f2a2 | |||
| 29f0add92b | |||
| e4b56b75cb | |||
| b8370a552a | |||
| 85e053aad0 | |||
| a5e99ccfd3 | |||
| cf24262084 | |||
| 6f1207e15f | |||
| 9deccabb98 | |||
| aefdee92b8 | |||
| 5266d68d2f | |||
| 437aeb84e5 | |||
| ac21d5fd9e | |||
| cbe5a4e1f4 | |||
| f7f14d9f62 | |||
| 40d791103d | |||
| 6024f2db60 | |||
| 96a48ffa54 | |||
| d728a47224 | |||
| 022b178b3f | |||
| 21caf3cac2 |
3
.claude-env
Normal file
3
.claude-env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Configuration PATH pour Claude Code
|
||||||
|
# Ce fichier sera lu automatiquement pour configurer l'environnement
|
||||||
|
export PATH="/Users/coissac/mamba/condabin:/opt/homebrew/lib/ruby/gems/3.4.0/bin:/opt/homebrew/opt/ruby/bin:/Users/coissac/go/bin:/Users/coissac/.cargo/bin:/Users/coissac/.modular/pkg/packages.modular.com_mojo/bin:/Applications/quarto/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Library/Frameworks/Python.framework/Versions/3.12/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/opt/X11/bin:/usr/local/dorado/dorado-0.9.5-osx-arm64/bin:/usr/local/go/bin:/usr/local/src/last-main/bin:/Users/coissac/travail/__MOI__/GO/obitools4/build:/opt/podman/bin:/Applications/quarto/bin:/Users/coissac/.cargo/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Users/coissac/.vscode-oss/extensions/ms-python.debugpy-2025.14.1-darwin-arm64/bundled/scripts/noConfigScripts:/Users/coissac/.orbstack/bin"
|
||||||
31
.claude/CLAUDE.md
Normal file
31
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# PMOMusic Project Configuration
|
||||||
|
|
||||||
|
## Version Control
|
||||||
|
Ce projet utilise **Jujutsu (jj)** pour le contrôle de version, PAS git.
|
||||||
|
- Utiliser les commandes `jj` au lieu des commandes `git`
|
||||||
|
- Bookmark principal : `main`
|
||||||
|
- Ne jamais suggérer de commandes git
|
||||||
|
|
||||||
|
## Environnement
|
||||||
|
Le PATH et les variables d'environnement sont configurés dans `.claude-env` à la racine du projet.
|
||||||
|
|
||||||
|
## Configuration de l'application
|
||||||
|
- Fichier de configuration principal : `.pmomusic/config.yaml`
|
||||||
|
- Configuration UPNP personnalisable pour différencier les instances en développement
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
Pendant le développement, plusieurs serveurs PMOMusic peuvent tourner en parallèle. Utiliser la configuration UPNP dans `.pmomusic/config.yaml` pour différencier les instances :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
host:
|
||||||
|
upnp:
|
||||||
|
manufacturer: "PMOMusic-Dev1"
|
||||||
|
udn_prefix: "pmomusic-dev1"
|
||||||
|
model_name_prefix: "PMOMusic-Dev1"
|
||||||
|
friendly_name_prefix: "PMOMusic-Dev1"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- Projet Rust multi-crates avec workspaces
|
||||||
|
- Crates principales : pmoupnp, pmomediaserver, pmomediarenderer, pmoconfig
|
||||||
|
- Pattern d'extension de configuration via traits (voir pmocache/src/config_ext.rs)
|
||||||
70
.dockerignore
Normal file
70
.dockerignore
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Build artifacts
|
||||||
|
target/
|
||||||
|
**/target/
|
||||||
|
|
||||||
|
# Webapp build artifacts and dependencies
|
||||||
|
pmoapp/webapp/node_modules/
|
||||||
|
pmoapp/webapp/dist/
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.jj/
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Cache directories
|
||||||
|
cache/
|
||||||
|
.pmomusic/
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
doc/
|
||||||
|
*.md
|
||||||
|
!Readme.md
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test_upnp/
|
||||||
|
examples/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.pcap
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
db/
|
||||||
|
|
||||||
|
# Old code and backups
|
||||||
|
old_code/
|
||||||
|
*.txt
|
||||||
|
!Cargo.lock
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
tools/
|
||||||
|
gupnp-tools/
|
||||||
|
|
||||||
|
# Build scripts (we have them in the Dockerfile)
|
||||||
|
Makefile
|
||||||
|
setup-deps.sh
|
||||||
|
setup-env.sh
|
||||||
|
|
||||||
|
# SVG and other assets not needed for runtime
|
||||||
|
*.svg
|
||||||
|
|
||||||
|
# Audio test files
|
||||||
|
*.ogg
|
||||||
|
*.flac
|
||||||
|
*.wav
|
||||||
|
*.mp3
|
||||||
29
.gitea/workflows/build-push.yaml
Normal file
29
.gitea/workflows/build-push.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main # Changez cela si votre branche principale a un autre nom
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Setup cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: dont-cache-${{ github.run_id }}
|
||||||
|
- name: Build and push image
|
||||||
|
uses: https://gargoton.petite-maison-orange.fr/pmo-actions/build-push-image@main
|
||||||
|
with:
|
||||||
|
image_name: public/pmomusic
|
||||||
|
image_tags: latest
|
||||||
|
no_cache: true
|
||||||
|
version_file: version.txt
|
||||||
|
check_uuid: 82a30d23-b3bd-4199-9237-776965831d20
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
|
.ollama
|
||||||
|
.ollamacode
|
||||||
/bin/
|
/bin/
|
||||||
/pkg/
|
/pkg/
|
||||||
/vendor/
|
/vendor/
|
||||||
@@ -6,14 +8,24 @@
|
|||||||
**/*.o
|
**/*.o
|
||||||
**/*.o.d
|
**/*.o.d
|
||||||
**/*.a
|
**/*.a
|
||||||
|
**/*.flac
|
||||||
|
**/*.aif
|
||||||
|
**/*.aiff
|
||||||
|
**/*.wav
|
||||||
|
**/*.opus
|
||||||
|
**/*.mp4
|
||||||
|
**/*.mp3
|
||||||
|
**/*.ogg
|
||||||
xxx
|
xxx
|
||||||
/dcai/
|
/dcai/
|
||||||
**/.pmomusic.yml
|
**/.pmomusic.yml
|
||||||
**/.pmomusic_covers/**
|
**/.pmomusic_covers/**
|
||||||
**/.DS_Strore/**
|
**/.pmomusic_audio/**
|
||||||
**/.DS_Strore
|
/.pmomusic
|
||||||
/target/
|
.DS_Store
|
||||||
.pmomusic_covers
|
target
|
||||||
|
/.pmomusic_covers
|
||||||
|
/.pmomusic_audio/**
|
||||||
C/src/soxr-0.1.3/Release/tests
|
C/src/soxr-0.1.3/Release/tests
|
||||||
**/Release/
|
**/Release/
|
||||||
**/Debug/
|
**/Debug/
|
||||||
@@ -22,3 +34,12 @@ xxx
|
|||||||
xx
|
xx
|
||||||
all.txt
|
all.txt
|
||||||
pmo_src.txt
|
pmo_src.txt
|
||||||
|
upmpdcli/
|
||||||
|
/*.xml
|
||||||
|
test_upnp*.cargo/
|
||||||
|
.cargo/
|
||||||
|
setup-env.sh
|
||||||
|
cache
|
||||||
|
gupnp-tools
|
||||||
|
pmo*_[0_9]*.txt
|
||||||
|
webapp_[0_9]*.txt
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
devices:
|
|
||||||
mediarenderer:
|
|
||||||
fakerenderer:
|
|
||||||
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
|
|
||||||
mediaserver:
|
|
||||||
qobuz:
|
|
||||||
udn: 28963b75-4c5f-4da7-b10e-ffafd
|
|
||||||
host:
|
|
||||||
http_port: '8080'
|
|
||||||
28
.pmomusic.yml.example
Normal file
28
.pmomusic.yml.example
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
host:
|
||||||
|
http_port: '8080'
|
||||||
|
cover_cache:
|
||||||
|
directory: ./.pmomusic_covers
|
||||||
|
size: 2000
|
||||||
|
audio_cache:
|
||||||
|
directory: ./.pmomusic_audio
|
||||||
|
size: 500
|
||||||
|
logger:
|
||||||
|
buffer_capacity: 200
|
||||||
|
enable_console: true
|
||||||
|
min_level: TRACE
|
||||||
|
mediarenderer:
|
||||||
|
mpd_renderer: null
|
||||||
|
mediaserver:
|
||||||
|
qobuz:
|
||||||
|
udn: uuid:28963b75-4c5f-4da7-b10e-ffafd
|
||||||
|
accounts:
|
||||||
|
qobuz:
|
||||||
|
username: your-email@example.com
|
||||||
|
password: 'YOUR_PASSWORD_HERE'
|
||||||
|
devices:
|
||||||
|
mediarenderer:
|
||||||
|
pmo_mediarenderer:
|
||||||
|
udn: 15a13316-daac-47f0-b64e-47e56f5e3b51
|
||||||
|
mediaserver:
|
||||||
|
pmo_mediaserver:
|
||||||
|
udn: 23df0bfa-cfef-4724-b731-00f66fadf176
|
||||||
38
.vscode/settings.json
vendored
38
.vscode/settings.json
vendored
@@ -1,7 +1,37 @@
|
|||||||
{
|
{
|
||||||
"makefile.configureOnOpen": false,
|
"makefile.configureOnOpen": false,
|
||||||
"git.enabled": false,
|
"git.enabled": false,
|
||||||
"claude-code.environmentVariables": [
|
"claude-code.environmentVariables": [],
|
||||||
|
|
||||||
]
|
// Exclusions de fichiers/dossiers inutiles pour Rust Analyzer
|
||||||
|
"files.exclude": {
|
||||||
|
"target": true,
|
||||||
|
"**/target": true,
|
||||||
|
"node_modules": true,
|
||||||
|
"tests/huge_benchmarks": true,
|
||||||
|
"examples": true,
|
||||||
|
"docs": true
|
||||||
|
},
|
||||||
|
// Rust Analyzer settings
|
||||||
|
"rust-analyzer.rustupPath": "/Users/coissac/.cargo/bin/rustup",
|
||||||
|
"rust-analyzer.cargoPath": "/Users/coissac/.cargo/bin/cargo",
|
||||||
|
"rust-analyzer.rustcSource": "discover",
|
||||||
|
"rust-analyzer.procMacro.enable": true,
|
||||||
|
"rust-analyzer.numThreads": 4,
|
||||||
|
"rust-analyzer.cargo.loadOutDirsFromCheck": false,
|
||||||
|
"rust-analyzer.checkOnSave.enable": true,
|
||||||
|
"rust-analyzer.checkOnSave.command": "check",
|
||||||
|
"rust-analyzer.checkOnSave.extraArgs": ["--all-features"],
|
||||||
|
"rust-analyzer.exclude": [
|
||||||
|
"target",
|
||||||
|
"tests/huge_benchmarks",
|
||||||
|
"examples",
|
||||||
|
"docs"
|
||||||
|
],
|
||||||
|
"rust-analyzer.server.extraEnv": {
|
||||||
|
"RA_LARGE_PROJECT": "1"
|
||||||
|
},
|
||||||
|
"rust-analyzer.runnables.command": null,
|
||||||
|
"rust-analyzer.server.path": null,
|
||||||
|
"rust-analyzer.cargo.allTargets": true,
|
||||||
}
|
}
|
||||||
121
BROWSEMETADATA_FIX.md
Normal file
121
BROWSEMETADATA_FIX.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# BrowseMetadata Fix for Radio Paradise - PMO Music
|
||||||
|
|
||||||
|
**Date:** 2025-11-27
|
||||||
|
**Issue:** gupnp-av-cp failed to get metadata for live streams and history containers
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
UPnP clients (like gupnp-av-cp) were unable to get metadata for:
|
||||||
|
- Live stream items (e.g., `radio-paradise:channel:mellow:live`)
|
||||||
|
- History containers (e.g., `radio-paradise:channel:mellow:history`)
|
||||||
|
|
||||||
|
Error:
|
||||||
|
```
|
||||||
|
Failed to get metadata for 'radio-paradise:channel:mellow:live'
|
||||||
|
Failed to get metadata for 'radio-paradise:channel:mellow:history'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
The UPnP ContentDirectory service has two browse modes:
|
||||||
|
- **BrowseMetadata**: Get metadata for a specific object (item or container)
|
||||||
|
- **BrowseDirectChildren**: Get the children of a container
|
||||||
|
|
||||||
|
The `ContentHandler::browse_metadata()` was calling `source.browse()` for all objects, but:
|
||||||
|
1. The `MusicSource::browse()` trait method is designed to return children, not object metadata
|
||||||
|
2. For leaf items (LiveStream, HistoryTrack), `RadioParadiseSource::browse()` was rejecting them as "cannot be browsed"
|
||||||
|
3. The trait doesn't provide a way to distinguish between BrowseMetadata and BrowseDirectChildren requests
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### 1. Modified ContentHandler ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
|
||||||
|
|
||||||
|
- `browse_metadata()` now tries `get_item()` first for leaf items before falling back to `browse()`
|
||||||
|
- This allows proper metadata retrieval for items (LiveStream, HistoryTrack)
|
||||||
|
|
||||||
|
### 2. Modified RadioParadiseSource ([pmoparadise/src/source.rs](pmoparadise/src/source.rs))
|
||||||
|
|
||||||
|
**For LiveStream items:**
|
||||||
|
- `browse()` now returns `BrowseResult::Items([live_item])` with the item's metadata
|
||||||
|
- This supports both BrowseMetadata (via ContentHandler) and direct browse calls
|
||||||
|
|
||||||
|
**For HistoryTrack items:**
|
||||||
|
- `browse()` now returns `BrowseResult::Items([track])` using `get_item()` internally
|
||||||
|
- Properly retrieves track metadata from the history playlist
|
||||||
|
|
||||||
|
**For History containers:**
|
||||||
|
- `browse()` now returns `BrowseResult::Mixed { containers: [history_container], items: [tracks] }`
|
||||||
|
- Provides both container metadata and its children in one result
|
||||||
|
|
||||||
|
### 3. Added Container Filtering ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
|
||||||
|
|
||||||
|
- `browse_result_to_didl()` now filters out containers that match the browsed `object_id`
|
||||||
|
- Prevents containers from appearing as children of themselves
|
||||||
|
- For History: BrowseDirectChildren returns only tracks, not the container
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. ✅ [pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs)
|
||||||
|
- Lines 120-135: Try get_item() first in browse_metadata()
|
||||||
|
- Lines 290-310: Added object_id parameter and container filtering in browse_result_to_didl()
|
||||||
|
- Lines 212, 286: Updated callers to pass object_id
|
||||||
|
|
||||||
|
2. ✅ [pmoparadise/src/source.rs](pmoparadise/src/source.rs)
|
||||||
|
- Lines 345-369: Modified History browse to return Mixed (container + items)
|
||||||
|
- Lines 371-377: Modified LiveStream browse to return item metadata
|
||||||
|
- Lines 379-383: Modified HistoryTrack browse to return track metadata
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
### Live Stream Metadata ✅
|
||||||
|
```bash
|
||||||
|
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:live
|
||||||
|
```
|
||||||
|
Returns:
|
||||||
|
```xml
|
||||||
|
<item id="radio-paradise:channel:mellow:live" parentID="radio-paradise:channel:mellow">
|
||||||
|
<dc:title>Unknown Title</dc:title>
|
||||||
|
<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>
|
||||||
|
<res protocolInfo="http-get:*:audio/flac:*">http://.../radioparadise/stream/mellow/flac</res>
|
||||||
|
</item>
|
||||||
|
```
|
||||||
|
|
||||||
|
### History Container Metadata ✅
|
||||||
|
```bash
|
||||||
|
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:history
|
||||||
|
```
|
||||||
|
Returns:
|
||||||
|
```xml
|
||||||
|
<container id="radio-paradise:channel:mellow:history" parentID="radio-paradise:channel:mellow">
|
||||||
|
<dc:title>Mellow Mix - History</dc:title>
|
||||||
|
<upnp:class>object.container.playlistContainer</upnp:class>
|
||||||
|
</container>
|
||||||
|
```
|
||||||
|
|
||||||
|
### History Children ✅
|
||||||
|
```bash
|
||||||
|
curl -X POST -H "SOAPAction: ..." BrowseDirectChildren radio-paradise:channel:mellow:history
|
||||||
|
```
|
||||||
|
Returns only track items (not the container itself)
|
||||||
|
|
||||||
|
## Design Notes
|
||||||
|
|
||||||
|
This solution works around a fundamental limitation in the `MusicSource` trait:
|
||||||
|
- The `browse()` method doesn't receive the `browse_flag` parameter
|
||||||
|
- It can't distinguish between BrowseMetadata and BrowseDirectChildren
|
||||||
|
- We use `get_item()` for items and `browse()` for containers
|
||||||
|
- Container filtering ensures correct BrowseDirectChildren behavior
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] BrowseMetadata works for LiveStream items
|
||||||
|
- [x] BrowseMetadata works for History containers
|
||||||
|
- [x] BrowseDirectChildren works for History (returns only tracks)
|
||||||
|
- [x] Container filtering prevents self-reference
|
||||||
|
- [ ] Test with BubbleUPnP (user to verify)
|
||||||
|
- [ ] Test with gupnp-av-cp (user to verify)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Original issue report: [UPNP_FIX_SUMMARY.md](UPNP_FIX_SUMMARY.md)
|
||||||
|
- UPnP AV Architecture: https://openconnectivity.org/developer/specifications/upnp-resources/upnp/
|
||||||
4985
Cargo.lock
generated
4985
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
54
Cargo.toml
54
Cargo.toml
@@ -1,3 +1,55 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl"]
|
members = [
|
||||||
|
"PMOMusic",
|
||||||
|
"pmoupnp",
|
||||||
|
"pmomediarenderer",
|
||||||
|
"pmomediaserver",
|
||||||
|
"pmoconfig",
|
||||||
|
"pmoutils",
|
||||||
|
"pmodidl",
|
||||||
|
"pmoserver",
|
||||||
|
"pmoapp",
|
||||||
|
"pmocache",
|
||||||
|
"pmocovers",
|
||||||
|
"pmoaudiocache",
|
||||||
|
"pmoaudio",
|
||||||
|
"pmoqobuz",
|
||||||
|
"pmoparadise",
|
||||||
|
"pmosource",
|
||||||
|
"pmoplaylist",
|
||||||
|
"pmoflac",
|
||||||
|
"pmometadata", "pmocontrol",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Core
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1.35", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "2.0" # ⚠️ Unifier sur 2.0 (vous avez 1.0 et 2.0)
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||||
|
|
||||||
|
# HTTP/XML
|
||||||
|
reqwest = { version = "0.12", default-features = false }
|
||||||
|
ureq = "3.1"
|
||||||
|
quick-xml = { version = "0.38", features = ["serialize"] } # ⚠️ Unifier 0.37→0.38
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1.18", features = ["v4"] }
|
||||||
|
crossbeam-channel = "0.5"
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
tokio-test = "0.4"
|
||||||
72
DEPENDENCIES.md
Normal file
72
DEPENDENCIES.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
## Diagramme des dépendances PMOMusic (crates internes)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
|
||||||
|
PMOMusic --> pmoapp
|
||||||
|
PMOMusic --> pmoaudio_ext
|
||||||
|
PMOMusic --> pmoaudiocache
|
||||||
|
PMOMusic --> pmocovers
|
||||||
|
PMOMusic --> pmoconfig
|
||||||
|
PMOMusic --> pmoserver
|
||||||
|
PMOMusic --> pmosource
|
||||||
|
PMOMusic --> pmoupnp
|
||||||
|
PMOMusic --> pmomediaserver
|
||||||
|
PMOMusic --> pmomediarenderer
|
||||||
|
PMOMusic --> pmoqobuz
|
||||||
|
PMOMusic --> pmoparadise
|
||||||
|
|
||||||
|
pmoaudio_ext --> pmoaudio
|
||||||
|
pmoaudio_ext --> pmocovers
|
||||||
|
pmoaudio_ext --> pmocache
|
||||||
|
pmoaudio_ext --> pmoaudiocache
|
||||||
|
pmoaudio_ext --> pmometadata
|
||||||
|
pmoaudio_ext --> pmoplaylist
|
||||||
|
|
||||||
|
pmoaudiocache --> pmocache
|
||||||
|
pmoaudiocache --> pmometadata
|
||||||
|
|
||||||
|
pmocovers --> pmocache
|
||||||
|
|
||||||
|
pmoplaylist --> pmocache
|
||||||
|
pmoplaylist --> pmoaudiocache
|
||||||
|
pmoplaylist --> pmometadata
|
||||||
|
pmoplaylist --> pmodidl
|
||||||
|
|
||||||
|
pmosource --> pmoaudiocache
|
||||||
|
pmosource --> pmocovers
|
||||||
|
pmosource --> pmocache
|
||||||
|
pmosource --> pmoplaylist
|
||||||
|
pmosource --> pmodidl
|
||||||
|
pmosource --> pmoconfig
|
||||||
|
pmosource --> pmoserver
|
||||||
|
pmosource --> pmoupnp
|
||||||
|
|
||||||
|
pmoparadise --> pmosource
|
||||||
|
pmoparadise --> pmoaudiocache
|
||||||
|
pmoparadise --> pmoplaylist
|
||||||
|
pmoparadise --> pmoserver
|
||||||
|
pmoparadise --> pmoconfig
|
||||||
|
|
||||||
|
pmoqobuz --> pmosource
|
||||||
|
pmoqobuz --> pmoaudiocache
|
||||||
|
pmoqobuz --> pmocovers
|
||||||
|
pmoqobuz --> pmoserver
|
||||||
|
pmoqobuz --> pmoconfig
|
||||||
|
|
||||||
|
pmomediaserver --> pmoserver
|
||||||
|
pmomediaserver --> pmosource
|
||||||
|
pmomediaserver --> pmoconfig
|
||||||
|
pmomediaserver --> pmocovers
|
||||||
|
pmomediaserver --> pmoaudiocache
|
||||||
|
pmomediaserver --> pmoplaylist
|
||||||
|
|
||||||
|
pmoupnp --> pmoserver
|
||||||
|
pmoupnp --> pmocovers
|
||||||
|
pmoupnp --> pmoaudiocache
|
||||||
|
pmoupnp --> pmoplaylist
|
||||||
|
pmoupnp --> pmocache
|
||||||
|
pmoupnp --> pmoconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
> Flèches = “dépend de”. Dépendances externes non représentées. Cette vue correspond aux features activées par défaut dans la workspace.
|
||||||
302
DOCKER.md
Normal file
302
DOCKER.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# Docker Deployment Guide for PMOMusic
|
||||||
|
|
||||||
|
Ce guide explique comment construire et déployer PMOMusic avec Docker.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Le Dockerfile utilise une approche multi-stage pour créer une image minimale :
|
||||||
|
|
||||||
|
1. **Stage 1 (webapp-builder)** : Compile l'application Vue.js avec Node.js
|
||||||
|
2. **Stage 2 (rust-builder)** : Compile le binaire Rust avec toutes ses dépendances
|
||||||
|
3. **Stage 3 (runtime)** : Image finale minimale Debian Slim avec uniquement le binaire et les bibliothèques runtime
|
||||||
|
|
||||||
|
### Avantages
|
||||||
|
|
||||||
|
- **Binaire auto-contenu** : L'application web est embarquée dans le binaire Rust
|
||||||
|
- **Image minimale** : ~200-300MB (vs plusieurs GB pour les images de build)
|
||||||
|
- **Sécurité** : Exécution en tant qu'utilisateur non-root
|
||||||
|
- **Reproductibilité** : Build complet et déterministe
|
||||||
|
|
||||||
|
## Build de l'image
|
||||||
|
|
||||||
|
### Option 1 : Build manuel avec Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build l'image
|
||||||
|
docker build -t pmomusic:latest .
|
||||||
|
|
||||||
|
# Le build prend environ 10-15 minutes selon votre machine
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2 : Build avec docker-compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build et démarre le conteneur
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
# Ou juste build
|
||||||
|
docker-compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build optimisé avec cache
|
||||||
|
|
||||||
|
Pour accélérer les builds successifs, Docker réutilise les couches en cache :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build avec cache
|
||||||
|
docker build -t pmomusic:latest .
|
||||||
|
|
||||||
|
# Build sans cache (force rebuild complet)
|
||||||
|
docker build --no-cache -t pmomusic:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exécution du conteneur
|
||||||
|
|
||||||
|
### Option 1 : Avec docker-compose (recommandé)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Démarrer en arrière-plan
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Voir les logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Arrêter
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Redémarrer
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2 : Avec docker run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run en mode interactif
|
||||||
|
docker run -it --rm \
|
||||||
|
--name pmomusic \
|
||||||
|
--network host \
|
||||||
|
-v $(pwd)/config:/home/pmomusic/.pmomusic \
|
||||||
|
-v $(pwd)/cache:/home/pmomusic/cache \
|
||||||
|
pmomusic:latest
|
||||||
|
|
||||||
|
# Run en mode détaché
|
||||||
|
docker run -d \
|
||||||
|
--name pmomusic \
|
||||||
|
--network host \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-v $(pwd)/config:/home/pmomusic/.pmomusic \
|
||||||
|
-v $(pwd)/cache:/home/pmomusic/cache \
|
||||||
|
pmomusic:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
Par défaut, PMOMusic écoute sur le port **8080**. Vous pouvez modifier cela :
|
||||||
|
|
||||||
|
- Dans `docker-compose.yml` : modifier la section `ports`
|
||||||
|
- Avec `docker run` : utiliser `-p 8080:8080`
|
||||||
|
|
||||||
|
### Volumes
|
||||||
|
|
||||||
|
Deux volumes sont recommandés pour la persistance :
|
||||||
|
|
||||||
|
- **Configuration** : `/home/pmomusic/.pmomusic` - Fichiers de configuration
|
||||||
|
- **Cache** : `/home/pmomusic/cache` - Cache audio et métadonnées
|
||||||
|
|
||||||
|
### Variables d'environnement
|
||||||
|
|
||||||
|
Configurable via `docker-compose.yml` ou `-e` avec `docker run` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Niveau de logs Rust
|
||||||
|
RUST_LOG=debug
|
||||||
|
|
||||||
|
# Autres variables (selon votre configuration)
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Réseau
|
||||||
|
|
||||||
|
Pour UPnP/DLNA, utilisez **network_mode: host** pour permettre :
|
||||||
|
- La découverte multicast
|
||||||
|
- La communication avec les devices UPnP sur le réseau local
|
||||||
|
|
||||||
|
**Note** : Le mode `host` ne fonctionne que sur Linux. Sur macOS/Windows avec Docker Desktop, utilisez le mapping de ports standard.
|
||||||
|
|
||||||
|
## Gestion de l'image
|
||||||
|
|
||||||
|
### Taille de l'image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Voir la taille de l'image
|
||||||
|
docker images pmomusic:latest
|
||||||
|
|
||||||
|
# Résultat attendu : ~200-300MB
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nettoyage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Supprimer l'image
|
||||||
|
docker rmi pmomusic:latest
|
||||||
|
|
||||||
|
# Nettoyer les images de build intermédiaires
|
||||||
|
docker builder prune
|
||||||
|
|
||||||
|
# Nettoyer tous les caches Docker (libère beaucoup d'espace)
|
||||||
|
docker system prune -a
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build multi-plateforme
|
||||||
|
|
||||||
|
Pour builder pour différentes architectures (ARM64, AMD64) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Créer un builder multi-plateforme
|
||||||
|
docker buildx create --name multiarch --use
|
||||||
|
|
||||||
|
# Build pour AMD64 et ARM64
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
-t pmomusic:latest \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
# Note : nécessite un registry Docker (Docker Hub, GHCR, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Déploiement en production
|
||||||
|
|
||||||
|
### 1. Avec docker-compose (simple)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sur le serveur de production
|
||||||
|
git clone <votre-repo>
|
||||||
|
cd pmomusic
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Avec un registry Docker (recommandé)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sur votre machine de dev
|
||||||
|
docker build -t yourregistry.com/pmomusic:v1.0.0 .
|
||||||
|
docker push yourregistry.com/pmomusic:v1.0.0
|
||||||
|
|
||||||
|
# Sur le serveur de production
|
||||||
|
docker pull yourregistry.com/pmomusic:v1.0.0
|
||||||
|
docker run -d ... yourregistry.com/pmomusic:v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Avec un orchestrateur (Kubernetes, Docker Swarm)
|
||||||
|
|
||||||
|
Créer un fichier de déploiement approprié selon votre orchestrateur.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Logs du conteneur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs en temps réel
|
||||||
|
docker logs -f pmomusic
|
||||||
|
|
||||||
|
# Logs avec docker-compose
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entrer dans le conteneur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Shell interactif (bash n'est pas disponible, utiliser sh)
|
||||||
|
docker exec -it pmomusic sh
|
||||||
|
|
||||||
|
# Vérifier les processus
|
||||||
|
docker exec -it pmomusic ps aux
|
||||||
|
|
||||||
|
# Vérifier les fichiers
|
||||||
|
docker exec -it pmomusic ls -la /home/pmomusic
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health check
|
||||||
|
|
||||||
|
Le conteneur inclut un health check. Vérifier l'état :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Voir l'état de santé
|
||||||
|
docker inspect --format='{{.State.Health.Status}}' pmomusic
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Le build échoue
|
||||||
|
|
||||||
|
1. **Erreur de dépendances npm** :
|
||||||
|
- Vérifier que `pmoapp/webapp/package.json` est correct
|
||||||
|
- Essayer `docker build --no-cache`
|
||||||
|
|
||||||
|
2. **Erreur de compilation Rust** :
|
||||||
|
- Vérifier que tous les fichiers Cargo.toml sont présents
|
||||||
|
- Vérifier les dépendances système (libsoxr, libasound2)
|
||||||
|
|
||||||
|
3. **Out of memory** :
|
||||||
|
- Augmenter la mémoire allouée à Docker Desktop (settings)
|
||||||
|
- Utiliser `--memory` pour limiter la mémoire du build
|
||||||
|
|
||||||
|
### Le conteneur ne démarre pas
|
||||||
|
|
||||||
|
1. **Port déjà utilisé** :
|
||||||
|
```bash
|
||||||
|
# Vérifier quel processus utilise le port 8080
|
||||||
|
sudo lsof -i :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Permissions** :
|
||||||
|
- Vérifier les permissions des volumes montés
|
||||||
|
- Le conteneur s'exécute en tant qu'utilisateur `pmomusic` (UID 1000)
|
||||||
|
|
||||||
|
3. **Configuration manquante** :
|
||||||
|
- Créer les répertoires de configuration avant de démarrer :
|
||||||
|
```bash
|
||||||
|
mkdir -p config cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### UPnP ne fonctionne pas
|
||||||
|
|
||||||
|
1. **Network mode** :
|
||||||
|
- Sur Linux : utiliser `network_mode: host`
|
||||||
|
- Sur macOS/Windows : UPnP peut ne pas fonctionner correctement avec Docker Desktop
|
||||||
|
|
||||||
|
2. **Firewall** :
|
||||||
|
- Vérifier que les ports UPnP ne sont pas bloqués
|
||||||
|
- Autoriser le multicast sur le réseau
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Optimisations du build
|
||||||
|
|
||||||
|
1. **Build cache** : Docker réutilise les couches en cache
|
||||||
|
2. **Multi-stage build** : Réduit la taille de l'image finale
|
||||||
|
3. **Strip des symboles** : Le binaire est strippé pour réduire sa taille
|
||||||
|
|
||||||
|
### Optimisations runtime
|
||||||
|
|
||||||
|
1. **Resource limits** : Définir des limites CPU/mémoire dans docker-compose.yml
|
||||||
|
2. **Volumes** : Utiliser des volumes pour les données persistantes
|
||||||
|
3. **Logs** : Configurer la rotation des logs Docker
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- ✅ Exécution en tant qu'utilisateur non-root
|
||||||
|
- ✅ Image minimale (surface d'attaque réduite)
|
||||||
|
- ✅ Pas de secrets dans l'image
|
||||||
|
- ✅ Health checks activés
|
||||||
|
- ✅ Certificats CA inclus pour HTTPS
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- [Dockerfile](./Dockerfile)
|
||||||
|
- [docker-compose.yml](./docker-compose.yml)
|
||||||
|
- [.dockerignore](./.dockerignore)
|
||||||
|
- [Documentation Rust](./Readme.md)
|
||||||
117
Dockerfile
Normal file
117
Dockerfile
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# ===================================
|
||||||
|
# Stage 1: Build Vue.js webapp
|
||||||
|
# ===================================
|
||||||
|
FROM node:22-alpine AS webapp-builder
|
||||||
|
|
||||||
|
WORKDIR /webapp
|
||||||
|
|
||||||
|
# Copy webapp package files
|
||||||
|
COPY pmoapp/webapp/package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --production=false
|
||||||
|
|
||||||
|
# Copy webapp source
|
||||||
|
COPY pmoapp/webapp/ ./
|
||||||
|
|
||||||
|
# Build the webapp
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# Stage 2: Build Rust binary
|
||||||
|
# ===================================
|
||||||
|
FROM rustlang/rust:nightly-bookworm AS rust-builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install system dependencies for building
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libsoxr-dev \
|
||||||
|
libasound2-dev \
|
||||||
|
pkg-config \
|
||||||
|
cmake \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy Cargo workspace files
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
|
||||||
|
# Copy all crates
|
||||||
|
COPY PMOMusic/ ./PMOMusic/
|
||||||
|
COPY pmoupnp/ ./pmoupnp/
|
||||||
|
COPY pmomediarenderer/ ./pmomediarenderer/
|
||||||
|
COPY pmomediaserver/ ./pmomediaserver/
|
||||||
|
COPY pmoconfig/ ./pmoconfig/
|
||||||
|
COPY pmoutils/ ./pmoutils/
|
||||||
|
COPY pmodidl/ ./pmodidl/
|
||||||
|
COPY pmoserver/ ./pmoserver/
|
||||||
|
COPY pmocache/ ./pmocache/
|
||||||
|
COPY pmocovers/ ./pmocovers/
|
||||||
|
COPY pmoaudiocache/ ./pmoaudiocache/
|
||||||
|
COPY pmoaudio/ ./pmoaudio/
|
||||||
|
COPY pmoqobuz/ ./pmoqobuz/
|
||||||
|
COPY pmoparadise/ ./pmoparadise/
|
||||||
|
COPY pmosource/ ./pmosource/
|
||||||
|
COPY pmoplaylist/ ./pmoplaylist/
|
||||||
|
COPY pmoflac/ ./pmoflac/
|
||||||
|
COPY pmometadata/ ./pmometadata/
|
||||||
|
COPY pmocontrol/ ./pmocontrol/
|
||||||
|
COPY pmoaudio-ext/ ./pmoaudio-ext/
|
||||||
|
COPY pmoapp/ ./pmoapp/
|
||||||
|
|
||||||
|
# Copy the webapp dist from previous stage
|
||||||
|
COPY --from=webapp-builder /webapp/dist ./pmoapp/webapp/dist/
|
||||||
|
|
||||||
|
# Build the Rust binary in release mode
|
||||||
|
RUN cargo build --release --bin PMOMusic
|
||||||
|
|
||||||
|
# Strip debug symbols to reduce binary size
|
||||||
|
RUN strip /build/target/release/PMOMusic
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# Stage 3: Minimal runtime image
|
||||||
|
# ===================================
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
ARG BUILD_DATE
|
||||||
|
LABEL org.opencontainers.image.created=$BUILD_DATE
|
||||||
|
LABEL org.opencontainers.image.title="PMOMusic UPNP Music Server"
|
||||||
|
LABEL org.opencontainers.image.version="0.1"
|
||||||
|
LABEL org.opencontainers.image.authors="eric@coissac.eu"
|
||||||
|
LABEL org.opencontainers.image.licenses="CeCILL-2.0"
|
||||||
|
LABEL org.opencontainers.image.url="https://gargoton.petite-maison-orange.fr/eric/pmomusic"
|
||||||
|
LABEL org.opencontainers.image.description="PMOMusic est une application Rust proposant en un binaire unique une Serveur de Musique, et une point de controle UPNP controlable via une application web."
|
||||||
|
|
||||||
|
# Install only runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libsoxr0 \
|
||||||
|
libasound2 \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create a non-root user
|
||||||
|
RUN useradd -m -u 1000 pmomusic
|
||||||
|
|
||||||
|
# Copy the binary from builder
|
||||||
|
COPY --from=rust-builder /build/target/release/PMOMusic /usr/local/bin/PMOMusic
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
RUN chown pmomusic:pmomusic /usr/local/bin/PMOMusic
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER pmomusic
|
||||||
|
|
||||||
|
# Create directories for configuration and cache
|
||||||
|
RUN mkdir -p /home/pmomusic/.pmomusic
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /home/pmomusic
|
||||||
|
|
||||||
|
# Expose default port (adjust if needed)
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check (adjust the URL if needed)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/usr/local/bin/PMOMusic", "--help"] || exit 1
|
||||||
|
|
||||||
|
# Run the binary
|
||||||
|
ENTRYPOINT ["/usr/local/bin/PMOMusic"]
|
||||||
296
INSTALL_LIBSOXR.md
Normal file
296
INSTALL_LIBSOXR.md
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# Installation des dépendances système sans droits sudo
|
||||||
|
|
||||||
|
Ce document explique comment installer les dépendances système de `pmoaudio` localement sans privilèges administrateur.
|
||||||
|
|
||||||
|
## Dépendances requises
|
||||||
|
|
||||||
|
1. **libsoxr** - Nécessaire pour `ResamplingNode` (resampling audio haute qualité)
|
||||||
|
2. **libasound2** (ALSA) - Nécessaire pour `AudioSink` via cpal (lecture audio sur Linux)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Les crates `soxr` et `cpal` nécessitent des bibliothèques système. Dans un environnement sans droits sudo (comme Claude Code), voici comment les installer localement.
|
||||||
|
|
||||||
|
## Méthode : Installation locale via apt-get download
|
||||||
|
|
||||||
|
### 1. Télécharger les packages .deb
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.local
|
||||||
|
|
||||||
|
# Pour libsoxr (ResamplingNode)
|
||||||
|
apt-get download libsoxr-dev libsoxr0
|
||||||
|
|
||||||
|
# Pour ALSA (AudioSink)
|
||||||
|
# Note: libasound2t64 contient la bibliothèque partagée, libasound2-dev les headers
|
||||||
|
apt-get download libasound2-dev libasound2t64
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela télécharge les fichiers `.deb` sans les installer système-wide.
|
||||||
|
|
||||||
|
### 2. Extraire les packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Extraire libsoxr
|
||||||
|
dpkg -x libsoxr-dev_*.deb .
|
||||||
|
dpkg -x libsoxr0_*.deb .
|
||||||
|
|
||||||
|
# Extraire ALSA
|
||||||
|
dpkg -x libasound2-dev_*.deb .
|
||||||
|
dpkg -x libasound2t64_*.deb .
|
||||||
|
```
|
||||||
|
|
||||||
|
Les fichiers sont extraits dans `~/.local/usr/lib/x86_64-linux-gnu/` et `~/.local/usr/include/`.
|
||||||
|
|
||||||
|
### 3. Configurer les variables d'environnement
|
||||||
|
|
||||||
|
Ajouter à votre `~/.bashrc` ou exporter dans votre session :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:** Ces variables doivent être définies dans chaque session où vous compilez le projet.
|
||||||
|
|
||||||
|
### 4. Vérifier l'installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier libsoxr
|
||||||
|
pkg-config --libs --cflags soxr
|
||||||
|
|
||||||
|
# Vérifier ALSA
|
||||||
|
pkg-config --libs --cflags alsa
|
||||||
|
```
|
||||||
|
|
||||||
|
Devrait retourner quelque chose comme :
|
||||||
|
```
|
||||||
|
# soxr
|
||||||
|
-I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr
|
||||||
|
|
||||||
|
# alsa
|
||||||
|
-I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utilisation avec Cargo
|
||||||
|
|
||||||
|
### Pour les builds réguliers
|
||||||
|
|
||||||
|
Les variables d'environnement suffisent pour `cargo build` et `cargo run`.
|
||||||
|
|
||||||
|
### Pour les tests
|
||||||
|
|
||||||
|
Les tests nécessitent également la configuration du linker. Deux options :
|
||||||
|
|
||||||
|
#### Option A : Configuration locale du projet (NON RECOMMANDÉ pour le versioning)
|
||||||
|
|
||||||
|
Créer `.cargo/config.toml` dans chaque crate :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[build]
|
||||||
|
rustflags = ["-L", "/root/.local/usr/lib/x86_64-linux-gnu"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ NE PAS committer ces fichiers** - ils contiennent des chemins spécifiques à votre installation.
|
||||||
|
|
||||||
|
#### Option B : Variables d'environnement pour cargo test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pour d'autres distributions
|
||||||
|
|
||||||
|
### macOS (avec Homebrew)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install libsoxr
|
||||||
|
# Note: ALSA n'est pas nécessaire sur macOS (rodio utilise CoreAudio)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debian/Ubuntu (avec sudo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install libsoxr-dev libasound2-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fedora/RHEL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install soxr-devel
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Erreur : "Package 'soxr' was not found" ou "Package 'alsa' was not found"
|
||||||
|
|
||||||
|
- Vérifier que `PKG_CONFIG_PATH` contient le bon chemin
|
||||||
|
- Vérifier que les fichiers `soxr.pc` et `alsa.pc` existent dans ce répertoire
|
||||||
|
|
||||||
|
### Erreur de link : "unable to find library -lsoxr" ou "-lasound"
|
||||||
|
|
||||||
|
- Pour `cargo build` : vérifier `LD_LIBRARY_PATH`
|
||||||
|
- Pour `cargo test` : utiliser la configuration rustflags (Option A ci-dessus)
|
||||||
|
|
||||||
|
### Le test compile mais échoue au runtime
|
||||||
|
|
||||||
|
```
|
||||||
|
error while loading shared libraries: libsoxr.so.0: cannot open shared object file
|
||||||
|
```
|
||||||
|
|
||||||
|
Solution : Ajouter `LD_LIBRARY_PATH` également pour l'exécution :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guide complet pour environnement Claude Code
|
||||||
|
|
||||||
|
### Configuration initiale (à faire une seule fois)
|
||||||
|
|
||||||
|
Dans une session Claude Code (https://claude.ai/code), vous n'avez pas de droits sudo.
|
||||||
|
|
||||||
|
**🚀 Méthode rapide (recommandée) :**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Installation automatique des dépendances (une seule fois)
|
||||||
|
./setup-deps.sh
|
||||||
|
|
||||||
|
# 2. Configuration des variables d'environnement (à chaque session)
|
||||||
|
source setup-env.sh
|
||||||
|
|
||||||
|
# 3. Compilation
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
**📋 Méthode manuelle (si les scripts ne fonctionnent pas) :**
|
||||||
|
|
||||||
|
#### 1. Installation des dépendances
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Créer le répertoire local
|
||||||
|
mkdir -p ~/.local
|
||||||
|
cd ~/.local
|
||||||
|
|
||||||
|
# Télécharger tous les packages nécessaires
|
||||||
|
apt-get download libsoxr-dev libsoxr0 libasound2-dev libasound2t64
|
||||||
|
|
||||||
|
# Extraire tous les packages
|
||||||
|
dpkg -x libsoxr-dev_*.deb .
|
||||||
|
dpkg -x libsoxr0_*.deb .
|
||||||
|
dpkg -x libasound2-dev_*.deb .
|
||||||
|
dpkg -x libasound2t64_*.deb .
|
||||||
|
|
||||||
|
# Retourner au projet
|
||||||
|
cd /home/user/pmomusic
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Configuration des variables d'environnement
|
||||||
|
|
||||||
|
**IMPORTANT:** Ces variables doivent être exportées dans CHAQUE session Claude Code avant de compiler :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Astuce :** Créez un fichier `setup-env.sh` pour ne pas avoir à retaper ces commandes à chaque session :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > setup-env.sh << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Script de configuration des variables d'environnement pour PMOMusic
|
||||||
|
# Usage: source setup-env.sh
|
||||||
|
|
||||||
|
# Configuration des chemins pour libsoxr et libasound2
|
||||||
|
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||||
|
|
||||||
|
echo "Variables d'environnement configurées pour PMOMusic"
|
||||||
|
echo " PKG_CONFIG_PATH=$PKG_CONFIG_PATH"
|
||||||
|
echo " LD_LIBRARY_PATH=$LD_LIBRARY_PATH"
|
||||||
|
echo " RUSTFLAGS=$RUSTFLAGS"
|
||||||
|
echo ""
|
||||||
|
echo "Vous pouvez maintenant compiler avec: cargo build"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x setup-env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis dans chaque session :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source setup-env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Note :** Le fichier `setup-env.sh` est dans `.gitignore` (configuration locale), vous devez le créer vous-même avec le contenu ci-dessus.
|
||||||
|
|
||||||
|
#### 3. Vérifier l'installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier que pkg-config trouve les bibliothèques
|
||||||
|
pkg-config --libs --cflags soxr
|
||||||
|
pkg-config --libs --cflags alsa
|
||||||
|
|
||||||
|
# Devrait afficher quelque chose comme :
|
||||||
|
# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr
|
||||||
|
# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Compiler et tester
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compiler le workspace complet
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Tester l'exemple play_and_cache de pmoparadise
|
||||||
|
cargo run --package pmoparadise --example play_and_cache --features full -- 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow pour chaque nouvelle session
|
||||||
|
|
||||||
|
À chaque fois que vous démarrez une nouvelle session Claude Code :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Configuration de l'environnement
|
||||||
|
source setup-env.sh
|
||||||
|
|
||||||
|
# 2. Compilation
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# 3. Exécution des exemples
|
||||||
|
cargo run --package pmoparadise --example play_and_cache --features full -- 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT :** Si vous oubliez d'exporter les variables, vous obtiendrez des erreurs comme :
|
||||||
|
```
|
||||||
|
error: failed to run custom build command for `soxr-sys`
|
||||||
|
Package 'soxr' was not found in the pkg-config search path
|
||||||
|
```
|
||||||
|
|
||||||
|
ou
|
||||||
|
|
||||||
|
```
|
||||||
|
rust-lld: error: unable to find library -lasound
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution :** Exécutez `source setup-env.sh` et recompilez.
|
||||||
|
|
||||||
|
### Notes importantes
|
||||||
|
|
||||||
|
- ✅ Les dépendances installées dans `~/.local` persistent entre les sessions
|
||||||
|
- ✅ Les variables d'environnement doivent être réexportées à chaque nouvelle session
|
||||||
|
- ❌ NE JAMAIS créer de fichiers `.cargo/config.toml` dans le projet (chemins spécifiques)
|
||||||
|
- ❌ NE JAMAIS committer `setup-env.sh` (configuration locale)
|
||||||
|
- 💡 Sur macOS (via Homebrew) : seul `libsoxr` est nécessaire (pas d'ALSA)
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- libsoxr GitHub: https://github.com/chirlu/soxr
|
||||||
|
- Documentation pkg-config: https://www.freedesktop.org/wiki/Software/pkg-config/
|
||||||
89
INSTALL_NOTES.md
Normal file
89
INSTALL_NOTES.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Notes d'installation pour PMOMusic
|
||||||
|
|
||||||
|
## Prérequis système
|
||||||
|
|
||||||
|
### libsoxr (obligatoire pour pmoaudio - resampling)
|
||||||
|
|
||||||
|
La bibliothèque `libsoxr` est requise pour le resampling audio dans `pmoaudio`.
|
||||||
|
|
||||||
|
### libasound2/ALSA (obligatoire pour pmoaudio - lecture audio sur Linux)
|
||||||
|
|
||||||
|
La bibliothèque ALSA est requise pour `AudioSink` via `cpal` sur Linux. Sur macOS et Windows, aucune dépendance externe n'est nécessaire (CoreAudio et WASAPI sont utilisés).
|
||||||
|
|
||||||
|
**Installation** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian/Ubuntu
|
||||||
|
sudo apt-get install libsoxr-dev libasound2-dev
|
||||||
|
|
||||||
|
# Fedora/RHEL
|
||||||
|
sudo dnf install libsoxr-devel alsa-lib-devel
|
||||||
|
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S libsoxr alsa-lib
|
||||||
|
|
||||||
|
# macOS (Homebrew) - ALSA non nécessaire sur macOS
|
||||||
|
brew install libsoxr
|
||||||
|
|
||||||
|
# Alpine Linux
|
||||||
|
apk add soxr-dev alsa-lib-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sans privilèges root (Claude Code, environnements sans sudo)** :
|
||||||
|
|
||||||
|
🚀 **Installation automatique** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Installation des dépendances (une seule fois)
|
||||||
|
./setup-deps.sh
|
||||||
|
|
||||||
|
# 2. Configuration de l'environnement (à chaque session)
|
||||||
|
source setup-env.sh
|
||||||
|
|
||||||
|
# 3. Compilation
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour plus de détails, consultez `INSTALL_LIBSOXR.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nouveaux composants
|
||||||
|
|
||||||
|
### PlaylistSource (pmoaudio-ext)
|
||||||
|
|
||||||
|
Source audio qui lit une playlist `pmoplaylist` et diffuse les pistes en continu.
|
||||||
|
|
||||||
|
**Feature** : `playlist`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compiler avec la feature playlist
|
||||||
|
cargo build --package pmoaudio-ext --features playlist
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Important** : Cette source émet du PCM avec sample_rate et bit_depth **variables**. Pour un flux homogène, ajoutez dans le pipeline :
|
||||||
|
- `ResamplingNode` (normalise le sample_rate)
|
||||||
|
- `ToI24Node` / `ToI16Node` (normalise la profondeur de bits)
|
||||||
|
|
||||||
|
### ResamplingNode (pmoaudio)
|
||||||
|
|
||||||
|
Nœud générique qui normalise le sample_rate vers une valeur cible fixe.
|
||||||
|
|
||||||
|
**Usage** :
|
||||||
|
```rust
|
||||||
|
let mut resampler = ResamplingNode::new(48000); // Force 48kHz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compilation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compiler tout le workspace (nécessite libsoxr)
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Compiler sans pmoaudio (si libsoxr manque)
|
||||||
|
cargo build --package pmoplaylist
|
||||||
|
cargo build --package pmoaudiocache
|
||||||
|
# etc.
|
||||||
|
```
|
||||||
28
Makefile
28
Makefile
@@ -1,8 +1,10 @@
|
|||||||
# Makefile pour projet Rust + Vue.js
|
# Makefile pour projet Rust + Vue.js
|
||||||
# Variables de configuration
|
# Variables de configuration
|
||||||
CARGO = cargo
|
CARGO = cargo
|
||||||
|
CARGO_NIGHTLY = rustup run nightly cargo
|
||||||
|
FEATURES ?=
|
||||||
NPM = npm
|
NPM = npm
|
||||||
WEBAPP_DIR = pmoupnp/webapp
|
WEBAPP_DIR = pmoapp/webapp
|
||||||
DIST_DIR = $(WEBAPP_DIR)/dist
|
DIST_DIR = $(WEBAPP_DIR)/dist
|
||||||
RUST_TARGET = target/release
|
RUST_TARGET = target/release
|
||||||
DOC_DIR = target/doc
|
DOC_DIR = target/doc
|
||||||
@@ -14,7 +16,9 @@ YELLOW = \033[1;33m
|
|||||||
RED = \033[0;31m
|
RED = \033[0;31m
|
||||||
NC = \033[0m # No Color
|
NC = \033[0m # No Color
|
||||||
|
|
||||||
.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch
|
.DEFAULT_GOAL := simd
|
||||||
|
|
||||||
|
.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch simd scalar
|
||||||
|
|
||||||
# Cible par défaut
|
# Cible par défaut
|
||||||
all: build
|
all: build
|
||||||
@@ -29,15 +33,15 @@ build: webapp release
|
|||||||
@echo "$(GREEN)✓ Build complet terminé$(NC)"
|
@echo "$(GREEN)✓ Build complet terminé$(NC)"
|
||||||
|
|
||||||
## release: Compile le binaire Rust en mode release
|
## release: Compile le binaire Rust en mode release
|
||||||
release:
|
release: webapp
|
||||||
@echo "$(YELLOW)→ Compilation Rust (release)...$(NC)"
|
@echo "$(YELLOW)→ Compilation Rust (release)...$(NC)"
|
||||||
$(CARGO) build --release
|
$(CARGO) build --release $(FEATURES)
|
||||||
@echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)"
|
@echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)"
|
||||||
|
|
||||||
## debug: Compile le binaire Rust en mode debug
|
## debug: Compile le binaire Rust en mode debug
|
||||||
debug:
|
debug: webapp
|
||||||
@echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)"
|
@echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)"
|
||||||
$(CARGO) build
|
$(CARGO) build $(FEATURES)
|
||||||
@echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)"
|
@echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)"
|
||||||
|
|
||||||
## test: Exécute tous les tests Rust
|
## test: Exécute tous les tests Rust
|
||||||
@@ -46,6 +50,18 @@ test:
|
|||||||
$(CARGO) test --all
|
$(CARGO) test --all
|
||||||
@echo "$(GREEN)✓ Tests terminés$(NC)"
|
@echo "$(GREEN)✓ Tests terminés$(NC)"
|
||||||
|
|
||||||
|
## simd: Compile l'application en mode SIMD (nightly requis)
|
||||||
|
simd:
|
||||||
|
@echo "$(YELLOW)→ Build SIMD (nightly)...$(NC)"
|
||||||
|
$(MAKE) release CARGO="$(CARGO_NIGHTLY)" FEATURES="--features simd"
|
||||||
|
@echo "$(GREEN)✓ Build SIMD terminé$(NC)"
|
||||||
|
|
||||||
|
## scalar: Compile l'application en mode scalaire
|
||||||
|
scalar:
|
||||||
|
@echo "$(YELLOW)→ Build scalaire...$(NC)"
|
||||||
|
$(MAKE) release FEATURES=""
|
||||||
|
@echo "$(GREEN)✓ Build scalaire terminé$(NC)"
|
||||||
|
|
||||||
## test-doc: Teste les exemples dans la documentation
|
## test-doc: Teste les exemples dans la documentation
|
||||||
test-doc:
|
test-doc:
|
||||||
@echo "$(YELLOW)→ Test des exemples de documentation...$(NC)"
|
@echo "$(YELLOW)→ Test des exemples de documentation...$(NC)"
|
||||||
|
|||||||
485
OPTIMIZATION_PREBUFFER_TO_PLAYLIST.md
Normal file
485
OPTIMIZATION_PREBUFFER_TO_PLAYLIST.md
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
# Optimisation: Réduction du délai prebuffer → playlist (19s → 1s)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Le système de progressive caching fonctionne correctement, mais il y a un délai non optimal entre le moment où le prebuffer est atteint et le moment où la track est ajoutée à la playlist.
|
||||||
|
|
||||||
|
### État actuel (branche `claude/fix-play-and-cache-streaming-011CUsMBxH4fsgoadgkiPdoK`)
|
||||||
|
|
||||||
|
**Timing mesuré:**
|
||||||
|
```
|
||||||
|
t=0.6s : Prebuffer complete (512KB téléchargés) ✅
|
||||||
|
t=19.2s : tokio::join!() complete (pump_future finit)
|
||||||
|
t=19.2s : Track added to playlist
|
||||||
|
t=19.7s : Playback starts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Délai total: ~19 secondes**
|
||||||
|
|
||||||
|
### Code actuel problématique
|
||||||
|
|
||||||
|
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs:167-178`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Exécuter pump et add_from_reader en parallèle
|
||||||
|
let pump_future = pump_track_segments(
|
||||||
|
first_segment,
|
||||||
|
&mut rx, // ← emprunte muablement rx
|
||||||
|
pcm_tx,
|
||||||
|
bits_per_sample,
|
||||||
|
sample_rate,
|
||||||
|
&stop_token,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Attendre les deux tâches en parallèle
|
||||||
|
let (cache_result, pump_result) = tokio::join!(cache_future, pump_future);
|
||||||
|
|
||||||
|
let pk = cache_result.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to add to cache: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (_chunks, _samples, _duration_sec, stop_reason) = pump_result?;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Le problème:** `tokio::join!()` attend que **LES DEUX** futures se terminent:
|
||||||
|
- `cache_future` retourne après prebuffer (~0.6s) ✅
|
||||||
|
- `pump_future` lit **toute** la première track du RadioParadiseStreamSource (~19s) ⏱️
|
||||||
|
|
||||||
|
Donc même si le prebuffer est atteint en 0.6s, on attend 19s avant de push à la playlist!
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Réduire le délai à **~1 seconde** en pushant à la playlist **immédiatement après le prebuffer**, sans attendre que `pump_future` se termine.
|
||||||
|
|
||||||
|
**Timing visé:**
|
||||||
|
```
|
||||||
|
t=0.6s : Prebuffer complete ✅
|
||||||
|
t=0.7s : Track added to playlist ← IMMÉDIAT!
|
||||||
|
t=1.2s : Playback starts ← ~1 seconde!
|
||||||
|
t=19.2s : pump_future finit en arrière-plan
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contraintes techniques
|
||||||
|
|
||||||
|
### 1. Problème du borrow checker
|
||||||
|
|
||||||
|
`pump_future` emprunte muablement `rx`:
|
||||||
|
```rust
|
||||||
|
async fn pump_track_segments(
|
||||||
|
first_segment: Arc<AudioSegment>,
|
||||||
|
rx: &mut mpsc::Receiver<Arc<AudioSegment>>, // ← &mut borrow
|
||||||
|
// ...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
On ne peut pas faire:
|
||||||
|
```rust
|
||||||
|
tokio::pin!(cache_future);
|
||||||
|
tokio::pin!(pump_future); // ← pump_future contient un &mut rx
|
||||||
|
|
||||||
|
let pk = cache_future.await; // cache_future termine
|
||||||
|
|
||||||
|
// ❌ ERREUR: on a toujours un borrow mutable de rx dans pump_future
|
||||||
|
// On ne peut pas continuer à utiliser rx (ou l'objet qui le contient)
|
||||||
|
playlist_handle.push(pk.clone()).await;
|
||||||
|
|
||||||
|
let result = pump_future.await; // pump_future continue
|
||||||
|
```
|
||||||
|
|
||||||
|
Le borrow checker nous empêche d'attendre `cache_future` seul, puis de faire d'autres opérations, puis d'attendre `pump_future`, car `pump_future` garde un borrow mutable de `rx` pendant toute sa durée de vie.
|
||||||
|
|
||||||
|
### 2. Contraintes de l'API
|
||||||
|
|
||||||
|
- `pump_track_segments()` doit lire `rx` pour recevoir les segments du RadioParadiseStreamSource
|
||||||
|
- Le FlacCacheSinkLogic doit garder ownership de `rx` pour traiter les tracks suivantes
|
||||||
|
- `pump_future` ne peut pas être spawné dans un tokio::spawn car il retourne un `StopReason` nécessaire pour la logique métier
|
||||||
|
|
||||||
|
## Solutions possibles
|
||||||
|
|
||||||
|
### Solution A: Refactoriser pump_track_segments pour prendre ownership de rx
|
||||||
|
|
||||||
|
**Approche:**
|
||||||
|
1. Créer `pump_track_segments_owned` qui prend ownership de `rx`
|
||||||
|
2. Cette fonction retourne `(result, rx)` - elle rend ownership de `rx`
|
||||||
|
3. Spawner cette future dans tokio::spawn
|
||||||
|
4. Attendre cache_future seul, push immédiatement
|
||||||
|
5. Attendre la task spawnée plus tard
|
||||||
|
|
||||||
|
**Signature:**
|
||||||
|
```rust
|
||||||
|
async fn pump_track_segments_owned(
|
||||||
|
first_segment: Arc<AudioSegment>,
|
||||||
|
rx: mpsc::Receiver<Arc<AudioSegment>>, // ownership!
|
||||||
|
pcm_tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
bits_per_sample: u8,
|
||||||
|
expected_rate: u32,
|
||||||
|
stop_token: CancellationToken,
|
||||||
|
) -> Result<(u64, u64, f64, StopReason, mpsc::Receiver<Arc<AudioSegment>>), AudioError>
|
||||||
|
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ rend rx
|
||||||
|
```
|
||||||
|
|
||||||
|
**Utilisation:**
|
||||||
|
```rust
|
||||||
|
let pump_handle = tokio::spawn(pump_track_segments_owned(
|
||||||
|
first_segment,
|
||||||
|
rx, // move ownership
|
||||||
|
pcm_tx,
|
||||||
|
bits_per_sample,
|
||||||
|
sample_rate,
|
||||||
|
stop_token.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Attendre SEULEMENT le prebuffer
|
||||||
|
let pk = cache_future.await?;
|
||||||
|
|
||||||
|
// Push IMMÉDIATEMENT à la playlist
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
if let Some(ref playlist_handle) = self.playlist_handle {
|
||||||
|
playlist_handle.push(pk.clone()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAINTENANT attendre que pump finisse
|
||||||
|
let (result, rx_returned) = pump_handle.await.unwrap()?;
|
||||||
|
rx = rx_returned; // récupérer rx pour la prochaine track
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantages:**
|
||||||
|
- ✅ Pas de problème de borrow checker
|
||||||
|
- ✅ Push immédiat après prebuffer
|
||||||
|
- ✅ Délai réduit à ~1s
|
||||||
|
|
||||||
|
**Inconvénients:**
|
||||||
|
- ⚠️ Nécessite de modifier la signature de `pump_track_segments`
|
||||||
|
- ⚠️ Plus complexe (ownership passé puis rendu)
|
||||||
|
|
||||||
|
### Solution B: Utiliser un channel pour signaler le prebuffer
|
||||||
|
|
||||||
|
**Approche:**
|
||||||
|
1. Créer un oneshot channel `(prebuffer_tx, prebuffer_rx)`
|
||||||
|
2. `cache_future` envoie le pk via `prebuffer_tx` dès le prebuffer atteint
|
||||||
|
3. Le code principal attend `prebuffer_rx`, push immédiatement
|
||||||
|
4. Puis attend `tokio::join!()` normalement
|
||||||
|
|
||||||
|
**Code:**
|
||||||
|
```rust
|
||||||
|
let (prebuffer_tx, prebuffer_rx) = tokio::sync::oneshot::channel();
|
||||||
|
|
||||||
|
let cache_future = async {
|
||||||
|
let pk = self.cache.add_from_reader(...).await?;
|
||||||
|
let _ = prebuffer_tx.send(pk.clone()); // Signal prebuffer!
|
||||||
|
Ok(pk)
|
||||||
|
};
|
||||||
|
|
||||||
|
let pump_future = pump_track_segments(...);
|
||||||
|
|
||||||
|
// Spawner les deux en parallèle
|
||||||
|
let cache_handle = tokio::spawn(cache_future);
|
||||||
|
let pump_handle = tokio::spawn(pump_future);
|
||||||
|
|
||||||
|
// Attendre SEULEMENT le signal de prebuffer
|
||||||
|
let pk = prebuffer_rx.await.unwrap();
|
||||||
|
|
||||||
|
// Push IMMÉDIATEMENT à la playlist
|
||||||
|
playlist_handle.push(pk.clone()).await?;
|
||||||
|
|
||||||
|
// Puis attendre que tout finisse
|
||||||
|
let (cache_result, pump_result) = tokio::join!(cache_handle, pump_handle);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantages:**
|
||||||
|
- ✅ Pas besoin de changer les signatures
|
||||||
|
- ✅ Push immédiat après prebuffer
|
||||||
|
|
||||||
|
**Inconvénients:**
|
||||||
|
- ⚠️ Nécessite de wrapper cache_future pour envoyer le signal
|
||||||
|
- ⚠️ Ajoute un oneshot channel
|
||||||
|
|
||||||
|
### Solution C: Modifier l'API du cache pour avoir un callback
|
||||||
|
|
||||||
|
**Approche:**
|
||||||
|
1. Ajouter un paramètre callback à `add_from_reader()`
|
||||||
|
2. Le cache appelle ce callback dès le prebuffer atteint
|
||||||
|
3. Le callback push à la playlist
|
||||||
|
|
||||||
|
**Signature:**
|
||||||
|
```rust
|
||||||
|
pub async fn add_from_reader_with_callback<R, F>(
|
||||||
|
&self,
|
||||||
|
source_uri: Option<&str>,
|
||||||
|
reader: R,
|
||||||
|
length: Option<u64>,
|
||||||
|
collection: Option<&str>,
|
||||||
|
on_prebuffer: F, // ← nouveau callback
|
||||||
|
) -> Result<String>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Send + Unpin + 'static,
|
||||||
|
F: FnOnce(String) + Send + 'static, // F reçoit le pk
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantages:**
|
||||||
|
- ✅ API propre et réutilisable
|
||||||
|
- ✅ Pas de problème de borrow checker
|
||||||
|
|
||||||
|
**Inconvénients:**
|
||||||
|
- ⚠️ Nécessite de modifier l'API du cache (impact sur autres parties du code)
|
||||||
|
- ⚠️ Ajoute de la complexité à l'API
|
||||||
|
|
||||||
|
## Recommandation
|
||||||
|
|
||||||
|
**Je recommande la Solution A** (refactoriser `pump_track_segments_owned`):
|
||||||
|
- Plus explicite et claire
|
||||||
|
- Pas d'impact sur l'API du cache
|
||||||
|
- Ownership bien défini (passage puis retour de rx)
|
||||||
|
- Testable indépendamment
|
||||||
|
|
||||||
|
## Plan d'implémentation
|
||||||
|
|
||||||
|
### Étape 1: Créer pump_track_segments_owned
|
||||||
|
|
||||||
|
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Pompe les segments pour une seule track (s'arrête au TrackBoundary).
|
||||||
|
///
|
||||||
|
/// Version qui prend ownership de rx pour permettre un await séparé du cache.
|
||||||
|
/// Retourne rx à la fin pour permettre le traitement des tracks suivantes.
|
||||||
|
async fn pump_track_segments_owned(
|
||||||
|
first_segment: Arc<AudioSegment>,
|
||||||
|
mut rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||||
|
pcm_tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
bits_per_sample: u8,
|
||||||
|
expected_rate: u32,
|
||||||
|
stop_token: CancellationToken,
|
||||||
|
) -> Result<(u64, u64, f64, StopReason, mpsc::Receiver<Arc<AudioSegment>>), AudioError> {
|
||||||
|
let mut chunks = 0u64;
|
||||||
|
let mut samples = 0u64;
|
||||||
|
let mut duration_sec = 0.0f64;
|
||||||
|
|
||||||
|
// Traiter le premier segment
|
||||||
|
if let Some(chunk) = first_segment.as_chunk() {
|
||||||
|
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
|
||||||
|
if !pcm_bytes.is_empty() {
|
||||||
|
if pcm_tx.send(pcm_bytes).await.is_err() {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||||
|
}
|
||||||
|
chunks += 1;
|
||||||
|
samples += chunk.len() as u64;
|
||||||
|
duration_sec += chunk.len() as f64 / expected_rate as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop pour le reste des segments...
|
||||||
|
loop {
|
||||||
|
let segment = tokio::select! {
|
||||||
|
result = rx.recv() => {
|
||||||
|
match result {
|
||||||
|
Some(seg) => seg,
|
||||||
|
None => {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = stop_token.cancelled() => {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match &segment.segment {
|
||||||
|
_AudioSegment::Chunk(chunk) => {
|
||||||
|
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
|
||||||
|
if !pcm_bytes.is_empty() {
|
||||||
|
if pcm_tx.send(pcm_bytes).await.is_err() {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||||
|
}
|
||||||
|
chunks += 1;
|
||||||
|
samples += chunk.len() as u64;
|
||||||
|
duration_sec += chunk.len() as f64 / expected_rate as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_AudioSegment::Sync(marker) => match &**marker {
|
||||||
|
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::TrackBoundary(metadata.clone()), rx));
|
||||||
|
}
|
||||||
|
SyncMarker::EndOfStream => {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::EndOfStream, rx));
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Étape 2: Modifier FlacCacheSinkLogic::process
|
||||||
|
|
||||||
|
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs:~167`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let collection_ref = self.collection.as_deref();
|
||||||
|
let cache_future = self.cache.add_from_reader(
|
||||||
|
None,
|
||||||
|
flac_stream,
|
||||||
|
None,
|
||||||
|
collection_ref,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spawner pump_future avec ownership de rx
|
||||||
|
let pump_handle = tokio::spawn(pump_track_segments_owned(
|
||||||
|
first_segment,
|
||||||
|
rx, // move ownership!
|
||||||
|
pcm_tx,
|
||||||
|
bits_per_sample,
|
||||||
|
sample_rate,
|
||||||
|
stop_token.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Attendre SEULEMENT le prebuffer (cache retourne après 512KB)
|
||||||
|
tracing::debug!("FlacCacheSink: Waiting for cache prebuffer to complete");
|
||||||
|
let pk = cache_future.await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to add to cache: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::debug!("FlacCacheSink: Prebuffer complete with pk {}, pushing to playlist NOW", pk);
|
||||||
|
|
||||||
|
// Copier les métadonnées AVANT push
|
||||||
|
if let Some(src_metadata) = track_metadata.clone() {
|
||||||
|
let dest_metadata = self.cache.track_metadata(&pk);
|
||||||
|
pmometadata::copy_metadata_into(&src_metadata, &dest_metadata)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to copy metadata to cache: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push IMMÉDIATEMENT à la playlist (après prebuffer, avant pump complet!)
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
if let Some(ref playlist_handle) = self.playlist_handle {
|
||||||
|
tracing::debug!("FlacCacheSink: Pushing pk {} to playlist", pk);
|
||||||
|
playlist_handle.push(pk.clone()).await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to add to playlist: {}", e))
|
||||||
|
})?;
|
||||||
|
tracing::debug!("FlacCacheSink: Successfully pushed to playlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAINTENANT attendre que pump finisse (il continue en arrière-plan)
|
||||||
|
tracing::debug!("FlacCacheSink: Waiting for pump to complete");
|
||||||
|
let pump_result = pump_handle.await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Pump task panicked: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (_chunks, _samples, _duration_sec, stop_reason, rx_returned) = pump_result?;
|
||||||
|
rx = rx_returned; // récupérer rx pour la prochaine track
|
||||||
|
tracing::debug!("FlacCacheSink: Pump completed");
|
||||||
|
|
||||||
|
// Continuer avec download des covers en arrière-plan...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Étape 3: Tester
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nettoyer et rebuild
|
||||||
|
rm -rf /tmp/pmomusic_test
|
||||||
|
source setup-env.sh
|
||||||
|
cargo build --example play_and_cache --features full
|
||||||
|
|
||||||
|
# Tester avec logs de timing
|
||||||
|
RUST_LOG=debug target/debug/examples/play_and_cache 0 --null-audio 2>&1 | \
|
||||||
|
grep -E "Prebuffer complete|Pushing pk.*to playlist|popped track" | \
|
||||||
|
head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résultats attendus:**
|
||||||
|
```
|
||||||
|
[TIME_A] FlacCacheSink: Prebuffer complete with pk XXX, pushing to playlist NOW
|
||||||
|
[TIME_B] FlacCacheSink: Successfully pushed to playlist
|
||||||
|
[TIME_C] PlaylistSourceLogic: popped track from playlist
|
||||||
|
|
||||||
|
Délai (TIME_C - TIME_A) devrait être < 1 seconde!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Étape 4: Valider le comportement
|
||||||
|
|
||||||
|
Vérifier que:
|
||||||
|
1. ✅ Le prebuffer est atteint rapidement (~0.6s)
|
||||||
|
2. ✅ Le push à la playlist est immédiat (~0.1s après prebuffer)
|
||||||
|
3. ✅ La lecture démarre rapidement (~1s total)
|
||||||
|
4. ✅ Toutes les tracks se suivent correctement
|
||||||
|
5. ✅ Les completion markers sont créés
|
||||||
|
6. ✅ Les tracks suivantes fonctionnent (rx est bien récupéré)
|
||||||
|
7. ✅ Pas de panic ou deadlock
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Si le borrow checker proteste
|
||||||
|
|
||||||
|
Vérifier que:
|
||||||
|
- `pump_track_segments_owned` prend bien ownership de `rx` (pas `&mut`)
|
||||||
|
- `rx` est bien retourné dans le tuple de retour
|
||||||
|
- `rx = rx_returned;` récupère bien ownership après await
|
||||||
|
|
||||||
|
### Si les tracks suivantes ne fonctionnent pas
|
||||||
|
|
||||||
|
Vérifier que:
|
||||||
|
- `rx` est bien réassigné après le pump: `rx = rx_returned;`
|
||||||
|
- La loop dans `process()` continue correctement avec le nouveau `rx`
|
||||||
|
|
||||||
|
### Si le timing n'est pas amélioré
|
||||||
|
|
||||||
|
Ajouter des logs avec timestamps:
|
||||||
|
```rust
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let pk = cache_future.await?;
|
||||||
|
tracing::info!("Prebuffer took {:?}", start.elapsed());
|
||||||
|
|
||||||
|
let start2 = std::time::Instant::now();
|
||||||
|
playlist_handle.push(pk.clone()).await?;
|
||||||
|
tracing::info!("Playlist push took {:?}", start2.elapsed());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers à modifier
|
||||||
|
|
||||||
|
1. **pmoaudio-ext/src/sinks/flac_cache_sink.rs**
|
||||||
|
- Ajouter `pump_track_segments_owned()` (~ligne 432)
|
||||||
|
- Modifier `FlacCacheSinkLogic::process()` (~ligne 167)
|
||||||
|
|
||||||
|
## Tests de régression
|
||||||
|
|
||||||
|
Après l'implémentation, tester:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test 1: Premier download (cache vide)
|
||||||
|
rm -rf /tmp/pmomusic_test
|
||||||
|
target/debug/examples/play_and_cache 0 --null-audio
|
||||||
|
|
||||||
|
# Test 2: Deuxième download (fichier déjà en cache)
|
||||||
|
# Ne pas supprimer /tmp/pmomusic_test
|
||||||
|
target/debug/examples/play_and_cache 0 --null-audio
|
||||||
|
|
||||||
|
# Test 3: Download interrompu (Ctrl+C)
|
||||||
|
target/debug/examples/play_and_cache 0 --null-audio
|
||||||
|
# Appuyer Ctrl+C après 2 secondes
|
||||||
|
|
||||||
|
# Test 4: Plusieurs tracks consécutives
|
||||||
|
# Laisser tourner 1 minute pour voir plusieurs tracks
|
||||||
|
timeout 60 target/debug/examples/play_and_cache 0 --null-audio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Métriques de succès
|
||||||
|
|
||||||
|
- ✅ Délai prebuffer → playlist: **< 1 seconde** (actuellement ~19s)
|
||||||
|
- ✅ Délai prebuffer → lecture: **< 2 secondes** (actuellement ~19.5s)
|
||||||
|
- ✅ Pas de régression fonctionnelle
|
||||||
|
- ✅ Toutes les tracks se suivent correctement
|
||||||
|
- ✅ Les completion markers sont créés
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- Branche actuelle: `claude/fix-play-and-cache-streaming-011CUsMBxH4fsgoadgkiPdoK`
|
||||||
|
- Code de référence: commit `ed0bbfb` (Add FlacCacheSink debug logs - system now works!)
|
||||||
|
- Issue originale: "play_and_cache n'a pas le comportement souhaité"
|
||||||
285
PLAYER_PMOSOURCE_README.md
Normal file
285
PLAYER_PMOSOURCE_README.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# Player Générique PMO Music
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Ce document décrit l'implémentation d'un nouveau player web générique qui utilise **uniquement** l'API du trait `pmosource` sans dépendre d'aucune implémentation spécifique (comme `pmoparadise`).
|
||||||
|
|
||||||
|
## Objectifs
|
||||||
|
|
||||||
|
L'objectif principal est de **tester l'API `pmosource` dans un cas d'application concret** afin d'identifier ce qui manque ou pourrait être amélioré dans l'API générique.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Service API TypeScript (`pmoapp/webapp/src/services/pmosource.ts`)
|
||||||
|
|
||||||
|
Service qui encapsule toutes les interactions avec l'API REST de pmosource :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Endpoints utilisés
|
||||||
|
GET /api/sources // Liste les sources
|
||||||
|
GET /api/sources/{id} // Info sur une source
|
||||||
|
GET /api/sources/{id}/root // Container racine
|
||||||
|
GET /api/sources/{id}/browse // Parcourt un container
|
||||||
|
GET /api/sources/{id}/resolve // Résout l'URI d'un item
|
||||||
|
GET /api/sources/{id}/image // Image de la source
|
||||||
|
GET /api/sources/{id}/capabilities // Capacités de la source
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fonctions implémentées :**
|
||||||
|
- `listSources()` - Liste toutes les sources enregistrées
|
||||||
|
- `getSource(id)` - Récupère une source spécifique
|
||||||
|
- `getSourceRoot(id)` - Récupère le container racine
|
||||||
|
- `browseSource(id, objectId?, pagination?)` - Navigation dans les containers
|
||||||
|
- `resolveUri(sourceId, objectId)` - Résout l'URI de streaming
|
||||||
|
- `getSourceImageUrl(id)` - URL de l'image de la source
|
||||||
|
|
||||||
|
### 2. Composant Player (`pmoapp/webapp/src/components/GenericMusicPlayer.vue`)
|
||||||
|
|
||||||
|
Composant Vue.js qui implémente :
|
||||||
|
|
||||||
|
#### Fonctionnalités implémentées
|
||||||
|
|
||||||
|
1. **Sélection de sources**
|
||||||
|
- Affichage de toutes les sources disponibles
|
||||||
|
- Affichage du logo de chaque source
|
||||||
|
- Affichage des capacités (FIFO, Search, Favorites)
|
||||||
|
|
||||||
|
2. **Navigation dans les containers**
|
||||||
|
- Breadcrumb pour remonter dans la hiérarchie
|
||||||
|
- Affichage des sous-containers (dossiers)
|
||||||
|
- Navigation par clic dans les containers
|
||||||
|
|
||||||
|
3. **Liste des morceaux**
|
||||||
|
- Affichage de tous les items audio d'un container
|
||||||
|
- Métadonnées : titre, artiste, album, cover art
|
||||||
|
- Numérotation des morceaux
|
||||||
|
|
||||||
|
4. **Lecteur audio**
|
||||||
|
- Lecture d'un morceau via résolution d'URI
|
||||||
|
- Contrôles audio natifs HTML5
|
||||||
|
- Section "Now Playing" avec métadonnées
|
||||||
|
- Gestion des erreurs de lecture
|
||||||
|
|
||||||
|
5. **Interface utilisateur**
|
||||||
|
- Design moderne avec dégradés et animations
|
||||||
|
- Responsive design
|
||||||
|
- Indicateurs visuels (morceau actif, en cours de lecture)
|
||||||
|
- Messages d'erreur clairs
|
||||||
|
|
||||||
|
### 3. Intégration
|
||||||
|
|
||||||
|
Le player a été configuré comme **page d'accueil par défaut** de l'application web PMO :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// router/index.ts
|
||||||
|
const routes = [
|
||||||
|
{ path: "/", name: "home", component: GenericMusicPlayer },
|
||||||
|
// ... autres routes
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ce qui fonctionne
|
||||||
|
|
||||||
|
✅ **Complètement fonctionnel avec l'API actuelle de pmosource :**
|
||||||
|
|
||||||
|
1. Découverte des sources disponibles
|
||||||
|
2. Navigation complète dans la hiérarchie des containers
|
||||||
|
3. Affichage des métadonnées des morceaux
|
||||||
|
4. Résolution des URIs et lecture audio
|
||||||
|
5. Affichage des images de sources
|
||||||
|
6. **Métadonnées temps réel via Server-Sent Events (SSE)** 🆕
|
||||||
|
- Mise à jour automatique toutes les 3 secondes
|
||||||
|
- Pas de polling, push serveur
|
||||||
|
- Reconnexion automatique
|
||||||
|
|
||||||
|
## Limitations identifiées et améliorations possibles
|
||||||
|
|
||||||
|
### 1. Métadonnées de couverture d'album
|
||||||
|
|
||||||
|
**Problème :** Le trait `MusicSource` n'expose pas directement de méthode pour résoudre les URIs de couvertures d'album.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- Le champ `album_art` dans `Item` contient parfois une URI
|
||||||
|
- Le champ `album_art_pk` contient une clé primaire mais pas d'URL exploitable directement
|
||||||
|
- Certaines implémentations (pmoparadise) utilisent `/cache/cover/{pk}` mais ce n'est pas standardisé
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Résout l'URI de la couverture d'album pour un item
|
||||||
|
async fn resolve_cover_uri(&self, object_id: &str) -> Result<Option<String>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Recherche globale
|
||||||
|
|
||||||
|
**Problème :** La méthode `search()` existe mais retourne `SearchNotSupported` par défaut.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- Pas d'interface standardisée pour la recherche dans l'UI
|
||||||
|
- Pas de retour clair sur les capacités de recherche
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Utiliser `capabilities().supports_search` pour afficher/masquer l'UI de recherche
|
||||||
|
- Documenter clairement le format attendu des requêtes de recherche
|
||||||
|
|
||||||
|
### 3. Pagination
|
||||||
|
|
||||||
|
**Problème :** L'API supporte la pagination mais les métadonnées ne permettent pas de connaître le nombre total d'items.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- `BrowseResponse.total` retourne le nombre d'items retournés, pas le total disponible
|
||||||
|
- Pas de méthode `get_total_count(object_id)` dans le trait
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Retourne le nombre total d'items dans un container
|
||||||
|
async fn get_total_count(&self, object_id: &str) -> Result<usize>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou ajouter `total_available` dans `BrowseResponse` :
|
||||||
|
```rust
|
||||||
|
pub struct SourceBrowseResponse {
|
||||||
|
// ... champs existants
|
||||||
|
pub total_available: Option<usize>, // Total disponible (pas juste retourné)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Métadonnées de stream en temps réel ✅ **IMPLÉMENTÉ**
|
||||||
|
|
||||||
|
**Solution implémentée :**
|
||||||
|
- ✅ Méthode `get_item(object_id)` dans le trait `MusicSource`
|
||||||
|
- ✅ Endpoint REST `GET /api/sources/{id}/item?object_id={id}` pour récupérer les métadonnées d'un item
|
||||||
|
- ✅ Endpoint SSE `GET /api/sources/{id}/item/stream?object_id={id}` pour recevoir les mises à jour en temps réel
|
||||||
|
- ✅ Le player web utilise Server-Sent Events (SSE) pour les métadonnées temps réel
|
||||||
|
|
||||||
|
**Comment ça fonctionne :**
|
||||||
|
1. Le serveur envoie automatiquement les métadonnées à jour toutes les 3 secondes via SSE
|
||||||
|
2. Le client se connecte avec `EventSource` (API browser native)
|
||||||
|
3. Les métadonnées sont automatiquement mises à jour dans l'interface sans polling
|
||||||
|
|
||||||
|
**Pour RadioParadise :**
|
||||||
|
- La méthode `get_item()` pour les live streams récupère les métadonnées depuis `/radioparadise/metadata/{slug}`
|
||||||
|
- Le SSE permet d'avoir les métadonnées à jour en moins de 3 secondes (au lieu de 10 secondes avec le polling)
|
||||||
|
|
||||||
|
### 5. Playlists utilisateur
|
||||||
|
|
||||||
|
**Problème :** Les méthodes existent (`get_user_playlists()`, `add_to_playlist()`) mais retournent `NotSupported` par défaut.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- Pas encore testé dans le player
|
||||||
|
- Nécessiterait une UI dédiée
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Créer une section "Playlists" dans le player
|
||||||
|
- Tester l'API avec une implémentation qui supporte les playlists (ex: Qobuz)
|
||||||
|
|
||||||
|
### 6. Favoris
|
||||||
|
|
||||||
|
**Problème :** Similaire aux playlists, l'API existe mais n'est pas testée.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Ajouter un bouton "⭐ Favoris" sur chaque morceau
|
||||||
|
- Afficher visuellement les morceaux favoris
|
||||||
|
- Créer une section "Mes Favoris"
|
||||||
|
|
||||||
|
### 7. Auto-play / Queue
|
||||||
|
|
||||||
|
**Problème :** Il n'y a pas de méthode pour gérer une file d'attente de lecture.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Interface pour gérer une queue de lecture
|
||||||
|
pub trait Playable: MusicSource {
|
||||||
|
async fn get_next_track(&self) -> Result<Option<Item>>;
|
||||||
|
async fn get_previous_track(&self) -> Result<Option<Item>>;
|
||||||
|
async fn add_to_queue(&self, item: Item) -> Result<()>;
|
||||||
|
async fn clear_queue(&self) -> Result<()>;
|
||||||
|
async fn get_queue(&self) -> Result<Vec<Item>>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Durée totale d'un container
|
||||||
|
|
||||||
|
**Problème :** Pour afficher "Album: 45:32 min, 12 morceaux", il faut parcourir tous les items.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Statistiques d'un container spécifique
|
||||||
|
async fn get_container_stats(&self, object_id: &str) -> Result<ContainerStats>;
|
||||||
|
|
||||||
|
pub struct ContainerStats {
|
||||||
|
pub item_count: usize,
|
||||||
|
pub total_duration_ms: Option<u64>,
|
||||||
|
pub total_size_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Formats audio disponibles
|
||||||
|
|
||||||
|
**Problème :** La méthode `get_available_formats()` existe mais n'est pas exploitée dans l'UI.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Ajouter un sélecteur de qualité dans le player
|
||||||
|
- Afficher les formats disponibles (FLAC 24/96, MP3 320, etc.)
|
||||||
|
|
||||||
|
### 10. État du cache
|
||||||
|
|
||||||
|
**Problème :** Les méthodes existent (`get_cache_status()`, `cache_item()`) mais ne sont pas intégrées.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Afficher un indicateur de cache sur chaque morceau
|
||||||
|
- Bouton "📥 Télécharger" pour mettre en cache
|
||||||
|
- Barre de progression pour le téléchargement
|
||||||
|
|
||||||
|
## Prochaines étapes
|
||||||
|
|
||||||
|
### Court terme
|
||||||
|
1. ✅ Tester le player avec `pmoparadise` (déjà implémenté)
|
||||||
|
2. 🔄 Identifier les bugs et limitations pratiques
|
||||||
|
3. 🔄 Tester avec une deuxième source (ex: `pmoqobuz`) pour valider la généricité
|
||||||
|
|
||||||
|
### Moyen terme
|
||||||
|
1. Implémenter les fonctionnalités manquantes identifiées ci-dessus
|
||||||
|
2. Ajouter la gestion de queue et auto-play
|
||||||
|
3. Ajouter la recherche si supportée
|
||||||
|
4. Intégrer la gestion du cache
|
||||||
|
|
||||||
|
### Long terme
|
||||||
|
1. Support des playlists utilisateur
|
||||||
|
2. Support des favoris
|
||||||
|
3. Égaliseur et effets audio
|
||||||
|
4. Visualisations audio
|
||||||
|
5. Mode hors-ligne avec cache
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Le player générique démontre que **l'API `pmosource` est déjà très utilisable** pour créer une application musicale fonctionnelle. Les principales limitations concernent :
|
||||||
|
|
||||||
|
1. **Les métadonnées de couvertures** (pas d'URL standardisée)
|
||||||
|
2. **La pagination avancée** (pas de compte total)
|
||||||
|
3. **Les métadonnées temps réel** (pour les streams live)
|
||||||
|
4. **La gestion de queue** (pas d'API dédiée)
|
||||||
|
|
||||||
|
Ces limitations ne sont pas bloquantes mais leur résolution améliorerait significativement l'expérience utilisateur et la complétude de l'API.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
Pour tester le player :
|
||||||
|
|
||||||
|
1. Lancer le serveur backend avec au moins une source enregistrée :
|
||||||
|
```bash
|
||||||
|
cargo run --example single_channel_server --features full
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Accéder à l'application web :
|
||||||
|
```
|
||||||
|
http://localhost:8080/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Le player devrait afficher automatiquement les sources disponibles et permettre la navigation et la lecture.
|
||||||
|
|
||||||
|
## Remarques importantes
|
||||||
|
|
||||||
|
- ✅ Le player **n'utilise QUE l'API pmosource générique**
|
||||||
|
- ✅ Aucune dépendance sur `pmoparadise` ou toute autre implémentation spécifique
|
||||||
|
- ✅ Tout est basé sur les endpoints REST de `pmosource::api`
|
||||||
|
- ✅ Le code est totalement réutilisable pour toute nouvelle source (Qobuz, Spotify, etc.)
|
||||||
BIN
PMOMusic/.pmomusic/cache_audio/cache.db
Normal file
BIN
PMOMusic/.pmomusic/cache_audio/cache.db
Normal file
Binary file not shown.
BIN
PMOMusic/.pmomusic/cache_covers/cache.db
Normal file
BIN
PMOMusic/.pmomusic/cache_covers/cache.db
Normal file
Binary file not shown.
21
PMOMusic/.pmomusic/config.yaml
Normal file
21
PMOMusic/.pmomusic/config.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
host:
|
||||||
|
http_port: '8080'
|
||||||
|
cover_cache:
|
||||||
|
directory: cache_covers
|
||||||
|
size: 2000
|
||||||
|
audio_cache:
|
||||||
|
directory: cache_audio
|
||||||
|
size: 500
|
||||||
|
logger:
|
||||||
|
buffer_capacity: 200
|
||||||
|
enable_console: true
|
||||||
|
min_level: INFO
|
||||||
|
playlists:
|
||||||
|
directory: playlists
|
||||||
|
devices:
|
||||||
|
mediarenderer:
|
||||||
|
pmo_mediarenderer:
|
||||||
|
udn: f77de90b-3a4a-408c-8462-3308ad500744
|
||||||
|
mediaserver:
|
||||||
|
pmo_mediaserver:
|
||||||
|
udn: 88b84e76-4de0-4ee6-b794-99cc4a278cc9
|
||||||
BIN
PMOMusic/.pmomusic/playlists/playlists.db
Normal file
BIN
PMOMusic/.pmomusic/playlists/playlists.db
Normal file
Binary file not shown.
@@ -6,10 +6,20 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
pmoconfig = { path = "../pmoconfig" }
|
pmoconfig = { path = "../pmoconfig" }
|
||||||
pmoupnp = { path = "../pmoupnp"}
|
pmoupnp = { path = "../pmoupnp"}
|
||||||
|
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||||
|
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] }
|
||||||
|
pmosource = { path = "../pmosource", features = ["server"] }
|
||||||
|
pmoserver = { path = "../pmoserver" }
|
||||||
|
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||||
|
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
|
||||||
|
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
|
||||||
|
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||||
|
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
|
||||||
|
|
||||||
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
|
||||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
tracing = { workspace = true }
|
||||||
tracing = "0.1.41"
|
|
||||||
tracing-subscriber = "0.3.20"
|
tracing-subscriber = "0.3.20"
|
||||||
axum = "0.8.4"
|
axum = "0.8.4"
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
|
utoipa = "5.4"
|
||||||
|
console-subscriber = "0.4.1"
|
||||||
|
|||||||
25
PMOMusic/Cargo.toml.backup
Normal file
25
PMOMusic/Cargo.toml.backup
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "PMOMusic"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pmoconfig = { path = "../pmoconfig" }
|
||||||
|
pmoupnp = { path = "../pmoupnp"}
|
||||||
|
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||||
|
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] }
|
||||||
|
pmosource = { path = "../pmosource", features = ["server"] }
|
||||||
|
pmoserver = { path = "../pmoserver" }
|
||||||
|
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||||
|
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
|
||||||
|
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
|
||||||
|
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||||
|
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
|
||||||
|
|
||||||
|
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = "0.3.20"
|
||||||
|
axum = "0.8.4"
|
||||||
|
serde_json = "1.0.145"
|
||||||
|
utoipa = "5.4"
|
||||||
|
console-subscriber = "0.4.1"
|
||||||
@@ -1,55 +1,137 @@
|
|||||||
use pmoupnp::{mediarenderer::avtransport::{actions::SETAVTRANSPORTURI, AVTTRANSPORT}, server::{
|
use pmoapp::{WebAppExt, Webapp};
|
||||||
logs::{log_dump, log_sse, LogState, SseLayer}, ServerBuilder, Webapp
|
use pmocontrol::ControlPointExt;
|
||||||
}, UpnpObject}; // ton module pmoupnp::server
|
use pmomediarenderer::MEDIA_RENDERER;
|
||||||
use tracing_subscriber::Registry;
|
use pmomediaserver::{
|
||||||
use tracing_subscriber::prelude::*;
|
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
|
||||||
|
};
|
||||||
|
use pmoserver::Server;
|
||||||
|
use pmosource::MusicSourceExt;
|
||||||
|
use pmoupnp::UpnpServerExt;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Charger la config
|
// ========== PHASE 1 : Infrastructure UPnP ==========
|
||||||
|
// #[cfg(tokio_unstable)]
|
||||||
let mut server = ServerBuilder::new_configured().build();
|
// console_subscriber::init();
|
||||||
|
|
||||||
// Ajouter des routes
|
|
||||||
server
|
|
||||||
.add_route("/hello", || async {
|
|
||||||
serde_json::json!({"message": "Hello World"})
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
|
let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application
|
||||||
server
|
server
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
.add_route("/info", || async {
|
.add_route("/info", || async {
|
||||||
serde_json::json!({"version": "1.0.0"})
|
serde_json::json!({"version": "1.0.0"})
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
server.add_spa::<Webapp>("/app").await;
|
// Initialiser le système de gestion des sources musicales avec API REST
|
||||||
|
info!("📡 Initializing music sources management system...");
|
||||||
// Gère la sortie des logs et sur le serveur SSE pour l'interface web et sur la console
|
|
||||||
let log_state = LogState::new(1000);
|
|
||||||
let subscriber = Registry::default()
|
|
||||||
.with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
|
||||||
.with_target(true)
|
|
||||||
.with_level(true)
|
|
||||||
.with_ansi(true), // Couleurs dans le terminal
|
|
||||||
)
|
|
||||||
.with(SseLayer::new(log_state.clone()));
|
|
||||||
tracing::subscriber::set_global_default(subscriber).unwrap();
|
|
||||||
|
|
||||||
server
|
server
|
||||||
.add_handler_with_state("/log-sse", log_sse, log_state.clone())
|
.write()
|
||||||
.await;
|
.await
|
||||||
|
.init_music_sources()
|
||||||
|
.await
|
||||||
|
.expect("Failed to initialize music sources API");
|
||||||
|
|
||||||
|
// ========== PHASE 2 : Configuration métier ==========
|
||||||
|
|
||||||
|
// Enregistrer les sources musicales
|
||||||
|
info!("🎵 Registering music sources...");
|
||||||
|
|
||||||
|
// Enregistrer Qobuz pour activer les lazy providers (QOBUZ:PK)
|
||||||
|
if let Err(e) = server.write().await.register_qobuz().await {
|
||||||
|
tracing::warn!("⚠️ Failed to register Qobuz source: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialiser les canaux de streaming Radio Paradise (pipelines + routes HTTP)
|
||||||
|
info!("📻 Initializing Radio Paradise streaming channels...");
|
||||||
|
if let Err(e) = server.write().await.init_paradise_streaming().await {
|
||||||
|
tracing::warn!("⚠️ Failed to initialize Paradise streaming: {}", e);
|
||||||
|
} else {
|
||||||
|
// Enregistrer la source Radio Paradise UPnP (inclut l'initialisation de l'API)
|
||||||
|
if let Err(e) = server.write().await.register_paradise().await {
|
||||||
|
tracing::warn!("⚠️ Failed to register Radio Paradise source: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lister toutes les sources enregistrées
|
||||||
|
let sources = server.read().await.list_music_sources().await;
|
||||||
|
info!("✅ {} music source(s) registered", sources.len());
|
||||||
|
for source in sources {
|
||||||
|
info!(" - {} ({})", source.name(), source.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enregistrer les devices UPnP (HTTP + SSDP automatique)
|
||||||
|
info!("📡 Registering UPnP devices...");
|
||||||
|
|
||||||
|
let renderer_instance = server
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.register_device(MEDIA_RENDERER.clone())
|
||||||
|
.await
|
||||||
|
.expect("Failed to register MediaRenderer");
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"✅ MediaRenderer ready at {}{}",
|
||||||
|
renderer_instance.base_url(),
|
||||||
|
renderer_instance.description_route()
|
||||||
|
);
|
||||||
|
|
||||||
|
let server_instance = server
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.register_device(MEDIA_SERVER.clone())
|
||||||
|
.await
|
||||||
|
.expect("Failed to register MediaServer");
|
||||||
|
|
||||||
|
// Enregistrer l'instance ContentDirectory pour les notifications GENA
|
||||||
|
if let Some(cd_service) = server_instance.get_service("ContentDirectory") {
|
||||||
|
pmomediaserver::contentdirectory::state::register_instance(&cd_service);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialiser les ProtocolInfo du MediaServer
|
||||||
|
server_instance.init_protocol_info();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"✅ MediaServer ready at {}{}",
|
||||||
|
server_instance.base_url(),
|
||||||
|
server_instance.description_route()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enregistrer le Control Point (découverte renderers/serveurs + API REST + SSE)
|
||||||
|
info!("🎛️ Registering Control Point...");
|
||||||
|
let _control_point = server
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.register_control_point(5)
|
||||||
|
.await
|
||||||
|
.expect("Failed to register Control Point");
|
||||||
|
|
||||||
|
// Ajouter la webapp via le trait WebAppExt
|
||||||
|
info!("📡 Registering Web application...");
|
||||||
server
|
server
|
||||||
.add_handler_with_state("/log-dump", log_dump, log_state.clone())
|
.write()
|
||||||
|
.await
|
||||||
|
.add_webapp_with_redirect::<Webapp>("/app")
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
server.add_redirect("/", "/app").await;
|
// ========== PHASE 3 : Démarrage du serveur ==========
|
||||||
|
|
||||||
info!("{}",AVTTRANSPORT.to_markdown());
|
info!("🌐 Starting HTTP server...");
|
||||||
info!("{}",AVTTRANSPORT.scpd_xml());
|
server.write().await.start().await;
|
||||||
|
|
||||||
server.start().await;
|
info!("✅ PMOMusic is ready!");
|
||||||
server.wait().await;
|
info!("Press Ctrl+C to stop...");
|
||||||
|
|
||||||
|
// Attendre le signal Ctrl+C et l'arrêt du serveur HTTP
|
||||||
|
server.write().await.wait().await;
|
||||||
|
|
||||||
|
// Le serveur HTTP est arrêté, mais des threads (ControlPoint, etc.) peuvent encore tourner
|
||||||
|
// Attendre 2 secondes pour laisser le temps aux threads de se terminer
|
||||||
|
info!("Waiting for background threads to finish...");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
// Forcer l'arrêt du processus (les threads du ControlPoint tournent en boucle infinie)
|
||||||
|
info!("✅ PMOMusic stopped");
|
||||||
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
447
Plan_d_implementation_webui_control_point.md
Normal file
447
Plan_d_implementation_webui_control_point.md
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
# PMOControl WebUI - Design Recommendations & Implementation Plan
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Based on my analysis of the existing codebase, I'm providing comprehensive recommendations for implementing a Vue.js WebUI for PMOControl. The system already has:
|
||||||
|
- A complete REST API with OpenAPI documentation (`/api/control/*`)
|
||||||
|
- SSE endpoints for real-time updates (`/api/control/events/*`)
|
||||||
|
- Vue 3 + TypeScript + Vite setup
|
||||||
|
- Existing components (GenericMusicPlayer, UpnpExplorer, Cache Managers, LogView)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions & Recommendations
|
||||||
|
|
||||||
|
### 1. State Management: **Use Pinia**
|
||||||
|
|
||||||
|
**Recommendation: Pinia (Vue 3's official state management)**
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- **Centralized real-time state**: Essential for managing SSE updates from multiple sources (renderers, media servers)
|
||||||
|
- **Multi-client synchronization**: Single source of truth for renderer states, volumes, playback positions
|
||||||
|
- **TypeScript native**: Better type inference than Vuex
|
||||||
|
- **DevTools integration**: Built-in debugging for SSE event flows
|
||||||
|
- **Composition API friendly**: Matches existing Vue 3 patterns in codebase
|
||||||
|
- **Performance**: Lightweight (~1KB), modular stores
|
||||||
|
- **Official Vue 3 recommendation**: Future-proof choice
|
||||||
|
|
||||||
|
**Store Architecture:**
|
||||||
|
```typescript
|
||||||
|
// stores/renderers.ts - Renderer state (SSE updates)
|
||||||
|
// stores/mediaServers.ts - Media server state (SSE updates)
|
||||||
|
// stores/playback.ts - Current playback session
|
||||||
|
// stores/ui.ts - UI state (selected renderer, view preferences)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits for your use case:**
|
||||||
|
- Handle 20+ concurrent clients with shared state
|
||||||
|
- Real-time SSE event synchronization across all views
|
||||||
|
- Easy to scale with multi-renderer, multi-server, multi-session architecture
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. UI Component Library: **Headless UI + Custom Components**
|
||||||
|
|
||||||
|
**Recommendation: Hybrid approach - Headless UI components + custom styling**
|
||||||
|
|
||||||
|
**Component Library: Shadcn-vue (Headless UI primitives)**
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- **Lightweight & performant**: Only import what you need
|
||||||
|
- **Full style control**: Match "carte uniforme, responsive, colorée selon statut" spec exactly
|
||||||
|
- **TypeScript-first**: Perfect type safety
|
||||||
|
- **Accessibility built-in**: ARIA compliance out of the box
|
||||||
|
- **No theme lock-in**: Complete CSS freedom
|
||||||
|
- **Composable primitives**: Card, Dialog, Dropdown, Slider components
|
||||||
|
|
||||||
|
**Why NOT a full framework (Vuetify, Element Plus)?**
|
||||||
|
- Heavy bundle size (100-500KB vs ~10KB for headless)
|
||||||
|
- Theme customization overhead
|
||||||
|
- Your spec requires custom status-based coloring
|
||||||
|
- Performance critical with 20 concurrent clients
|
||||||
|
|
||||||
|
**Alternative if you prefer pre-styled:** PrimeVue
|
||||||
|
- Good performance
|
||||||
|
- Customizable themes
|
||||||
|
- Strong TypeScript support
|
||||||
|
- But: 150KB+ bundle size
|
||||||
|
|
||||||
|
**Custom Components to Build:**
|
||||||
|
- `RendererCard` - Status-colored cards for each renderer
|
||||||
|
- `TransportControls` - Play/Pause/Stop/Next buttons
|
||||||
|
- `VolumeControl` - Slider with mute toggle
|
||||||
|
- `QueueViewer` - Playlist display with drag-drop
|
||||||
|
- `MediaServerBrowser` - Container navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Existing Components: **Reorganize into Debug Section**
|
||||||
|
|
||||||
|
**Recommendation: Keep existing components, create new PMOControl home**
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```
|
||||||
|
/app (root) → PMOControl Dashboard (NEW)
|
||||||
|
/app/debug → Dropdown menu
|
||||||
|
├─ /logs → LogView
|
||||||
|
├─ /upnp → UpnpExplorer
|
||||||
|
├─ /covers-cache → CoverCacheManager
|
||||||
|
├─ /audio-cache → AudioCacheManager
|
||||||
|
├─ /api-dashboard → APIDashboard
|
||||||
|
└─ /radio-paradise → RadioParadiseExplorer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Existing components are valuable for development/debugging
|
||||||
|
- Don't break existing functionality
|
||||||
|
- PMOControl becomes primary interface as specified
|
||||||
|
- Debug tools remain accessible but not prominent
|
||||||
|
- Matches current App.vue dropdown pattern
|
||||||
|
|
||||||
|
**Home Screen (/) - PMOControl Dashboard:**
|
||||||
|
- Grid of renderer cards (status-colored)
|
||||||
|
- Active playback session viewer
|
||||||
|
- Quick controls (play/pause/volume)
|
||||||
|
- Media server browser panel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Responsive Design: **Mobile-first with 3 breakpoints**
|
||||||
|
|
||||||
|
**Recommendation: Follow existing 768px pattern + add tablet/desktop**
|
||||||
|
|
||||||
|
**Breakpoints:**
|
||||||
|
```css
|
||||||
|
/* Mobile: < 768px (existing pattern) */
|
||||||
|
- Single column layout
|
||||||
|
- Stacked renderer cards
|
||||||
|
- Bottom-fixed playback controls
|
||||||
|
- Collapsible media browser
|
||||||
|
|
||||||
|
/* Tablet: 768px - 1024px */
|
||||||
|
- Two column layout
|
||||||
|
- Grid of renderer cards (2 columns)
|
||||||
|
- Side panel for media browser
|
||||||
|
- Floating playback controls
|
||||||
|
|
||||||
|
/* Desktop: > 1024px */
|
||||||
|
- Three column layout
|
||||||
|
- Renderer cards grid (3-4 columns)
|
||||||
|
- Persistent media browser sidebar
|
||||||
|
- Always-visible playback controls
|
||||||
|
```
|
||||||
|
|
||||||
|
**Target Devices:**
|
||||||
|
- **Primary**: Desktop browsers (control station)
|
||||||
|
- **Secondary**: Tablets (remote control)
|
||||||
|
- **Tertiary**: Mobile phones (quick controls)
|
||||||
|
|
||||||
|
**Performance considerations:**
|
||||||
|
- Virtualized lists for 20+ renderers (use vue-virtual-scroller)
|
||||||
|
- Lazy load album art
|
||||||
|
- Throttle SSE position updates (max 1/sec per renderer)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Icons: **Lucide Icons (SVG library)**
|
||||||
|
|
||||||
|
**Recommendation: Lucide Icons (NOT emoji)**
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- **Professional appearance**: Emojis inconsistent across platforms
|
||||||
|
- **Customizable**: Size, color, stroke width
|
||||||
|
- **Lightweight**: Tree-shakeable SVG imports (~1KB per icon)
|
||||||
|
- **Status coloring**: Icons can match card status colors
|
||||||
|
- **Accessibility**: Proper ARIA labels
|
||||||
|
- **Vue components**: `lucide-vue-next` package
|
||||||
|
|
||||||
|
**Icon mapping:**
|
||||||
|
```typescript
|
||||||
|
Play → PlayCircle
|
||||||
|
Pause → PauseCircle
|
||||||
|
Stop → StopCircle
|
||||||
|
Next → SkipForward
|
||||||
|
Volume → Volume2 / VolumeX (muted)
|
||||||
|
Renderer → Speaker / MonitorSpeaker
|
||||||
|
Server → Server / Database
|
||||||
|
Queue → ListMusic
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative if you prefer minimal bundle:** Heroicons
|
||||||
|
- Smaller set (fewer icons)
|
||||||
|
- Tailwind CSS integration
|
||||||
|
- But: less comprehensive for music player needs
|
||||||
|
|
||||||
|
**Why NOT emoji:**
|
||||||
|
- Platform inconsistencies (iOS ≠ Android ≠ Windows)
|
||||||
|
- No color control
|
||||||
|
- Accessibility issues
|
||||||
|
- Unprofessional for production UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### Real-time SSE Integration
|
||||||
|
|
||||||
|
**SSE Event Handling:**
|
||||||
|
```typescript
|
||||||
|
// services/controlPointSSE.ts
|
||||||
|
class ControlPointSSE {
|
||||||
|
private eventSource: EventSource
|
||||||
|
private renderersStore: ReturnType<typeof useRenderersStore>
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.eventSource = new EventSource('/api/control/events')
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('control', (e) => {
|
||||||
|
const event = JSON.parse(e.data)
|
||||||
|
|
||||||
|
if (event.category === 'renderer') {
|
||||||
|
this.handleRendererEvent(event)
|
||||||
|
} else if (event.category === 'media_server') {
|
||||||
|
this.handleServerEvent(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRendererEvent(event: RendererEventPayload) {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'state_changed':
|
||||||
|
this.renderersStore.updateState(event.renderer_id, event.state)
|
||||||
|
break
|
||||||
|
case 'volume_changed':
|
||||||
|
this.renderersStore.updateVolume(event.renderer_id, event.volume)
|
||||||
|
break
|
||||||
|
// ... handle all event types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Store Integration:**
|
||||||
|
```typescript
|
||||||
|
// stores/renderers.ts
|
||||||
|
export const useRenderersStore = defineStore('renderers', () => {
|
||||||
|
const renderers = ref<Map<string, RendererState>>(new Map())
|
||||||
|
|
||||||
|
// SSE updates
|
||||||
|
function updateState(id: string, state: string) {
|
||||||
|
const renderer = renderers.value.get(id)
|
||||||
|
if (renderer) {
|
||||||
|
renderer.transport_state = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST API calls
|
||||||
|
async function play(id: string) {
|
||||||
|
await fetch(`/api/control/renderers/${id}/play`, { method: 'POST' })
|
||||||
|
// SSE will update state automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renderers, updateState, play }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
|
||||||
|
**For 20+ concurrent clients:**
|
||||||
|
|
||||||
|
1. **Throttle position updates**:
|
||||||
|
```typescript
|
||||||
|
const throttledPositionUpdate = throttle((id, pos) => {
|
||||||
|
store.updatePosition(id, pos)
|
||||||
|
}, 1000) // Max 1 update/second
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Virtual scrolling** for renderer lists:
|
||||||
|
```bash
|
||||||
|
npm install vue-virtual-scroller
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Lazy load album art**:
|
||||||
|
```vue
|
||||||
|
<img :src="albumArt" loading="lazy" />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Debounce volume sliders**:
|
||||||
|
```typescript
|
||||||
|
const debouncedVolumeChange = debounce((id, vol) => {
|
||||||
|
api.setVolume(id, vol)
|
||||||
|
}, 300)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Memoize computed properties**:
|
||||||
|
```typescript
|
||||||
|
const activeRenderers = computed(() =>
|
||||||
|
renderers.value.filter(r => r.online)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Core Infrastructure (Week 1)
|
||||||
|
1. Install Pinia + configure stores
|
||||||
|
2. Install Lucide Icons
|
||||||
|
3. Create SSE service layer
|
||||||
|
4. Setup store structure (renderers, servers, playback, ui)
|
||||||
|
5. Connect SSE events to stores
|
||||||
|
|
||||||
|
### Phase 2: UI Components (Week 2)
|
||||||
|
5. Build RendererCard component (status-colored)
|
||||||
|
6. Build TransportControls component
|
||||||
|
7. Build VolumeControl component
|
||||||
|
8. Build QueueViewer component
|
||||||
|
9. Create responsive grid layouts
|
||||||
|
|
||||||
|
### Phase 3: Dashboard Assembly (Week 3)
|
||||||
|
10. Create PMOControl home view
|
||||||
|
11. Integrate all components
|
||||||
|
12. Add media server browser panel
|
||||||
|
13. Implement responsive breakpoints
|
||||||
|
14. Add loading states & error handling
|
||||||
|
|
||||||
|
### Phase 4: Polish & Testing (Week 4)
|
||||||
|
15. Test with 20+ concurrent clients
|
||||||
|
16. Performance profiling & optimization
|
||||||
|
17. Accessibility audit (ARIA, keyboard nav)
|
||||||
|
18. Cross-browser testing
|
||||||
|
19. Mobile/tablet testing
|
||||||
|
20. Documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies to Install
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.2.8",
|
||||||
|
"lucide-vue-next": "^0.470.0",
|
||||||
|
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
// Already installed: vue, vue-router, typescript, vite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total bundle size estimate:** +15KB gzipped (Pinia + Lucide + Virtual Scroller)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status-based Coloring Scheme
|
||||||
|
|
||||||
|
Based on "carte uniforme, responsive, colorée selon statut" spec:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Renderer Card Status Colors */
|
||||||
|
.renderer-card.playing {
|
||||||
|
border-color: #22c55e; /* green */
|
||||||
|
background: linear-gradient(135deg, #22c55e10, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.paused {
|
||||||
|
border-color: #f59e0b; /* amber */
|
||||||
|
background: linear-gradient(135deg, #f59e0b10, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.stopped {
|
||||||
|
border-color: #6b7280; /* gray */
|
||||||
|
background: linear-gradient(135deg, #6b728010, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.offline {
|
||||||
|
border-color: #ef4444; /* red */
|
||||||
|
background: linear-gradient(135deg, #ef444410, transparent);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.transitioning {
|
||||||
|
border-color: #3b82f6; /* blue */
|
||||||
|
background: linear-gradient(135deg, #3b82f610, transparent);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Answers to Your Specific Questions
|
||||||
|
|
||||||
|
### 1. State Management?
|
||||||
|
**Answer: Pinia** - Vue 3 official, perfect for SSE real-time updates, TypeScript native, lightweight
|
||||||
|
|
||||||
|
### 2. UI Component Library?
|
||||||
|
**Answer: Headless UI (Shadcn-vue) + Custom Components** - Full control over status-based styling, lightweight, no theme lock-in
|
||||||
|
|
||||||
|
### 3. Keep existing components?
|
||||||
|
**Answer: Yes, reorganize into Debug section** - Keep valuable dev tools, make PMOControl the new home screen
|
||||||
|
|
||||||
|
### 4. Responsive breakpoints?
|
||||||
|
**Answer: Mobile-first with 3 breakpoints** - <768px (mobile), 768-1024px (tablet), >1024px (desktop)
|
||||||
|
|
||||||
|
### 5. Icons?
|
||||||
|
**Answer: Lucide Icons (SVG library)** - Professional, customizable, status-colored, NOT emoji
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Mitigation
|
||||||
|
|
||||||
|
**Potential challenges:**
|
||||||
|
|
||||||
|
1. **SSE connection management across tabs**
|
||||||
|
- Solution: Use BroadcastChannel API for cross-tab sync
|
||||||
|
- Fallback: LocalStorage events
|
||||||
|
|
||||||
|
2. **20+ renderers performance**
|
||||||
|
- Solution: Virtual scrolling + throttled updates
|
||||||
|
- Monitor: Chrome DevTools Performance profiler
|
||||||
|
|
||||||
|
3. **Network reliability (SSE reconnection)**
|
||||||
|
- Solution: Exponential backoff reconnection
|
||||||
|
- UI indicator for connection status
|
||||||
|
|
||||||
|
4. **Album art loading (CORS, 404s)**
|
||||||
|
- Solution: Proxy through backend
|
||||||
|
- Fallback: Default placeholder image
|
||||||
|
|
||||||
|
5. **Browser compatibility (SSE support)**
|
||||||
|
- Chrome/Edge: Native support ✅
|
||||||
|
- Firefox: Native support ✅
|
||||||
|
- Safari: Native support ✅
|
||||||
|
- IE11: Use EventSource polyfill
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
**Performance targets:**
|
||||||
|
- Initial load: <2s (FCP)
|
||||||
|
- SSE event latency: <100ms
|
||||||
|
- UI interaction: <16ms (60fps)
|
||||||
|
- Memory usage: <50MB with 20 renderers
|
||||||
|
- Bundle size: <250KB gzipped
|
||||||
|
|
||||||
|
**Functionality checklist:**
|
||||||
|
- [ ] Display all discovered renderers in real-time
|
||||||
|
- [ ] Show accurate playback state (play/pause/stop)
|
||||||
|
- [ ] Volume control works across all renderer types
|
||||||
|
- [ ] Queue display syncs with server
|
||||||
|
- [ ] Media server browsing functional
|
||||||
|
- [ ] Playlist attachment working
|
||||||
|
- [ ] Responsive on mobile/tablet/desktop
|
||||||
|
- [ ] Accessible (WCAG AA compliance)
|
||||||
|
- [ ] 20+ concurrent clients supported
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Review & approve** this plan with stakeholders
|
||||||
|
2. **Clarify any ambiguities** in requirements
|
||||||
|
3. **Set up development environment** (install dependencies)
|
||||||
|
4. **Begin Phase 1** (Core Infrastructure)
|
||||||
|
|
||||||
|
Would you like me to proceed with implementation, or do you have questions about any of these recommendations?
|
||||||
45
Readme.md
45
Readme.md
@@ -1,5 +1,43 @@
|
|||||||
# Développement de l'application PMOMusic en RUST
|
# Développement de l'application PMOMusic en RUST
|
||||||
|
|
||||||
|
## 🚀 Démarrage rapide
|
||||||
|
|
||||||
|
### Installation des dépendances (environnement sans sudo)
|
||||||
|
|
||||||
|
Pour compiler PMOMusic dans un environnement sans privilèges sudo (comme Claude Code) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Installation automatique de libsoxr et libasound2 (une seule fois)
|
||||||
|
./setup-deps.sh
|
||||||
|
|
||||||
|
# 2. Créer le fichier setup-env.sh (une seule fois, voir INSTALL_LIBSOXR.md pour le contenu)
|
||||||
|
cat > setup-env.sh << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||||
|
echo "Variables d'environnement configurées pour PMOMusic"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 3. Configuration de l'environnement (à chaque nouvelle session)
|
||||||
|
source setup-env.sh
|
||||||
|
|
||||||
|
# 4. Compilation
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# 5. Test de l'exemple Radio Paradise
|
||||||
|
cargo run --package pmoparadise --example play_and_cache --features full -- 0
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Note :** Le fichier `setup-env.sh` est dans `.gitignore` car il contient une configuration locale.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **[INSTALL_NOTES.md](INSTALL_NOTES.md)** - Guide d'installation général
|
||||||
|
- **[INSTALL_LIBSOXR.md](INSTALL_LIBSOXR.md)** - Installation détaillée de libsoxr et ALSA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Création de la structure
|
## Création de la structure
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -98,3 +136,10 @@ jj rebase --continue
|
|||||||
|
|
||||||
pour résoudre les conflits
|
pour résoudre les conflits
|
||||||
|
|
||||||
|
## Installer rust sur mac
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install rustup-init
|
||||||
|
rustup-init
|
||||||
|
rustup default stable
|
||||||
|
```
|
||||||
27
SECURITY_CONFIG.md
Normal file
27
SECURITY_CONFIG.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Configuration Sécurisée
|
||||||
|
|
||||||
|
## Configuration de PMOMusic
|
||||||
|
|
||||||
|
Le fichier `.pmomusic.yml` contient des informations sensibles (mots de passe, identifiants).
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Copiez le fichier exemple :
|
||||||
|
```bash
|
||||||
|
cp .pmomusic.yml.example .pmomusic.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Éditez `.pmomusic.yml` et remplacez les valeurs par vos véritables identifiants :
|
||||||
|
- `accounts.qobuz.username` : votre email Qobuz
|
||||||
|
- `accounts.qobuz.password` : votre mot de passe Qobuz
|
||||||
|
|
||||||
|
3. **Important** : Ne commitez JAMAIS le fichier `.pmomusic.yml` dans git !
|
||||||
|
- Il est déjà dans `.gitignore`
|
||||||
|
- Utilisez des variables d'environnement pour la production
|
||||||
|
|
||||||
|
## Variables d'environnement (recommandé pour production)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export QOBUZ_USERNAME="votre-email@example.com"
|
||||||
|
export QOBUZ_PASSWORD="votre-mot-de-passe"
|
||||||
|
```
|
||||||
243
UPNP_ANALYSIS_REPORT.md
Normal file
243
UPNP_ANALYSIS_REPORT.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# Rapport d'Analyse UPnP - PMO Music vs Serveurs Fonctionnels
|
||||||
|
|
||||||
|
**Date:** 2025-11-26
|
||||||
|
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
|
||||||
|
|
||||||
|
## Résumé Exécutif
|
||||||
|
|
||||||
|
Le serveur PMO Music MediaServer est correctement découvert via SSDP et répond aux requêtes SOAP, mais présente plusieurs différences avec les serveurs qui fonctionnent (comme Upmpdcli). Les problèmes identifiés sont principalement liés aux en-têtes HTTP et aux métadonnées du device.
|
||||||
|
|
||||||
|
## Découverte Réseau
|
||||||
|
|
||||||
|
### Devices UPnP Détectés
|
||||||
|
|
||||||
|
| Device | IP | USN | Status |
|
||||||
|
|--------|------|-----|---------|
|
||||||
|
| PMO Music MediaServer | 192.168.0.138:8080 | uuid:8b8e9b19-9c65-4d59-b127-b34717658085 | ✅ Découvert |
|
||||||
|
| Upmpdcli (pizzicato) | 192.168.0.200:49152 | uuid:c110358f-d885-b44a-d6d3-dca6329ead0d | ✅ Découvert |
|
||||||
|
| Freebox | 192.168.0.254:52424 | uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | ✅ Découvert |
|
||||||
|
| Jellyfin | 192.168.0.34:8096 | uuid:526dedec-fde2-4224-bac6-06f7b11711cf | ✅ Découvert |
|
||||||
|
|
||||||
|
**Conclusion SSDP:** ✅ PMO Music est correctement annoncé et découvert via SSDP
|
||||||
|
|
||||||
|
## Comparaison des Descripteurs XML
|
||||||
|
|
||||||
|
### PMO Music MediaServer
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>0</minor> <!-- ⚠️ Version 1.0 -->
|
||||||
|
</specVersion>
|
||||||
|
<device>
|
||||||
|
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||||
|
<friendlyName>PMOMusic Media Server</friendlyName>
|
||||||
|
<manufacturer>PMOMusic</manufacturer>
|
||||||
|
<modelName>PMOMusic Media Server</modelName>
|
||||||
|
<UDN>uuid:8b8e9b19-9c65-4d59-b127-b34717658085</UDN> <!-- ✅ Format correct -->
|
||||||
|
<!-- ❌ Pas d'iconList -->
|
||||||
|
<serviceList>
|
||||||
|
<service>
|
||||||
|
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||||
|
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
|
||||||
|
<SCPDURL>/device/.../service/ContentDirectory/desc.xml</SCPDURL>
|
||||||
|
<controlURL>/device/.../service/ContentDirectory/control</controlURL>
|
||||||
|
<eventSubURL>/device/.../service/ContentDirectory/event</eventSubURL>
|
||||||
|
</service>
|
||||||
|
<service>
|
||||||
|
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
|
||||||
|
...
|
||||||
|
</service>
|
||||||
|
</serviceList>
|
||||||
|
</device>
|
||||||
|
</root>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upmpdcli (Fonctionnel)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>1</minor> <!-- ✅ Version 1.1 -->
|
||||||
|
</specVersion>
|
||||||
|
<device>
|
||||||
|
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||||
|
<manufacturer>lesbonscomptes.com/upmpdcli</manufacturer>
|
||||||
|
<modelName>Upmpdcli Media Server</modelName>
|
||||||
|
<friendlyName>pizzicato-Music-mediaserver</friendlyName>
|
||||||
|
<iconList> <!-- ✅ Présence d'icônes -->
|
||||||
|
<icon>
|
||||||
|
<mimetype>image/png</mimetype>
|
||||||
|
<width>64</width>
|
||||||
|
<height>64</height>
|
||||||
|
<depth>32</depth>
|
||||||
|
<url>/uuid-.../icon.png</url>
|
||||||
|
</icon>
|
||||||
|
</iconList>
|
||||||
|
<UDN>uuid:c110358f-d885-b44a-d6d3-dca6329ead0d</UDN>
|
||||||
|
<serviceList>
|
||||||
|
<!-- Mêmes services -->
|
||||||
|
</serviceList>
|
||||||
|
</device>
|
||||||
|
</root>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Différences Clés dans le Descripteur
|
||||||
|
|
||||||
|
| Élément | PMO Music | Upmpdcli | Impact |
|
||||||
|
|---------|-----------|----------|---------|
|
||||||
|
| **specVersion minor** | 0 | 1 | ⚠️ Moyen - Certains clients peuvent filtrer par version |
|
||||||
|
| **Ordre des éléments** | deviceType, friendlyName, manufacturer, modelName, UDN | deviceType, manufacturer, modelName, friendlyName, iconList, UDN | ⚠️ Faible - Ordre différent mais valide XML |
|
||||||
|
| **iconList** | ❌ Absent | ✅ Présent | ⚠️ Moyen - Requis pour certains clients |
|
||||||
|
| **UDN prefix** | ✅ uuid: | ✅ uuid: | ✅ Correct |
|
||||||
|
|
||||||
|
## Comparaison des Réponses SOAP
|
||||||
|
|
||||||
|
### Test 1: ConnectionManager::GetProtocolInfo
|
||||||
|
|
||||||
|
#### PMO Music
|
||||||
|
```http
|
||||||
|
Status: 200 OK
|
||||||
|
Content-Type: (absent) ⚠️ PROBLÈME CRITIQUE
|
||||||
|
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
|
||||||
|
<Source></Source> ⚠️ Vide
|
||||||
|
<Sink></Sink> ⚠️ Vide
|
||||||
|
</u:GetProtocolInfoResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Upmpdcli
|
||||||
|
```http
|
||||||
|
Status: 200 OK
|
||||||
|
Content-Type: text/xml; charset="utf-8" ✅ Présent
|
||||||
|
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
|
||||||
|
<Source></Source>
|
||||||
|
<Sink>http-get:*:audio/flac:*,http-get:*:audio/mp3:*,...</Sink> ✅ Formats listés
|
||||||
|
</u:GetProtocolInfoResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: ContentDirectory::Browse
|
||||||
|
|
||||||
|
Les deux serveurs répondent correctement, mais PMO Music manque toujours le header `Content-Type`.
|
||||||
|
|
||||||
|
## Problèmes Identifiés par Ordre de Criticité
|
||||||
|
|
||||||
|
### 🔴 CRITIQUE
|
||||||
|
|
||||||
|
1. **Absence du header Content-Type dans les réponses SOAP**
|
||||||
|
- **Impact:** Les clients UPnP stricts (comme BubbleUPnP) peuvent rejeter les réponses sans Content-Type
|
||||||
|
- **Spec UPnP:** La spécification UPnP Device Architecture 1.0 exige `Content-Type: text/xml; charset="utf-8"`
|
||||||
|
- **Localisation probable:** Dans le code de réponse SOAP du serveur UPnP
|
||||||
|
- **Fichiers à vérifier:**
|
||||||
|
- `pmoupnp/src/services/service_instance.rs` (handler SOAP)
|
||||||
|
- `pmoupnp/src/soap/builder.rs`
|
||||||
|
|
||||||
|
2. **ProtocolInfo vide pour Source et Sink**
|
||||||
|
- **Impact:** Les clients ne savent pas quels formats audio sont supportés
|
||||||
|
- **Spec UPnP:** ConnectionManager doit annoncer les formats supportés
|
||||||
|
- **Action:** Implémenter la liste des formats dans ConnectionManager
|
||||||
|
|
||||||
|
### 🟡 MOYEN
|
||||||
|
|
||||||
|
3. **specVersion 1.0 au lieu de 1.1**
|
||||||
|
- **Impact:** Certains clients modernes peuvent filtrer les devices UPnP 1.0
|
||||||
|
- **Solution:** Passer à specVersion 1.1
|
||||||
|
|
||||||
|
4. **Absence d'iconList**
|
||||||
|
- **Impact:** Pas d'icône visible dans les clients UPnP
|
||||||
|
- **Solution:** Ajouter au moins une icône PNG 64x64
|
||||||
|
|
||||||
|
### 🟢 FAIBLE
|
||||||
|
|
||||||
|
5. **Ordre des éléments XML différent**
|
||||||
|
- **Impact:** Minimal - XML valide dans tous les cas
|
||||||
|
- **Action:** Optionnel - standardiser l'ordre
|
||||||
|
|
||||||
|
## Recommandations d'Implémentation
|
||||||
|
|
||||||
|
### Priorité 1: Corriger le Content-Type
|
||||||
|
|
||||||
|
Localiser le code qui génère les réponses SOAP et ajouter le header:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Dans pmoupnp/src/services/service_instance.rs ou similaire
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")], // ← AJOUTER
|
||||||
|
xml
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Priorité 2: Implémenter GetProtocolInfo correctement
|
||||||
|
|
||||||
|
Dans ConnectionManager, retourner la liste des formats supportés:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Exemple de formats à supporter
|
||||||
|
let sink_protocols = vec![
|
||||||
|
"http-get:*:audio/flac:*",
|
||||||
|
"http-get:*:audio/mpeg:*",
|
||||||
|
"http-get:*:audio/mp4:*",
|
||||||
|
"http-get:*:audio/ogg:*",
|
||||||
|
// ...
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Priorité 3: Passer à UPnP 1.1
|
||||||
|
|
||||||
|
Changer la specVersion de 1.0 à 1.1 dans le device descriptor.
|
||||||
|
|
||||||
|
### Priorité 4: Ajouter une icône
|
||||||
|
|
||||||
|
Créer une icône PNG 64x64 et l'ajouter au descripteur:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<iconList>
|
||||||
|
<icon>
|
||||||
|
<mimetype>image/png</mimetype>
|
||||||
|
<width>64</width>
|
||||||
|
<height>64</height>
|
||||||
|
<depth>32</depth>
|
||||||
|
<url>/icon.png</url>
|
||||||
|
</icon>
|
||||||
|
</iconList>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers à Modifier
|
||||||
|
|
||||||
|
1. **pmoupnp/src/services/service_instance.rs** - Ajouter Content-Type aux réponses SOAP
|
||||||
|
2. **pmoupnp/src/devices/device_methods.rs** - Ajouter iconList au descripteur
|
||||||
|
3. **pmoupnp/src/devices/device.rs** - Passer specVersion à 1.1
|
||||||
|
4. **pmomediaserver/src/connectionmanager/actions/getprotocolinfo.rs** - Implémenter la liste des formats
|
||||||
|
|
||||||
|
## Tests de Validation
|
||||||
|
|
||||||
|
Après les corrections, vérifier:
|
||||||
|
|
||||||
|
1. ✅ `curl` sur le descripteur montre specVersion 1.1 et iconList
|
||||||
|
2. ✅ Requête SOAP GetProtocolInfo retourne `Content-Type: text/xml`
|
||||||
|
3. ✅ GetProtocolInfo retourne les formats supportés dans Sink
|
||||||
|
4. ✅ BubbleUPnP détecte et affiche le serveur PMO Music
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Le serveur PMO Music est **fonctionnellement correct** au niveau de SSDP et des services SOAP, mais présente des problèmes de conformité aux standards UPnP qui peuvent causer des rejets par certains clients stricts comme BubbleUPnP.
|
||||||
|
|
||||||
|
Les corrections sont simples et localisées. La priorité absolue est d'ajouter le header `Content-Type` aux réponses SOAP.
|
||||||
128
UPNP_FIX_SUMMARY.md
Normal file
128
UPNP_FIX_SUMMARY.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Résolution du Problème UPnP - PMO Music MediaServer
|
||||||
|
|
||||||
|
**Date:** 2025-11-26
|
||||||
|
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
|
||||||
|
|
||||||
|
## Diagnostic
|
||||||
|
|
||||||
|
Après une analyse approfondie avec des outils de découverte UPnP et de tests SOAP, le problème identifié était :
|
||||||
|
|
||||||
|
**🔴 PROBLÈME CRITIQUE : `SourceProtocolInfo` vide**
|
||||||
|
|
||||||
|
Le service `ConnectionManager` du MediaServer retournait des valeurs vides pour `SourceProtocolInfo`, ce qui empêchait les clients UPnP (comme BubbleUPnP) de savoir quels formats audio le serveur pouvait fournir.
|
||||||
|
|
||||||
|
### Réponse AVANT la correction :
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<u:GetProtocolInfoResponse>
|
||||||
|
<Source></Source> <!-- ❌ VIDE -->
|
||||||
|
<Sink></Sink>
|
||||||
|
</u:GetProtocolInfoResponse>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Solution Implémentée
|
||||||
|
|
||||||
|
### 1. Nouveau Module : `device_ext.rs`
|
||||||
|
|
||||||
|
Création d'un trait d'extension `MediaServerDeviceExt` pour `Arc<DeviceInstance>` qui initialise automatiquement les `ProtocolInfo`.
|
||||||
|
|
||||||
|
**Fichier:** [`pmomediaserver/src/device_ext.rs`](pmomediaserver/src/device_ext.rs)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait MediaServerDeviceExt {
|
||||||
|
/// Initialise les ProtocolInfo du ConnectionManager pour PMO Music.
|
||||||
|
///
|
||||||
|
/// PMO Music convertit tous les flux audio en FLAC (et OGG-FLAC).
|
||||||
|
fn init_protocol_info(&self);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Formats Supportés
|
||||||
|
|
||||||
|
PMO Music convertit tout au vol en FLAC, donc `SourceProtocolInfo` annonce :
|
||||||
|
|
||||||
|
- `http-get:*:audio/flac:*` - FLAC standard
|
||||||
|
- `http-get:*:audio/x-flac:*` - FLAC (format alternatif)
|
||||||
|
- `http-get:*:application/flac:*` - FLAC (MIME type alternatif)
|
||||||
|
- `http-get:*:application/x-flac:*` - FLAC (MIME type alternatif)
|
||||||
|
- `http-get:*:application/ogg:*` - OGG-FLAC
|
||||||
|
- `http-get:*:audio/ogg:*` - OGG-FLAC
|
||||||
|
- `http-get:*:audio/x-ogg:*` - OGG-FLAC (format alternatif)
|
||||||
|
|
||||||
|
### 3. Intégration dans `main.rs`
|
||||||
|
|
||||||
|
**Fichier:** [`PMOMusic/src/main.rs`](PMOMusic/src/main.rs)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmomediaserver::MediaServerDeviceExt;
|
||||||
|
|
||||||
|
let server_instance = server
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.register_device(MEDIA_SERVER.clone())
|
||||||
|
.await
|
||||||
|
.expect("Failed to register MediaServer");
|
||||||
|
|
||||||
|
// ✅ Initialiser les ProtocolInfo du MediaServer
|
||||||
|
server_instance.init_protocol_info();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Export dans `lib.rs`
|
||||||
|
|
||||||
|
**Fichier:** [`pmomediaserver/src/lib.rs`](pmomediaserver/src/lib.rs)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod device_ext;
|
||||||
|
pub use device_ext::MediaServerDeviceExt;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Réponse APRÈS la correction
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<u:GetProtocolInfoResponse>
|
||||||
|
<Source>http-get:*:audio/flac:*,http-get:*:audio/x-flac:*,http-get:*:application/flac:*,http-get:*:application/x-flac:*,http-get:*:application/ogg:*,http-get:*:audio/ogg:*,http-get:*:audio/x-ogg:*</Source> <!-- ✅ INITIALISÉ -->
|
||||||
|
<Sink></Sink> <!-- ✅ Vide pour un MediaServer (normal) -->
|
||||||
|
</u:GetProtocolInfoResponse>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers Modifiés
|
||||||
|
|
||||||
|
1. ✅ **Nouveau:** `pmomediaserver/src/device_ext.rs` - Trait d'extension pour initialiser ProtocolInfo
|
||||||
|
2. ✅ **Modifié:** `pmomediaserver/src/lib.rs` - Export du trait
|
||||||
|
3. ✅ **Modifié:** `PMOMusic/src/main.rs` - Appel à `init_protocol_info()`
|
||||||
|
|
||||||
|
## Test de Validation
|
||||||
|
|
||||||
|
Après redémarrage du serveur PMO Music, vérifier avec :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/test_soap.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou directement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Content-Type: text/xml" \
|
||||||
|
-H "SOAPAction: \"urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo\"" \
|
||||||
|
-d '<?xml version="1.0"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetProtocolInfo xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1"/>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>' \
|
||||||
|
http://localhost:8080/device/.../service/ConnectionManager/control
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prochaines Étapes
|
||||||
|
|
||||||
|
1. ✅ Redémarrer le serveur PMO Music
|
||||||
|
2. ⏳ Tester avec BubbleUPnP pour confirmer que le serveur est maintenant reconnu
|
||||||
|
3. ⏳ (Optionnel) Ajouter une icône pour le MediaServer (amélioration UX)
|
||||||
|
4. ⏳ (Optionnel) Passer à specVersion 1.1 (amélioration de compatibilité)
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- Rapport d'analyse complet : [`UPNP_ANALYSIS_REPORT.md`](UPNP_ANALYSIS_REPORT.md)
|
||||||
|
- UPnP AV Architecture Specification :
|
||||||
|
https://openconnectivity.org/developer/specifications/upnp-resources/upnp/
|
||||||
31
analyze_flac.sh
Executable file
31
analyze_flac.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Affiche toutes les stats d'un fichier FLAC
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "Usage: $0 <fichier.flac>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
FILE="$1"
|
||||||
|
|
||||||
|
if [ ! -f "$FILE" ]; then
|
||||||
|
echo "Error: File not found: $FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Analyzing: $(basename "$FILE") ==="
|
||||||
|
echo ""
|
||||||
|
echo "--- SoX Statistics ---"
|
||||||
|
sox "$FILE" -n stat 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- File Info ---"
|
||||||
|
file "$FILE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- FLAC Metadata ---"
|
||||||
|
metaflac --list "$FILE" 2>/dev/null || echo "metaflac not installed"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- Audio Integrity Check ---"
|
||||||
|
flac -t "$FILE" 2>&1 || echo "flac not installed"
|
||||||
BIN
bubble_upmpdcli.pcap
Normal file
BIN
bubble_upmpdcli.pcap
Normal file
Binary file not shown.
90
check_audio_quality.sh
Executable file
90
check_audio_quality.sh
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script pour vérifier la qualité audio (détection de clics via le ratio delta)
|
||||||
|
|
||||||
|
CACHE_DIR="${1:-/tmp/pmomusic_test/audio_cache}"
|
||||||
|
THRESHOLD=10.0 # Ratio Maximum delta / Mean delta acceptable
|
||||||
|
|
||||||
|
if [ ! -d "$CACHE_DIR" ]; then
|
||||||
|
echo "Error: Cache directory not found: $CACHE_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Audio Quality Check ==="
|
||||||
|
echo "Scanning: $CACHE_DIR"
|
||||||
|
echo "Threshold: Maximum/Mean delta ratio < $THRESHOLD"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Vérifier que sox est installé
|
||||||
|
if ! command -v sox &> /dev/null; then
|
||||||
|
echo "Error: sox is not installed. Install it with: sudo apt install sox"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
count=0
|
||||||
|
suspicious=0
|
||||||
|
good=0
|
||||||
|
|
||||||
|
for file in "$CACHE_DIR"/*.orig.flac; do
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
echo "No FLAC files found in $CACHE_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
filename=$(basename "$file")
|
||||||
|
|
||||||
|
# Obtenir les stats delta
|
||||||
|
stats=$(sox "$file" -n stat 2>&1)
|
||||||
|
|
||||||
|
max_delta=$(echo "$stats" | grep "Maximum delta" | awk '{print $3}')
|
||||||
|
mean_delta=$(echo "$stats" | grep "Mean delta" | awk '{print $3}')
|
||||||
|
|
||||||
|
if [ -z "$max_delta" ] || [ -z "$mean_delta" ]; then
|
||||||
|
echo "❌ $filename - Cannot parse stats"
|
||||||
|
suspicious=$((suspicious + 1))
|
||||||
|
count=$((count + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Éviter division par zéro
|
||||||
|
if (( $(echo "$mean_delta == 0" | bc -l) )); then
|
||||||
|
echo "❌ $filename - Invalid mean delta (0)"
|
||||||
|
suspicious=$((suspicious + 1))
|
||||||
|
count=$((count + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Calculer le ratio
|
||||||
|
ratio=$(echo "scale=2; $max_delta / $mean_delta" | bc -l)
|
||||||
|
|
||||||
|
# Comparer au seuil
|
||||||
|
is_bad=$(echo "$ratio > $THRESHOLD" | bc -l)
|
||||||
|
|
||||||
|
if [ "$is_bad" = "1" ]; then
|
||||||
|
echo "⚠️ CLICKS DETECTED: $filename"
|
||||||
|
echo " Max delta: $max_delta, Mean delta: $mean_delta, Ratio: ${ratio}x (threshold: ${THRESHOLD}x)"
|
||||||
|
suspicious=$((suspicious + 1))
|
||||||
|
else
|
||||||
|
echo "✓ OK: $filename (ratio: ${ratio}x)"
|
||||||
|
good=$((good + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary ==="
|
||||||
|
echo "Total files scanned: $count"
|
||||||
|
echo "✓ Good quality: $good"
|
||||||
|
echo "⚠️ Clicks detected: $suspicious"
|
||||||
|
|
||||||
|
if [ $suspicious -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Warning: $suspicious file(s) have clicks."
|
||||||
|
echo "These files were likely encoded with the old buffer size (8)."
|
||||||
|
echo "Delete the cache and re-download to fix: rm -rf $CACHE_DIR/*.flac"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ All files are good quality!"
|
||||||
|
exit 0
|
||||||
76
detect_clicks.sh
Executable file
76
detect_clicks.sh
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script pour détecter les clics dans les fichiers FLAC du cache
|
||||||
|
|
||||||
|
CACHE_DIR="${1:-/tmp/pmomusic_test/audio_cache}"
|
||||||
|
|
||||||
|
if [ ! -d "$CACHE_DIR" ]; then
|
||||||
|
echo "Error: Cache directory not found: $CACHE_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== FLAC Click Detection Tool ==="
|
||||||
|
echo "Scanning: $CACHE_DIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Vérifier que sox est installé
|
||||||
|
if ! command -v sox &> /dev/null; then
|
||||||
|
echo "Error: sox is not installed. Install it with: sudo apt install sox"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
count=0
|
||||||
|
suspicious=0
|
||||||
|
|
||||||
|
for file in "$CACHE_DIR"/*.orig.flac; do
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
echo "No FLAC files found in $CACHE_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
filename=$(basename "$file")
|
||||||
|
echo "Analyzing: $filename"
|
||||||
|
|
||||||
|
# Obtenir toutes les stats
|
||||||
|
stats=$(sox "$file" -n stat 2>&1)
|
||||||
|
|
||||||
|
# Extraire les valeurs importantes
|
||||||
|
pk_lev=$(echo "$stats" | grep "Pk lev dB" | awk '{print $4}')
|
||||||
|
rms_lev=$(echo "$stats" | grep "RMS lev dB" | awk '{print $4}')
|
||||||
|
crest=$(echo "$stats" | grep "Crest factor" | awk '{print $3}')
|
||||||
|
|
||||||
|
echo " Peak level: ${pk_lev:-N/A} dB"
|
||||||
|
echo " RMS level: ${rms_lev:-N/A} dB"
|
||||||
|
echo " Crest factor: ${crest:-N/A} dB"
|
||||||
|
|
||||||
|
# Analyser la variance d'amplitude (détection de clics)
|
||||||
|
# On compte le nombre de pics au-dessus d'un seuil
|
||||||
|
peaks=$(sox "$file" -n stats 2>&1 | grep "Maximum amplitude" | awk '{print $3}')
|
||||||
|
|
||||||
|
if [ -n "$peaks" ]; then
|
||||||
|
# Si le peak est proche de 1.0 (clipping), c'est suspect
|
||||||
|
is_clipping=$(echo "$peaks > 0.95" | bc -l 2>/dev/null)
|
||||||
|
if [ "$is_clipping" = "1" ]; then
|
||||||
|
echo " ⚠️ WARNING: Possible clipping detected!"
|
||||||
|
suspicious=$((suspicious + 1))
|
||||||
|
else
|
||||||
|
echo " ✓ OK"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ✓ OK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=== Summary ==="
|
||||||
|
echo "Files scanned: $count"
|
||||||
|
echo "Suspicious files: $suspicious"
|
||||||
|
|
||||||
|
if [ $suspicious -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Some files may have issues. Listen to them carefully."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
135
docker-build.sh
Executable file
135
docker-build.sh
Executable file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script de build Docker pour PMOMusic
|
||||||
|
# Usage: ./docker-build.sh [OPTIONS]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Couleurs pour l'affichage
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration par défaut
|
||||||
|
IMAGE_NAME="pmomusic"
|
||||||
|
TAG="latest"
|
||||||
|
NO_CACHE=false
|
||||||
|
PUSH=true
|
||||||
|
REGISTRY="niepce.petite-maison-orange.fr/public"
|
||||||
|
|
||||||
|
# Fonction d'aide
|
||||||
|
show_help() {
|
||||||
|
echo "Usage: $0 [OPTIONS]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -t, --tag TAG Tag de l'image (défaut: latest)"
|
||||||
|
echo " -r, --registry URL Registry Docker (ex: ghcr.io/user)"
|
||||||
|
echo " -n, --no-cache Build sans cache"
|
||||||
|
echo " -p, --push Push l'image vers le registry"
|
||||||
|
echo " -h, --help Affiche cette aide"
|
||||||
|
echo ""
|
||||||
|
echo "Exemples:"
|
||||||
|
echo " $0 # Build local avec tag 'latest'"
|
||||||
|
echo " $0 -t v1.0.0 # Build avec tag 'v1.0.0'"
|
||||||
|
echo " $0 -t v1.0.0 -r ghcr.io/user -p # Build et push vers GHCR"
|
||||||
|
echo " $0 -n # Build sans cache"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parsing des arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-t|--tag)
|
||||||
|
TAG="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-r|--registry)
|
||||||
|
REGISTRY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-n|--no-cache)
|
||||||
|
NO_CACHE=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-p|--push)
|
||||||
|
PUSH=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Erreur: Option inconnue '$1'${NC}"
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Construire le nom complet de l'image
|
||||||
|
if [ -n "$REGISTRY" ]; then
|
||||||
|
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG"
|
||||||
|
else
|
||||||
|
FULL_IMAGE_NAME="$IMAGE_NAME:$TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Afficher la configuration
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN}Build Docker PMOMusic${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Image: $FULL_IMAGE_NAME"
|
||||||
|
echo "No cache: $NO_CACHE"
|
||||||
|
echo "Push: $PUSH"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Construire la commande Docker
|
||||||
|
DOCKER_CMD="docker build"
|
||||||
|
|
||||||
|
if [ "$NO_CACHE" = true ]; then
|
||||||
|
DOCKER_CMD="$DOCKER_CMD --no-cache"
|
||||||
|
fi
|
||||||
|
|
||||||
|
DOCKER_CMD="$DOCKER_CMD --build-arg BUILD_DATE=\"$(date -u +'%Y-%m-%dT%H:%M:%SZ')\" -t $FULL_IMAGE_NAME ."
|
||||||
|
|
||||||
|
# Exécuter le build
|
||||||
|
echo -e "${YELLOW}→ Démarrage du build...${NC}"
|
||||||
|
echo "Commande: $DOCKER_CMD"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if eval "$DOCKER_CMD"; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✓ Build réussi !${NC}"
|
||||||
|
|
||||||
|
# Afficher la taille de l'image
|
||||||
|
IMAGE_SIZE=$(docker images "$FULL_IMAGE_NAME" --format "{{.Size}}")
|
||||||
|
echo "Taille de l'image: $IMAGE_SIZE"
|
||||||
|
|
||||||
|
# Push si demandé
|
||||||
|
if [ "$PUSH" = true ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}→ Push de l'image vers le registry...${NC}"
|
||||||
|
|
||||||
|
if docker push "$FULL_IMAGE_NAME"; then
|
||||||
|
echo -e "${GREEN}✓ Image pushée avec succès !${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Erreur lors du push${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN}Build terminé avec succès !${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Pour lancer le conteneur:"
|
||||||
|
echo " docker run -it --rm --network host $FULL_IMAGE_NAME"
|
||||||
|
echo ""
|
||||||
|
echo "Ou avec docker-compose:"
|
||||||
|
echo " docker-compose up -d"
|
||||||
|
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}✗ Erreur lors du build${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
pmomusic:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: pmomusic:latest
|
||||||
|
container_name: pmomusic
|
||||||
|
|
||||||
|
# Port mapping (adjust to your needs)
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
# Volume for persistent configuration
|
||||||
|
volumes:
|
||||||
|
- ./config:/home/pmomusic/.pmomusic
|
||||||
|
- ./cache:/home/pmomusic/cache
|
||||||
|
|
||||||
|
# Environment variables (adjust as needed)
|
||||||
|
environment:
|
||||||
|
- RUST_LOG=info
|
||||||
|
# Add other environment variables here
|
||||||
|
|
||||||
|
# Network mode for UPnP/DLNA discovery (host mode for multicast)
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Resource limits (optional)
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2.0'
|
||||||
|
memory: 1G
|
||||||
|
reservations:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 256M
|
||||||
5
headers.txt
Normal file
5
headers.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
HTTP/1.1 200 OK
|
||||||
|
content-type: text/xml; charset="utf-8"
|
||||||
|
content-length: 1593
|
||||||
|
date: Mon, 20 Oct 2025 17:44:48 GMT
|
||||||
|
|
||||||
431
media_
Normal file
431
media_
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoserver/src/config_ext.rs:35:5
|
||||||
|
|
|
||||||
|
35 | async fn init_config_api(&mut self) -> Result<()>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
35 - async fn init_config_api(&mut self) -> Result<()>;
|
||||||
|
35 + fn init_config_api(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: `pmoserver` (lib) generated 1 warning
|
||||||
|
warning: variable does not need to be mutable
|
||||||
|
--> pmocache/src/cache.rs:604:9
|
||||||
|
|
|
||||||
|
604 | mut reader: R,
|
||||||
|
| ----^^^^^^
|
||||||
|
| |
|
||||||
|
| help: remove this `mut`
|
||||||
|
|
|
||||||
|
= note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:97:5
|
||||||
|
|
|
||||||
|
97 | async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
97 - async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
|
||||||
|
97 + fn add_from_url(&self, url: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:111:5
|
||||||
|
|
|
||||||
|
111 | async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
111 - async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
|
||||||
|
111 + fn add_from_file(&self, path: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:118:5
|
||||||
|
|
|
||||||
|
118 | async fn get(&self, pk: &str) -> Result<PathBuf>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
118 - async fn get(&self, pk: &str) -> Result<PathBuf>;
|
||||||
|
118 + fn get(&self, pk: &str) -> impl std::future::Future<Output = Result<PathBuf>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:125:5
|
||||||
|
|
|
||||||
|
125 | async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
125 - async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
|
||||||
|
125 + fn get_collection(&self, collection: &str) -> impl std::future::Future<Output = Result<Vec<PathBuf>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:128:5
|
||||||
|
|
|
||||||
|
128 | async fn purge(&self) -> Result<()>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
128 - async fn purge(&self) -> Result<()>;
|
||||||
|
128 + fn purge(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:131:5
|
||||||
|
|
|
||||||
|
131 | async fn consolidate(&self) -> Result<()>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
131 - async fn consolidate(&self) -> Result<()>;
|
||||||
|
131 + fn consolidate(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:147:5
|
||||||
|
|
|
||||||
|
147 | async fn is_valid_pk(&self, pk: &str) -> bool {
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
147 ~ fn is_valid_pk(&self, pk: &str) -> impl std::future::Future<Output = bool> + Send {async {
|
||||||
|
148 | if self.get_database().get(pk, false).is_err() {
|
||||||
|
...
|
||||||
|
218 | false
|
||||||
|
219 ~ } }
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/pmoserver_ext.rs:391:5
|
||||||
|
|
|
||||||
|
391 | async fn init_generic_cache<C: CacheConfig + 'static>(
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
391 ~ fn init_generic_cache<C: CacheConfig + 'static>(
|
||||||
|
392 | &mut self,
|
||||||
|
...
|
||||||
|
395 | content_type: &'static str,
|
||||||
|
396 ~ ) -> impl std::future::Future<Output = anyhow::Result<Arc<Cache<C>>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: `pmocache` (lib) generated 9 warnings (run `cargo fix --lib -p pmocache` to apply 1 suggestion)
|
||||||
|
warning: unused import: `serde_json::Value`
|
||||||
|
--> pmoaudiocache/src/cache.rs:11:5
|
||||||
|
|
|
||||||
|
11 | use serde_json::Value;
|
||||||
|
| ^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||||
|
|
||||||
|
warning: unused import: `pmometadata::TrackMetadata`
|
||||||
|
--> pmoaudiocache/src/api.rs:11:5
|
||||||
|
|
|
||||||
|
11 | use pmometadata::TrackMetadata;
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/lib.rs:188:5
|
||||||
|
|
|
||||||
|
188 | async fn init_audio_cache(
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
188 ~ fn init_audio_cache(
|
||||||
|
189 | &mut self,
|
||||||
|
190 | cache_dir: &str,
|
||||||
|
191 | limit: usize,
|
||||||
|
192 ~ ) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/lib.rs:197:5
|
||||||
|
|
|
||||||
|
197 | async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
197 - async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
|
||||||
|
197 + fn init_audio_cache_configured(&mut self) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/metadata_ext.rs:36:5
|
||||||
|
|
|
||||||
|
36 | async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
36 - async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
36 + fn get_title(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/metadata_ext.rs:37:5
|
||||||
|
|
|
||||||
|
37 | async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
37 - async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
37 + fn get_artist(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/metadata_ext.rs:38:5
|
||||||
|
|
|
||||||
|
38 | async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
38 - async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
38 + fn get_album(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/metadata_ext.rs:39:5
|
||||||
|
|
|
||||||
|
39 | async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
39 - async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
|
||||||
|
39 + fn get_duration_secs(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<i64>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: `pmoaudiocache` (lib) generated 8 warnings (run `cargo fix --lib -p pmoaudiocache` to apply 1 suggestion)
|
||||||
|
warning: unused import: `tokio_stream::StreamExt`
|
||||||
|
--> pmoplaylist/src/sse.rs:16:5
|
||||||
|
|
|
||||||
|
16 | use tokio_stream::StreamExt;
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||||
|
|
||||||
|
warning: methods `list_playlist_ids` and `remove_by_cache_pk` are never used
|
||||||
|
--> pmoplaylist/src/persistence/mod.rs:219:18
|
||||||
|
|
|
||||||
|
17 | impl PersistenceManager {
|
||||||
|
| ----------------------- methods in this implementation
|
||||||
|
...
|
||||||
|
219 | pub async fn list_playlist_ids(&self) -> Result<Vec<String>> {
|
||||||
|
| ^^^^^^^^^^^^^^^^^
|
||||||
|
...
|
||||||
|
240 | pub async fn remove_by_cache_pk(&self, cache_pk: &str) -> Result<()> {
|
||||||
|
| ^^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
|
||||||
|
|
||||||
|
warning: function `PlaylistManager` should have a snake case name
|
||||||
|
--> pmoplaylist/src/manager.rs:620:8
|
||||||
|
|
|
||||||
|
620 | pub fn PlaylistManager() -> &'static PlaylistManager {
|
||||||
|
| ^^^^^^^^^^^^^^^ help: convert the identifier to snake case: `playlist_manager`
|
||||||
|
|
|
||||||
|
= note: `#[warn(non_snake_case)]` (part of `#[warn(nonstandard_style)]`) on by default
|
||||||
|
|
||||||
|
warning: `pmoplaylist` (lib) generated 3 warnings
|
||||||
|
warning: unused variable: `base_url`
|
||||||
|
--> pmoupnp/src/upnp_server.rs:306:13
|
||||||
|
|
|
||||||
|
306 | let base_url = self.info().base_url.clone();
|
||||||
|
| ^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_base_url`
|
||||||
|
|
|
||||||
|
= note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_api.rs:229:5
|
||||||
|
|
|
||||||
|
229 | async fn register_upnp_api(&mut self);
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
229 - async fn register_upnp_api(&mut self);
|
||||||
|
229 + fn register_upnp_api(&mut self) -> impl std::future::Future<Output = ()> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_server.rs:96:5
|
||||||
|
|
|
||||||
|
96 | async fn register_device(
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
96 ~ fn register_device(
|
||||||
|
97 | &mut self,
|
||||||
|
98 | device: Arc<Device>,
|
||||||
|
99 ~ ) -> impl std::future::Future<Output = Result<Arc<DeviceInstance>, DeviceError>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_server.rs:125:5
|
||||||
|
|
|
||||||
|
125 | async fn init_cover_cache(
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
125 ~ fn init_cover_cache(
|
||||||
|
126 | &mut self,
|
||||||
|
127 | cache_dir: &str,
|
||||||
|
128 | limit: usize,
|
||||||
|
129 ~ ) -> impl std::future::Future<Output = Result<Arc<CoverCache>, anyhow::Error>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_server.rs:144:5
|
||||||
|
|
|
||||||
|
144 | async fn init_audio_cache(
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
144 ~ fn init_audio_cache(
|
||||||
|
145 | &mut self,
|
||||||
|
146 | cache_dir: &str,
|
||||||
|
147 | limit: usize,
|
||||||
|
148 ~ ) -> impl std::future::Future<Output = Result<Arc<AudioCache>, anyhow::Error>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_server.rs:158:5
|
||||||
|
|
|
||||||
|
158 | async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
158 - async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
|
||||||
|
158 + fn init_caches(&mut self) -> impl std::future::Future<Output = Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_server.rs:225:5
|
||||||
|
|
|
||||||
|
225 | async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
225 - async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
|
||||||
|
225 + fn create_upnp_server() -> impl std::future::Future<Output = Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: `pmoupnp` (lib) generated 7 warnings
|
||||||
|
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s
|
||||||
|
Running `target/debug/examples/media_server_events_demo`
|
||||||
|
ControlPoint started; waiting 5s for discovery...
|
||||||
|
Discovered media servers:
|
||||||
|
- BubbleUPnP Media Server (SM-A536B) | model=BubbleUPnP Media Server | udn=uuid:d38a2dc7-13c1-4a39-ab36-513490cf6766 | location=http://192.168.0.98:58645/dev/d38a2dc7-13c1-4a39-ab36-513490cf6766/desc.xml
|
||||||
|
- fenice | model=Jellyfin Server | udn=uuid:526dedec-fde2-4224-bac6-06f7b11711cf | location=http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml
|
||||||
|
- PMOMusic Media Server | model=PMOMusic Media Server | udn=uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 | location=http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml
|
||||||
|
- Freebox Server | model=Freebox Media Server | udn=uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | location=http://192.168.0.254:52424/device.xml
|
||||||
|
Listening for ContentDirectory events for 90 seconds...
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=27)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:history
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=28)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=30)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=31)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=32)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=33)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=37)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=40)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=41)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=42)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=44)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=45)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=46)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=49)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=50)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=51)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=52)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=53)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=54)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=55)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=56)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=57)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=59)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=63)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=64)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=65)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=66)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=67)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=68)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=69)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
Monitoring finished.
|
||||||
80
old_code/pmoaudio/examples/multiroom_demo.rs
Normal file
80
old_code/pmoaudio/examples/multiroom_demo.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
//! Exemple de configuration multiroom avec BufferNode
|
||||||
|
//!
|
||||||
|
//! Démontre l'utilisation du buffer circulaire pour synchroniser
|
||||||
|
//! plusieurs sorties avec des délais différents
|
||||||
|
|
||||||
|
use pmoaudio::{BufferNode, SinkNode, SourceNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Multiroom Demo ===\n");
|
||||||
|
|
||||||
|
// Buffer avec capacité pour gérer les délais
|
||||||
|
let (buffer, buffer_tx) = BufferNode::new(50, 10);
|
||||||
|
|
||||||
|
// Créer 3 sorties avec délais différents
|
||||||
|
let (sink1, sink1_tx) = SinkNode::new("Room 1 (no delay)".to_string(), 10);
|
||||||
|
let (sink2, sink2_tx) = SinkNode::new("Room 2 (5 chunks delay)".to_string(), 10);
|
||||||
|
let (sink3, sink3_tx) = SinkNode::new("Room 3 (10 chunks delay)".to_string(), 10);
|
||||||
|
|
||||||
|
buffer.add_subscriber_with_offset(sink1_tx, 0).await;
|
||||||
|
buffer.add_subscriber_with_offset(sink2_tx, 5).await;
|
||||||
|
buffer.add_subscriber_with_offset(sink3_tx, 10).await;
|
||||||
|
|
||||||
|
// Spawn buffer et sinks
|
||||||
|
tokio::spawn(async move {
|
||||||
|
buffer.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink1_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink1.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink2_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink2.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink3_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink3.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
// Générer de l'audio dans une tâche séparée
|
||||||
|
println!("Generating audio for multiroom playback...\n");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
source.add_subscriber(buffer_tx);
|
||||||
|
source
|
||||||
|
.generate_chunks(30, 4800, 48000, 440.0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("Waiting for all rooms to finish...\n");
|
||||||
|
|
||||||
|
// Attendre toutes les sorties
|
||||||
|
let stats1 = sink1_handle.await.unwrap();
|
||||||
|
let stats2 = sink2_handle.await.unwrap();
|
||||||
|
let stats3 = sink3_handle.await.unwrap();
|
||||||
|
|
||||||
|
println!("\n=== Multiroom Summary ===");
|
||||||
|
println!(
|
||||||
|
"{}: {} chunks received",
|
||||||
|
stats1.name, stats1.chunks_received
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}: {} chunks received",
|
||||||
|
stats2.name, stats2.chunks_received
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}: {} chunks received",
|
||||||
|
stats3.name, stats3.chunks_received
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("\nNote: Delayed rooms receive fewer chunks due to the offset");
|
||||||
|
}
|
||||||
168
old_code/pmoaudio/examples/multiroom_volume_demo.rs
Normal file
168
old_code/pmoaudio/examples/multiroom_volume_demo.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//! Exemple complet de pipeline multiroom avec contrôle de volume
|
||||||
|
//!
|
||||||
|
//! Ce programme démontre :
|
||||||
|
//! - Une source audio unique
|
||||||
|
//! - Deux branches de sortie : Chromecast et DiskSink
|
||||||
|
//! - Un volume master avec deux VolumeNodes secondaires synchronisés
|
||||||
|
//! - Système d'événements pour la communication entre nodes
|
||||||
|
|
||||||
|
use pmoaudio::{
|
||||||
|
ChromecastConfig, ChromecastSink, DiskSink, DiskSinkConfig, SourceNode, VolumeNode,
|
||||||
|
};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("=== PMOAudio Multiroom Volume Demo ===\n");
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
let sample_rate = 48000u32;
|
||||||
|
let chunk_size = 4800usize; // 100ms à 48kHz
|
||||||
|
let num_chunks = 50; // 5 secondes de lecture
|
||||||
|
let frequency = 440.0; // La 440 Hz
|
||||||
|
|
||||||
|
// ===== 1. Créer la source audio =====
|
||||||
|
println!("1. Creating audio source...");
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
|
||||||
|
// ===== 2. Créer le volume master =====
|
||||||
|
println!("2. Creating master volume node...");
|
||||||
|
let (mut master_volume, master_tx) = VolumeNode::new("master".to_string(), 1.0, 50);
|
||||||
|
let master_handle = master_volume.get_handle();
|
||||||
|
|
||||||
|
// Channel pour les événements du volume master
|
||||||
|
let (master_event_tx, master_event_rx_chromecast) = mpsc::channel(10);
|
||||||
|
let (_, master_event_rx_disk) = mpsc::channel(10);
|
||||||
|
|
||||||
|
master_volume.subscribe_volume_events(master_event_tx);
|
||||||
|
|
||||||
|
source.add_subscriber(master_tx);
|
||||||
|
|
||||||
|
// ===== 3. Créer les branches de sortie =====
|
||||||
|
|
||||||
|
// Branche 1: Chromecast avec volume secondaire
|
||||||
|
println!("3a. Creating Chromecast output branch...");
|
||||||
|
let (mut chromecast_volume, chromecast_volume_tx) =
|
||||||
|
VolumeNode::new("chromecast_volume".to_string(), 0.8, 50);
|
||||||
|
|
||||||
|
chromecast_volume.set_master_volume_source(master_event_rx_chromecast);
|
||||||
|
|
||||||
|
let chromecast_config = ChromecastConfig {
|
||||||
|
device_address: "192.168.1.100".to_string(),
|
||||||
|
device_name: "Living Room".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (chromecast_sink, chromecast_sink_tx) =
|
||||||
|
ChromecastSink::new("chromecast1".to_string(), chromecast_config, 50);
|
||||||
|
|
||||||
|
chromecast_volume.add_subscriber(chromecast_sink_tx);
|
||||||
|
master_volume.add_subscriber(chromecast_volume_tx);
|
||||||
|
|
||||||
|
// Branche 2: DiskSink avec volume secondaire
|
||||||
|
println!("3b. Creating DiskSink output branch...");
|
||||||
|
let (mut disk_volume, disk_volume_tx) = VolumeNode::new("disk_volume".to_string(), 0.9, 50);
|
||||||
|
|
||||||
|
disk_volume.set_master_volume_source(master_event_rx_disk);
|
||||||
|
|
||||||
|
let disk_config = DiskSinkConfig {
|
||||||
|
output_dir: std::env::temp_dir().join("pmoaudio_demo"),
|
||||||
|
filename: Some("multiroom_output.wav".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (disk_sink, disk_sink_tx) = DiskSink::new("disk1".to_string(), disk_config, 50);
|
||||||
|
|
||||||
|
disk_volume.add_subscriber(disk_sink_tx);
|
||||||
|
master_volume.add_subscriber(disk_volume_tx);
|
||||||
|
|
||||||
|
// ===== 4. Lancer tous les nodes =====
|
||||||
|
println!("4. Starting pipeline nodes...\n");
|
||||||
|
|
||||||
|
// Spawn master volume
|
||||||
|
let master_volume_handle = tokio::spawn(async move {
|
||||||
|
master_volume.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn chromecast branch
|
||||||
|
let chromecast_volume_handle = tokio::spawn(async move {
|
||||||
|
chromecast_volume.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let chromecast_sink_handle = tokio::spawn(async move {
|
||||||
|
let stats = chromecast_sink.run().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn disk branch
|
||||||
|
let disk_volume_handle = tokio::spawn(async move {
|
||||||
|
disk_volume.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let disk_sink_handle = tokio::spawn(async move {
|
||||||
|
let stats = disk_sink.run().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 5. Contrôler le volume pendant la lecture =====
|
||||||
|
let master_handle_clone = master_handle.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Attendre un peu, puis diminuer le volume
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
println!("\n>>> Decreasing master volume to 0.7");
|
||||||
|
master_handle_clone.set_volume(0.7).await;
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
println!(">>> Decreasing master volume to 0.4");
|
||||||
|
master_handle_clone.set_volume(0.4).await;
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
println!(">>> Increasing master volume back to 1.0");
|
||||||
|
master_handle_clone.set_volume(1.0).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 6. Générer et envoyer les chunks audio =====
|
||||||
|
println!("5. Generating and streaming audio...");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
source
|
||||||
|
.generate_chunks(num_chunks, chunk_size, sample_rate, frequency)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!("\n>>> Audio generation complete!");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 7. Attendre la fin de tous les nodes =====
|
||||||
|
println!("6. Waiting for all nodes to complete...\n");
|
||||||
|
|
||||||
|
// Attendre que les sinks terminent
|
||||||
|
chromecast_sink_handle.await?;
|
||||||
|
disk_sink_handle.await?;
|
||||||
|
|
||||||
|
// Nettoyer
|
||||||
|
master_volume_handle.abort();
|
||||||
|
chromecast_volume_handle.abort();
|
||||||
|
disk_volume_handle.abort();
|
||||||
|
|
||||||
|
println!("\n=== Demo completed successfully! ===");
|
||||||
|
println!("\nSummary:");
|
||||||
|
println!(
|
||||||
|
"- Generated {} chunks of {} samples each",
|
||||||
|
num_chunks, chunk_size
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"- Total duration: {:.2} seconds",
|
||||||
|
(num_chunks as usize * chunk_size) as f32 / sample_rate as f32
|
||||||
|
);
|
||||||
|
println!("- Output to Chromecast: Living Room (192.168.1.100)");
|
||||||
|
println!(
|
||||||
|
"- Output to file: {}",
|
||||||
|
std::env::temp_dir()
|
||||||
|
.join("pmoaudio_demo")
|
||||||
|
.join("multiroom_output.wav")
|
||||||
|
.display()
|
||||||
|
);
|
||||||
|
println!("- Master volume control demonstrated with live changes");
|
||||||
|
println!("\nAll streams received synchronized volume updates!");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
126
old_code/pmoaudio/examples/pipeline_demo.rs
Normal file
126
old_code/pmoaudio/examples/pipeline_demo.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
//! Exemple de pipeline audio stéréo complet avec tous les nodes
|
||||||
|
//!
|
||||||
|
//! Pipeline: SourceNode → DecoderNode → DspNode → BufferNode → TimerNode → SinkNode(s)
|
||||||
|
|
||||||
|
use pmoaudio::{BufferNode, DecoderNode, DspNode, SinkNode, SourceNode, TimerNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== PMOAudio Pipeline Demo ===\n");
|
||||||
|
|
||||||
|
// Créer le pipeline de nodes
|
||||||
|
|
||||||
|
// 2. DecoderNode - passthrough dans cet exemple
|
||||||
|
let (mut decoder, decoder_tx) = DecoderNode::new(10);
|
||||||
|
|
||||||
|
// 3. DspNode - applique un gain de 0.5
|
||||||
|
let (mut dsp, dsp_tx) = DspNode::new(10, 0.5);
|
||||||
|
|
||||||
|
// 4. BufferNode - buffer circulaire pour multiroom
|
||||||
|
let (mut buffer, buffer_tx) = BufferNode::new(100, 10);
|
||||||
|
|
||||||
|
// 5. TimerNode - calcule la position temporelle
|
||||||
|
let (mut timer, timer_tx) = TimerNode::new(10);
|
||||||
|
|
||||||
|
// 6. SinkNodes - deux destinations finales
|
||||||
|
let (sink1, sink1_tx) = SinkNode::new("Main Output".to_string(), 10);
|
||||||
|
let (sink2, sink2_tx) = SinkNode::new("Secondary Output".to_string(), 10);
|
||||||
|
|
||||||
|
// Ajouter un abonné au BufferNode avec offset (multiroom simulation)
|
||||||
|
let (sink3, sink3_tx) = SinkNode::new("Delayed Output".to_string(), 10);
|
||||||
|
buffer.add_subscriber_with_offset(sink3_tx, 5).await; // 5 chunks de retard
|
||||||
|
|
||||||
|
// Connecter le pipeline
|
||||||
|
decoder.add_subscriber(dsp_tx);
|
||||||
|
dsp.add_subscriber(buffer_tx);
|
||||||
|
buffer.add_next_subscriber(timer_tx); // BufferNode -> TimerNode
|
||||||
|
timer.add_subscriber(sink1_tx);
|
||||||
|
timer.add_subscriber(sink2_tx);
|
||||||
|
|
||||||
|
// Obtenir un handle pour lire la position du TimerNode
|
||||||
|
let timer_handle = timer.get_position_handle();
|
||||||
|
|
||||||
|
// Spawn tous les nodes
|
||||||
|
let decoder_handle = tokio::spawn(async move {
|
||||||
|
decoder.run_passthrough().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let dsp_handle = tokio::spawn(async move {
|
||||||
|
dsp.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let buffer_handle = tokio::spawn(async move {
|
||||||
|
buffer.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let timer_handle_task = tokio::spawn(async move {
|
||||||
|
timer.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink1_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink1.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink2_handle = tokio::spawn(async move {
|
||||||
|
sink2.run_silent().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink3_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink3.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn une tâche pour afficher la position périodiquement
|
||||||
|
let position_monitor = tokio::spawn(async move {
|
||||||
|
for _ in 0..10 {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
let position = timer_handle.position_sec().await;
|
||||||
|
let samples = timer_handle.elapsed_samples().await;
|
||||||
|
println!("Position: {:.3} sec ({} samples)", position, samples);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Générer des chunks audio
|
||||||
|
println!("Generating audio chunks...\n");
|
||||||
|
let chunk_size = 4800; // 100ms à 48kHz
|
||||||
|
let sample_rate = 48000;
|
||||||
|
let frequency = 440.0; // La 440Hz
|
||||||
|
|
||||||
|
// Source node dans une tâche séparée
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
source.add_subscriber(decoder_tx);
|
||||||
|
|
||||||
|
// Générer 50 chunks (environ 5 secondes)
|
||||||
|
source
|
||||||
|
.generate_chunks(50, chunk_size, sample_rate, frequency)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("\nChunks sent. Processing...\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attendre que tous les nodes terminent
|
||||||
|
decoder_handle.await.unwrap();
|
||||||
|
dsp_handle.await.unwrap();
|
||||||
|
buffer_handle.await.unwrap();
|
||||||
|
timer_handle_task.await.unwrap();
|
||||||
|
|
||||||
|
let stats1 = sink1_handle.await.unwrap();
|
||||||
|
sink2_handle.await.unwrap();
|
||||||
|
let stats3 = sink3_handle.await.unwrap();
|
||||||
|
position_monitor.await.unwrap();
|
||||||
|
|
||||||
|
println!("\n=== Pipeline Demo Complete ===");
|
||||||
|
println!(
|
||||||
|
"Main output processed: {} chunks, {:.3} sec",
|
||||||
|
stats1.chunks_received, stats1.total_duration_sec
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Delayed output processed: {} chunks, {:.3} sec",
|
||||||
|
stats3.chunks_received, stats3.total_duration_sec
|
||||||
|
);
|
||||||
|
}
|
||||||
96
old_code/pmoaudio/examples/quick_start.rs
Normal file
96
old_code/pmoaudio/examples/quick_start.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
//! Quick Start - Démonstration rapide des nouvelles fonctionnalités
|
||||||
|
//!
|
||||||
|
//! Cet exemple montre l'utilisation des principales nouvelles fonctionnalités :
|
||||||
|
//! - VolumeNode avec contrôle dynamique
|
||||||
|
//! - DiskSink pour écriture sur disque
|
||||||
|
//! - Pipeline simple et efficace
|
||||||
|
|
||||||
|
use pmoaudio::{AudioFileFormat, DiskSink, DiskSinkConfig, SourceNode, VolumeNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("=== PMOAudio Quick Start ===\n");
|
||||||
|
|
||||||
|
// 1. Créer la source audio (génère un signal de test)
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
|
||||||
|
// 2. Créer un VolumeNode pour contrôler le volume
|
||||||
|
let (mut volume, volume_tx) = VolumeNode::new("main".to_string(), 0.8, 10);
|
||||||
|
let volume_handle = volume.get_handle();
|
||||||
|
|
||||||
|
// 3. Créer un DiskSink pour écrire sur disque
|
||||||
|
let output_dir = std::env::temp_dir().join("pmoaudio_quickstart");
|
||||||
|
let config = DiskSinkConfig {
|
||||||
|
output_dir: output_dir.clone(),
|
||||||
|
filename: Some("quickstart_output.wav".to_string()),
|
||||||
|
format: AudioFileFormat::Wav,
|
||||||
|
buffer_size: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (disk_sink, disk_tx) = DiskSink::new("disk".to_string(), config, 10);
|
||||||
|
|
||||||
|
// 4. Connecter le pipeline : Source → Volume → DiskSink
|
||||||
|
source.add_subscriber(volume_tx);
|
||||||
|
volume.add_subscriber(disk_tx);
|
||||||
|
|
||||||
|
println!("Pipeline configured:");
|
||||||
|
println!(" SourceNode → VolumeNode (vol=0.8) → DiskSink");
|
||||||
|
println!(" Output: {}/quickstart_output.wav\n", output_dir.display());
|
||||||
|
|
||||||
|
// 5. Lancer les nodes
|
||||||
|
let volume_handle_clone = volume_handle.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
volume.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let disk_handle = tokio::spawn(async move {
|
||||||
|
let stats = disk_sink.run().await.unwrap();
|
||||||
|
println!("\nDiskSink Statistics:");
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Démonstration du contrôle de volume pendant la lecture
|
||||||
|
tokio::spawn(async move {
|
||||||
|
println!("Generating audio with volume changes...");
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
println!(" → Volume: 0.8 (initial)");
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
volume_handle_clone.set_volume(0.5).await;
|
||||||
|
println!(" → Volume: 0.5 (decreased)");
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
volume_handle_clone.set_volume(1.0).await;
|
||||||
|
println!(" → Volume: 1.0 (maximum)");
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
volume_handle_clone.set_volume(0.3).await;
|
||||||
|
println!(" → Volume: 0.3 (low)");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Générer l'audio (10 chunks de 4800 samples à 48kHz = ~1 seconde)
|
||||||
|
source
|
||||||
|
.generate_chunks(
|
||||||
|
10, // nombre de chunks
|
||||||
|
4800, // samples par chunk (100ms @ 48kHz)
|
||||||
|
48000, // sample rate
|
||||||
|
440.0, // fréquence (La 440 Hz)
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 8. Attendre la fin du traitement
|
||||||
|
let stats = disk_handle.await?;
|
||||||
|
|
||||||
|
// 9. Résumé
|
||||||
|
println!("\n=== Summary ===");
|
||||||
|
println!("✓ Audio file generated successfully");
|
||||||
|
println!("✓ {} chunks written", stats.chunks_written);
|
||||||
|
println!("✓ Duration: {:.2} seconds", stats.total_duration_sec);
|
||||||
|
println!("✓ Volume was dynamically adjusted during playback");
|
||||||
|
println!("\nYou can play the file with:");
|
||||||
|
println!(" ffplay {}/quickstart_output.wav", output_dir.display());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
53
old_code/pmoaudio/examples/simple_pipeline.rs
Normal file
53
old_code/pmoaudio/examples/simple_pipeline.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//! Exemple simple de pipeline audio : Source → Timer → Sink
|
||||||
|
//!
|
||||||
|
//! Démontre l'utilisation basique du pipeline avec calcul de position
|
||||||
|
|
||||||
|
use pmoaudio::{SinkNode, SourceNode, TimerNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Simple Pipeline Example ===\n");
|
||||||
|
|
||||||
|
// Créer les nodes
|
||||||
|
let (mut timer, timer_tx) = TimerNode::new(10);
|
||||||
|
let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
|
||||||
|
|
||||||
|
// Connecter
|
||||||
|
timer.add_subscriber(sink_tx);
|
||||||
|
|
||||||
|
// Handle pour monitorer la position
|
||||||
|
let timer_handle = timer.get_position_handle();
|
||||||
|
|
||||||
|
// Spawn timer et sink
|
||||||
|
tokio::spawn(async move {
|
||||||
|
timer.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
// Générer quelques secondes d'audio dans une tâche séparée
|
||||||
|
println!("Generating 440Hz sine wave...\n");
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
source.add_subscriber(timer_tx);
|
||||||
|
|
||||||
|
source
|
||||||
|
.generate_chunks(30, 4800, 48000, 440.0) // ~3 secondes
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// La source est drop ici, fermant le channel
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attendre la fin
|
||||||
|
let stats = sink_handle.await.unwrap();
|
||||||
|
|
||||||
|
let final_position = timer_handle.position_sec().await;
|
||||||
|
println!("\nFinal position: {:.3} seconds", final_position);
|
||||||
|
println!("Total duration: {:.3} seconds", stats.total_duration_sec);
|
||||||
|
}
|
||||||
52
old_code/pmoaudio/examples/streaming_demo.rs
Normal file
52
old_code/pmoaudio/examples/streaming_demo.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//! Exemple de streaming audio en temps réel
|
||||||
|
//!
|
||||||
|
//! Démontre l'utilisation du pipeline avec génération de chunks
|
||||||
|
//! en temps réel avec timing approprié
|
||||||
|
|
||||||
|
use pmoaudio::{SinkNode, SourceNode, TimerNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Streaming Demo ===\n");
|
||||||
|
println!("Streaming audio in real-time for 3 seconds...\n");
|
||||||
|
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
let (mut timer, timer_tx) = TimerNode::new(20);
|
||||||
|
let (sink, sink_tx) = SinkNode::new("Streaming Output".to_string(), 20);
|
||||||
|
|
||||||
|
source.add_subscriber(timer_tx);
|
||||||
|
timer.add_subscriber(sink_tx);
|
||||||
|
|
||||||
|
let timer_handle = timer.get_position_handle();
|
||||||
|
|
||||||
|
// Spawn le pipeline
|
||||||
|
tokio::spawn(async move {
|
||||||
|
timer.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink_handle = tokio::spawn(async move {
|
||||||
|
sink.run_with_logging().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Monitor la position
|
||||||
|
let monitor_handle = tokio::spawn(async move {
|
||||||
|
for _ in 0..15 {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
let position = timer_handle.position_sec().await;
|
||||||
|
println!("Playback position: {:.3} sec", position);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream des chunks avec timing réel
|
||||||
|
// 100ms par chunk à 48kHz = 4800 samples
|
||||||
|
source
|
||||||
|
.stream_chunks(4800, 48000, 440.0, 3000) // 3 secondes
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("\nStreaming complete.");
|
||||||
|
|
||||||
|
// Attendre la fin
|
||||||
|
sink_handle.await.unwrap();
|
||||||
|
monitor_handle.await.unwrap();
|
||||||
|
}
|
||||||
58
old_code/pmoaudio/examples/volume_control_demo.rs
Normal file
58
old_code/pmoaudio/examples/volume_control_demo.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//! Exemple simple de contrôle de volume
|
||||||
|
//!
|
||||||
|
//! Démontre l'utilisation du VolumeNode avec changements dynamiques
|
||||||
|
|
||||||
|
use pmoaudio::{SinkNode, SourceNode, VolumeNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("=== Volume Control Demo ===\n");
|
||||||
|
|
||||||
|
// Créer la source
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
|
||||||
|
// Créer le volume node
|
||||||
|
let (mut volume, volume_tx) = VolumeNode::new("main".to_string(), 1.0, 10);
|
||||||
|
let volume_handle = volume.get_handle();
|
||||||
|
|
||||||
|
// Créer le sink
|
||||||
|
let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
|
||||||
|
|
||||||
|
// Connecter le pipeline
|
||||||
|
source.add_subscriber(volume_tx);
|
||||||
|
volume.add_subscriber(sink_tx);
|
||||||
|
|
||||||
|
// Lancer les nodes
|
||||||
|
tokio::spawn(async move { volume.run().await.unwrap() });
|
||||||
|
|
||||||
|
let sink_handle = tokio::spawn(async move { sink.run_with_stats().await.unwrap() });
|
||||||
|
|
||||||
|
// Contrôler le volume pendant la lecture
|
||||||
|
let volume_control = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
println!("Setting volume to 0.5");
|
||||||
|
volume_handle.set_volume(0.5).await;
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
println!("Setting volume to 0.2");
|
||||||
|
volume_handle.set_volume(0.2).await;
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
println!("Setting volume to 1.0");
|
||||||
|
volume_handle.set_volume(1.0).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Générer l'audio
|
||||||
|
source
|
||||||
|
.generate_chunks(20, 4800, 48000, 440.0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
volume_control.await?;
|
||||||
|
let stats = sink_handle.await?;
|
||||||
|
|
||||||
|
println!("\nFinal statistics:");
|
||||||
|
stats.display();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
245
old_code/pmoaudio/nodes/buffer_node.rs
Normal file
245
old_code/pmoaudio/nodes/buffer_node.rs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
use crate::{
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioSegment,
|
||||||
|
};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{mpsc, RwLock};
|
||||||
|
|
||||||
|
/// Subscriber avec son propre offset dans le buffer
|
||||||
|
struct BufferSubscriber {
|
||||||
|
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||||
|
offset: usize, // Position dans le buffer circulaire
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BufferNode avec buffer circulaire pour support multiroom
|
||||||
|
///
|
||||||
|
/// Ce node maintient un buffer circulaire de chunks et permet à plusieurs
|
||||||
|
/// abonnés de lire avec des offsets différents, ce qui est idéal pour des
|
||||||
|
/// configurations multiroom où différentes pièces peuvent avoir un léger
|
||||||
|
/// délai de synchronisation.
|
||||||
|
///
|
||||||
|
/// # Fonctionnement
|
||||||
|
///
|
||||||
|
/// - Le buffer est implémenté avec un `VecDeque` de taille fixe
|
||||||
|
/// - Chaque abonné peut avoir un offset indépendant (en nombre de chunks)
|
||||||
|
/// - Utilise `try_send` pour éviter de bloquer si un abonné est saturé
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::{BufferNode, SinkNode};
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let (buffer, buffer_tx) = BufferNode::new(50, 10);
|
||||||
|
///
|
||||||
|
/// let (sink1, sink1_tx) = SinkNode::new("Room 1".to_string(), 10);
|
||||||
|
/// let (sink2, sink2_tx) = SinkNode::new("Room 2".to_string(), 10);
|
||||||
|
///
|
||||||
|
/// // Room 1 sans délai
|
||||||
|
/// buffer.add_subscriber_with_offset(sink1_tx, 0).await;
|
||||||
|
///
|
||||||
|
/// // Room 2 avec 5 chunks de retard
|
||||||
|
/// buffer.add_subscriber_with_offset(sink2_tx, 5).await;
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move { buffer.run().await.unwrap() });
|
||||||
|
/// // ... spawn sinks et source
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct BufferNode {
|
||||||
|
buffer: Arc<RwLock<VecDeque<Arc<AudioSegment>>>>,
|
||||||
|
subscribers: Arc<RwLock<Vec<BufferSubscriber>>>,
|
||||||
|
buffer_size: usize,
|
||||||
|
rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||||
|
next_subscribers: MultiSubscriberNode, // Pour passer au node suivant
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BufferNode {
|
||||||
|
/// Crée un nouveau BufferNode
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `buffer_size` - Taille maximale du buffer circulaire
|
||||||
|
/// * `channel_size` - Taille du channel bounded pour backpressure
|
||||||
|
pub fn new(buffer_size: usize, channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioSegment>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(buffer_size))),
|
||||||
|
subscribers: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
buffer_size,
|
||||||
|
rx,
|
||||||
|
next_subscribers: MultiSubscriberNode::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un abonné avec un offset spécifique (pour multiroom)
|
||||||
|
pub async fn add_subscriber_with_offset(
|
||||||
|
&self,
|
||||||
|
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||||
|
offset: usize,
|
||||||
|
) {
|
||||||
|
let mut subs = self.subscribers.write().await;
|
||||||
|
subs.push(BufferSubscriber { tx, offset });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un abonné sans offset (commence au chunk courant)
|
||||||
|
pub async fn add_subscriber(&self, tx: mpsc::Sender<Arc<AudioSegment>>) {
|
||||||
|
self.add_subscriber_with_offset(tx, 0).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un abonné pour le node suivant (sans buffer)
|
||||||
|
pub fn add_next_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioSegment>>) {
|
||||||
|
self.next_subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du BufferNode
|
||||||
|
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||||
|
let mut chunk_index = 0usize;
|
||||||
|
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
// Ajouter au buffer circulaire
|
||||||
|
{
|
||||||
|
let mut buffer = self.buffer.write().await;
|
||||||
|
if buffer.len() >= self.buffer_size {
|
||||||
|
buffer.pop_front();
|
||||||
|
}
|
||||||
|
buffer.push_back(chunk.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoyer aux abonnés avec offset
|
||||||
|
{
|
||||||
|
let buffer = self.buffer.read().await;
|
||||||
|
let mut subs = self.subscribers.write().await;
|
||||||
|
|
||||||
|
for sub in subs.iter_mut() {
|
||||||
|
// Calculer l'index dans le buffer en fonction de l'offset
|
||||||
|
let target_index = if chunk_index >= sub.offset {
|
||||||
|
chunk_index - sub.offset
|
||||||
|
} else {
|
||||||
|
continue; // Pas encore assez de données
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vérifier si le chunk est disponible dans le buffer
|
||||||
|
let buffer_age = chunk_index - target_index;
|
||||||
|
if buffer_age < buffer.len() {
|
||||||
|
let chunk_to_send = &buffer[buffer.len() - buffer_age - 1];
|
||||||
|
// try_send non-bloquant pour éviter de bloquer la source
|
||||||
|
let _ = sub.tx.try_send(chunk_to_send.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push vers les nodes suivants sans buffer
|
||||||
|
self.next_subscribers.try_push(chunk).await?;
|
||||||
|
|
||||||
|
chunk_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version avec push synchrone au lieu de try_push
|
||||||
|
pub async fn run_blocking(mut self) -> Result<(), AudioError> {
|
||||||
|
let mut chunk_index = 0usize;
|
||||||
|
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
// Ajouter au buffer circulaire
|
||||||
|
{
|
||||||
|
let mut buffer = self.buffer.write().await;
|
||||||
|
if buffer.len() >= self.buffer_size {
|
||||||
|
buffer.pop_front();
|
||||||
|
}
|
||||||
|
buffer.push_back(chunk.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoyer aux abonnés avec offset
|
||||||
|
{
|
||||||
|
let buffer = self.buffer.read().await;
|
||||||
|
let subs = self.subscribers.read().await;
|
||||||
|
|
||||||
|
for sub in subs.iter() {
|
||||||
|
let target_index = if chunk_index >= sub.offset {
|
||||||
|
chunk_index - sub.offset
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let buffer_age = chunk_index - target_index;
|
||||||
|
if buffer_age < buffer.len() {
|
||||||
|
let chunk_to_send = &buffer[buffer.len() - buffer_age - 1];
|
||||||
|
let _ = sub.tx.send(chunk_to_send.clone()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push vers les nodes suivants
|
||||||
|
for _ in 0..self.next_subscribers.subscribers.len() {
|
||||||
|
self.next_subscribers.push(chunk.clone()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_buffer_node_basic() {
|
||||||
|
let (mut node, tx) = BufferNode::new(10, 5);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(5);
|
||||||
|
|
||||||
|
node.add_next_subscriber(out_tx);
|
||||||
|
|
||||||
|
// Spawn le node
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer des chunks
|
||||||
|
for i in 0..3 {
|
||||||
|
let chunk = AudioSegment::AudioChunk(AudioChunk::new(i, vec![[0i32; 2]; 100], 48000, BitDepth::B24));
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recevoir les chunks
|
||||||
|
for i in 0..3 {
|
||||||
|
let chunk = out_rx.recv().await.unwrap();
|
||||||
|
assert_eq!(chunk.order(), i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_buffer_node_with_offset() {
|
||||||
|
let (node, tx) = BufferNode::new(10, 10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
// Ajouter un abonné avec offset de 2 chunks
|
||||||
|
node.add_subscriber_with_offset(out_tx, 2).await;
|
||||||
|
|
||||||
|
// Spawn le node
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer 5 chunks
|
||||||
|
for i in 0..5 {
|
||||||
|
let chunk = AudioSegment::new(i, vec![[0i32; 2]; 100], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
// L'abonné devrait recevoir les chunks 0, 1, 2 (avec 2 chunks de retard)
|
||||||
|
let chunk = out_rx.try_recv().unwrap();
|
||||||
|
assert_eq!(chunk.order(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
290
old_code/pmoaudio/nodes/chromecast_sink.rs
Normal file
290
old_code/pmoaudio/nodes/chromecast_sink.rs
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
//! ChromecastSink - Diffuse le flux audio vers un périphérique Chromecast
|
||||||
|
//!
|
||||||
|
//! Ce module fournit un sink qui envoie le flux audio à un Chromecast.
|
||||||
|
//! Note: Cette implémentation est une version mock/skeleton. Une vraie implémentation
|
||||||
|
//! nécessiterait une bibliothèque comme `rust-cast` ou similaire.
|
||||||
|
|
||||||
|
use crate::{nodes::AudioError, AudioChunk};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// Configuration pour le ChromecastSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChromecastConfig {
|
||||||
|
/// Nom ou adresse IP du Chromecast
|
||||||
|
pub device_address: String,
|
||||||
|
|
||||||
|
/// Nom amical du device
|
||||||
|
pub device_name: String,
|
||||||
|
|
||||||
|
/// Port de communication (défaut: 8009)
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
/// Taille du buffer de streaming
|
||||||
|
pub buffer_size: usize,
|
||||||
|
|
||||||
|
/// Format d'encodage pour le streaming
|
||||||
|
pub encoding: StreamEncoding,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ChromecastConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
device_address: "192.168.1.100".to_string(),
|
||||||
|
device_name: "Living Room".to_string(),
|
||||||
|
port: 8009,
|
||||||
|
buffer_size: 50,
|
||||||
|
encoding: StreamEncoding::Mp3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats d'encodage supportés pour le streaming
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum StreamEncoding {
|
||||||
|
/// MP3 (compatible avec la plupart des Chromecasts)
|
||||||
|
Mp3,
|
||||||
|
/// AAC
|
||||||
|
Aac,
|
||||||
|
/// Opus
|
||||||
|
Opus,
|
||||||
|
/// PCM non compressé (haute qualité, bande passante élevée)
|
||||||
|
Pcm,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ChromecastSink - Diffuse vers un périphérique Chromecast
|
||||||
|
///
|
||||||
|
/// Ce sink encode le flux audio et le streame vers un Chromecast.
|
||||||
|
/// La connexion est établie lors de l'initialisation et maintenue pendant toute la durée.
|
||||||
|
///
|
||||||
|
/// # Implémentation actuelle
|
||||||
|
///
|
||||||
|
/// Cette version est un mock qui simule l'envoi au Chromecast.
|
||||||
|
/// Pour une vraie implémentation, il faudrait:
|
||||||
|
/// - Utiliser une bibliothèque comme `rust-cast`
|
||||||
|
/// - Établir une connexion TLS avec le device
|
||||||
|
/// - Lancer une application de récepteur sur le Chromecast
|
||||||
|
/// - Encoder l'audio dans le format approprié
|
||||||
|
/// - Streamer via HTTP ou WebSocket
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::{ChromecastSink, ChromecastConfig};
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let config = ChromecastConfig {
|
||||||
|
/// device_address: "192.168.1.100".to_string(),
|
||||||
|
/// device_name: "Living Room".to_string(),
|
||||||
|
/// ..Default::default()
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// let (sink, sink_tx) = ChromecastSink::new("chromecast1".to_string(), config, 10);
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// sink.run().await.unwrap()
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct ChromecastSink {
|
||||||
|
/// Identifiant du sink
|
||||||
|
node_id: String,
|
||||||
|
|
||||||
|
/// Channel pour recevoir les chunks audio
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
|
||||||
|
/// Configuration
|
||||||
|
config: ChromecastConfig,
|
||||||
|
|
||||||
|
/// État de la connexion (mock)
|
||||||
|
connected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChromecastSink {
|
||||||
|
/// Crée un nouveau ChromecastSink
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `node_id` - Identifiant unique du sink
|
||||||
|
/// * `config` - Configuration du Chromecast
|
||||||
|
/// * `channel_size` - Taille du buffer du channel
|
||||||
|
pub fn new(
|
||||||
|
node_id: String,
|
||||||
|
config: ChromecastConfig,
|
||||||
|
channel_size: usize,
|
||||||
|
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let sink = Self {
|
||||||
|
node_id,
|
||||||
|
rx,
|
||||||
|
config,
|
||||||
|
connected: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
(sink, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Établit la connexion avec le Chromecast (mock)
|
||||||
|
async fn connect(&mut self) -> Result<(), AudioError> {
|
||||||
|
println!(
|
||||||
|
"[{}] Connecting to Chromecast '{}' at {}:{}...",
|
||||||
|
self.node_id, self.config.device_name, self.config.device_address, self.config.port
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simuler une connexion
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||||
|
|
||||||
|
self.connected = true;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[{}] Connected to Chromecast '{}' successfully",
|
||||||
|
self.node_id, self.config.device_name
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie un chunk au Chromecast (mock)
|
||||||
|
async fn send_chunk(&self, _chunk: &AudioChunk) -> Result<(), AudioError> {
|
||||||
|
if !self.connected {
|
||||||
|
return Err(AudioError::ProcessingError(
|
||||||
|
"Not connected to Chromecast".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dans une vraie implémentation:
|
||||||
|
// 1. Appliquer le gain
|
||||||
|
// 2. Encoder dans le format approprié (MP3, AAC, etc.)
|
||||||
|
// 3. Envoyer via le protocole Chromecast
|
||||||
|
|
||||||
|
// Pour l'instant, simplement simuler un délai d'envoi
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_micros(50)).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Déconnecte proprement du Chromecast (mock)
|
||||||
|
async fn disconnect(&mut self) -> Result<(), AudioError> {
|
||||||
|
if self.connected {
|
||||||
|
println!(
|
||||||
|
"[{}] Disconnecting from Chromecast '{}'...",
|
||||||
|
self.node_id, self.config.device_name
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simuler la déconnexion
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
self.connected = false;
|
||||||
|
|
||||||
|
println!("[{}] Disconnected successfully", self.node_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du ChromecastSink
|
||||||
|
pub async fn run(mut self) -> Result<ChromecastStats, AudioError> {
|
||||||
|
// Établir la connexion
|
||||||
|
self.connect().await?;
|
||||||
|
|
||||||
|
let mut stats = ChromecastStats::new(self.node_id.clone(), self.config.device_name.clone());
|
||||||
|
|
||||||
|
// Boucle principale
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
// Appliquer le gain si nécessaire
|
||||||
|
let chunk_to_send = if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
Arc::clone(&chunk).apply_gain()
|
||||||
|
} else {
|
||||||
|
Arc::clone(&chunk)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Envoyer au Chromecast
|
||||||
|
self.send_chunk(&chunk_to_send).await?;
|
||||||
|
|
||||||
|
stats.record_chunk(&chunk_to_send);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déconnexion propre
|
||||||
|
self.disconnect().await?;
|
||||||
|
|
||||||
|
stats.finalize();
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques du ChromecastSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChromecastStats {
|
||||||
|
pub node_id: String,
|
||||||
|
pub device_name: String,
|
||||||
|
pub chunks_sent: u64,
|
||||||
|
pub total_samples: u64,
|
||||||
|
pub total_duration_sec: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChromecastStats {
|
||||||
|
pub fn new(node_id: String, device_name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
node_id,
|
||||||
|
device_name,
|
||||||
|
chunks_sent: 0,
|
||||||
|
total_samples: 0,
|
||||||
|
total_duration_sec: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
|
||||||
|
self.chunks_sent += 1;
|
||||||
|
self.total_samples += chunk.len() as u64;
|
||||||
|
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finalize(&mut self) {
|
||||||
|
// Calculs finaux si nécessaire
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display(&self) {
|
||||||
|
println!("\n=== Chromecast Statistics: {} ===", self.node_id);
|
||||||
|
println!("Device: {}", self.device_name);
|
||||||
|
println!("Chunks sent: {}", self.chunks_sent);
|
||||||
|
println!("Total samples: {}", self.total_samples);
|
||||||
|
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||||
|
println!("==================================\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::i32;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_chromecast_sink_basic() {
|
||||||
|
let config = ChromecastConfig {
|
||||||
|
device_address: "127.0.0.1".to_string(),
|
||||||
|
device_name: "Test Device".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (sink, tx) = ChromecastSink::new("test".to_string(), config, 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { sink.run().await });
|
||||||
|
|
||||||
|
// Envoyer quelques chunks
|
||||||
|
for i in 0..5 {
|
||||||
|
let stereo = vec![[i32::MAX / 2; 2]; 1000];
|
||||||
|
let chunk = AudioChunk::new(i, stereo, 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let stats = handle.await.unwrap().unwrap();
|
||||||
|
assert_eq!(stats.chunks_sent, 5);
|
||||||
|
assert_eq!(stats.device_name, "Test Device");
|
||||||
|
}
|
||||||
|
}
|
||||||
162
old_code/pmoaudio/nodes/decoder_node.rs
Normal file
162
old_code/pmoaudio/nodes/decoder_node.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
use crate::{
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioChunk,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// DecoderNode - Décode des chunks audio
|
||||||
|
///
|
||||||
|
/// Version mock qui passe simplement les chunks (ou simule un décodage simple)
|
||||||
|
pub struct DecoderNode {
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecoderNode {
|
||||||
|
pub fn new(channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
rx,
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode passthrough - passe les chunks sans modification
|
||||||
|
pub async fn run_passthrough(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode mock décodage - simule un changement de sample rate
|
||||||
|
pub async fn run_with_resampling(mut self, target_sample_rate: u32) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
if chunk.sample_rate() == target_sample_rate {
|
||||||
|
// Pas besoin de resampling
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
} else {
|
||||||
|
// Simuler un resampling (mock simple)
|
||||||
|
let ratio = target_sample_rate as f64 / chunk.sample_rate() as f64;
|
||||||
|
let new_len = (chunk.len() as f64 * ratio) as usize;
|
||||||
|
|
||||||
|
let pairs = chunk.to_pairs_f32();
|
||||||
|
let mut resampled = Vec::with_capacity(new_len);
|
||||||
|
|
||||||
|
// Resampling linéaire simple (mock)
|
||||||
|
for i in 0..new_len {
|
||||||
|
let src_pos = i as f64 / ratio;
|
||||||
|
let src_idx = src_pos as usize;
|
||||||
|
|
||||||
|
if src_idx + 1 < pairs.len() {
|
||||||
|
let frac = src_pos - src_idx as f64;
|
||||||
|
let alpha = (1.0 - frac) as f32;
|
||||||
|
let beta = frac as f32;
|
||||||
|
let left_sample = pairs[src_idx][0] * alpha + pairs[src_idx + 1][0] * beta;
|
||||||
|
let right_sample = pairs[src_idx][1] * alpha + pairs[src_idx + 1][1] * beta;
|
||||||
|
|
||||||
|
resampled.push([left_sample, right_sample]);
|
||||||
|
} else if src_idx < pairs.len() {
|
||||||
|
resampled.push(pairs[src_idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_chunk = AudioChunk::from_pairs_f32(
|
||||||
|
chunk.order(),
|
||||||
|
resampled,
|
||||||
|
target_sample_rate,
|
||||||
|
chunk.bit_depth(),
|
||||||
|
);
|
||||||
|
if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
new_chunk = new_chunk.set_gain_db(chunk.gain_db());
|
||||||
|
}
|
||||||
|
self.subscribers.push(new_chunk).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_decoder_passthrough() {
|
||||||
|
let (mut node, tx) = DecoderNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run_passthrough().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
0,
|
||||||
|
vec![1.0, 2.0, 3.0],
|
||||||
|
vec![4.0, 5.0, 6.0],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Recevoir le chunk
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
assert!(Arc::ptr_eq(&chunk, &received));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_decoder_resampling() {
|
||||||
|
let (mut node, tx) = DecoderNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run_with_resampling(96000).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk à 48000 Hz
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
// Recevoir le chunk resampleé
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
assert_eq!(received.sample_rate(), 96000);
|
||||||
|
// Le chunk devrait être environ 2x plus grand
|
||||||
|
assert!(received.len() > 150 && received.len() < 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_decoder_no_resampling_needed() {
|
||||||
|
let (mut node, tx) = DecoderNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run_with_resampling(48000).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk déjà au bon sample rate
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Le chunk devrait être passé sans modification
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
assert!(Arc::ptr_eq(&chunk, &received));
|
||||||
|
}
|
||||||
|
}
|
||||||
500
old_code/pmoaudio/nodes/disk_sink.rs
Normal file
500
old_code/pmoaudio/nodes/disk_sink.rs
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
//! DiskSink - Écrit le flux audio dans un fichier
|
||||||
|
//!
|
||||||
|
//! Ce module fournit un sink qui écrit les chunks audio sur disque,
|
||||||
|
//! avec support de la dérivation automatique du nom de fichier depuis la source.
|
||||||
|
|
||||||
|
use crate::{events::SourceNameUpdateEvent, nodes::AudioError, AudioChunk};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::sync::{mpsc, RwLock};
|
||||||
|
|
||||||
|
/// Configuration pour le DiskSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiskSinkConfig {
|
||||||
|
/// Chemin racine où écrire les fichiers
|
||||||
|
pub output_dir: PathBuf,
|
||||||
|
|
||||||
|
/// Nom de fichier explicite (optionnel)
|
||||||
|
/// Si None, sera dérivé du nom de la source
|
||||||
|
pub filename: Option<String>,
|
||||||
|
|
||||||
|
/// Format d'écriture
|
||||||
|
pub format: AudioFileFormat,
|
||||||
|
|
||||||
|
/// Taille du buffer d'écriture (en chunks)
|
||||||
|
pub buffer_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DiskSinkConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
output_dir: PathBuf::from("."),
|
||||||
|
filename: None,
|
||||||
|
format: AudioFileFormat::Wav,
|
||||||
|
buffer_size: 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats de fichiers audio supportés
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum AudioFileFormat {
|
||||||
|
/// Format WAV (non compressé)
|
||||||
|
Wav,
|
||||||
|
/// Format FLAC (compressé sans perte)
|
||||||
|
Flac,
|
||||||
|
/// Format brut PCM
|
||||||
|
Raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioFileFormat {
|
||||||
|
/// Retourne l'extension de fichier appropriée
|
||||||
|
pub fn extension(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
AudioFileFormat::Wav => "wav",
|
||||||
|
AudioFileFormat::Flac => "flac",
|
||||||
|
AudioFileFormat::Raw => "pcm",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DiskSink - Écrit le flux audio dans un fichier sur disque
|
||||||
|
///
|
||||||
|
/// Ce sink consomme les chunks audio et les écrit dans un fichier.
|
||||||
|
/// Le nom du fichier peut être dérivé automatiquement du nom de la source
|
||||||
|
/// via les événements `SourceNameUpdateEvent`.
|
||||||
|
///
|
||||||
|
/// # Caractéristiques
|
||||||
|
///
|
||||||
|
/// - Écriture asynchrone avec buffer
|
||||||
|
/// - Dérivation automatique du nom de fichier depuis la source
|
||||||
|
/// - Support de plusieurs formats (WAV, FLAC, PCM brut)
|
||||||
|
/// - Gestion du gain : applique le gain avant l'écriture
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::{DiskSink, DiskSinkConfig};
|
||||||
|
/// use std::path::PathBuf;
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let config = DiskSinkConfig {
|
||||||
|
/// output_dir: PathBuf::from("/tmp/audio"),
|
||||||
|
/// filename: Some("output.wav".to_string()),
|
||||||
|
/// ..Default::default()
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// let (sink, sink_tx) = DiskSink::new("disk1".to_string(), config, 10);
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// sink.run().await.unwrap()
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct DiskSink {
|
||||||
|
/// Identifiant du sink
|
||||||
|
node_id: String,
|
||||||
|
|
||||||
|
/// Channel pour recevoir les chunks audio
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
|
||||||
|
/// Configuration
|
||||||
|
config: DiskSinkConfig,
|
||||||
|
|
||||||
|
/// Nom de fichier résolu (partagé)
|
||||||
|
resolved_filename: Arc<RwLock<Option<PathBuf>>>,
|
||||||
|
|
||||||
|
/// Receiver pour les événements de nom de source (optionnel)
|
||||||
|
source_name_rx: Option<mpsc::Receiver<SourceNameUpdateEvent>>,
|
||||||
|
|
||||||
|
/// Writer pour le fichier
|
||||||
|
writer: Option<AudioFileWriter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskSink {
|
||||||
|
/// Crée un nouveau DiskSink
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `node_id` - Identifiant unique du sink
|
||||||
|
/// * `config` - Configuration du sink
|
||||||
|
/// * `channel_size` - Taille du buffer du channel
|
||||||
|
pub fn new(
|
||||||
|
node_id: String,
|
||||||
|
config: DiskSinkConfig,
|
||||||
|
channel_size: usize,
|
||||||
|
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let sink = Self {
|
||||||
|
node_id,
|
||||||
|
rx,
|
||||||
|
config,
|
||||||
|
resolved_filename: Arc::new(RwLock::new(None)),
|
||||||
|
source_name_rx: None,
|
||||||
|
writer: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(sink, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure la source des événements de nom de source
|
||||||
|
pub fn set_source_name_source(&mut self, rx: mpsc::Receiver<SourceNameUpdateEvent>) {
|
||||||
|
self.source_name_rx = Some(rx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Résout le nom du fichier de sortie
|
||||||
|
///
|
||||||
|
/// Si un filename explicite est fourni dans la config, l'utilise.
|
||||||
|
/// Sinon, utilise le source_name avec l'extension appropriée.
|
||||||
|
fn resolve_filename(&self, source_name: Option<&str>) -> PathBuf {
|
||||||
|
let filename = if let Some(ref explicit_name) = self.config.filename {
|
||||||
|
explicit_name.clone()
|
||||||
|
} else if let Some(name) = source_name {
|
||||||
|
// Nettoyer le nom de la source pour en faire un nom de fichier valide
|
||||||
|
let clean_name = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_alphanumeric() || c == '_' || c == '-' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
format!("{}.{}", clean_name, self.config.format.extension())
|
||||||
|
} else {
|
||||||
|
// Fallback sur un nom par défaut
|
||||||
|
format!("{}.{}", self.node_id, self.config.format.extension())
|
||||||
|
};
|
||||||
|
|
||||||
|
self.config.output_dir.join(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialise le writer pour le fichier de sortie
|
||||||
|
async fn initialize_writer(&mut self, source_name: Option<&str>) -> Result<(), AudioError> {
|
||||||
|
let path = self.resolve_filename(source_name);
|
||||||
|
*self.resolved_filename.write().await = Some(path.clone());
|
||||||
|
|
||||||
|
// Créer le répertoire parent si nécessaire
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to create directory: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer le writer approprié selon le format
|
||||||
|
let writer = match self.config.format {
|
||||||
|
AudioFileFormat::Wav => AudioFileWriter::new_wav(path).await?,
|
||||||
|
AudioFileFormat::Flac => {
|
||||||
|
// FLAC nécessiterait une bibliothèque externe, pour l'instant utiliser WAV
|
||||||
|
AudioFileWriter::new_wav(path).await?
|
||||||
|
}
|
||||||
|
AudioFileFormat::Raw => AudioFileWriter::new_raw(path).await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.writer = Some(writer);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du DiskSink
|
||||||
|
pub async fn run(mut self) -> Result<DiskSinkStats, AudioError> {
|
||||||
|
let mut stats = DiskSinkStats::new(self.node_id.clone());
|
||||||
|
let mut source_name: Option<String> = None;
|
||||||
|
let mut initialized = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Recevoir les chunks audio
|
||||||
|
chunk_opt = self.rx.recv() => {
|
||||||
|
match chunk_opt {
|
||||||
|
Some(chunk) => {
|
||||||
|
// Initialiser le writer à la réception du premier chunk
|
||||||
|
if !initialized {
|
||||||
|
self.initialize_writer(source_name.as_deref()).await?;
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appliquer le gain avant l'écriture
|
||||||
|
let chunk_with_gain = if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
Arc::clone(&chunk).apply_gain()
|
||||||
|
} else {
|
||||||
|
Arc::clone(&chunk)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Écrire le chunk
|
||||||
|
if let Some(ref mut writer) = self.writer {
|
||||||
|
writer.write_chunk(&chunk_with_gain).await?;
|
||||||
|
stats.record_chunk(&chunk_with_gain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Channel fermé, terminer
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recevoir les mises à jour du nom de source
|
||||||
|
source_event_opt = async {
|
||||||
|
if let Some(ref mut rx) = self.source_name_rx {
|
||||||
|
rx.recv().await
|
||||||
|
} else {
|
||||||
|
std::future::pending().await
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
if let Some(event) = source_event_opt {
|
||||||
|
source_name = Some(event.source_name.clone());
|
||||||
|
|
||||||
|
// Si on n'a pas encore initialisé, le nom sera utilisé plus tard
|
||||||
|
// Sinon, on pourrait décider de fermer le fichier actuel et d'en créer un nouveau
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer le fichier proprement
|
||||||
|
if let Some(writer) = self.writer {
|
||||||
|
writer.close().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.finalize();
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writer pour fichiers audio
|
||||||
|
struct AudioFileWriter {
|
||||||
|
file: File,
|
||||||
|
format: AudioFileFormat,
|
||||||
|
sample_rate: Option<u32>,
|
||||||
|
total_samples: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioFileWriter {
|
||||||
|
/// Crée un writer WAV
|
||||||
|
async fn new_wav(path: PathBuf) -> Result<Self, AudioError> {
|
||||||
|
let file = File::create(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Failed to create file: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
file,
|
||||||
|
format: AudioFileFormat::Wav,
|
||||||
|
sample_rate: None,
|
||||||
|
total_samples: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un writer pour PCM brut
|
||||||
|
async fn new_raw(path: PathBuf) -> Result<Self, AudioError> {
|
||||||
|
let file = File::create(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Failed to create file: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
file,
|
||||||
|
format: AudioFileFormat::Raw,
|
||||||
|
sample_rate: None,
|
||||||
|
total_samples: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Écrit un chunk audio
|
||||||
|
async fn write_chunk(&mut self, chunk: &AudioChunk) -> Result<(), AudioError> {
|
||||||
|
// Enregistrer le sample rate du premier chunk
|
||||||
|
if self.sample_rate.is_none() {
|
||||||
|
let sr = chunk.sample_rate();
|
||||||
|
self.sample_rate = Some(sr);
|
||||||
|
|
||||||
|
// Pour WAV, écrire l'en-tête (simplifié)
|
||||||
|
if matches!(self.format, AudioFileFormat::Wav) {
|
||||||
|
self.write_wav_header(sr).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir en bytes (little-endian 16-bit PCM)
|
||||||
|
let mut bytes = Vec::with_capacity(chunk.len() * 4);
|
||||||
|
let max_val = chunk.bit_depth().max_value();
|
||||||
|
for frame in chunk.frames() {
|
||||||
|
let left = (frame[0] as f32 / max_val).clamp(-1.0, 1.0);
|
||||||
|
let right = (frame[1] as f32 / max_val).clamp(-1.0, 1.0);
|
||||||
|
let sample_i16 = (left * 32767.0) as i16;
|
||||||
|
bytes.extend_from_slice(&sample_i16.to_le_bytes());
|
||||||
|
let sample_r16 = (right * 32767.0) as i16;
|
||||||
|
bytes.extend_from_slice(&sample_r16.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.file.write_all(&bytes).await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to write audio data: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.total_samples += chunk.len();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Écrit un en-tête WAV simplifié
|
||||||
|
async fn write_wav_header(&mut self, sample_rate: u32) -> Result<(), AudioError> {
|
||||||
|
// En-tête WAV basique (sera mis à jour à la fermeture)
|
||||||
|
let mut header = Vec::new();
|
||||||
|
|
||||||
|
// RIFF chunk
|
||||||
|
header.extend_from_slice(b"RIFF");
|
||||||
|
header.extend_from_slice(&0u32.to_le_bytes()); // Taille (à mettre à jour)
|
||||||
|
header.extend_from_slice(b"WAVE");
|
||||||
|
|
||||||
|
// fmt chunk
|
||||||
|
header.extend_from_slice(b"fmt ");
|
||||||
|
header.extend_from_slice(&16u32.to_le_bytes()); // Taille du fmt chunk
|
||||||
|
header.extend_from_slice(&1u16.to_le_bytes()); // Format PCM
|
||||||
|
header.extend_from_slice(&2u16.to_le_bytes()); // 2 canaux (stéréo)
|
||||||
|
header.extend_from_slice(&sample_rate.to_le_bytes());
|
||||||
|
header.extend_from_slice(&(sample_rate * 4).to_le_bytes()); // Byte rate
|
||||||
|
header.extend_from_slice(&4u16.to_le_bytes()); // Block align
|
||||||
|
header.extend_from_slice(&16u16.to_le_bytes()); // Bits per sample
|
||||||
|
|
||||||
|
// data chunk header
|
||||||
|
header.extend_from_slice(b"data");
|
||||||
|
header.extend_from_slice(&0u32.to_le_bytes()); // Taille des données (à mettre à jour)
|
||||||
|
|
||||||
|
self.file.write_all(&header).await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to write WAV header: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ferme le fichier et met à jour l'en-tête si nécessaire
|
||||||
|
async fn close(mut self) -> Result<(), AudioError> {
|
||||||
|
if matches!(self.format, AudioFileFormat::Wav) {
|
||||||
|
// Mettre à jour les tailles dans l'en-tête WAV
|
||||||
|
let data_size = (self.total_samples * 4) as u32; // 2 bytes per sample * 2 channels
|
||||||
|
let file_size = data_size + 36;
|
||||||
|
|
||||||
|
// Positionner au début et réécrire les tailles
|
||||||
|
use tokio::io::AsyncSeekExt;
|
||||||
|
self.file
|
||||||
|
.seek(std::io::SeekFrom::Start(4))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to seek in file: {}", e))
|
||||||
|
})?;
|
||||||
|
self.file
|
||||||
|
.write_all(&file_size.to_le_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to update file size: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.file
|
||||||
|
.seek(std::io::SeekFrom::Start(40))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to seek in file: {}", e))
|
||||||
|
})?;
|
||||||
|
self.file
|
||||||
|
.write_all(&data_size.to_le_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to update data size: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.file
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Failed to flush file: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques du DiskSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiskSinkStats {
|
||||||
|
pub node_id: String,
|
||||||
|
pub chunks_written: u64,
|
||||||
|
pub total_samples: u64,
|
||||||
|
pub total_duration_sec: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskSinkStats {
|
||||||
|
pub fn new(node_id: String) -> Self {
|
||||||
|
Self {
|
||||||
|
node_id,
|
||||||
|
chunks_written: 0,
|
||||||
|
total_samples: 0,
|
||||||
|
total_duration_sec: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
|
||||||
|
self.chunks_written += 1;
|
||||||
|
self.total_samples += chunk.len() as u64;
|
||||||
|
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finalize(&mut self) {
|
||||||
|
// Pourrait effectuer des calculs finaux ici
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display(&self) {
|
||||||
|
println!("\n=== DiskSink Statistics: {} ===", self.node_id);
|
||||||
|
println!("Chunks written: {}", self.chunks_written);
|
||||||
|
println!("Total samples: {}", self.total_samples);
|
||||||
|
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||||
|
println!("============================\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_disk_sink_basic() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("pmoaudio_test");
|
||||||
|
tokio::fs::create_dir_all(&temp_dir).await.unwrap();
|
||||||
|
|
||||||
|
let config = DiskSinkConfig {
|
||||||
|
output_dir: temp_dir.clone(),
|
||||||
|
filename: Some("test_output.wav".to_string()),
|
||||||
|
format: AudioFileFormat::Wav,
|
||||||
|
buffer_size: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (sink, tx) = DiskSink::new("test".to_string(), config, 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { sink.run().await });
|
||||||
|
|
||||||
|
// Envoyer quelques chunks
|
||||||
|
for i in 0..5 {
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
i,
|
||||||
|
vec![0.5; 1000],
|
||||||
|
vec![0.5; 1000],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let stats = handle.await.unwrap().unwrap();
|
||||||
|
assert_eq!(stats.chunks_written, 5);
|
||||||
|
|
||||||
|
// Vérifier que le fichier existe
|
||||||
|
let output_path = temp_dir.join("test_output.wav");
|
||||||
|
assert!(output_path.exists());
|
||||||
|
|
||||||
|
// Nettoyage
|
||||||
|
tokio::fs::remove_file(output_path).await.ok();
|
||||||
|
tokio::fs::remove_dir(temp_dir).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
254
old_code/pmoaudio/nodes/dsp_node.rs
Normal file
254
old_code/pmoaudio/nodes/dsp_node.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
use crate::{
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioChunk,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// DspNode - Applique des transformations DSP aux chunks audio
|
||||||
|
///
|
||||||
|
/// Clone les données uniquement si elles doivent être modifiées
|
||||||
|
pub struct DspNode {
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
gain_db: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DspNode {
|
||||||
|
pub fn new(channel_size: usize, gain_db: f32) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
rx,
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
gain_db,
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applique le gain aux chunks
|
||||||
|
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
if self.gain_db.abs() < f32::EPSILON {
|
||||||
|
// Gain = 0 dB, pas de transformation nécessaire
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gain_linear = AudioChunk::gain_linear_from_db(self.gain_db as f64) as f32;
|
||||||
|
let mut pairs = chunk.to_pairs_f32();
|
||||||
|
for frame in &mut pairs {
|
||||||
|
frame[0] *= gain_linear;
|
||||||
|
frame[1] *= gain_linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_chunk = AudioChunk::from_pairs_f32(
|
||||||
|
chunk.order(),
|
||||||
|
pairs,
|
||||||
|
chunk.sample_rate(),
|
||||||
|
chunk.bit_depth(),
|
||||||
|
);
|
||||||
|
if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
new_chunk = new_chunk.set_gain_db(chunk.gain_db());
|
||||||
|
}
|
||||||
|
self.subscribers.push(new_chunk).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour le gain dynamiquement (nécessite un `Arc<RwLock<f32>>` dans une version réelle)
|
||||||
|
pub fn set_gain_db(&mut self, gain_db: f32) {
|
||||||
|
self.gain_db = gain_db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DspNode avec filtre passe-bas simple (mock)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct LowPassDspNode {
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
alpha: f32, // Coefficient du filtre
|
||||||
|
prev_left: f32,
|
||||||
|
prev_right: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LowPassDspNode {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn new(channel_size: usize, cutoff_ratio: f32) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
// Filtre RC simple: alpha = dt / (RC + dt)
|
||||||
|
// cutoff_ratio entre 0 (tout couper) et 1 (tout passer)
|
||||||
|
let alpha = cutoff_ratio.clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
rx,
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
alpha,
|
||||||
|
prev_left: 0.0,
|
||||||
|
prev_right: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
let pairs = chunk.to_pairs_f32();
|
||||||
|
let mut filtered = Vec::with_capacity(pairs.len());
|
||||||
|
|
||||||
|
for sample in pairs.iter() {
|
||||||
|
self.prev_left = self.prev_left + self.alpha * (sample[0] - self.prev_left);
|
||||||
|
self.prev_right = self.prev_right + self.alpha * (sample[1] - self.prev_right);
|
||||||
|
filtered.push([self.prev_left, self.prev_right]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_chunk = AudioChunk::from_pairs_f32(
|
||||||
|
chunk.order(),
|
||||||
|
filtered,
|
||||||
|
chunk.sample_rate(),
|
||||||
|
chunk.bit_depth(),
|
||||||
|
);
|
||||||
|
if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
new_chunk = new_chunk.set_gain_db(chunk.gain_db());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.subscribers.push(new_chunk).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dsp_node_unity_gain() {
|
||||||
|
let (mut node, tx) = DspNode::new(10, 0.0);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
0,
|
||||||
|
vec![0.25, 0.5, 0.75],
|
||||||
|
vec![0.1, 0.2, 0.3],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Avec gain = 1.0, le chunk ne devrait pas être cloné
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
assert!(Arc::ptr_eq(&chunk, &received));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dsp_node_gain() {
|
||||||
|
let gain_db = AudioChunk::gain_db_from_linear(2.0) as f32;
|
||||||
|
let (mut node, tx) = DspNode::new(10, gain_db);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
0,
|
||||||
|
vec![0.25, 0.5, 0.75],
|
||||||
|
vec![0.1, 0.2, 0.3],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
// Vérifier que le gain a été appliqué
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
let frames = received.to_pairs_f32();
|
||||||
|
const EPS: f32 = 1e-3;
|
||||||
|
assert!((frames[0][0] - 0.5).abs() < EPS);
|
||||||
|
assert!((frames[1][0] - 1.0).abs() < EPS);
|
||||||
|
assert!((frames[2][0] - 1.0).abs() < EPS); // Clamp at full scale
|
||||||
|
assert!((frames[0][1] - 0.2).abs() < EPS);
|
||||||
|
assert!((frames[1][1] - 0.4).abs() < EPS);
|
||||||
|
assert!((frames[2][1] - 0.6).abs() < EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_lowpass_dsp_node() {
|
||||||
|
let (mut node, tx) = LowPassDspNode::new(10, 0.5);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk avec un signal carré
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
0,
|
||||||
|
vec![1.0, 1.0, 1.0, -1.0, -1.0, -1.0],
|
||||||
|
vec![1.0, 1.0, 1.0, -1.0, -1.0, -1.0],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
// Le filtre devrait lisser le signal
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
let frames = received.to_pairs_f32();
|
||||||
|
assert!(frames[0][0].abs() < 1.0); // Premier échantillon lissé
|
||||||
|
assert!(frames[2][0].abs() < 1.0); // Signal ne devrait pas atteindre 1.0 immédiatement
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dsp_node_multiple_subscribers() {
|
||||||
|
let gain_db = AudioChunk::gain_db_from_linear(0.5) as f32;
|
||||||
|
let (mut node, tx) = DspNode::new(10, gain_db);
|
||||||
|
let (out_tx1, mut out_rx1) = mpsc::channel(10);
|
||||||
|
let (out_tx2, mut out_rx2) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx1);
|
||||||
|
node.add_subscriber(out_tx2);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(0, vec![0.8, 0.4], vec![0.8, 0.4], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
// Les deux abonnés devraient recevoir le même Arc
|
||||||
|
let received1 = out_rx1.recv().await.unwrap();
|
||||||
|
let received2 = out_rx2.recv().await.unwrap();
|
||||||
|
|
||||||
|
assert!(Arc::ptr_eq(&received1, &received2));
|
||||||
|
let frames = received1.to_pairs_f32();
|
||||||
|
const EPS: f32 = 1e-3;
|
||||||
|
assert!((frames[0][0] - 0.4).abs() < EPS); // 0.8 * 0.5
|
||||||
|
assert!((frames[1][0] - 0.2).abs() < EPS); // 0.4 * 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
398
old_code/pmoaudio/nodes/mpd_sink.rs
Normal file
398
old_code/pmoaudio/nodes/mpd_sink.rs
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
//! MpdSink - Envoie le flux audio à un démon MPD (Music Player Daemon)
|
||||||
|
//!
|
||||||
|
//! Ce module fournit un sink qui streame l'audio vers un démon MPD distant ou local.
|
||||||
|
//! Note: Cette implémentation est une version mock/skeleton. Une vraie implémentation
|
||||||
|
//! nécessiterait le protocole MPD complet et l'utilisation de bibliothèques comme `mpd`.
|
||||||
|
|
||||||
|
use crate::{nodes::AudioError, AudioChunk};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// Configuration pour le MpdSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MpdConfig {
|
||||||
|
/// Adresse du serveur MPD
|
||||||
|
pub host: String,
|
||||||
|
|
||||||
|
/// Port du serveur MPD (défaut: 6600)
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
/// Mot de passe optionnel
|
||||||
|
pub password: Option<String>,
|
||||||
|
|
||||||
|
/// Nom de l'output MPD à utiliser (optionnel)
|
||||||
|
pub output_name: Option<String>,
|
||||||
|
|
||||||
|
/// Taille du buffer
|
||||||
|
pub buffer_size: usize,
|
||||||
|
|
||||||
|
/// Format d'envoi
|
||||||
|
pub format: MpdAudioFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MpdConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
host: "localhost".to_string(),
|
||||||
|
port: 6600,
|
||||||
|
password: None,
|
||||||
|
output_name: None,
|
||||||
|
buffer_size: 50,
|
||||||
|
format: MpdAudioFormat::S16Le,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats audio supportés par MPD
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum MpdAudioFormat {
|
||||||
|
/// Signed 16-bit Little Endian
|
||||||
|
S16Le,
|
||||||
|
/// Signed 24-bit Little Endian
|
||||||
|
S24Le,
|
||||||
|
/// Signed 32-bit Little Endian
|
||||||
|
S32Le,
|
||||||
|
/// Float 32-bit
|
||||||
|
F32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpdAudioFormat {
|
||||||
|
/// Retourne le nom du format pour le protocole MPD
|
||||||
|
pub fn as_mpd_string(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
MpdAudioFormat::S16Le => "16:16:2",
|
||||||
|
MpdAudioFormat::S24Le => "24:24:2",
|
||||||
|
MpdAudioFormat::S32Le => "32:32:2",
|
||||||
|
MpdAudioFormat::F32 => "f:32:2",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MpdSink - Streame vers un démon MPD
|
||||||
|
///
|
||||||
|
/// Ce sink se connecte à un serveur MPD et lui envoie le flux audio.
|
||||||
|
/// MPD peut ensuite router l'audio vers différents outputs (ALSA, PulseAudio, HTTP, etc.).
|
||||||
|
///
|
||||||
|
/// # Implémentation actuelle
|
||||||
|
///
|
||||||
|
/// Cette version est un mock qui simule la communication avec MPD.
|
||||||
|
/// Pour une vraie implémentation, il faudrait:
|
||||||
|
/// - Implémenter le protocole MPD (commandes textuelles sur TCP)
|
||||||
|
/// - S'authentifier si nécessaire
|
||||||
|
/// - Configurer le format audio
|
||||||
|
/// - Envoyer les données PCM via le protocole approprié
|
||||||
|
/// - Gérer les commandes de contrôle (play, pause, stop)
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::{MpdSink, MpdConfig};
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let config = MpdConfig {
|
||||||
|
/// host: "localhost".to_string(),
|
||||||
|
/// port: 6600,
|
||||||
|
/// password: None,
|
||||||
|
/// ..Default::default()
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// let (sink, sink_tx) = MpdSink::new("mpd1".to_string(), config, 10);
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// sink.run().await.unwrap()
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct MpdSink {
|
||||||
|
/// Identifiant du sink
|
||||||
|
node_id: String,
|
||||||
|
|
||||||
|
/// Channel pour recevoir les chunks audio
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
|
||||||
|
/// Configuration
|
||||||
|
config: MpdConfig,
|
||||||
|
|
||||||
|
/// État de la connexion (mock)
|
||||||
|
connected: bool,
|
||||||
|
|
||||||
|
/// Version du serveur MPD (mock)
|
||||||
|
mpd_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpdSink {
|
||||||
|
/// Crée un nouveau MpdSink
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `node_id` - Identifiant unique du sink
|
||||||
|
/// * `config` - Configuration MPD
|
||||||
|
/// * `channel_size` - Taille du buffer du channel
|
||||||
|
pub fn new(
|
||||||
|
node_id: String,
|
||||||
|
config: MpdConfig,
|
||||||
|
channel_size: usize,
|
||||||
|
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let sink = Self {
|
||||||
|
node_id,
|
||||||
|
rx,
|
||||||
|
config,
|
||||||
|
connected: false,
|
||||||
|
mpd_version: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(sink, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Établit la connexion avec le serveur MPD (mock)
|
||||||
|
async fn connect(&mut self) -> Result<(), AudioError> {
|
||||||
|
println!(
|
||||||
|
"[{}] Connecting to MPD at {}:{}...",
|
||||||
|
self.node_id, self.config.host, self.config.port
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simuler une connexion TCP
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
// Dans une vraie implémentation:
|
||||||
|
// 1. Établir connexion TCP
|
||||||
|
// 2. Lire la bannière de version
|
||||||
|
// 3. S'authentifier si password fourni
|
||||||
|
// 4. Configurer le format audio
|
||||||
|
|
||||||
|
self.mpd_version = Some("0.23.0".to_string());
|
||||||
|
self.connected = true;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[{}] Connected to MPD v{} successfully",
|
||||||
|
self.node_id,
|
||||||
|
self.mpd_version.as_ref().unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Configurer le format audio
|
||||||
|
self.configure_audio_format().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure le format audio sur MPD (mock)
|
||||||
|
async fn configure_audio_format(&self) -> Result<(), AudioError> {
|
||||||
|
println!(
|
||||||
|
"[{}] Configuring audio format: {}",
|
||||||
|
self.node_id,
|
||||||
|
self.config.format.as_mpd_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dans une vraie implémentation:
|
||||||
|
// Envoyer une commande MPD pour configurer le format
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie un chunk au serveur MPD (mock)
|
||||||
|
async fn send_chunk(&self, _chunk: &AudioChunk) -> Result<(), AudioError> {
|
||||||
|
if !self.connected {
|
||||||
|
return Err(AudioError::ProcessingError(
|
||||||
|
"Not connected to MPD".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dans une vraie implémentation:
|
||||||
|
// 1. Appliquer le gain
|
||||||
|
// 2. Convertir dans le format approprié (S16LE, etc.)
|
||||||
|
// 3. Envoyer via le protocole MPD (probablement via une commande `sendmessage` ou pipe)
|
||||||
|
|
||||||
|
// Simuler un délai d'envoi
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_micros(50)).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Déconnecte proprement du serveur MPD (mock)
|
||||||
|
async fn disconnect(&mut self) -> Result<(), AudioError> {
|
||||||
|
if self.connected {
|
||||||
|
println!("[{}] Disconnecting from MPD...", self.node_id);
|
||||||
|
|
||||||
|
// Dans une vraie implémentation:
|
||||||
|
// Envoyer la commande "close"
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
self.connected = false;
|
||||||
|
|
||||||
|
println!("[{}] Disconnected successfully", self.node_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du MpdSink
|
||||||
|
pub async fn run(mut self) -> Result<MpdStats, AudioError> {
|
||||||
|
// Établir la connexion
|
||||||
|
self.connect().await?;
|
||||||
|
|
||||||
|
let mut stats = MpdStats::new(
|
||||||
|
self.node_id.clone(),
|
||||||
|
format!("{}:{}", self.config.host, self.config.port),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Boucle principale
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
// Appliquer le gain si nécessaire
|
||||||
|
let chunk_to_send = if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
Arc::clone(&chunk).apply_gain()
|
||||||
|
} else {
|
||||||
|
Arc::clone(&chunk)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Envoyer au serveur MPD
|
||||||
|
self.send_chunk(&chunk_to_send).await?;
|
||||||
|
|
||||||
|
stats.record_chunk(&chunk_to_send);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déconnexion propre
|
||||||
|
self.disconnect().await?;
|
||||||
|
|
||||||
|
stats.finalize();
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne un handle pour contrôler le sink (mock)
|
||||||
|
pub fn get_handle(&self) -> MpdHandle {
|
||||||
|
MpdHandle {
|
||||||
|
node_id: self.node_id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle pour contrôler le MpdSink
|
||||||
|
///
|
||||||
|
/// Permet d'envoyer des commandes de contrôle au serveur MPD
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MpdHandle {
|
||||||
|
node_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpdHandle {
|
||||||
|
/// Commande play (mock)
|
||||||
|
pub async fn play(&self) -> Result<(), AudioError> {
|
||||||
|
println!("[{}] MPD command: play", self.node_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commande pause (mock)
|
||||||
|
pub async fn pause(&self) -> Result<(), AudioError> {
|
||||||
|
println!("[{}] MPD command: pause", self.node_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commande stop (mock)
|
||||||
|
pub async fn stop(&self) -> Result<(), AudioError> {
|
||||||
|
println!("[{}] MPD command: stop", self.node_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change le volume MPD (0-100) (mock)
|
||||||
|
pub async fn set_volume(&self, volume: u8) -> Result<(), AudioError> {
|
||||||
|
let clamped = volume.min(100);
|
||||||
|
println!("[{}] MPD command: setvol {}", self.node_id, clamped);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques du MpdSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MpdStats {
|
||||||
|
pub node_id: String,
|
||||||
|
pub server_address: String,
|
||||||
|
pub chunks_sent: u64,
|
||||||
|
pub total_samples: u64,
|
||||||
|
pub total_duration_sec: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpdStats {
|
||||||
|
pub fn new(node_id: String, server_address: String) -> Self {
|
||||||
|
Self {
|
||||||
|
node_id,
|
||||||
|
server_address,
|
||||||
|
chunks_sent: 0,
|
||||||
|
total_samples: 0,
|
||||||
|
total_duration_sec: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
|
||||||
|
self.chunks_sent += 1;
|
||||||
|
self.total_samples += chunk.len() as u64;
|
||||||
|
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finalize(&mut self) {
|
||||||
|
// Calculs finaux si nécessaire
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display(&self) {
|
||||||
|
println!("\n=== MPD Sink Statistics: {} ===", self.node_id);
|
||||||
|
println!("Server: {}", self.server_address);
|
||||||
|
println!("Chunks sent: {}", self.chunks_sent);
|
||||||
|
println!("Total samples: {}", self.total_samples);
|
||||||
|
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||||
|
println!("===============================\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mpd_sink_basic() {
|
||||||
|
let config = MpdConfig {
|
||||||
|
host: "localhost".to_string(),
|
||||||
|
port: 6600,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (sink, tx) = MpdSink::new("test".to_string(), config, 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { sink.run().await });
|
||||||
|
|
||||||
|
// Envoyer quelques chunks
|
||||||
|
for i in 0..5 {
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
i,
|
||||||
|
vec![0.5; 1000],
|
||||||
|
vec![0.5; 1000],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let stats = handle.await.unwrap().unwrap();
|
||||||
|
assert_eq!(stats.chunks_sent, 5);
|
||||||
|
assert_eq!(stats.server_address, "localhost:6600");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mpd_handle() {
|
||||||
|
let config = MpdConfig::default();
|
||||||
|
let (sink, _tx) = MpdSink::new("test".to_string(), config, 10);
|
||||||
|
|
||||||
|
let handle = sink.get_handle();
|
||||||
|
|
||||||
|
// Tester les commandes (mock)
|
||||||
|
handle.play().await.unwrap();
|
||||||
|
handle.pause().await.unwrap();
|
||||||
|
handle.set_volume(75).await.unwrap();
|
||||||
|
handle.stop().await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
212
old_code/pmoaudio/nodes/sink_node.rs
Normal file
212
old_code/pmoaudio/nodes/sink_node.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
use crate::{nodes::AudioError, AudioChunk};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// SinkNode - Node terminal qui consomme les chunks audio
|
||||||
|
///
|
||||||
|
/// Version mock pour tests et logging
|
||||||
|
pub struct SinkNode {
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SinkNode {
|
||||||
|
pub fn new(name: String, channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self { rx, name };
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version silencieuse - consomme les chunks sans action
|
||||||
|
pub async fn run_silent(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(_chunk) = self.rx.recv().await {
|
||||||
|
// Ne rien faire, juste consommer
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version avec logging
|
||||||
|
pub async fn run_with_logging(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
println!(
|
||||||
|
"[{}] Received chunk #{} - {} samples @ {} Hz",
|
||||||
|
self.name,
|
||||||
|
chunk.order(),
|
||||||
|
chunk.len(),
|
||||||
|
chunk.sample_rate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version avec statistiques
|
||||||
|
pub async fn run_with_stats(mut self) -> Result<SinkStats, AudioError> {
|
||||||
|
let mut stats = SinkStats::new(self.name.clone());
|
||||||
|
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
stats.process_chunk(&chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version mock pour écriture dans un fichier (simule l'écriture)
|
||||||
|
pub async fn run_mock_file_writer(mut self) -> Result<usize, AudioError> {
|
||||||
|
let mut total_samples = 0;
|
||||||
|
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
total_samples += chunk.len();
|
||||||
|
// Simuler l'écriture avec un petit délai
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_micros(10)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(total_samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques collectées par un SinkNode
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SinkStats {
|
||||||
|
pub name: String,
|
||||||
|
pub chunks_received: u64,
|
||||||
|
pub total_samples: u64,
|
||||||
|
pub total_duration_sec: f64,
|
||||||
|
pub peak_left: f32,
|
||||||
|
pub peak_right: f32,
|
||||||
|
pub rms_left: f64,
|
||||||
|
pub rms_right: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SinkStats {
|
||||||
|
pub fn new(name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
chunks_received: 0,
|
||||||
|
total_samples: 0,
|
||||||
|
total_duration_sec: 0.0,
|
||||||
|
peak_left: 0.0,
|
||||||
|
peak_right: 0.0,
|
||||||
|
rms_left: 0.0,
|
||||||
|
rms_right: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_chunk(&mut self, chunk: &AudioChunk) {
|
||||||
|
self.chunks_received += 1;
|
||||||
|
let len = chunk.len() as u64;
|
||||||
|
self.total_samples += len;
|
||||||
|
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
|
||||||
|
|
||||||
|
let inv_max = 1.0f32 / chunk.bit_depth().max_value();
|
||||||
|
let mut peak_left = self.peak_left;
|
||||||
|
let mut peak_right = self.peak_right;
|
||||||
|
let mut sum_squares_left = 0.0f64;
|
||||||
|
let mut sum_squares_right = 0.0f64;
|
||||||
|
|
||||||
|
for frame in chunk.frames() {
|
||||||
|
let left = frame[0] as f32 * inv_max;
|
||||||
|
let right = frame[1] as f32 * inv_max;
|
||||||
|
let left_abs = left.abs();
|
||||||
|
let right_abs = right.abs();
|
||||||
|
if left_abs > peak_left {
|
||||||
|
peak_left = left_abs;
|
||||||
|
}
|
||||||
|
if right_abs > peak_right {
|
||||||
|
peak_right = right_abs;
|
||||||
|
}
|
||||||
|
let l64 = left as f64;
|
||||||
|
let r64 = right as f64;
|
||||||
|
sum_squares_left += l64 * l64;
|
||||||
|
sum_squares_right += r64 * r64;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.peak_left = peak_left;
|
||||||
|
self.peak_right = peak_right;
|
||||||
|
|
||||||
|
let prev_samples = self.total_samples - len;
|
||||||
|
self.rms_left = ((self.rms_left.powi(2) * prev_samples as f64 + sum_squares_left)
|
||||||
|
/ self.total_samples as f64)
|
||||||
|
.sqrt();
|
||||||
|
self.rms_right = ((self.rms_right.powi(2) * prev_samples as f64 + sum_squares_right)
|
||||||
|
/ self.total_samples as f64)
|
||||||
|
.sqrt();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display(&self) {
|
||||||
|
println!("\n=== Sink Statistics: {} ===", self.name);
|
||||||
|
println!("Chunks received: {}", self.chunks_received);
|
||||||
|
println!("Total samples: {}", self.total_samples);
|
||||||
|
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||||
|
println!("Peak L/R: {:.3} / {:.3}", self.peak_left, self.peak_right);
|
||||||
|
println!("RMS L/R: {:.3} / {:.3}", self.rms_left, self.rms_right);
|
||||||
|
println!("========================\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
const BD: BitDepth = BitDepth::B24;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sink_node_silent() {
|
||||||
|
let (node, tx) = SinkNode::new("test".to_string(), 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { node.run_silent().await });
|
||||||
|
|
||||||
|
// Envoyer quelques chunks
|
||||||
|
for i in 0..3 {
|
||||||
|
let chunk = AudioChunk::from_channels_f32(i, vec![0.0; 100], vec![0.0; 100], 48000, BD);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
handle.await.unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sink_node_stats() {
|
||||||
|
let (node, tx) = SinkNode::new("test".to_string(), 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { node.run_with_stats().await });
|
||||||
|
|
||||||
|
// Envoyer des chunks avec signal connu
|
||||||
|
for i in 0..3 {
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(i, vec![1.0; 1000], vec![0.5; 1000], 48000, BD);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
let stats = handle.await.unwrap().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(stats.chunks_received, 3);
|
||||||
|
assert_eq!(stats.total_samples, 3000);
|
||||||
|
assert!((stats.peak_left - 1.0).abs() < 1e-6);
|
||||||
|
assert!((stats.peak_right - 0.5).abs() < 1e-6);
|
||||||
|
assert!((stats.rms_left - 1.0).abs() < 0.001);
|
||||||
|
assert!((stats.rms_right - 0.5).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sink_node_file_writer() {
|
||||||
|
let (node, tx) = SinkNode::new("writer".to_string(), 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { node.run_mock_file_writer().await });
|
||||||
|
|
||||||
|
// Envoyer des chunks
|
||||||
|
for i in 0..5 {
|
||||||
|
let chunk = AudioChunk::from_channels_f32(i, vec![0.0; 100], vec![0.0; 100], 48000, BD);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
let total_samples = handle.await.unwrap().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(total_samples, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
old_code/pmoaudio/nodes/source_node.rs
Normal file
171
old_code/pmoaudio/nodes/source_node.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
use crate::{
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioChunk, BitDepth,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// SourceNode - Génère ou lit des chunks audio depuis une source
|
||||||
|
///
|
||||||
|
/// Ce node est la source du pipeline. Version mock pour tests.
|
||||||
|
pub struct SourceNode {
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BIT_DEPTH: BitDepth = BitDepth::B24;
|
||||||
|
|
||||||
|
impl SourceNode {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Génère un chunk de test avec une forme d'onde sinusoïdale
|
||||||
|
pub fn generate_test_chunk(
|
||||||
|
order: u64,
|
||||||
|
size: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
frequency: f32,
|
||||||
|
) -> Arc<AudioChunk> {
|
||||||
|
let mut left = Vec::with_capacity(size);
|
||||||
|
let mut right = Vec::with_capacity(size);
|
||||||
|
|
||||||
|
for i in 0..size {
|
||||||
|
let t = (order * size as u64 + i as u64) as f32 / sample_rate as f32;
|
||||||
|
let sample = (2.0 * std::f32::consts::PI * frequency * t).sin();
|
||||||
|
left.push(sample);
|
||||||
|
right.push(sample * 0.8); // Légèrement différent pour la stéréo
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioChunk::from_channels_f32(order, left, right, sample_rate, DEFAULT_BIT_DEPTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Génère et envoie des chunks de test
|
||||||
|
pub async fn generate_chunks(
|
||||||
|
&self,
|
||||||
|
count: u64,
|
||||||
|
chunk_size: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
frequency: f32,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
for i in 0..count {
|
||||||
|
let chunk = Self::generate_test_chunk(i, chunk_size, sample_rate, frequency);
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Génère des chunks silencieux
|
||||||
|
pub async fn generate_silence(
|
||||||
|
&self,
|
||||||
|
count: u64,
|
||||||
|
chunk_size: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
for i in 0..count {
|
||||||
|
let stereo = vec![[0i32; 2]; chunk_size];
|
||||||
|
let chunk = AudioChunk::new(i, stereo, sample_rate, DEFAULT_BIT_DEPTH);
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version streaming : génère des chunks continuellement avec délai
|
||||||
|
pub async fn stream_chunks(
|
||||||
|
&self,
|
||||||
|
chunk_size: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
frequency: f32,
|
||||||
|
duration_ms: u64,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
let chunk_duration_ms = (chunk_size as f64 / sample_rate as f64 * 1000.0) as u64;
|
||||||
|
let mut order = 0u64;
|
||||||
|
|
||||||
|
let start = tokio::time::Instant::now();
|
||||||
|
let duration = tokio::time::Duration::from_millis(duration_ms);
|
||||||
|
|
||||||
|
while start.elapsed() < duration {
|
||||||
|
let chunk = Self::generate_test_chunk(order, chunk_size, sample_rate, frequency);
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
|
||||||
|
order += 1;
|
||||||
|
|
||||||
|
// Attendre pour simuler le timing réel
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(chunk_duration_ms)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SourceNode {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_source_node_generation() {
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
let (tx, mut rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
source.add_subscriber(tx);
|
||||||
|
|
||||||
|
// Générer 3 chunks
|
||||||
|
source.generate_chunks(3, 100, 48000, 440.0).await.unwrap();
|
||||||
|
|
||||||
|
// Vérifier la réception
|
||||||
|
for i in 0..3 {
|
||||||
|
let chunk = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(chunk.order(), i);
|
||||||
|
assert_eq!(chunk.len(), 100);
|
||||||
|
assert_eq!(chunk.sample_rate(), 48000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sine_wave_generation() {
|
||||||
|
let chunk = SourceNode::generate_test_chunk(0, 48000, 48000, 440.0);
|
||||||
|
|
||||||
|
// Vérifier qu'on a bien une sinusoïde
|
||||||
|
// À 440 Hz avec 48000 samples/s, on devrait avoir 440 cycles
|
||||||
|
let pairs = chunk.to_pairs_f32();
|
||||||
|
let left: Vec<f32> = pairs.iter().map(|frame| frame[0]).collect();
|
||||||
|
|
||||||
|
// Trouver les passages par zéro
|
||||||
|
let mut zero_crossings = 0;
|
||||||
|
for i in 1..left.len() {
|
||||||
|
if (left[i - 1] < 0.0 && left[i] >= 0.0) || (left[i - 1] >= 0.0 && left[i] < 0.0) {
|
||||||
|
zero_crossings += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 440 cycles = 880 passages par zéro (approximativement)
|
||||||
|
assert!(zero_crossings > 850 && zero_crossings < 910);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_source_node_silence() {
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
let (tx, mut rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
source.add_subscriber(tx);
|
||||||
|
|
||||||
|
source.generate_silence(2, 100, 48000).await.unwrap();
|
||||||
|
|
||||||
|
for _ in 0..2 {
|
||||||
|
let chunk = rx.recv().await.unwrap();
|
||||||
|
assert!(chunk.frames().iter().all(|frame| frame[0] == 0));
|
||||||
|
assert!(chunk.frames().iter().all(|frame| frame[1] == 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
291
old_code/pmoaudio/nodes/timer_node.rs
Normal file
291
old_code/pmoaudio/nodes/timer_node.rs
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
use crate::{
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioChunk,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{mpsc, RwLock};
|
||||||
|
|
||||||
|
/// TimerNode - Node passthrough qui calcule la position temporelle
|
||||||
|
///
|
||||||
|
/// Ce node ne modifie pas les données audio, il les passe directement
|
||||||
|
/// aux abonnés tout en maintenant un compteur de samples pour calculer
|
||||||
|
/// la position en secondes.
|
||||||
|
///
|
||||||
|
/// # Fonctionnement
|
||||||
|
///
|
||||||
|
/// Pour chaque chunk reçu:
|
||||||
|
/// 1. Incrémente `elapsed_samples += chunk.len()`
|
||||||
|
/// 2. Calcule `position_sec = elapsed_samples / sample_rate`
|
||||||
|
/// 3. Push le chunk (sans modification) vers les abonnés
|
||||||
|
///
|
||||||
|
/// # Utilisation
|
||||||
|
///
|
||||||
|
/// Le TimerNode fournit un [`TimerHandle`] qui permet de lire la position
|
||||||
|
/// depuis d'autres threads/tasks sans bloquer le pipeline.
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::TimerNode;
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let (mut timer, timer_tx) = TimerNode::new(10);
|
||||||
|
/// let handle = timer.get_position_handle();
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// timer.run().await.unwrap();
|
||||||
|
/// });
|
||||||
|
///
|
||||||
|
/// // Lire la position depuis un autre thread
|
||||||
|
/// let position = handle.position_sec().await;
|
||||||
|
/// println!("Position: {:.2} sec", position);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct TimerNode {
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
elapsed_samples: Arc<RwLock<u64>>,
|
||||||
|
current_sample_rate: Arc<RwLock<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimerNode {
|
||||||
|
/// Crée un nouveau TimerNode
|
||||||
|
pub fn new(channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
rx,
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
elapsed_samples: Arc::new(RwLock::new(0)),
|
||||||
|
current_sample_rate: Arc::new(RwLock::new(48000)), // Default
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un abonné
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la position actuelle en secondes
|
||||||
|
pub async fn position_sec(&self) -> f64 {
|
||||||
|
let elapsed = *self.elapsed_samples.read().await;
|
||||||
|
let sample_rate = *self.current_sample_rate.read().await;
|
||||||
|
elapsed as f64 / sample_rate as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le nombre total d'échantillons écoulés
|
||||||
|
pub async fn elapsed_samples(&self) -> u64 {
|
||||||
|
*self.elapsed_samples.read().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset le compteur
|
||||||
|
pub async fn reset(&self) {
|
||||||
|
let mut elapsed = self.elapsed_samples.write().await;
|
||||||
|
*elapsed = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du TimerNode
|
||||||
|
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
// Mettre à jour le sample rate si nécessaire
|
||||||
|
{
|
||||||
|
let mut sr = self.current_sample_rate.write().await;
|
||||||
|
if *sr != chunk.sample_rate() {
|
||||||
|
*sr = chunk.sample_rate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrémenter le compteur d'échantillons
|
||||||
|
{
|
||||||
|
let mut elapsed = self.elapsed_samples.write().await;
|
||||||
|
*elapsed += chunk.len() as u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push immédiatement le même chunk vers les abonnés (passthrough)
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version non-bloquante avec try_push
|
||||||
|
pub async fn run_nonblocking(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
{
|
||||||
|
let mut sr = self.current_sample_rate.write().await;
|
||||||
|
if *sr != chunk.sample_rate() {
|
||||||
|
*sr = chunk.sample_rate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut elapsed = self.elapsed_samples.write().await;
|
||||||
|
*elapsed += chunk.len() as u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.subscribers.try_push(chunk).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne un handle pour lire la position depuis d'autres threads
|
||||||
|
pub fn get_position_handle(&self) -> TimerHandle {
|
||||||
|
TimerHandle {
|
||||||
|
elapsed_samples: self.elapsed_samples.clone(),
|
||||||
|
current_sample_rate: self.current_sample_rate.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle pour lire la position du TimerNode depuis d'autres threads
|
||||||
|
///
|
||||||
|
/// Ce handle peut être cloné et utilisé depuis plusieurs threads/tasks
|
||||||
|
/// pour monitorer la position de lecture sans bloquer le pipeline.
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::TimerNode;
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let (mut timer, _tx) = TimerNode::new(10);
|
||||||
|
/// let handle = timer.get_position_handle();
|
||||||
|
/// let handle_clone = handle.clone();
|
||||||
|
///
|
||||||
|
/// // Utiliser depuis plusieurs tasks
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// loop {
|
||||||
|
/// let pos = handle_clone.position_sec().await;
|
||||||
|
/// println!("Position: {:.2}s", pos);
|
||||||
|
/// tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
/// }
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TimerHandle {
|
||||||
|
elapsed_samples: Arc<RwLock<u64>>,
|
||||||
|
current_sample_rate: Arc<RwLock<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimerHandle {
|
||||||
|
/// Retourne la position actuelle en secondes
|
||||||
|
pub async fn position_sec(&self) -> f64 {
|
||||||
|
let elapsed = *self.elapsed_samples.read().await;
|
||||||
|
let sample_rate = *self.current_sample_rate.read().await;
|
||||||
|
elapsed as f64 / sample_rate as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le nombre total d'échantillons écoulés
|
||||||
|
pub async fn elapsed_samples(&self) -> u64 {
|
||||||
|
*self.elapsed_samples.read().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_timer_node_position_calculation() {
|
||||||
|
let (mut node, tx) = TimerNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
let handle = node.get_position_handle();
|
||||||
|
|
||||||
|
// Spawn le node
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer 3 chunks de 1000 samples à 48000 Hz
|
||||||
|
for i in 0..3 {
|
||||||
|
let stereo = vec![[0i32; 2]; 1000];
|
||||||
|
let chunk = AudioChunk::new(i, stereo, 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendre que les chunks soient traités
|
||||||
|
for _ in 0..3 {
|
||||||
|
out_rx.recv().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la position
|
||||||
|
let position = handle.position_sec().await;
|
||||||
|
let expected = 3000.0 / 48000.0; // 3 chunks * 1000 samples / 48000 Hz
|
||||||
|
assert!((position - expected).abs() < 0.0001);
|
||||||
|
|
||||||
|
let elapsed = handle.elapsed_samples().await;
|
||||||
|
assert_eq!(elapsed, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_timer_node_passthrough() {
|
||||||
|
let (mut node, tx) = TimerNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk
|
||||||
|
let chunk = AudioChunk::from_channels_i32(
|
||||||
|
42,
|
||||||
|
vec![100, 200, 300],
|
||||||
|
vec![400, 500, 600],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Recevoir le chunk
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
|
||||||
|
// Vérifier que c'est le même Arc (pas de clone des données)
|
||||||
|
assert!(Arc::ptr_eq(&chunk, &received));
|
||||||
|
assert_eq!(received.order(), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_timer_node_sample_rate_change() {
|
||||||
|
let (mut node, tx) = TimerNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
let handle = node.get_position_handle();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chunk à 48000 Hz
|
||||||
|
let chunk1 = AudioChunk::new(0, vec![[0i32; 2]; 48000], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk1).await.unwrap();
|
||||||
|
out_rx.recv().await.unwrap();
|
||||||
|
|
||||||
|
// Après 48000 samples à 48000 Hz = 1 seconde
|
||||||
|
let pos1 = handle.position_sec().await;
|
||||||
|
assert!((pos1 - 1.0).abs() < 0.0001);
|
||||||
|
|
||||||
|
// Chunk à 96000 Hz
|
||||||
|
let chunk2 = AudioChunk::new(1, vec![[0i32; 2]; 96000], 96000, BitDepth::B24);
|
||||||
|
tx.send(chunk2).await.unwrap();
|
||||||
|
out_rx.recv().await.unwrap();
|
||||||
|
|
||||||
|
// Position calculée avec le nouveau sample rate
|
||||||
|
let pos2 = handle.position_sec().await;
|
||||||
|
let expected = (48000.0 + 96000.0) / 96000.0;
|
||||||
|
assert!((pos2 - expected).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
}
|
||||||
363
old_code/pmoaudio/nodes/volume_node.rs
Normal file
363
old_code/pmoaudio/nodes/volume_node.rs
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
//! Volume nodes - Contrôle du volume audio
|
||||||
|
//!
|
||||||
|
//! Ce module fournit des nodes pour ajuster le volume du flux audio,
|
||||||
|
//! avec support du volume master/secondaire et notification des changements.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
events::{EventPublisher, VolumeChangeEvent},
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioChunk,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{mpsc, RwLock};
|
||||||
|
|
||||||
|
/// VolumeNode - Applique un gain au flux audio (contrôle software)
|
||||||
|
///
|
||||||
|
/// Ce node modifie le champ `gain` de chaque `AudioChunk` qui le traverse.
|
||||||
|
/// Le gain est multiplié avec le gain existant du chunk, permettant ainsi
|
||||||
|
/// une chaîne de contrôles de volume.
|
||||||
|
///
|
||||||
|
/// # Caractéristiques
|
||||||
|
///
|
||||||
|
/// - Thread-safe : le volume peut être modifié pendant l'exécution via `set_volume`
|
||||||
|
/// - Notification : émet des événements `VolumeChangeEvent` lors des changements
|
||||||
|
/// - Master/Slave : peut s'abonner à un volume master pour synchronisation
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::VolumeNode;
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let (volume_node, volume_tx) = VolumeNode::new("Room 1".to_string(), 0.8, 10);
|
||||||
|
///
|
||||||
|
/// // Modifier le volume pendant l'exécution
|
||||||
|
/// let handle = volume_node.get_handle();
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
/// handle.set_volume(0.5).await;
|
||||||
|
/// });
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move { volume_node.run().await.unwrap() });
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct VolumeNode {
|
||||||
|
/// Channel pour recevoir les chunks audio
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
|
||||||
|
/// Subscribers pour les chunks modifiés
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
|
||||||
|
/// Volume courant (partagé via RwLock pour lecture/écriture thread-safe)
|
||||||
|
volume: Arc<RwLock<f32>>,
|
||||||
|
|
||||||
|
/// Publisher pour les événements de changement de volume
|
||||||
|
volume_publisher: EventPublisher<VolumeChangeEvent>,
|
||||||
|
|
||||||
|
/// Identifiant unique du node (pour traçabilité)
|
||||||
|
node_id: String,
|
||||||
|
|
||||||
|
/// Receiver pour les événements de volume master (optionnel)
|
||||||
|
master_volume_rx: Option<mpsc::Receiver<VolumeChangeEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeNode {
|
||||||
|
/// Crée un nouveau VolumeNode
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `node_id` - Identifiant unique du node
|
||||||
|
/// * `initial_volume` - Volume initial (0.0 à 1.0)
|
||||||
|
/// * `channel_size` - Taille du buffer du channel
|
||||||
|
pub fn new(
|
||||||
|
node_id: String,
|
||||||
|
initial_volume: f32,
|
||||||
|
channel_size: usize,
|
||||||
|
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
rx,
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
volume: Arc::new(RwLock::new(initial_volume)),
|
||||||
|
volume_publisher: EventPublisher::new(),
|
||||||
|
node_id,
|
||||||
|
master_volume_rx: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un subscriber pour recevoir les chunks audio modifiés
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un subscriber pour les événements de changement de volume
|
||||||
|
pub fn subscribe_volume_events(&mut self, tx: mpsc::Sender<VolumeChangeEvent>) {
|
||||||
|
self.volume_publisher.subscribe(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure ce node pour écouter un volume master
|
||||||
|
///
|
||||||
|
/// Le node appliquera à la fois son volume local ET le volume master reçu.
|
||||||
|
pub fn set_master_volume_source(&mut self, rx: mpsc::Receiver<VolumeChangeEvent>) {
|
||||||
|
self.master_volume_rx = Some(rx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne un handle pour contrôler le volume depuis un autre contexte
|
||||||
|
pub fn get_handle(&self) -> VolumeHandle {
|
||||||
|
VolumeHandle {
|
||||||
|
volume: self.volume.clone(),
|
||||||
|
node_id: self.node_id.clone(),
|
||||||
|
publisher: Arc::new(RwLock::new(self.volume_publisher.clone())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du VolumeNode
|
||||||
|
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||||
|
let mut master_volume = 1.0f32;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Recevoir les chunks audio
|
||||||
|
chunk_opt = self.rx.recv() => {
|
||||||
|
match chunk_opt {
|
||||||
|
Some(chunk) => {
|
||||||
|
let local_volume = *self.volume.read().await;
|
||||||
|
let total_volume = (local_volume * master_volume).max(0.0);
|
||||||
|
|
||||||
|
// Créer un nouveau chunk avec le gain modifié (conversion vers dB)
|
||||||
|
let modified_chunk =
|
||||||
|
chunk.with_modified_gain_linear(total_volume as f64);
|
||||||
|
|
||||||
|
// Envoyer aux subscribers
|
||||||
|
self.subscribers.push(modified_chunk).await?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Channel fermé, terminer
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recevoir les mises à jour du volume master (si configuré)
|
||||||
|
master_event_opt = async {
|
||||||
|
if let Some(ref mut rx) = self.master_volume_rx {
|
||||||
|
rx.recv().await
|
||||||
|
} else {
|
||||||
|
// Bloquer indéfiniment si pas de master
|
||||||
|
std::future::pending().await
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
if let Some(event) = master_event_opt {
|
||||||
|
master_volume = event.volume;
|
||||||
|
|
||||||
|
// Optionnel : re-publier l'événement combiné
|
||||||
|
let local_volume = *self.volume.read().await;
|
||||||
|
let combined_event = VolumeChangeEvent {
|
||||||
|
volume: local_volume * master_volume,
|
||||||
|
source_node_id: self.node_id.clone(),
|
||||||
|
};
|
||||||
|
self.volume_publisher.publish(combined_event).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle pour contrôler un VolumeNode depuis un autre contexte
|
||||||
|
///
|
||||||
|
/// Ce handle permet de modifier le volume et de notifier les subscribers
|
||||||
|
/// sans avoir accès direct au node.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct VolumeHandle {
|
||||||
|
volume: Arc<RwLock<f32>>,
|
||||||
|
node_id: String,
|
||||||
|
publisher: Arc<RwLock<EventPublisher<VolumeChangeEvent>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeHandle {
|
||||||
|
/// Modifie le volume
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `new_volume` - Nouveau volume (0.0 à 1.0)
|
||||||
|
pub async fn set_volume(&self, new_volume: f32) {
|
||||||
|
let clamped = new_volume.clamp(0.0, 1.0);
|
||||||
|
*self.volume.write().await = clamped;
|
||||||
|
|
||||||
|
// Publier l'événement de changement
|
||||||
|
let event = VolumeChangeEvent {
|
||||||
|
volume: clamped,
|
||||||
|
source_node_id: self.node_id.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.publisher.read().await.publish(event).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtient le volume courant
|
||||||
|
pub async fn get_volume(&self) -> f32 {
|
||||||
|
*self.volume.read().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Augmente le volume de manière relative
|
||||||
|
pub async fn adjust_volume(&self, delta: f32) {
|
||||||
|
let current = *self.volume.read().await;
|
||||||
|
self.set_volume(current + delta).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HardwareVolumeNode - Contrôle matériel du volume
|
||||||
|
///
|
||||||
|
/// Ce node simule un contrôle hardware du volume. Dans une implémentation réelle,
|
||||||
|
/// il communiquerait avec le driver audio pour ajuster le volume matériel.
|
||||||
|
///
|
||||||
|
/// Pour cette version, il agit de manière similaire à `VolumeNode` mais pourrait
|
||||||
|
/// être étendu pour utiliser des APIs système spécifiques.
|
||||||
|
pub struct HardwareVolumeNode {
|
||||||
|
inner: VolumeNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HardwareVolumeNode {
|
||||||
|
/// Crée un nouveau HardwareVolumeNode
|
||||||
|
pub fn new(
|
||||||
|
node_id: String,
|
||||||
|
initial_volume: f32,
|
||||||
|
channel_size: usize,
|
||||||
|
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (inner, tx) = VolumeNode::new(node_id, initial_volume, channel_size);
|
||||||
|
|
||||||
|
(Self { inner }, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un subscriber
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.inner.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtient un handle pour contrôler le volume
|
||||||
|
pub fn get_handle(&self) -> VolumeHandle {
|
||||||
|
self.inner.get_handle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement
|
||||||
|
pub async fn run(self) -> Result<(), AudioError> {
|
||||||
|
// Dans une vraie implémentation, on communiquerait avec le hardware ici
|
||||||
|
// Pour l'instant, délègue au VolumeNode standard
|
||||||
|
self.inner.run().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_volume_node_basic() {
|
||||||
|
let (mut node, tx) = VolumeNode::new("test".to_string(), 0.5, 10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { node.run().await });
|
||||||
|
|
||||||
|
// Envoyer un chunk avec gain 1.0
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
// Recevoir le chunk modifié
|
||||||
|
let modified = out_rx.recv().await.unwrap();
|
||||||
|
assert!((modified.gain_linear() - 0.5).abs() < 1e-6);
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
handle.await.unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_volume_handle() {
|
||||||
|
let (node, tx) = VolumeNode::new("test".to_string(), 1.0, 10);
|
||||||
|
let handle = node.get_handle();
|
||||||
|
|
||||||
|
tokio::spawn(async move { node.run().await });
|
||||||
|
|
||||||
|
// Modifier le volume via le handle
|
||||||
|
handle.set_volume(0.3).await;
|
||||||
|
|
||||||
|
let volume = handle.get_volume().await;
|
||||||
|
assert!((volume - 0.3).abs() < f32::EPSILON);
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_volume_events() {
|
||||||
|
let (mut node, tx) = VolumeNode::new("test".to_string(), 1.0, 10);
|
||||||
|
let (event_tx, mut event_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.subscribe_volume_events(event_tx);
|
||||||
|
let handle = node.get_handle();
|
||||||
|
|
||||||
|
tokio::spawn(async move { node.run().await });
|
||||||
|
|
||||||
|
// Changer le volume
|
||||||
|
handle.set_volume(0.7).await;
|
||||||
|
|
||||||
|
// Vérifier l'événement
|
||||||
|
let event = event_rx.recv().await.unwrap();
|
||||||
|
assert!((event.volume - 0.7).abs() < f32::EPSILON);
|
||||||
|
assert_eq!(event.source_node_id, "test");
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_master_slave_volume() {
|
||||||
|
// Créer le master
|
||||||
|
let (mut master, master_tx) = VolumeNode::new("master".to_string(), 1.0, 10);
|
||||||
|
let (master_event_tx, master_event_rx) = mpsc::channel(10);
|
||||||
|
master.subscribe_volume_events(master_event_tx);
|
||||||
|
let master_handle = master.get_handle();
|
||||||
|
|
||||||
|
// Créer le slave
|
||||||
|
let (mut slave, slave_tx) = VolumeNode::new("slave".to_string(), 0.8, 10);
|
||||||
|
slave.set_master_volume_source(master_event_rx);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
slave.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move { master.run().await });
|
||||||
|
tokio::spawn(async move { slave.run().await });
|
||||||
|
|
||||||
|
// Envoyer un chunk au slave
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
|
||||||
|
slave_tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
|
// Modifier le volume master
|
||||||
|
master_handle.set_volume(0.5).await;
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
|
// Envoyer un autre chunk
|
||||||
|
let chunk2 =
|
||||||
|
AudioChunk::from_channels_f32(1, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
|
||||||
|
slave_tx.send(chunk2).await.unwrap();
|
||||||
|
|
||||||
|
// Le deuxième chunk devrait avoir un gain de 0.8 * 0.5 = 0.4 (≈ -7.96 dB)
|
||||||
|
let _first = out_rx.recv().await.unwrap(); // gain ≈ 0.8
|
||||||
|
let second = out_rx.recv().await.unwrap(); // gain ≈ 0.4
|
||||||
|
|
||||||
|
assert!((second.gain_linear() - 0.4).abs() < 0.01);
|
||||||
|
|
||||||
|
drop(master_tx);
|
||||||
|
drop(slave_tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
old_code/pmoplaylist/examples/basic_usage.rs
Normal file
170
old_code/pmoplaylist/examples/basic_usage.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
//! Exemple d'utilisation basique de pmoplaylist
|
||||||
|
//!
|
||||||
|
//! Pour exécuter cet exemple :
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run -p pmoplaylist --example basic_usage
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Exemple pmoplaylist ===\n");
|
||||||
|
|
||||||
|
// 1. Créer une playlist FIFO
|
||||||
|
println!("1. Création d'une playlist avec capacité de 5 tracks...");
|
||||||
|
let playlist = FifoPlaylist::new(
|
||||||
|
"my-radio".to_string(),
|
||||||
|
"Ma Radio Préférée".to_string(),
|
||||||
|
5,
|
||||||
|
DEFAULT_IMAGE,
|
||||||
|
);
|
||||||
|
println!(" ✓ Playlist créée: {}", playlist.title().await);
|
||||||
|
println!(" ✓ ID: {}", playlist.id().await);
|
||||||
|
println!(" ✓ Capacité: 5 tracks");
|
||||||
|
println!(" ✓ Update ID initial: {}\n", playlist.update_id().await);
|
||||||
|
|
||||||
|
// 2. Ajouter des tracks
|
||||||
|
println!("2. Ajout de 3 tracks...");
|
||||||
|
let tracks = vec![
|
||||||
|
Track::new(
|
||||||
|
"track-1",
|
||||||
|
"Bohemian Rhapsody",
|
||||||
|
"http://example.com/queen/bohemian.flac",
|
||||||
|
)
|
||||||
|
.with_artist("Queen")
|
||||||
|
.with_album("A Night at the Opera")
|
||||||
|
.with_duration(354)
|
||||||
|
.with_image("http://example.com/covers/queen-anato.jpg"),
|
||||||
|
Track::new(
|
||||||
|
"track-2",
|
||||||
|
"Stairway to Heaven",
|
||||||
|
"http://example.com/zeppelin/stairway.mp3",
|
||||||
|
)
|
||||||
|
.with_artist("Led Zeppelin")
|
||||||
|
.with_album("Led Zeppelin IV")
|
||||||
|
.with_duration(482),
|
||||||
|
Track::new(
|
||||||
|
"track-3",
|
||||||
|
"Hotel California",
|
||||||
|
"http://example.com/eagles/hotel.flac",
|
||||||
|
)
|
||||||
|
.with_artist("Eagles")
|
||||||
|
.with_album("Hotel California")
|
||||||
|
.with_duration(391),
|
||||||
|
];
|
||||||
|
|
||||||
|
for track in tracks {
|
||||||
|
playlist.append_track(track.clone()).await;
|
||||||
|
println!(
|
||||||
|
" ✓ Ajouté: {} - {}",
|
||||||
|
track.title,
|
||||||
|
track.artist.unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n Total tracks: {}", playlist.len().await);
|
||||||
|
println!(" Update ID: {}\n", playlist.update_id().await);
|
||||||
|
|
||||||
|
// 3. Tester le comportement FIFO
|
||||||
|
println!("3. Test du comportement FIFO (capacité = 5)...");
|
||||||
|
println!(" Ajout de 4 tracks supplémentaires...");
|
||||||
|
|
||||||
|
for i in 4..=7 {
|
||||||
|
let track = Track::new(
|
||||||
|
format!("track-{}", i),
|
||||||
|
format!("Song Number {}", i),
|
||||||
|
format!("http://example.com/songs/{}.mp3", i),
|
||||||
|
);
|
||||||
|
playlist.append_track(track).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" ✓ Total tracks (limité par capacité): {}",
|
||||||
|
playlist.len().await
|
||||||
|
);
|
||||||
|
|
||||||
|
// Afficher les tracks actuels
|
||||||
|
let items = playlist.get_items(0, 10).await;
|
||||||
|
println!("\n Tracks actuels dans la FIFO:");
|
||||||
|
for (idx, track) in items.iter().enumerate() {
|
||||||
|
println!(" {}. {} ({})", idx + 1, track.title, track.id);
|
||||||
|
}
|
||||||
|
println!(" (Les tracks 1 et 2 ont été supprimés automatiquement)\n");
|
||||||
|
|
||||||
|
// 4. Supprimer le plus ancien
|
||||||
|
println!("4. Suppression du track le plus ancien...");
|
||||||
|
if let Some(removed) = playlist.remove_oldest().await {
|
||||||
|
println!(" ✓ Supprimé: {} ({})", removed.title, removed.id);
|
||||||
|
}
|
||||||
|
println!(" Total tracks: {}", playlist.len().await);
|
||||||
|
println!(" Update ID: {}\n", playlist.update_id().await);
|
||||||
|
|
||||||
|
// 5. Supprimer par ID
|
||||||
|
println!("5. Suppression d'un track par ID (track-5)...");
|
||||||
|
if playlist.remove_by_id("track-5").await {
|
||||||
|
println!(" ✓ Track supprimé");
|
||||||
|
}
|
||||||
|
println!(" Total tracks: {}", playlist.len().await);
|
||||||
|
println!(" Update ID: {}\n", playlist.update_id().await);
|
||||||
|
|
||||||
|
// 6. Générer un Container DIDL-Lite
|
||||||
|
println!("6. Génération du Container DIDL-Lite...");
|
||||||
|
let container = playlist.as_container().await;
|
||||||
|
println!(" Container:");
|
||||||
|
println!(" - ID: {}", container.id);
|
||||||
|
println!(" - Parent ID: {}", container.parent_id);
|
||||||
|
println!(" - Title: {}", container.title);
|
||||||
|
println!(" - Class: {}", container.class);
|
||||||
|
println!(
|
||||||
|
" - Child Count: {}\n",
|
||||||
|
container.child_count.unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Générer des Items DIDL-Lite
|
||||||
|
println!("7. Génération des Items DIDL-Lite...");
|
||||||
|
let didl_items = playlist
|
||||||
|
.as_objects(0, 10, Some("http://myserver/api/default-image"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
println!(" Items DIDL-Lite:");
|
||||||
|
for (idx, item) in didl_items.iter().enumerate() {
|
||||||
|
println!("\n Item {}:", idx + 1);
|
||||||
|
println!(" - ID: {}", item.id);
|
||||||
|
println!(" - Title: {}", item.title);
|
||||||
|
println!(" - Artist: {}", item.artist.as_deref().unwrap_or("N/A"));
|
||||||
|
println!(" - Album: {}", item.album.as_deref().unwrap_or("N/A"));
|
||||||
|
println!(" - Class: {}", item.class);
|
||||||
|
println!(" - Parent ID: {}", item.parent_id);
|
||||||
|
|
||||||
|
if !item.resources.is_empty() {
|
||||||
|
println!(" - Resource URI: {}", item.resources[0].url);
|
||||||
|
if let Some(ref duration) = item.resources[0].duration {
|
||||||
|
println!(" - Duration: {}", duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref art) = item.album_art {
|
||||||
|
println!(" - Album Art: {}", art);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Image par défaut
|
||||||
|
println!("\n8. Image par défaut...");
|
||||||
|
let default_image = playlist.default_image().await;
|
||||||
|
println!(
|
||||||
|
" ✓ Taille de l'image par défaut: {} bytes",
|
||||||
|
default_image.len()
|
||||||
|
);
|
||||||
|
println!(" (Cette image peut être servie via un endpoint HTTP)\n");
|
||||||
|
|
||||||
|
// 9. Vider la playlist
|
||||||
|
println!("9. Vidage de la playlist...");
|
||||||
|
playlist.clear().await;
|
||||||
|
println!(" ✓ Playlist vidée");
|
||||||
|
println!(" Total tracks: {}", playlist.len().await);
|
||||||
|
println!(" Is empty: {}", playlist.is_empty().await);
|
||||||
|
println!(" Update ID final: {}\n", playlist.update_id().await);
|
||||||
|
|
||||||
|
println!("=== Exemple terminé ===");
|
||||||
|
}
|
||||||
217
old_code/pmoplaylist/examples/http_server_integration.rs
Normal file
217
old_code/pmoplaylist/examples/http_server_integration.rs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
//! Exemple d'intégration avec un serveur HTTP
|
||||||
|
//!
|
||||||
|
//! Cet exemple montre comment exposer une playlist FIFO via des endpoints HTTP simples.
|
||||||
|
//! Dans un vrai MediaServer UPnP, ces endpoints seraient appelés par le protocole ContentDirectory.
|
||||||
|
//!
|
||||||
|
//! Pour exécuter :
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run -p pmoplaylist --example http_server_integration
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Intégration HTTP Server ===\n");
|
||||||
|
|
||||||
|
// Créer une playlist partagée
|
||||||
|
let playlist = Arc::new(FifoPlaylist::new(
|
||||||
|
"my-radio".to_string(),
|
||||||
|
"My Internet Radio".to_string(),
|
||||||
|
20,
|
||||||
|
DEFAULT_IMAGE,
|
||||||
|
));
|
||||||
|
|
||||||
|
println!("📻 Playlist créée: {}", playlist.title().await);
|
||||||
|
println!("🆔 ID: {}\n", playlist.id().await);
|
||||||
|
|
||||||
|
// Ajouter quelques tracks initiaux
|
||||||
|
println!("📝 Ajout de tracks initiaux...");
|
||||||
|
let initial_tracks = vec![
|
||||||
|
("The Beatles", "Come Together", "Abbey Road", 259),
|
||||||
|
("Nirvana", "Smells Like Teen Spirit", "Nevermind", 301),
|
||||||
|
("Queen", "Bohemian Rhapsody", "A Night at the Opera", 354),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (idx, (artist, title, album, duration)) in initial_tracks.iter().enumerate() {
|
||||||
|
playlist
|
||||||
|
.append_track(
|
||||||
|
Track::new(
|
||||||
|
format!("track-{}", idx),
|
||||||
|
*title,
|
||||||
|
format!("http://media.server/music/{}.flac", idx),
|
||||||
|
)
|
||||||
|
.with_artist(*artist)
|
||||||
|
.with_album(*album)
|
||||||
|
.with_duration(*duration)
|
||||||
|
.with_image(format!("http://media.server/covers/{}.jpg", idx)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
println!(" ✓ {} - {}", artist, title);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Simuler différents endpoints HTTP
|
||||||
|
|
||||||
|
// 1. GET /playlist/container - Retourne le container DIDL-Lite
|
||||||
|
println!("🌐 Endpoint: GET /playlist/container");
|
||||||
|
simulate_get_container(playlist.clone()).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 2. GET /playlist/items?offset=0&count=10 - Retourne les items
|
||||||
|
println!("🌐 Endpoint: GET /playlist/items?offset=0&count=10");
|
||||||
|
simulate_get_items(playlist.clone(), 0, 10).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 3. GET /playlist/metadata - Retourne les métadonnées
|
||||||
|
println!("🌐 Endpoint: GET /playlist/metadata");
|
||||||
|
simulate_get_metadata(playlist.clone()).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 4. POST /playlist/track - Ajoute un nouveau track
|
||||||
|
println!("🌐 Endpoint: POST /playlist/track");
|
||||||
|
let new_track = Track::new(
|
||||||
|
"track-new-1",
|
||||||
|
"Stairway to Heaven",
|
||||||
|
"http://media.server/music/stairway.flac",
|
||||||
|
)
|
||||||
|
.with_artist("Led Zeppelin")
|
||||||
|
.with_album("Led Zeppelin IV")
|
||||||
|
.with_duration(482);
|
||||||
|
|
||||||
|
simulate_add_track(playlist.clone(), new_track).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 5. DELETE /playlist/oldest - Supprime le plus ancien
|
||||||
|
println!("🌐 Endpoint: DELETE /playlist/oldest");
|
||||||
|
simulate_delete_oldest(playlist.clone()).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 6. GET /playlist/default-image - Retourne l'image par défaut
|
||||||
|
println!("🌐 Endpoint: GET /playlist/default-image");
|
||||||
|
simulate_get_default_image(playlist.clone()).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 7. Vérifier l'état final
|
||||||
|
println!("📊 État final:");
|
||||||
|
let final_items = playlist.get_items(0, 10).await;
|
||||||
|
println!(" Total tracks: {}", playlist.len().await);
|
||||||
|
println!(" Update ID: {}", playlist.update_id().await);
|
||||||
|
println!("\n Tracks actuels:");
|
||||||
|
for (idx, track) in final_items.iter().enumerate() {
|
||||||
|
let artist = track.artist.as_deref().unwrap_or("Unknown");
|
||||||
|
println!(" {}. {} - {}", idx + 1, artist, track.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n=== Exemple terminé ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule GET /playlist/container
|
||||||
|
async fn simulate_get_container(playlist: Arc<FifoPlaylist>) {
|
||||||
|
let container = playlist.as_container().await;
|
||||||
|
|
||||||
|
println!(" Response (JSON representation):");
|
||||||
|
println!(" {{");
|
||||||
|
println!(" \"id\": \"{}\",", container.id);
|
||||||
|
println!(" \"parentId\": \"{}\",", container.parent_id);
|
||||||
|
println!(" \"title\": \"{}\",", container.title);
|
||||||
|
println!(" \"class\": \"{}\",", container.class);
|
||||||
|
println!(
|
||||||
|
" \"childCount\": {}",
|
||||||
|
container.child_count.unwrap_or_default()
|
||||||
|
);
|
||||||
|
println!(" }}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule GET /playlist/items?offset=X&count=Y
|
||||||
|
async fn simulate_get_items(playlist: Arc<FifoPlaylist>, offset: usize, count: usize) {
|
||||||
|
let items = playlist
|
||||||
|
.as_objects(offset, count, Some("http://media.server/api/default-image"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
println!(" Response: {} items", items.len());
|
||||||
|
println!(" [");
|
||||||
|
for (idx, item) in items.iter().enumerate() {
|
||||||
|
println!(" {{");
|
||||||
|
println!(" \"id\": \"{}\",", item.id);
|
||||||
|
println!(" \"title\": \"{}\",", item.title);
|
||||||
|
println!(
|
||||||
|
" \"artist\": \"{}\",",
|
||||||
|
item.artist.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" \"album\": \"{}\",",
|
||||||
|
item.album.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
println!(" \"class\": \"{}\",", item.class);
|
||||||
|
if !item.resources.is_empty() {
|
||||||
|
println!(" \"uri\": \"{}\",", item.resources[0].url);
|
||||||
|
}
|
||||||
|
print!(" }}");
|
||||||
|
if idx < items.len() - 1 {
|
||||||
|
println!(",");
|
||||||
|
} else {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(" ]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule GET /playlist/metadata
|
||||||
|
async fn simulate_get_metadata(playlist: Arc<FifoPlaylist>) {
|
||||||
|
let update_id = playlist.update_id().await;
|
||||||
|
let last_change = playlist.last_change().await;
|
||||||
|
let count = playlist.len().await;
|
||||||
|
let id = playlist.id().await;
|
||||||
|
let title = playlist.title().await;
|
||||||
|
|
||||||
|
println!(" Response:");
|
||||||
|
println!(" {{");
|
||||||
|
println!(" \"id\": \"{}\",", id);
|
||||||
|
println!(" \"title\": \"{}\",", title);
|
||||||
|
println!(" \"trackCount\": {},", count);
|
||||||
|
println!(" \"updateId\": {},", update_id);
|
||||||
|
println!(" \"lastChange\": \"{:?}\"", last_change);
|
||||||
|
println!(" }}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule POST /playlist/track
|
||||||
|
async fn simulate_add_track(playlist: Arc<FifoPlaylist>, track: Track) {
|
||||||
|
let old_update_id = playlist.update_id().await;
|
||||||
|
|
||||||
|
playlist.append_track(track.clone()).await;
|
||||||
|
|
||||||
|
let new_update_id = playlist.update_id().await;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" Track added: {} - {}",
|
||||||
|
track.artist.as_deref().unwrap_or("Unknown"),
|
||||||
|
track.title
|
||||||
|
);
|
||||||
|
println!(" Update ID: {} → {}", old_update_id, new_update_id);
|
||||||
|
println!(" Response: 201 Created");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule DELETE /playlist/oldest
|
||||||
|
async fn simulate_delete_oldest(playlist: Arc<FifoPlaylist>) {
|
||||||
|
if let Some(removed) = playlist.remove_oldest().await {
|
||||||
|
println!(" Track removed: {} ({})", removed.title, removed.id);
|
||||||
|
println!(" New update ID: {}", playlist.update_id().await);
|
||||||
|
println!(" Response: 200 OK");
|
||||||
|
} else {
|
||||||
|
println!(" No tracks to remove");
|
||||||
|
println!(" Response: 404 Not Found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule GET /playlist/default-image
|
||||||
|
async fn simulate_get_default_image(playlist: Arc<FifoPlaylist>) {
|
||||||
|
let image_bytes = playlist.default_image().await;
|
||||||
|
|
||||||
|
println!(" Response:");
|
||||||
|
println!(" Content-Type: image/webp");
|
||||||
|
println!(" Content-Length: {} bytes", image_bytes.len());
|
||||||
|
println!(" Status: 200 OK");
|
||||||
|
println!(" (Image WebP {} bytes ready to serve)", image_bytes.len());
|
||||||
|
}
|
||||||
198
old_code/pmoplaylist/examples/radio_streaming.rs
Normal file
198
old_code/pmoplaylist/examples/radio_streaming.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
//! Exemple simulant une radio en streaming
|
||||||
|
//!
|
||||||
|
//! Cet exemple démontre :
|
||||||
|
//! - L'utilisation de FifoPlaylist dans un contexte multi-thread
|
||||||
|
//! - La simulation d'un flux radio continu
|
||||||
|
//! - La surveillance des changements via update_id
|
||||||
|
//!
|
||||||
|
//! Pour exécuter :
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run -p pmoplaylist --example radio_streaming
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Simulation Radio en Streaming ===\n");
|
||||||
|
|
||||||
|
// Créer une radio avec historique limité à 10 tracks
|
||||||
|
let radio = FifoPlaylist::new(
|
||||||
|
"radio-paradise".to_string(),
|
||||||
|
"Radio Paradise - Main Mix".to_string(),
|
||||||
|
10,
|
||||||
|
DEFAULT_IMAGE,
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("📻 Radio créée: {}", radio.title().await);
|
||||||
|
println!("📊 Capacité: 10 tracks (historique limité)");
|
||||||
|
println!("🆔 ID: {}\n", radio.id().await);
|
||||||
|
|
||||||
|
// Cloner pour les différentes tâches
|
||||||
|
let radio_streamer = radio.clone();
|
||||||
|
let radio_monitor = radio.clone();
|
||||||
|
let radio_client = radio.clone();
|
||||||
|
|
||||||
|
// Tâche 1: Simuler le streaming (ajoute des tracks régulièrement)
|
||||||
|
let streamer = tokio::spawn(async move {
|
||||||
|
println!("🎵 [STREAMER] Démarrage du flux radio...\n");
|
||||||
|
|
||||||
|
let tracks_data = vec![
|
||||||
|
("Radiohead", "Paranoid Android", "OK Computer", 383),
|
||||||
|
("Massive Attack", "Teardrop", "Mezzanine", 329),
|
||||||
|
(
|
||||||
|
"Pink Floyd",
|
||||||
|
"Shine On You Crazy Diamond",
|
||||||
|
"Wish You Were Here",
|
||||||
|
810,
|
||||||
|
),
|
||||||
|
("Portishead", "Glory Box", "Dummy", 305),
|
||||||
|
("Dire Straits", "Sultans of Swing", "Dire Straits", 349),
|
||||||
|
("The Cure", "Pictures of You", "Disintegration", 428),
|
||||||
|
("David Bowie", "Heroes", "Heroes", 371),
|
||||||
|
(
|
||||||
|
"Talking Heads",
|
||||||
|
"Once in a Lifetime",
|
||||||
|
"Remain in Light",
|
||||||
|
259,
|
||||||
|
),
|
||||||
|
("Fleetwood Mac", "Dreams", "Rumours", 257),
|
||||||
|
(
|
||||||
|
"The Smiths",
|
||||||
|
"There Is a Light That Never Goes Out",
|
||||||
|
"The Queen Is Dead",
|
||||||
|
244,
|
||||||
|
),
|
||||||
|
("Joy Division", "Love Will Tear Us Apart", "Closer", 206),
|
||||||
|
("New Order", "Blue Monday", "Power, Corruption & Lies", 448),
|
||||||
|
("Depeche Mode", "Enjoy the Silence", "Violator", 376),
|
||||||
|
("R.E.M.", "Losing My Religion", "Out of Time", 269),
|
||||||
|
(
|
||||||
|
"U2",
|
||||||
|
"Where the Streets Have No Name",
|
||||||
|
"The Joshua Tree",
|
||||||
|
337,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (idx, (artist, title, album, duration)) in tracks_data.iter().enumerate() {
|
||||||
|
let track = Track::new(
|
||||||
|
format!("radio-track-{}", idx),
|
||||||
|
*title,
|
||||||
|
format!("http://stream.radioparadise.com/track/{}", idx),
|
||||||
|
)
|
||||||
|
.with_artist(*artist)
|
||||||
|
.with_album(*album)
|
||||||
|
.with_duration(*duration);
|
||||||
|
|
||||||
|
radio_streamer.append_track(track).await;
|
||||||
|
|
||||||
|
println!("🎵 [STREAMER] Now Playing: {} - {}", artist, title);
|
||||||
|
|
||||||
|
// Simuler l'attente entre les tracks
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n🎵 [STREAMER] Fin du streaming");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tâche 2: Monitorer les changements (update_id)
|
||||||
|
let monitor = tokio::spawn(async move {
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
println!("👁️ [MONITOR] Surveillance des changements...\n");
|
||||||
|
|
||||||
|
let mut last_update_id = 0;
|
||||||
|
let mut iterations = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let current_update_id = radio_monitor.update_id().await;
|
||||||
|
let count = radio_monitor.len().await;
|
||||||
|
|
||||||
|
if current_update_id != last_update_id {
|
||||||
|
println!(
|
||||||
|
"👁️ [MONITOR] Changement détecté! Update ID: {} → {} | Tracks: {}",
|
||||||
|
last_update_id, current_update_id, count
|
||||||
|
);
|
||||||
|
last_update_id = current_update_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
iterations += 1;
|
||||||
|
if iterations >= 50 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n👁️ [MONITOR] Fin de la surveillance");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tâche 3: Client consultant l'historique
|
||||||
|
let client = tokio::spawn(async move {
|
||||||
|
sleep(Duration::from_millis(2000)).await;
|
||||||
|
|
||||||
|
println!("\n📱 [CLIENT] Consultation de l'historique de la radio...\n");
|
||||||
|
|
||||||
|
// Consulter plusieurs fois pendant le streaming
|
||||||
|
for i in 0..3 {
|
||||||
|
sleep(Duration::from_millis(2000)).await;
|
||||||
|
|
||||||
|
let history = radio_client.get_items(0, 10).await;
|
||||||
|
let update_id = radio_client.update_id().await;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"📱 [CLIENT] Consultation #{} (Update ID: {})",
|
||||||
|
i + 1,
|
||||||
|
update_id
|
||||||
|
);
|
||||||
|
println!(" Historique actuel ({} tracks):", history.len());
|
||||||
|
|
||||||
|
for (idx, track) in history.iter().enumerate() {
|
||||||
|
let artist = track.artist.as_deref().unwrap_or("Unknown");
|
||||||
|
println!(" {}. {} - {}", idx + 1, artist, track.title);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer le container DIDL-Lite à la fin
|
||||||
|
println!("📱 [CLIENT] Génération du Container DIDL-Lite...");
|
||||||
|
let container = radio_client.as_container().await;
|
||||||
|
println!(" Container ID: {}", container.id);
|
||||||
|
println!(" Title: {}", container.title);
|
||||||
|
println!(
|
||||||
|
" Child Count: {}",
|
||||||
|
container.child_count.unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("\n📱 [CLIENT] Fin de la consultation");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attendre que toutes les tâches se terminent
|
||||||
|
let _ = tokio::join!(streamer, monitor, client);
|
||||||
|
|
||||||
|
// Afficher l'état final
|
||||||
|
println!("\n=== État Final ===");
|
||||||
|
println!("📊 Total tracks dans la radio: {}", radio.len().await);
|
||||||
|
println!("🆔 Update ID final: {}", radio.update_id().await);
|
||||||
|
|
||||||
|
let final_history = radio.get_items(0, 10).await;
|
||||||
|
println!("\n🎵 Historique final (10 derniers tracks):");
|
||||||
|
for (idx, track) in final_history.iter().enumerate() {
|
||||||
|
let artist = track.artist.as_deref().unwrap_or("Unknown");
|
||||||
|
let duration_min = track.duration.map(|d| d / 60).unwrap_or(0);
|
||||||
|
let duration_sec = track.duration.map(|d| d % 60).unwrap_or(0);
|
||||||
|
println!(
|
||||||
|
" {}. {} - {} ({}:{:02})",
|
||||||
|
idx + 1,
|
||||||
|
artist,
|
||||||
|
track.title,
|
||||||
|
duration_min,
|
||||||
|
duration_sec
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n=== Simulation terminée ===");
|
||||||
|
}
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "pmomusic",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
15
pmoapp/Cargo.toml
Normal file
15
pmoapp/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "pmoapp"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rust-embed = "8.5.0"
|
||||||
|
|
||||||
|
[dependencies.pmoserver]
|
||||||
|
path = "../pmoserver"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
pmoserver = ["dep:pmoserver"]
|
||||||
15
pmoapp/Cargo.toml.backup
Normal file
15
pmoapp/Cargo.toml.backup
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "pmoapp"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rust-embed = "8.5.0"
|
||||||
|
|
||||||
|
[dependencies.pmoserver]
|
||||||
|
path = "../pmoserver"
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
pmoserver = ["dep:pmoserver"]
|
||||||
318
pmoapp/src/lib.rs
Executable file
318
pmoapp/src/lib.rs
Executable file
@@ -0,0 +1,318 @@
|
|||||||
|
//! # pmoapp - Application web UPnP pour PMOMusic
|
||||||
|
//!
|
||||||
|
//! Cette crate fournit l'application web frontend pour le contrôle et la visualisation
|
||||||
|
//! des devices UPnP MediaRenderer, intégrée via RustEmbed pour être servie par pmoserver.
|
||||||
|
//!
|
||||||
|
//! ## Vue d'ensemble
|
||||||
|
//!
|
||||||
|
//! `pmoapp` est une application Vue.js 3 moderne avec TypeScript qui offre une interface
|
||||||
|
//! utilisateur pour :
|
||||||
|
//! - Visualiser les logs système en temps réel (Server-Sent Events)
|
||||||
|
//! - Contrôler les devices UPnP MediaRenderer
|
||||||
|
//! - Afficher et formater automatiquement le XML dans les logs
|
||||||
|
//!
|
||||||
|
//! ## Fonctionnalités
|
||||||
|
//!
|
||||||
|
//! ### 📦 Frontend intégré
|
||||||
|
//! - Application web compilée et embarquée dans le binaire Rust
|
||||||
|
//! - Aucun fichier statique externe à gérer en production
|
||||||
|
//! - Intégration via `RustEmbed` pour une distribution simplifiée
|
||||||
|
//!
|
||||||
|
//! ### 🎨 Interface utilisateur
|
||||||
|
//! - **LogView** : Visualisation des logs en temps réel avec filtres par niveau
|
||||||
|
//! - **Auto-scroll** : Défilement automatique des nouveaux logs (désactivable)
|
||||||
|
//! - **Formatage XML** : Détection et coloration syntaxique automatique du XML
|
||||||
|
//! - **Design responsive** : Compatible desktop et mobile
|
||||||
|
//! - **Thème sombre** : Style inspiré de VS Code pour une meilleure lisibilité
|
||||||
|
//!
|
||||||
|
//! ### 🚀 Zero configuration
|
||||||
|
//! - Pas besoin de serveur web séparé pour les assets
|
||||||
|
//! - Les fichiers sont servis directement depuis la mémoire du binaire
|
||||||
|
//! - Configuration automatique du routing Vue Router
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! ### Stack technique
|
||||||
|
//!
|
||||||
|
//! - **Frontend** : Vue.js 3 avec Composition API
|
||||||
|
//! - **Langage** : TypeScript
|
||||||
|
//! - **Build** : Vite (rapide, moderne, HMR)
|
||||||
|
//! - **Routing** : Vue Router
|
||||||
|
//! - **Markdown** : Marked.js pour le rendu
|
||||||
|
//! - **Sécurité** : DOMPurify pour la sanitization HTML
|
||||||
|
//!
|
||||||
|
//! ### Structure des fichiers
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! pmoapp/
|
||||||
|
//! ├── Cargo.toml # Dépendances Rust (rust-embed)
|
||||||
|
//! ├── src/
|
||||||
|
//! │ └── lib.rs # Point d'entrée Rust (ce fichier)
|
||||||
|
//! └── webapp/
|
||||||
|
//! ├── src/
|
||||||
|
//! │ ├── main.ts # Point d'entrée Vue.js
|
||||||
|
//! │ ├── App.vue # Composant racine
|
||||||
|
//! │ ├── router/ # Configuration Vue Router
|
||||||
|
//! │ └── components/
|
||||||
|
//! │ ├── LogView.vue # Visualiseur de logs SSE
|
||||||
|
//! │ └── ...
|
||||||
|
//! ├── dist/ # Build output (généré, non versionné)
|
||||||
|
//! ├── package.json # Dépendances npm
|
||||||
|
//! └── vite.config.ts # Configuration Vite
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Workflow de build
|
||||||
|
//!
|
||||||
|
//! ### 1. Build de la webapp (Vue.js)
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! # Installation des dépendances
|
||||||
|
//! cd pmoapp/webapp
|
||||||
|
//! npm install
|
||||||
|
//!
|
||||||
|
//! # Build de production
|
||||||
|
//! npm run build
|
||||||
|
//! # Génère : webapp/dist/index.html, assets/*.js, assets/*.css
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### 2. Compilation Rust
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! cargo build
|
||||||
|
//! # RustEmbed inclut automatiquement les fichiers de webapp/dist/
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### 3. Utilisation avec Makefile
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! # Build complet (webapp + Rust)
|
||||||
|
//! make build
|
||||||
|
//!
|
||||||
|
//! # Ou juste la webapp
|
||||||
|
//! make webapp
|
||||||
|
//!
|
||||||
|
//! # Clean
|
||||||
|
//! make clean
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Utilisation
|
||||||
|
//!
|
||||||
|
//! ### Exemple basique
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use pmoapp::Webapp;
|
||||||
|
//! use pmoserver::ServerBuilder;
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() {
|
||||||
|
//! let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080)
|
||||||
|
//! .build();
|
||||||
|
//!
|
||||||
|
//! // Ajouter la webapp comme Single Page Application
|
||||||
|
//! server.add_spa::<Webapp>("/app").await;
|
||||||
|
//!
|
||||||
|
//! // Ajouter une redirection de la racine vers /app
|
||||||
|
//! server.add_redirect("/", "/app").await;
|
||||||
|
//!
|
||||||
|
//! server.start().await;
|
||||||
|
//! server.wait().await;
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Exemple avec logs SSE
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use pmoapp::Webapp;
|
||||||
|
//! use pmoserver::{ServerBuilder, logs::{LogState, SseLayer}};
|
||||||
|
//! use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() {
|
||||||
|
//! // Configuration des logs avec SSE
|
||||||
|
//! let log_state = LogState::new(1000); // Buffer de 1000 logs
|
||||||
|
//! tracing_subscriber::registry()
|
||||||
|
//! .with(tracing_subscriber::fmt::layer())
|
||||||
|
//! .with(SseLayer::new(log_state.clone()))
|
||||||
|
//! .init();
|
||||||
|
//!
|
||||||
|
//! let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080).build();
|
||||||
|
//!
|
||||||
|
//! // Endpoints SSE pour les logs
|
||||||
|
//! server.add_handler_with_state("/log-sse", pmoserver::logs::log_sse, log_state.clone()).await;
|
||||||
|
//! server.add_handler_with_state("/log-dump", pmoserver::logs::log_dump, log_state).await;
|
||||||
|
//!
|
||||||
|
//! // Webapp (consommera les logs via /log-sse)
|
||||||
|
//! server.add_spa::<Webapp>("/app").await;
|
||||||
|
//! server.add_redirect("/", "/app").await;
|
||||||
|
//!
|
||||||
|
//! server.start().await;
|
||||||
|
//! server.wait().await;
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Développement
|
||||||
|
//!
|
||||||
|
//! ### Mode développement Vue.js
|
||||||
|
//!
|
||||||
|
//! Pour développer la webapp avec Hot Module Replacement :
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! cd pmoapp/webapp
|
||||||
|
//! npm run dev
|
||||||
|
//! # Serveur de dev sur http://localhost:5173
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Rebuild après modifications
|
||||||
|
//!
|
||||||
|
//! Après avoir modifié le code Vue.js :
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! # Rebuild webapp + recompile Rust
|
||||||
|
//! make build
|
||||||
|
//!
|
||||||
|
//! # Ou séparément
|
||||||
|
//! make webapp # Build Vue.js seulement
|
||||||
|
//! cargo build # Recompile Rust (intègre le nouveau dist/)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Composants Vue.js
|
||||||
|
//!
|
||||||
|
//! ### LogView
|
||||||
|
//!
|
||||||
|
//! Composant principal pour la visualisation des logs :
|
||||||
|
//!
|
||||||
|
//! - **Connexion SSE** : Stream temps réel via EventSource
|
||||||
|
//! - **Filtrage** : Par niveau (TRACE, DEBUG, INFO, WARN, ERROR)
|
||||||
|
//! - **Auto-scroll** : Activable/désactivable
|
||||||
|
//! - **Formatage** : Markdown + détection XML automatique
|
||||||
|
//! - **Buffer** : Limite à 1000 logs en mémoire
|
||||||
|
//! - **Déduplication** : Évite les logs en double
|
||||||
|
//!
|
||||||
|
//! ### Formatage XML
|
||||||
|
//!
|
||||||
|
//! Le composant LogView détecte automatiquement le XML dans les messages :
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Input: "INFO: <?xml version=\"1.0\"?><scpd>...</scpd>"
|
||||||
|
//! Output: Bloc de code avec coloration syntaxique XML
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! - Détection via regex : `<?xml` ou balises courantes (`<scpd>`, `<service>`, etc.)
|
||||||
|
//! - Conversion en bloc markdown : ` ```xml ... ``` `
|
||||||
|
//! - Rendu avec coloration et scrollbar pour le XML long
|
||||||
|
//!
|
||||||
|
//! ## Intégration avec pmoupnp
|
||||||
|
//!
|
||||||
|
//! La webapp communique avec les devices UPnP via les endpoints HTTP fournis par
|
||||||
|
//! `pmoserver` et `pmoupnp` :
|
||||||
|
//!
|
||||||
|
//! - `/log-sse` : Stream de logs (Server-Sent Events)
|
||||||
|
//! - `/log-dump` : Historique des logs
|
||||||
|
//! - `/device/*/description.xml` : Descripteurs UPnP
|
||||||
|
//! - `/service/*/control` : Endpoints de contrôle SOAP
|
||||||
|
//! - `/service/*/event` : Souscription aux événements UPnP
|
||||||
|
//!
|
||||||
|
//! ## Notes de déploiement
|
||||||
|
//!
|
||||||
|
//! ### Taille du binaire
|
||||||
|
//!
|
||||||
|
//! La webapp ajoutera ~150KB au binaire (compressé avec gzip par RustEmbed).
|
||||||
|
//!
|
||||||
|
//! ### Cache du navigateur
|
||||||
|
//!
|
||||||
|
//! Les assets sont servis avec des hashes dans les noms de fichiers
|
||||||
|
//! (`index-BBZcSinC.js`) pour un cache busting automatique.
|
||||||
|
//!
|
||||||
|
//! ### Compatibilité navigateurs
|
||||||
|
//!
|
||||||
|
//! - Chrome/Edge : ✅ Moderne
|
||||||
|
//! - Firefox : ✅ Moderne
|
||||||
|
//! - Safari : ✅ iOS 13+
|
||||||
|
//! - IE11 : ❌ Non supporté (utilise ES modules)
|
||||||
|
//!
|
||||||
|
//! ## Voir aussi
|
||||||
|
//!
|
||||||
|
//! - [`pmoserver`] : Serveur HTTP Axum pour servir la webapp
|
||||||
|
//! - [`pmoupnp`] : Bibliothèque UPnP MediaRenderer
|
||||||
|
//! - [Vue.js Documentation](https://vuejs.org/)
|
||||||
|
//! - [Vite Documentation](https://vitejs.dev/)
|
||||||
|
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
|
/// Structure représentant l'application web embarquée.
|
||||||
|
///
|
||||||
|
/// Cette structure utilise `RustEmbed` pour inclure tous les fichiers
|
||||||
|
/// du répertoire `webapp/dist` dans le binaire au moment de la compilation.
|
||||||
|
///
|
||||||
|
/// ## Exemple
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// use pmoapp::{Webapp, WebAppExt};
|
||||||
|
/// use pmoserver::ServerBuilder;
|
||||||
|
///
|
||||||
|
/// # async fn example() {
|
||||||
|
/// let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080).build();
|
||||||
|
///
|
||||||
|
/// // Ajouter la webapp via le trait WebAppExt
|
||||||
|
/// server.add_webapp::<Webapp>("/app").await;
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
#[derive(RustEmbed, Clone)]
|
||||||
|
#[folder = "webapp/dist"]
|
||||||
|
pub struct Webapp;
|
||||||
|
|
||||||
|
/// Trait pour étendre un serveur HTTP avec des fonctionnalités webapp.
|
||||||
|
///
|
||||||
|
/// Ce trait permet à `pmoapp` d'ajouter des méthodes d'extension sur des types
|
||||||
|
/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dépendent de `pmoapp`.
|
||||||
|
///
|
||||||
|
/// # Architecture
|
||||||
|
///
|
||||||
|
/// Similaire au pattern utilisé par `pmoupnp` pour `UpnpServer`, ce trait permet
|
||||||
|
/// une extension propre et découplée :
|
||||||
|
///
|
||||||
|
/// - `pmoserver` définit un serveur HTTP générique
|
||||||
|
/// - `pmoapp` étend ce serveur avec des méthodes webapp via ce trait
|
||||||
|
/// - Le serveur n'a pas besoin de connaître `pmoapp`
|
||||||
|
///
|
||||||
|
/// # Exemple d'implémentation
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// impl WebAppExt for pmoserver::Server {
|
||||||
|
/// fn add_webapp<W: RustEmbed>(&mut self, path: &str) -> ... {
|
||||||
|
/// // Délègue à la méthode interne add_spa
|
||||||
|
/// self.add_spa::<W>(path)
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub trait WebAppExt {
|
||||||
|
/// Ajoute une Single Page Application au serveur.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - Le chemin où monter la webapp (ex: "/app")
|
||||||
|
///
|
||||||
|
/// # Type Parameter
|
||||||
|
///
|
||||||
|
/// * `W` - Type RustEmbed contenant les fichiers de la webapp
|
||||||
|
async fn add_webapp<W>(&mut self, path: &str)
|
||||||
|
where
|
||||||
|
W: RustEmbed + Clone + Send + Sync + 'static;
|
||||||
|
|
||||||
|
/// Ajoute une webapp avec une redirection automatique depuis la racine.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - Le chemin où monter la webapp (ex: "/app")
|
||||||
|
///
|
||||||
|
/// # Type Parameter
|
||||||
|
///
|
||||||
|
/// * `W` - Type RustEmbed contenant les fichiers de la webapp
|
||||||
|
async fn add_webapp_with_redirect<W>(&mut self, path: &str)
|
||||||
|
where
|
||||||
|
W: RustEmbed + Clone + Send + Sync + 'static;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implémentation du trait pour pmoserver::Server (feature-gated)
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
mod pmoserver_impl;
|
||||||
79
pmoapp/src/pmoserver_impl.rs
Normal file
79
pmoapp/src/pmoserver_impl.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
//! Implémentation du trait WebAppExt pour le serveur pmoserver
|
||||||
|
//!
|
||||||
|
//! Ce module enrichit `pmoserver::Server` avec les fonctionnalités webapp en
|
||||||
|
//! implémentant le trait [`WebAppExt`](crate::WebAppExt). Cette implémentation
|
||||||
|
//! permet d'enregistrer facilement des webapps embarquées sur le serveur.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! `pmoapp` étend `pmoserver::Server` sans que `pmoserver` connaisse `pmoapp`.
|
||||||
|
//! C'est le pattern d'extension : `pmoapp` ajoute des fonctionnalités à un type
|
||||||
|
//! externe via un trait, similaire au pattern utilisé par `pmoupnp` pour `UpnpServer`.
|
||||||
|
//!
|
||||||
|
//! ## Exemple d'utilisation
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use pmoapp::{Webapp, WebAppExt};
|
||||||
|
//! use pmoserver::ServerBuilder;
|
||||||
|
//!
|
||||||
|
//! # async fn example() {
|
||||||
|
//! let mut server = ServerBuilder::new("MyApp", "http://localhost", 8080).build();
|
||||||
|
//!
|
||||||
|
//! // Le trait WebAppExt est automatiquement disponible
|
||||||
|
//! server.add_webapp::<Webapp>("/app").await;
|
||||||
|
//!
|
||||||
|
//! // Ou avec redirection
|
||||||
|
//! server.add_webapp_with_redirect::<Webapp>("/app").await;
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::WebAppExt;
|
||||||
|
use pmoserver::Server;
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
|
impl WebAppExt for Server {
|
||||||
|
async fn add_webapp<W>(&mut self, path: &str)
|
||||||
|
where
|
||||||
|
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let mount_path = normalize_mount_path(path);
|
||||||
|
mount_spa_with_trailing_slash_redirect::<W>(self, &mount_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_webapp_with_redirect<W>(&mut self, path: &str)
|
||||||
|
where
|
||||||
|
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let mount_path = normalize_mount_path(path);
|
||||||
|
|
||||||
|
mount_spa_with_trailing_slash_redirect::<W>(self, &mount_path).await;
|
||||||
|
self.add_redirect("/", &mount_path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// S'assure que les chemins SPA sont cohérents : `"/app"` devient `"/app"`,
|
||||||
|
/// tandis que `"/"` reste tel quel. Les espaces ou slashs multiples sont
|
||||||
|
/// nettoyés pour éviter des routes dupliquées.
|
||||||
|
fn normalize_mount_path(path: &str) -> String {
|
||||||
|
let trimmed = path.trim();
|
||||||
|
|
||||||
|
if trimmed.is_empty() || trimmed == "/" {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
format!("/{}", trimmed.trim_matches('/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monte la SPA et ajoute automatiquement une redirection `"/app/" -> "/app"`
|
||||||
|
/// afin que les URLs avec slash final servent également l'application.
|
||||||
|
async fn mount_spa_with_trailing_slash_redirect<W>(server: &mut Server, path: &str)
|
||||||
|
where
|
||||||
|
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
server.add_spa::<W>(path).await;
|
||||||
|
|
||||||
|
if path != "/" {
|
||||||
|
let trailing = format!("{}/", path.trim_end_matches('/'));
|
||||||
|
server.add_redirect(&trailing, path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
1739
pmoapp/webapp/package-lock.json
generated
Normal file
1739
pmoapp/webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
pmoapp/webapp/package.json
Normal file
30
pmoapp/webapp/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "webapp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vueuse/core": "^14.1.0",
|
||||||
|
"dompurify": "^3.2.7",
|
||||||
|
"lucide-vue-next": "^0.555.0",
|
||||||
|
"marked": "^16.3.0",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.21",
|
||||||
|
"vue-router": "^4.5.1",
|
||||||
|
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^7.1.7",
|
||||||
|
"vue-tsc": "^3.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
31
pmoapp/webapp/src/App.vue
Normal file
31
pmoapp/webapp/src/App.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Notifications Toast -->
|
||||||
|
<NotificationToast />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import NotificationToast from '@/components/NotificationToast.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
430
pmoapp/webapp/src/assets/styles/glass-theme.css
Normal file
430
pmoapp/webapp/src/assets/styles/glass-theme.css
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* Liquid Glass Theme - Glassmorphism Effects
|
||||||
|
* Style inspiré de macOS Big Sur et suivants
|
||||||
|
* Utilise backdrop-filter pour des effets de flou et transparence
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
CLASSES DE BASE GLASS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* Panel glass standard (fond clair) */
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px 0 rgba(31, 38, 135, 0.15),
|
||||||
|
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel glass pour fond sombre */
|
||||||
|
.glass-panel-dark {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px 0 rgba(0, 0, 0, 0.37),
|
||||||
|
inset 0 1px 0 0 rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variantes d'intensité */
|
||||||
|
.glass-subtle {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(150%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-medium {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
backdrop-filter: blur(15px) saturate(170%);
|
||||||
|
-webkit-backdrop-filter: blur(15px) saturate(170%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-strong {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(30px) saturate(200%);
|
||||||
|
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BOUTONS GLASS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.glass-button {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(10px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(150%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:disabled {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bouton glass primary (avec couleur d'accent) */
|
||||||
|
.glass-button-primary {
|
||||||
|
background: rgba(102, 126, 234, 0.3);
|
||||||
|
backdrop-filter: blur(10px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(150%);
|
||||||
|
border: 1px solid rgba(102, 126, 234, 0.5);
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button-primary:hover {
|
||||||
|
background: rgba(102, 126, 234, 0.4);
|
||||||
|
border-color: rgba(102, 126, 234, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
INPUTS GLASS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.glass-input {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-input:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-color: rgba(102, 126, 234, 0.5);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
CARTES GLASS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(15px) saturate(170%);
|
||||||
|
-webkit-backdrop-filter: blur(15px) saturate(170%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
SLIDER GLASS (pour volume control)
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.glass-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 8px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-slider:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumb Webkit (Chrome, Safari) */
|
||||||
|
.glass-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
box-shadow:
|
||||||
|
0 6px 16px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-slider::-webkit-slider-thumb:active {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumb Mozilla */
|
||||||
|
.glass-slider::-moz-range-thumb {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.2),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-slider::-moz-range-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
OVERLAY GLASS (pour CurrentTrack)
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.glass-overlay {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-overlay::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(0, 0, 0, 0.3) 100%);
|
||||||
|
backdrop-filter: blur(0px);
|
||||||
|
-webkit-backdrop-filter: blur(0px);
|
||||||
|
transition: backdrop-filter 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-overlay:hover::before {
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
UTILITAIRES
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* Backdrop blur seul */
|
||||||
|
.glass-blur {
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-blur-sm {
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-blur-lg {
|
||||||
|
backdrop-filter: blur(30px);
|
||||||
|
-webkit-backdrop-filter: blur(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Saturation */
|
||||||
|
.glass-saturate {
|
||||||
|
backdrop-filter: saturate(180%);
|
||||||
|
-webkit-backdrop-filter: saturate(180%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bordures glass */
|
||||||
|
.glass-border {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-border-subtle {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-border-strong {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shadow glass */
|
||||||
|
.glass-shadow {
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-shadow-lg {
|
||||||
|
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inset shadow (lumière interne) */
|
||||||
|
.glass-inset-light {
|
||||||
|
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
THÈME SOMBRE (prefers-color-scheme)
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-subtle {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-medium {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-strong {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
FALLBACK (pas de backdrop-filter)
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@supports not (backdrop-filter: blur(20px)) {
|
||||||
|
.glass-panel,
|
||||||
|
.glass-panel-dark,
|
||||||
|
.glass-subtle,
|
||||||
|
.glass-medium,
|
||||||
|
.glass-strong {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.glass-panel,
|
||||||
|
.glass-panel-dark,
|
||||||
|
.glass-subtle,
|
||||||
|
.glass-medium,
|
||||||
|
.glass-strong {
|
||||||
|
background: rgba(0, 0, 0, 0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-button,
|
||||||
|
.glass-button-primary {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-slider {
|
||||||
|
background: rgba(200, 200, 200, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
ANIMATIONS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@keyframes glassShimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% center;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(255, 255, 255, 0.1) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: glassShimmer 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
PERFORMANCE
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* GPU acceleration pour les éléments glass animés */
|
||||||
|
.glass-panel,
|
||||||
|
.glass-button,
|
||||||
|
.glass-card,
|
||||||
|
.glass-slider {
|
||||||
|
transform: translateZ(0);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Réduire l'animation sur préférence système */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.glass-button,
|
||||||
|
.glass-card,
|
||||||
|
.glass-slider::-webkit-slider-thumb,
|
||||||
|
.glass-slider::-moz-range-thumb {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-shimmer {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
436
pmoapp/webapp/src/assets/styles/pmocontrol.css
Normal file
436
pmoapp/webapp/src/assets/styles/pmocontrol.css
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
/* Styles PMOControl spécifiques */
|
||||||
|
@import './variables.css';
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Reset & Base Styles
|
||||||
|
======================================== */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch-friendly tap targets on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
button, a, [role="button"] {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus visible for accessibility */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Status Badge Styles
|
||||||
|
======================================== */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.playing {
|
||||||
|
background-color: var(--status-playing-bg);
|
||||||
|
color: var(--status-playing);
|
||||||
|
border: 1px solid var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.paused {
|
||||||
|
background-color: var(--status-paused-bg);
|
||||||
|
color: var(--status-paused);
|
||||||
|
border: 1px solid var(--status-paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.stopped {
|
||||||
|
background-color: var(--status-stopped-bg);
|
||||||
|
color: var(--status-stopped);
|
||||||
|
border: 1px solid var(--status-stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.offline {
|
||||||
|
background-color: var(--status-offline-bg);
|
||||||
|
color: var(--status-offline);
|
||||||
|
border: 1px solid var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.transitioning {
|
||||||
|
background-color: var(--status-transitioning-bg);
|
||||||
|
color: var(--status-transitioning);
|
||||||
|
border: 1px solid var(--status-transitioning);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Card Styles
|
||||||
|
======================================== */
|
||||||
|
.pmo-card {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: box-shadow var(--transition-base), transform var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmo-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pmo-card.active {
|
||||||
|
border-color: var(--status-playing);
|
||||||
|
box-shadow: 0 0 0 2px var(--status-playing-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Renderer Card Status Borders
|
||||||
|
======================================== */
|
||||||
|
.renderer-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.playing {
|
||||||
|
border-left: 4px solid var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.paused {
|
||||||
|
border-left: 4px solid var(--status-paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.stopped {
|
||||||
|
border-left: 4px solid var(--status-stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.offline {
|
||||||
|
border-left: 4px solid var(--status-offline);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Queue Item Styles
|
||||||
|
======================================== */
|
||||||
|
.queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.current {
|
||||||
|
background-color: var(--status-playing-bg);
|
||||||
|
border: 1px solid var(--status-playing);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.current::before {
|
||||||
|
content: '▶';
|
||||||
|
color: var(--status-playing);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Button Styles
|
||||||
|
======================================== */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-lg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--status-playing);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Grid Layouts (Responsive)
|
||||||
|
======================================== */
|
||||||
|
.grid-cards {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet: 2 columns */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.grid-cards {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop: 3 columns */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.grid-cards {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Animations
|
||||||
|
======================================== */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion support */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Performance Optimizations
|
||||||
|
======================================== */
|
||||||
|
/* Use will-change for frequently animated elements */
|
||||||
|
.progress-bar-fill,
|
||||||
|
input[type="range"]::-webkit-slider-thumb,
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover,
|
||||||
|
.pmo-card:hover {
|
||||||
|
will-change: transform, box-shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPU acceleration for smoother animations */
|
||||||
|
.fade-in,
|
||||||
|
.spin,
|
||||||
|
.status-badge.transitioning {
|
||||||
|
transform: translateZ(0);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Volume Slider
|
||||||
|
======================================== */
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--status-playing);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--status-playing);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Progress Bar (Seekbar)
|
||||||
|
======================================== */
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--status-playing);
|
||||||
|
transition: width 100ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Notification Toast
|
||||||
|
======================================== */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--spacing-lg);
|
||||||
|
right: var(--spacing-lg);
|
||||||
|
z-index: var(--z-toast);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
animation: fadeIn var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item.success {
|
||||||
|
background-color: var(--status-playing);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item.error {
|
||||||
|
background-color: var(--status-offline);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item.warning {
|
||||||
|
background-color: var(--status-paused);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item.info {
|
||||||
|
background-color: var(--status-transitioning);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Utilities
|
||||||
|
======================================== */
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
113
pmoapp/webapp/src/assets/styles/variables.css
Normal file
113
pmoapp/webapp/src/assets/styles/variables.css
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/* Variables CSS globales pour PMOControl */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ========================================
|
||||||
|
Status Colors
|
||||||
|
======================================== */
|
||||||
|
--status-playing: #22c55e; /* Vert pour en lecture */
|
||||||
|
--status-playing-bg: #22c55e15; /* Background playing */
|
||||||
|
--status-paused: #f59e0b; /* Orange pour en pause */
|
||||||
|
--status-paused-bg: #f59e0b15; /* Background paused */
|
||||||
|
--status-stopped: #6b7280; /* Gris pour arrêté */
|
||||||
|
--status-stopped-bg: #6b728015; /* Background stopped */
|
||||||
|
--status-offline: #ef4444; /* Rouge pour offline */
|
||||||
|
--status-offline-bg: #ef444415; /* Background offline */
|
||||||
|
--status-transitioning: #3b82f6; /* Bleu pour transition */
|
||||||
|
--status-transitioning-bg: #3b82f615; /* Background transitioning */
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Breakpoints (pour media queries)
|
||||||
|
======================================== */
|
||||||
|
--breakpoint-mobile: 768px;
|
||||||
|
--breakpoint-tablet: 1024px;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Spacing Scale
|
||||||
|
======================================== */
|
||||||
|
--spacing-xs: 0.25rem; /* 4px */
|
||||||
|
--spacing-sm: 0.5rem; /* 8px */
|
||||||
|
--spacing-md: 1rem; /* 16px */
|
||||||
|
--spacing-lg: 1.5rem; /* 24px */
|
||||||
|
--spacing-xl: 2rem; /* 32px */
|
||||||
|
--spacing-2xl: 3rem; /* 48px */
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Border Radius
|
||||||
|
======================================== */
|
||||||
|
--radius-sm: 0.25rem; /* 4px */
|
||||||
|
--radius-md: 0.5rem; /* 8px */
|
||||||
|
--radius-lg: 1rem; /* 16px */
|
||||||
|
--radius-full: 9999px; /* Pill shape */
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Shadows
|
||||||
|
======================================== */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Typography
|
||||||
|
======================================== */
|
||||||
|
--font-sans: system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: ui-monospace, monospace;
|
||||||
|
|
||||||
|
--text-xs: 0.75rem; /* 12px */
|
||||||
|
--text-sm: 0.875rem; /* 14px */
|
||||||
|
--text-base: 1rem; /* 16px */
|
||||||
|
--text-lg: 1.125rem; /* 18px */
|
||||||
|
--text-xl: 1.25rem; /* 20px */
|
||||||
|
--text-2xl: 1.5rem; /* 24px */
|
||||||
|
--text-3xl: 1.875rem; /* 30px */
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Colors (neutral palette)
|
||||||
|
======================================== */
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-bg-secondary: #f3f4f6;
|
||||||
|
--color-bg-tertiary: #e5e7eb;
|
||||||
|
|
||||||
|
--color-text: #111827;
|
||||||
|
--color-text-secondary: #6b7280;
|
||||||
|
--color-text-tertiary: #9ca3af;
|
||||||
|
|
||||||
|
--color-border: #d1d5db;
|
||||||
|
--color-border-light: #e5e7eb;
|
||||||
|
|
||||||
|
/* Primary color (brand) */
|
||||||
|
--color-primary: #667eea;
|
||||||
|
--color-primary-hover: #5568d3;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Transitions
|
||||||
|
======================================== */
|
||||||
|
--transition-fast: 150ms ease-in-out;
|
||||||
|
--transition-normal: 250ms ease-in-out;
|
||||||
|
--transition-base: 300ms ease-in-out;
|
||||||
|
--transition-slow: 500ms ease-in-out;
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Z-index layers
|
||||||
|
======================================== */
|
||||||
|
--z-dropdown: 100;
|
||||||
|
--z-modal: 200;
|
||||||
|
--z-toast: 300;
|
||||||
|
--z-tooltip: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support (optionnel pour l'avenir) */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-bg: #111827;
|
||||||
|
--color-bg-secondary: #1f2937;
|
||||||
|
--color-bg-tertiary: #374151;
|
||||||
|
|
||||||
|
--color-text: #f9fafb;
|
||||||
|
--color-text-secondary: #d1d5db;
|
||||||
|
--color-text-tertiary: #9ca3af;
|
||||||
|
|
||||||
|
--color-border: #4b5563;
|
||||||
|
--color-border-light: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 496 B |
523
pmoapp/webapp/src/components/APIDashboard.vue
Normal file
523
pmoapp/webapp/src/components/APIDashboard.vue
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
<template>
|
||||||
|
<div class="api-dashboard">
|
||||||
|
<div class="header">
|
||||||
|
<h1>API Dashboard</h1>
|
||||||
|
<p class="subtitle">Vue d'ensemble des APIs disponibles dans PMOMusic</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">Chargement des APIs...</div>
|
||||||
|
<div v-else-if="error" class="error">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-else class="dashboard-content">
|
||||||
|
<!-- Stats globales -->
|
||||||
|
<div class="stats-section">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">🚀</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value">{{ registry?.apis.length || 0 }}</div>
|
||||||
|
<div class="stat-label">APIs disponibles</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">🔌</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value">{{ registry?.total_endpoints || 0 }}</div>
|
||||||
|
<div class="stat-label">Endpoints totaux</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste des APIs -->
|
||||||
|
<div class="apis-grid">
|
||||||
|
<div
|
||||||
|
v-for="api in registry?.apis"
|
||||||
|
:key="api.name"
|
||||||
|
class="api-card"
|
||||||
|
>
|
||||||
|
<div class="api-header">
|
||||||
|
<div class="api-icon">{{ getApiIcon(api.name) }}</div>
|
||||||
|
<div class="api-title-section">
|
||||||
|
<h3>{{ api.title }}</h3>
|
||||||
|
<p class="api-name">{{ api.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="api-version">v{{ api.version }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-body">
|
||||||
|
<div v-if="api.description" class="api-description" v-html="renderMarkdown(api.description)"></div>
|
||||||
|
<p v-else class="api-description empty">Aucune description disponible</p>
|
||||||
|
|
||||||
|
<div class="api-stats">
|
||||||
|
<div class="api-stat">
|
||||||
|
<span class="stat-icon">📍</span>
|
||||||
|
<span class="stat-text">{{ api.endpoint_count }} endpoints</span>
|
||||||
|
</div>
|
||||||
|
<div class="api-stat">
|
||||||
|
<span class="stat-icon">🔗</span>
|
||||||
|
<span class="stat-text">{{ api.path }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-footer">
|
||||||
|
<a :href="api.swagger_ui_path" target="_blank" class="btn-swagger">
|
||||||
|
<span class="btn-icon">📖</span>
|
||||||
|
<span>Documentation Swagger</span>
|
||||||
|
</a>
|
||||||
|
<a :href="api.openapi_json_path" target="_blank" class="btn-json">
|
||||||
|
<span class="btn-icon">📄</span>
|
||||||
|
<span>Spec OpenAPI</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message si aucune API -->
|
||||||
|
<div v-if="!registry?.apis || registry.apis.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">🔍</div>
|
||||||
|
<h3>Aucune API enregistrée</h3>
|
||||||
|
<p>Les APIs seront affichées ici au fur et à mesure de leur enregistrement.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
// Configurer marked pour un rendu simple
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ApiRegistryEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
swagger_ui_path: string;
|
||||||
|
openapi_json_path: string;
|
||||||
|
endpoint_count: number;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiRegistry {
|
||||||
|
apis: ApiRegistryEntry[];
|
||||||
|
total_endpoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const registry = ref<ApiRegistry | null>(null);
|
||||||
|
|
||||||
|
async function fetchRegistry() {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await fetch('/api/registry');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch API registry: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.value = await response.json();
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Failed to load API registry';
|
||||||
|
console.error('Error fetching API registry:', e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiIcon(name: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
covers: '🎨',
|
||||||
|
audio: '🎵',
|
||||||
|
sources: '📡',
|
||||||
|
upnp: '🔌',
|
||||||
|
devices: '📱',
|
||||||
|
cache: '💾',
|
||||||
|
mediaserver: '🎬',
|
||||||
|
renderer: '🎭',
|
||||||
|
};
|
||||||
|
|
||||||
|
return icons[name.toLowerCase()] || '🔧';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(markdown: string): string {
|
||||||
|
const rawHtml = marked.parse(markdown, { async: false }) as string;
|
||||||
|
return DOMPurify.sanitize(rawHtml, {
|
||||||
|
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
||||||
|
ALLOWED_ATTR: ['href', 'target', 'class']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchRegistry();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.api-dashboard {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #e74c3c;
|
||||||
|
background: #fff5f5;
|
||||||
|
border: 2px solid #fc8181;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content {
|
||||||
|
animation: fadeIn 0.5s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Section */
|
||||||
|
.stats-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.3);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 12px 24px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* APIs Grid */
|
||||||
|
.apis-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-title-section {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-title-section h3 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-version {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description {
|
||||||
|
color: #4a5568;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description.empty {
|
||||||
|
color: #a0aec0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour le contenu markdown rendu */
|
||||||
|
.api-description :deep(p) {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description :deep(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description :deep(em) {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description :deep(code) {
|
||||||
|
background: #f7fafc;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: #d63384;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description :deep(pre) {
|
||||||
|
background: #f7fafc;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description :deep(pre code) {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description :deep(ul),
|
||||||
|
.api-description :deep(ol) {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description :deep(li) {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description :deep(a) {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description :deep(a:hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-stat .stat-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-stat .stat-text {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-footer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-swagger,
|
||||||
|
.btn-json {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-swagger {
|
||||||
|
border-right: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-swagger:hover {
|
||||||
|
background: #f7fafc;
|
||||||
|
color: #5a67d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-json {
|
||||||
|
color: #48bb78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-json:hover {
|
||||||
|
background: #f7fafc;
|
||||||
|
color: #38a169;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.api-dashboard {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apis-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-footer {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-swagger {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1379
pmoapp/webapp/src/components/AudioCacheManager.vue
Normal file
1379
pmoapp/webapp/src/components/AudioCacheManager.vue
Normal file
File diff suppressed because it is too large
Load Diff
575
pmoapp/webapp/src/components/CoverCacheManager.vue
Normal file
575
pmoapp/webapp/src/components/CoverCacheManager.vue
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cover-cache-manager">
|
||||||
|
<div class="header">
|
||||||
|
<h2>🖼️ Cover Cache Manager</h2>
|
||||||
|
<div class="stats">
|
||||||
|
<span>{{ images.length }} images</span>
|
||||||
|
<span v-if="totalHits > 0">{{ totalHits }} hits</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire d'ajout -->
|
||||||
|
<div class="add-form">
|
||||||
|
<h3>➕ Add New Cover</h3>
|
||||||
|
<form @submit.prevent="handleAddImage">
|
||||||
|
<div class="form-group">
|
||||||
|
<input
|
||||||
|
v-model="newImageUrl"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/cover.jpg"
|
||||||
|
required
|
||||||
|
:disabled="isAdding"
|
||||||
|
/>
|
||||||
|
<button type="submit" :disabled="isAdding || !newImageUrl">
|
||||||
|
{{ isAdding ? "Adding..." : "Add Image" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="addError" class="error">❌ {{ addError }}</p>
|
||||||
|
<p v-if="addSuccess" class="success">✅ {{ addSuccess }}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contrôles -->
|
||||||
|
<div class="controls">
|
||||||
|
<div class="sort-controls">
|
||||||
|
<label>Sort by:</label>
|
||||||
|
<select v-model="sortBy">
|
||||||
|
<option value="hits">Most Used</option>
|
||||||
|
<option value="last_used">Recently Used</option>
|
||||||
|
<option value="recent">Recently Added</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button @click="refreshImages" :disabled="isLoading">
|
||||||
|
🔄 {{ isLoading ? "Loading..." : "Refresh" }}
|
||||||
|
</button>
|
||||||
|
<button @click="handleConsolidate" :disabled="isConsolidating" class="btn-secondary">
|
||||||
|
🔧 {{ isConsolidating ? "Consolidating..." : "Consolidate" }}
|
||||||
|
</button>
|
||||||
|
<button @click="handlePurge" class="btn-danger" :disabled="isPurging">
|
||||||
|
🗑️ {{ isPurging ? "Purging..." : "Purge All" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Galerie d'images -->
|
||||||
|
<div v-if="isLoading && images.length === 0" class="loading-state">
|
||||||
|
⏳ Loading images...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="images.length === 0" class="empty-state">
|
||||||
|
📭 No images in cache. Add one using the form above!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="image-grid">
|
||||||
|
<div
|
||||||
|
v-for="image in sortedImages"
|
||||||
|
:key="image.pk"
|
||||||
|
class="image-card"
|
||||||
|
@click="selectedImage = image"
|
||||||
|
>
|
||||||
|
<div class="image-wrapper">
|
||||||
|
<img
|
||||||
|
:src="getImageUrlWithFallback(image.pk, 256)"
|
||||||
|
:alt="resolveOrigin(image) || image.pk"
|
||||||
|
loading="lazy"
|
||||||
|
@error="(e) => handleImageError(image.pk, e)"
|
||||||
|
/>
|
||||||
|
<div class="image-overlay">
|
||||||
|
<span class="hits">👁️ {{ image.hits }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-info">
|
||||||
|
<div class="pk">{{ image.pk }}</div>
|
||||||
|
<div class="url" :title="resolveOrigin(image) || 'Unknown source'">
|
||||||
|
{{ truncateUrl(resolveOrigin(image)) }}
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span v-if="image.last_used" class="last-used">
|
||||||
|
🕐 {{ formatDate(image.last_used) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-actions">
|
||||||
|
<button
|
||||||
|
@click.stop="handleDeleteImage(image.pk)"
|
||||||
|
class="btn-delete"
|
||||||
|
:disabled="deletingImages.has(image.pk)"
|
||||||
|
>
|
||||||
|
{{ deletingImages.has(image.pk) ? "..." : "🗑️" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de détails -->
|
||||||
|
<div v-if="selectedImage" class="modal" @click="selectedImage = null">
|
||||||
|
<div class="modal-content" @click.stop>
|
||||||
|
<button class="modal-close" @click="selectedImage = null">✕</button>
|
||||||
|
<img
|
||||||
|
:src="getImageUrlWithFallback(selectedImage.pk)"
|
||||||
|
:alt="resolveOrigin(selectedImage) || selectedImage.pk"
|
||||||
|
class="modal-image"
|
||||||
|
@error="(e) => selectedImage && handleImageError(selectedImage.pk, e)"
|
||||||
|
/>
|
||||||
|
<div class="modal-info">
|
||||||
|
<h3>Image Details</h3>
|
||||||
|
<p><strong>PK:</strong> {{ selectedImage.pk }}</p>
|
||||||
|
<p v-if="resolveOrigin(selectedImage)">
|
||||||
|
<strong>Source URL:</strong>
|
||||||
|
<a :href="resolveOrigin(selectedImage)" target="_blank">{{ resolveOrigin(selectedImage) }}</a>
|
||||||
|
</p>
|
||||||
|
<p v-else><strong>Source URL:</strong> Unknown</p>
|
||||||
|
<p><strong>Hits:</strong> {{ selectedImage.hits }}</p>
|
||||||
|
<p v-if="selectedImage.last_used"><strong>Last Used:</strong> {{ formatDate(selectedImage.last_used) }}</p>
|
||||||
|
<div class="links">
|
||||||
|
<p><strong>WebP:</strong> <a :href="getImageUrl(selectedImage.pk)" target="_blank">{{ getImageUrl(selectedImage.pk) }}</a></p>
|
||||||
|
<p><strong>JPEG:</strong> <a :href="getJpegUrl(selectedImage.pk)" target="_blank">{{ getJpegUrl(selectedImage.pk) }}</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="copyImageUrl(selectedImage.pk)" class="btn-secondary">
|
||||||
|
📋 Copy URL
|
||||||
|
</button>
|
||||||
|
<button @click="handleDeleteImage(selectedImage.pk); selectedImage = null" class="btn-danger">
|
||||||
|
🗑️ Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import type { CacheEntry } from "../services/coverCache";
|
||||||
|
import {
|
||||||
|
listImages,
|
||||||
|
addImage,
|
||||||
|
deleteImage,
|
||||||
|
purgeCache,
|
||||||
|
consolidateCache,
|
||||||
|
getImageUrl,
|
||||||
|
getJpegUrl,
|
||||||
|
getOriginUrl,
|
||||||
|
waitForDownload,
|
||||||
|
getDefaultImageUrl,
|
||||||
|
} from "../services/coverCache";
|
||||||
|
|
||||||
|
// --- États ---
|
||||||
|
const images = ref<CacheEntry[]>([]);
|
||||||
|
const selectedImage = ref<CacheEntry | null>(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const sortBy = ref<"hits" | "last_used" | "recent">("hits");
|
||||||
|
|
||||||
|
// Formulaire d'ajout
|
||||||
|
const newImageUrl = ref("");
|
||||||
|
const isAdding = ref(false);
|
||||||
|
const addError = ref("");
|
||||||
|
const addSuccess = ref("");
|
||||||
|
|
||||||
|
// Contrôles
|
||||||
|
const isConsolidating = ref(false);
|
||||||
|
const isPurging = ref(false);
|
||||||
|
const deletingImages = ref(new Set<string>());
|
||||||
|
|
||||||
|
// Gestion des erreurs de chargement d'images
|
||||||
|
const failedImages = ref(new Set<string>());
|
||||||
|
|
||||||
|
// --- Computed ---
|
||||||
|
const totalHits = computed(() => images.value.reduce((sum, i) => sum + i.hits, 0));
|
||||||
|
|
||||||
|
const sortedImages = computed(() => {
|
||||||
|
const arr = [...images.value];
|
||||||
|
switch (sortBy.value) {
|
||||||
|
case "hits": return arr.sort((a,b)=>b.hits-a.hits);
|
||||||
|
case "last_used":
|
||||||
|
return arr.sort((a,b)=>{
|
||||||
|
if(!a.last_used) return 1;
|
||||||
|
if(!b.last_used) return -1;
|
||||||
|
return new Date(b.last_used).getTime()-new Date(a.last_used).getTime();
|
||||||
|
});
|
||||||
|
case "recent": return arr.reverse();
|
||||||
|
default: return arr;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Fonctions ---
|
||||||
|
async function refreshImages() {
|
||||||
|
isLoading.value = true;
|
||||||
|
try { images.value = await listImages(); }
|
||||||
|
finally { isLoading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddImage() {
|
||||||
|
if(!newImageUrl.value) return;
|
||||||
|
isAdding.value = true; addError.value=""; addSuccess.value="";
|
||||||
|
try {
|
||||||
|
const result = await addImage(newImageUrl.value);
|
||||||
|
addSuccess.value = `Image downloading... PK: ${result.pk}`;
|
||||||
|
|
||||||
|
// Attendre que le téléchargement et la transformation soient terminés
|
||||||
|
await waitForDownload(result.pk);
|
||||||
|
|
||||||
|
addSuccess.value = `Image added! PK: ${result.pk}`;
|
||||||
|
newImageUrl.value = "";
|
||||||
|
await refreshImages();
|
||||||
|
} catch(e:any) { addError.value = e.message ?? "Failed to add image"; }
|
||||||
|
finally { isAdding.value=false; setTimeout(()=>addSuccess.value="",3000); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteImage(pk:string){
|
||||||
|
if(!confirm(`Delete image ${pk}?`)) return;
|
||||||
|
deletingImages.value.add(pk);
|
||||||
|
try{ await deleteImage(pk); await refreshImages(); }
|
||||||
|
finally{ deletingImages.value.delete(pk); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePurge(){
|
||||||
|
if(!confirm("⚠️ Delete ALL images?")) return;
|
||||||
|
isPurging.value = true;
|
||||||
|
try{ await purgeCache(); await refreshImages(); }
|
||||||
|
finally{ isPurging.value=false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConsolidate(){
|
||||||
|
if(!confirm("Consolidate cache?")) return;
|
||||||
|
isConsolidating.value=true;
|
||||||
|
try{ await consolidateCache(); await refreshImages(); }
|
||||||
|
finally{ isConsolidating.value=false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyImageUrl(pk:string){
|
||||||
|
const webpUrl = window.location.origin + getImageUrl(pk);
|
||||||
|
const jpegUrl = window.location.origin + getJpegUrl(pk);
|
||||||
|
navigator.clipboard.writeText(`${webpUrl}\n${jpegUrl}`);
|
||||||
|
alert("✅ URLs copied (WebP + JPEG)!");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOrigin(entry: CacheEntry | null): string | undefined {
|
||||||
|
return entry ? getOriginUrl(entry) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateUrl(url?:string,maxLength=40){
|
||||||
|
if(!url) return "Unknown source";
|
||||||
|
return url.length<=maxLength?url:url.slice(0,maxLength-3)+"...";
|
||||||
|
}
|
||||||
|
function formatDate(dateString:string){
|
||||||
|
const d=new Date(dateString), diff=Date.now()-d.getTime(), days=Math.floor(diff/(1000*60*60*24));
|
||||||
|
if(days===0)return"Today"; if(days===1)return"Yesterday"; if(days<7)return`${days} days ago`; return d.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageUrlWithFallback(pk: string, size?: number): string {
|
||||||
|
if (failedImages.value.has(pk)) {
|
||||||
|
return getDefaultImageUrl();
|
||||||
|
}
|
||||||
|
return getImageUrl(pk, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageError(pk: string, event: Event) {
|
||||||
|
failedImages.value.add(pk);
|
||||||
|
(event.target as HTMLImageElement).src = getDefaultImageUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(()=>refreshImages());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cover-cache-manager {
|
||||||
|
padding: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1400px) {
|
||||||
|
.cover-cache-manager {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cover-cache-manager {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 2px solid #444;
|
||||||
|
}
|
||||||
|
.header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #999;
|
||||||
|
} /* Formulaire d'ajout */
|
||||||
|
.add-form {
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.add-form h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.form-group input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.form-group button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: #61dafb;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.form-group button:hover:not(:disabled) {
|
||||||
|
background: #4fa8c5;
|
||||||
|
}
|
||||||
|
.form-group button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
color: #51cf66;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
} /* Contrôles */
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.sort-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sort-controls label {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.sort-controls select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
button:not(.btn-danger):not(.btn-secondary) {
|
||||||
|
background: #61dafb;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
button:not(.btn-danger):not(.btn-secondary):hover:not(:disabled) {
|
||||||
|
background: #4fa8c5;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #555;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #ee5a52;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
} /* États */
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #999;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
} /* Grille d'images */
|
||||||
|
.image-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.image-card {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.image-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.image-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 100%; /* Ratio 1:1 */
|
||||||
|
background: #1a1a1a;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.image-wrapper img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.image-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.hits {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.image-info {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.pk {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #61dafb;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.url {
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
.image-actions {
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
.btn-delete {
|
||||||
|
width: 100%;
|
||||||
|
background: #555;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn-delete:hover:not(:disabled) {
|
||||||
|
background: #ff6b6b;
|
||||||
|
} /* Modal */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.modal-close:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
.modal-image {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.modal-info {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.modal-info h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
.modal-info p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.modal-info a {
|
||||||
|
color: #61dafb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.modal-info a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1627
pmoapp/webapp/src/components/GenericMusicPlayer.vue
Normal file
1627
pmoapp/webapp/src/components/GenericMusicPlayer.vue
Normal file
File diff suppressed because it is too large
Load Diff
1059
pmoapp/webapp/src/components/LogView.vue
Normal file
1059
pmoapp/webapp/src/components/LogView.vue
Normal file
File diff suppressed because it is too large
Load Diff
172
pmoapp/webapp/src/components/NotificationToast.vue
Normal file
172
pmoapp/webapp/src/components/NotificationToast.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
|
import { AlertCircle, CheckCircle, Info, X, AlertTriangle } from 'lucide-vue-next'
|
||||||
|
import type { Notification } from '@/stores/ui'
|
||||||
|
|
||||||
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
|
function removeNotification(id: string) {
|
||||||
|
uiStore.removeNotification(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcon(type: Notification['type']) {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return CheckCircle
|
||||||
|
case 'error':
|
||||||
|
return AlertCircle
|
||||||
|
case 'warning':
|
||||||
|
return AlertTriangle
|
||||||
|
case 'info':
|
||||||
|
return Info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeClass(type: Notification['type']) {
|
||||||
|
return `notification-${type}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="notification-container">
|
||||||
|
<TransitionGroup name="notification-list">
|
||||||
|
<div
|
||||||
|
v-for="notification in uiStore.notifications"
|
||||||
|
:key="notification.id"
|
||||||
|
:class="['notification', getTypeClass(notification.type)]"
|
||||||
|
>
|
||||||
|
<component :is="getIcon(notification.type)" :size="20" class="notification-icon" />
|
||||||
|
<p class="notification-message">{{ notification.message }}</p>
|
||||||
|
<button
|
||||||
|
class="notification-close"
|
||||||
|
@click="removeNotification(notification.id)"
|
||||||
|
title="Fermer"
|
||||||
|
>
|
||||||
|
<X :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-container {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--spacing-lg);
|
||||||
|
right: var(--spacing-lg);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
max-width: 400px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
pointer-events: auto;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close:hover {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type-specific styles */
|
||||||
|
.notification-success {
|
||||||
|
border-left: 4px solid var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success .notification-icon {
|
||||||
|
color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
border-left: 4px solid var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error .notification-icon {
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-warning {
|
||||||
|
border-left: 4px solid var(--status-paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-warning .notification-icon {
|
||||||
|
color: var(--status-paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
border-left: 4px solid var(--status-transitioning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info .notification-icon {
|
||||||
|
color: var(--status-transitioning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.notification-list-enter-active,
|
||||||
|
.notification-list-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.notification-container {
|
||||||
|
top: var(--spacing-md);
|
||||||
|
right: var(--spacing-md);
|
||||||
|
left: var(--spacing-md);
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1597
pmoapp/webapp/src/components/PlayListManager.vue
Normal file
1597
pmoapp/webapp/src/components/PlayListManager.vue
Normal file
File diff suppressed because it is too large
Load Diff
2832
pmoapp/webapp/src/components/RadioParadiseExplorer.vue
Normal file
2832
pmoapp/webapp/src/components/RadioParadiseExplorer.vue
Normal file
File diff suppressed because it is too large
Load Diff
506
pmoapp/webapp/src/components/UpnpExplorer.vue
Normal file
506
pmoapp/webapp/src/components/UpnpExplorer.vue
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
<template>
|
||||||
|
<div class="upnp-explorer">
|
||||||
|
<div class="header">
|
||||||
|
<h2>🎵 UPnP Device Explorer</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<button @click="refreshDevices" :disabled="isLoading" class="refresh-btn">
|
||||||
|
{{ isLoading ? '⏳ Loading...' : '🔄 Refresh' }}
|
||||||
|
</button>
|
||||||
|
<span class="device-count">{{ devices.length }} device(s)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- État de chargement -->
|
||||||
|
<div v-if="isLoading && devices.length === 0" class="loading-state">
|
||||||
|
⏳ Loading UPnP devices...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- État vide -->
|
||||||
|
<div v-else-if="!isLoading && devices.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">📡</div>
|
||||||
|
<p>No UPnP devices found</p>
|
||||||
|
<p class="hint">Devices will appear here once registered</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste des devices avec leurs services intégrés -->
|
||||||
|
<div v-else class="devices-list">
|
||||||
|
<div
|
||||||
|
v-for="device in devicesWithDetails"
|
||||||
|
:key="device.udn"
|
||||||
|
class="device-section"
|
||||||
|
>
|
||||||
|
<!-- En-tête du device -->
|
||||||
|
<div class="device-header" @click="toggleDevice(device.udn)">
|
||||||
|
<div class="device-title">
|
||||||
|
<span class="device-icon">{{ getDeviceIcon(device.device_type) }}</span>
|
||||||
|
<div class="device-names">
|
||||||
|
<span class="device-name">{{ device.friendly_name }}</span>
|
||||||
|
<span class="device-type">{{ device.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-meta">
|
||||||
|
<span v-if="device.services" class="service-count">
|
||||||
|
{{ device.services.length }} service(s)
|
||||||
|
</span>
|
||||||
|
<span class="expand-icon">{{ expandedDevices.has(device.udn) ? '▼' : '▶' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Détails du device (expandable) -->
|
||||||
|
<transition name="expand">
|
||||||
|
<div v-if="expandedDevices.has(device.udn)" class="device-details">
|
||||||
|
<!-- Chargement des détails -->
|
||||||
|
<div v-if="!device.services" class="loading-services">
|
||||||
|
⏳ Loading services...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services -->
|
||||||
|
<div v-else class="device-content">
|
||||||
|
<div class="device-summary">
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">UDN:</span>
|
||||||
|
<code class="meta-value">{{ device.udn }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row" v-if="device.description_url">
|
||||||
|
<span class="meta-label">Description:</span>
|
||||||
|
<a
|
||||||
|
:href="device.description_url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="meta-link"
|
||||||
|
>
|
||||||
|
View XML
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row" v-if="device.base_url">
|
||||||
|
<span class="meta-label">Base URL:</span>
|
||||||
|
<code class="meta-value">{{ device.base_url }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="services-list">
|
||||||
|
<ServicePanel
|
||||||
|
v-for="service in device.services"
|
||||||
|
:key="service.name"
|
||||||
|
:service="service"
|
||||||
|
:device-udn="device.udn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast de notification d'erreur -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="error" class="error-toast" @click="error = null">
|
||||||
|
❌ {{ error }}
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import ServicePanel from './upnp/ServicePanel.vue'
|
||||||
|
|
||||||
|
const devices = ref([])
|
||||||
|
const deviceDetails = ref(new Map()) // UDN -> détails complets
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const expandedDevices = ref(new Set())
|
||||||
|
const refreshInterval = ref(null)
|
||||||
|
|
||||||
|
// Devices avec leurs détails fusionnés
|
||||||
|
const devicesWithDetails = computed(() => {
|
||||||
|
return devices.value.map(device => {
|
||||||
|
const details = deviceDetails.value.get(device.udn)
|
||||||
|
return details ? { ...device, ...details } : device
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function getDeviceIcon(deviceType) {
|
||||||
|
if (deviceType?.includes('MediaRenderer')) return '🎵'
|
||||||
|
if (deviceType?.includes('MediaServer')) return '💿'
|
||||||
|
return '📱'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevices() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/upnp/devices')
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
devices.value = data.devices || []
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load devices:', err)
|
||||||
|
error.value = `Failed to load devices: ${err.message}`
|
||||||
|
setTimeout(() => error.value = null, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDeviceDetails(udn) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/upnp/devices/${encodeURIComponent(udn)}`)
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||||
|
|
||||||
|
const details = await response.json()
|
||||||
|
deviceDetails.value.set(udn, details)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load device details:', err)
|
||||||
|
error.value = `Failed to load device details: ${err.message}`
|
||||||
|
setTimeout(() => error.value = null, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDevice(udn) {
|
||||||
|
if (expandedDevices.value.has(udn)) {
|
||||||
|
expandedDevices.value.delete(udn)
|
||||||
|
} else {
|
||||||
|
expandedDevices.value.add(udn)
|
||||||
|
// Charger les détails si pas encore fait
|
||||||
|
if (!deviceDetails.value.has(udn)) {
|
||||||
|
loadDeviceDetails(udn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDevices() {
|
||||||
|
isLoading.value = true
|
||||||
|
await loadDevices()
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh toutes les 30 secondes
|
||||||
|
onMounted(() => {
|
||||||
|
refreshDevices()
|
||||||
|
refreshInterval.value = setInterval(loadDevices, 30000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshInterval.value) {
|
||||||
|
clearInterval(refreshInterval.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upnp-explorer {
|
||||||
|
padding: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1400px) {
|
||||||
|
.upnp-explorer {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.upnp-explorer {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 2px solid rgba(52, 152, 219, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #ecf0f1;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background: linear-gradient(135deg, #3498db, #2980b9);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #5dade2, #3498db);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-count {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(52, 152, 219, 0.2);
|
||||||
|
border: 1px solid rgba(52, 152, 219, 0.4);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #3498db;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* États */
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: #95a5a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Devices list */
|
||||||
|
.devices-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(52, 152, 219, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section:hover {
|
||||||
|
border-color: rgba(52, 152, 219, 0.6);
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-header:hover {
|
||||||
|
background: rgba(52, 152, 219, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-names {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-name {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-type {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #95a5a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-count {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
background: rgba(46, 204, 113, 0.2);
|
||||||
|
border: 1px solid rgba(46, 204, 113, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #2ecc71;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
color: #3498db;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Device details */
|
||||||
|
.device-details {
|
||||||
|
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||||
|
border-top: 1px solid rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-services {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #95a5a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid rgba(52, 152, 219, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #95a5a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
background: rgba(44, 62, 80, 0.6);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-link {
|
||||||
|
color: #1abc9c;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.expand-enter-active,
|
||||||
|
.expand-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
max-height: 5000px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-enter-from,
|
||||||
|
.expand-leave-to {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error toast */
|
||||||
|
.error-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
background: linear-gradient(135deg, #e74c3c, #c0392b);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1000;
|
||||||
|
max-width: 400px;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.upnp-explorer {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-meta {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
251
pmoapp/webapp/src/components/pmocontrol/ActionMenu.vue
Normal file
251
pmoapp/webapp/src/components/pmocontrol/ActionMenu.vue
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRenderers } from '@/composables/useRenderers'
|
||||||
|
import { Play, ListPlus, ChevronRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
type: 'container' | 'item'
|
||||||
|
entryId: string
|
||||||
|
serverId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
playNow: [rendererId: string]
|
||||||
|
addToQueue: [rendererId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { onlineRenderers } = useRenderers()
|
||||||
|
const showMenu = ref(false)
|
||||||
|
const showRendererSubmenu = ref<'play' | 'queue' | null>(null)
|
||||||
|
|
||||||
|
function handleAction(action: 'play' | 'queue', rendererId: string) {
|
||||||
|
showMenu.value = false
|
||||||
|
showRendererSubmenu.value = null
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'play':
|
||||||
|
emit('playNow', rendererId)
|
||||||
|
break
|
||||||
|
case 'queue':
|
||||||
|
emit('addToQueue', rendererId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
showMenu.value = !showMenu.value
|
||||||
|
if (!showMenu.value) {
|
||||||
|
showRendererSubmenu.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="action-menu-container" @click.stop>
|
||||||
|
<button class="action-menu-trigger" @click="toggleMenu" :title="'Actions'">
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showMenu" class="action-menu" @click.stop>
|
||||||
|
<!-- Play Now -->
|
||||||
|
<div
|
||||||
|
class="menu-item"
|
||||||
|
@mouseenter="showRendererSubmenu = 'play'"
|
||||||
|
@mouseleave="showRendererSubmenu = null"
|
||||||
|
>
|
||||||
|
<Play :size="16" />
|
||||||
|
<span>Lire maintenant</span>
|
||||||
|
<ChevronRight :size="16" class="submenu-arrow" />
|
||||||
|
|
||||||
|
<!-- Submenu renderers -->
|
||||||
|
<div v-if="showRendererSubmenu === 'play'" class="renderer-submenu">
|
||||||
|
<div
|
||||||
|
v-for="renderer in onlineRenderers"
|
||||||
|
:key="renderer.id"
|
||||||
|
class="submenu-item"
|
||||||
|
@click="handleAction('play', renderer.id)"
|
||||||
|
>
|
||||||
|
{{ renderer.friendly_name }}
|
||||||
|
</div>
|
||||||
|
<div v-if="onlineRenderers.length === 0" class="submenu-empty">
|
||||||
|
Aucun renderer disponible
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add to Queue -->
|
||||||
|
<div
|
||||||
|
class="menu-item"
|
||||||
|
@mouseenter="showRendererSubmenu = 'queue'"
|
||||||
|
@mouseleave="showRendererSubmenu = null"
|
||||||
|
>
|
||||||
|
<ListPlus :size="16" />
|
||||||
|
<span>Ajouter à la queue</span>
|
||||||
|
<ChevronRight :size="16" class="submenu-arrow" />
|
||||||
|
|
||||||
|
<div v-if="showRendererSubmenu === 'queue'" class="renderer-submenu">
|
||||||
|
<div
|
||||||
|
v-for="renderer in onlineRenderers"
|
||||||
|
:key="renderer.id"
|
||||||
|
class="submenu-item"
|
||||||
|
@click="handleAction('queue', renderer.id)"
|
||||||
|
>
|
||||||
|
{{ renderer.friendly_name }}
|
||||||
|
</div>
|
||||||
|
<div v-if="onlineRenderers.length === 0" class="submenu-empty">
|
||||||
|
Aucun renderer disponible
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backdrop to close menu -->
|
||||||
|
<div v-if="showMenu" class="menu-backdrop" @click="showMenu = false"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.action-menu-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu-trigger:hover {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
min-width: 200px;
|
||||||
|
z-index: var(--z-dropdown);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:first-child {
|
||||||
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:last-child {
|
||||||
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:only-child {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item svg:first-child {
|
||||||
|
color: var(--color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item span {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-arrow {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-submenu {
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 0;
|
||||||
|
margin-left: var(--spacing-xs);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
min-width: 180px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: calc(var(--z-dropdown) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-item {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-empty {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: calc(var(--z-dropdown) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar for long renderer lists */
|
||||||
|
.renderer-submenu::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-submenu::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-submenu::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-submenu::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
156
pmoapp/webapp/src/components/pmocontrol/Breadcrumb.vue
Normal file
156
pmoapp/webapp/src/components/pmocontrol/Breadcrumb.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronRight, Home } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
items: BreadcrumbItem[]
|
||||||
|
serverId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [containerId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleNavigate(containerId: string) {
|
||||||
|
emit('navigate', containerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
emit('navigate', '0')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="breadcrumb" aria-label="Fil d'Ariane">
|
||||||
|
<ol class="breadcrumb-list">
|
||||||
|
<!-- Home -->
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<button class="breadcrumb-link" @click="goHome" title="Accueil">
|
||||||
|
<Home :size="16" />
|
||||||
|
<span>Accueil</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Path items -->
|
||||||
|
<template v-for="(item, index) in items" :key="item.id">
|
||||||
|
<li class="breadcrumb-separator" aria-hidden="true">
|
||||||
|
<ChevronRight :size="16" />
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<button
|
||||||
|
v-if="index < items.length - 1"
|
||||||
|
class="breadcrumb-link"
|
||||||
|
@click="handleNavigate(item.id)"
|
||||||
|
:title="item.title"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="breadcrumb-current" :title="item.title">
|
||||||
|
{{ item.title }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.breadcrumb {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-separator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-link:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-current {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.breadcrumb::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.breadcrumb-link,
|
||||||
|
.breadcrumb-current {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
173
pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue
Normal file
173
pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||||
|
import { Folder, Music } from 'lucide-vue-next'
|
||||||
|
import ActionMenu from './ActionMenu.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entry: ContainerEntry
|
||||||
|
serverId: string
|
||||||
|
showActions?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
browse: [containerId: string]
|
||||||
|
playNow: [containerId: string, rendererId: string]
|
||||||
|
addToQueue: [containerId: string, rendererId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const iconComponent = computed(() => {
|
||||||
|
const cls = props.entry.class.toLowerCase()
|
||||||
|
if (cls.includes('playlist')) return Music
|
||||||
|
if (cls.includes('album')) return Music
|
||||||
|
return Folder
|
||||||
|
})
|
||||||
|
|
||||||
|
const containerType = computed(() => {
|
||||||
|
const cls = props.entry.class.toLowerCase()
|
||||||
|
if (cls.includes('playlist')) return 'Playlist'
|
||||||
|
if (cls.includes('album')) return 'Album'
|
||||||
|
if (cls.includes('artist')) return 'Artiste'
|
||||||
|
if (cls.includes('genre')) return 'Genre'
|
||||||
|
return 'Dossier'
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleBrowse() {
|
||||||
|
emit('browse', props.entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayNow(rendererId: string) {
|
||||||
|
emit('playNow', props.entry.id, rendererId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddToQueue(rendererId: string) {
|
||||||
|
emit('addToQueue', props.entry.id, rendererId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container-item">
|
||||||
|
<!-- Main content (clickable) -->
|
||||||
|
<button class="container-content" @click="handleBrowse">
|
||||||
|
<div class="container-icon">
|
||||||
|
<component :is="iconComponent" :size="24" />
|
||||||
|
</div>
|
||||||
|
<div class="container-metadata">
|
||||||
|
<div class="container-title">{{ entry.title }}</div>
|
||||||
|
<div class="container-details">
|
||||||
|
<span class="container-type">{{ containerType }}</span>
|
||||||
|
<span v-if="entry.child_count !== null" class="container-count">
|
||||||
|
{{ entry.child_count }} élément{{ entry.child_count > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Actions menu -->
|
||||||
|
<div class="container-actions">
|
||||||
|
<ActionMenu
|
||||||
|
type="container"
|
||||||
|
:entry-id="entry.id"
|
||||||
|
:server-id="serverId"
|
||||||
|
@play-now="handlePlayNow"
|
||||||
|
@add-to-queue="handleAddToQueue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-metadata {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-details {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-type {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-count::before {
|
||||||
|
content: '•';
|
||||||
|
margin-right: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
532
pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue
Normal file
532
pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, toRef, ref } from 'vue'
|
||||||
|
import { useRenderer } from '@/composables/useRenderers'
|
||||||
|
import { Music, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rendererId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||||
|
const metadata = computed(() => state.value?.current_track)
|
||||||
|
|
||||||
|
// État pour l'overlay de la cover
|
||||||
|
const showCoverOverlay = ref(false)
|
||||||
|
const showMetadata = ref(false)
|
||||||
|
|
||||||
|
function openCoverOverlay() {
|
||||||
|
if (hasCover.value) {
|
||||||
|
showCoverOverlay.value = true
|
||||||
|
showMetadata.value = false // Reset metadata visibility when opening
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCoverOverlay() {
|
||||||
|
showCoverOverlay.value = false
|
||||||
|
showMetadata.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détecter le clic sur la partie basse de l'image
|
||||||
|
function handleOverlayContentClick(event: MouseEvent) {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
const clickY = event.clientY - rect.top
|
||||||
|
const height = rect.height
|
||||||
|
|
||||||
|
// Si le clic est dans le tiers inférieur, toggle les métadonnées
|
||||||
|
if (clickY > height * 0.66) {
|
||||||
|
showMetadata.value = !showMetadata.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcul du pourcentage de progression
|
||||||
|
const progressPercent = computed(() => {
|
||||||
|
const position = state.value?.position_ms
|
||||||
|
const duration = state.value?.duration_ms
|
||||||
|
if (position && duration && duration > 0) {
|
||||||
|
return (position / duration) * 100
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Formater la durée en MM:SS
|
||||||
|
function formatTime(ms: number | null | undefined): string {
|
||||||
|
if (!ms) return '--:--'
|
||||||
|
const totalSeconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTime = computed(() => formatTime(state.value?.position_ms))
|
||||||
|
const totalTime = computed(() => formatTime(state.value?.duration_ms))
|
||||||
|
|
||||||
|
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||||
|
|
||||||
|
function handleImageError(event: Event) {
|
||||||
|
const img = event.target as HTMLImageElement
|
||||||
|
img.style.display = 'none'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="current-track">
|
||||||
|
<!-- Cover Art -->
|
||||||
|
<div class="cover-container" :class="{ clickable: hasCover }" @click="openCoverOverlay">
|
||||||
|
<img
|
||||||
|
v-if="hasCover"
|
||||||
|
:src="metadata?.album_art_uri!"
|
||||||
|
:alt="metadata?.album || 'Album cover'"
|
||||||
|
class="cover-image"
|
||||||
|
loading="lazy"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
<div v-else class="cover-placeholder">
|
||||||
|
<Music :size="64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<div class="metadata">
|
||||||
|
<h2 class="title">{{ metadata?.title || 'Aucun titre' }}</h2>
|
||||||
|
<p class="artist">{{ metadata?.artist || 'Artiste inconnu' }}</p>
|
||||||
|
<p class="album" v-if="metadata?.album">{{ metadata.album }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-bar-fill"
|
||||||
|
:style="{ width: `${progressPercent}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="time-display">
|
||||||
|
<span>{{ currentTime }}</span>
|
||||||
|
<span>{{ totalTime }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay cover en grand avec effet glassmorphism -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="cover-overlay">
|
||||||
|
<div v-if="showCoverOverlay" class="cover-overlay" @click="closeCoverOverlay">
|
||||||
|
<div class="cover-overlay-content" @click.stop="handleOverlayContentClick">
|
||||||
|
<button class="cover-overlay-close" @click="closeCoverOverlay" title="Fermer">
|
||||||
|
<X :size="24" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="hasCover"
|
||||||
|
:src="metadata?.album_art_uri!"
|
||||||
|
:alt="metadata?.album || 'Album cover'"
|
||||||
|
class="cover-overlay-image"
|
||||||
|
/>
|
||||||
|
<Transition name="metadata-fade">
|
||||||
|
<div v-if="metadata && showMetadata" class="cover-overlay-metadata">
|
||||||
|
<h2>{{ metadata.title }}</h2>
|
||||||
|
<p class="artist">{{ metadata.artist }}</p>
|
||||||
|
<p v-if="metadata.album" class="album">{{ metadata.album }}</p>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.current-track {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-container {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-container.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-container.clickable:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--spacing-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.cover-container {
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.cover-container {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode kiosque - compactage pour petites hauteurs (800x600) */
|
||||||
|
@media (max-height: 700px) and (orientation: landscape) {
|
||||||
|
.current-track {
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-container {
|
||||||
|
max-width: 160px !important;
|
||||||
|
max-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata {
|
||||||
|
margin-top: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay cover en grand - Liquid metal style */
|
||||||
|
.cover-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(20px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-content {
|
||||||
|
position: relative;
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
height: calc(100vh - 32px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(40px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow:
|
||||||
|
0 20px 60px rgba(0, 0, 0, 0.5),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||||
|
inset 0 -1px 0 rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.cover-overlay-content {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-metadata {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-close {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--spacing-md);
|
||||||
|
right: var(--spacing-md);
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: scale(1.1) rotate(90deg);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-close:active {
|
||||||
|
transform: scale(1.0) rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.cover-overlay-close {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-close:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-image {
|
||||||
|
width: calc(100vw - 64px);
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow:
|
||||||
|
0 30px 80px rgba(0, 0, 0, 0.6),
|
||||||
|
0 0 2px rgba(255, 255, 255, 0.2);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-metadata {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(30px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-metadata h2 {
|
||||||
|
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin: 0;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.8);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-metadata .artist {
|
||||||
|
font-size: clamp(1rem, 2.5vw, 1.5rem);
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6);
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-metadata .album {
|
||||||
|
font-size: clamp(0.875rem, 2vw, 1.125rem);
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
margin: 6px 0 0 0;
|
||||||
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations de transition */
|
||||||
|
.cover-overlay-enter-active,
|
||||||
|
.cover-overlay-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-enter-active .cover-overlay-content,
|
||||||
|
.cover-overlay-leave-active .cover-overlay-content {
|
||||||
|
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-enter-from,
|
||||||
|
.cover-overlay-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
backdrop-filter: blur(0px);
|
||||||
|
-webkit-backdrop-filter: blur(0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-enter-from .cover-overlay-content,
|
||||||
|
.cover-overlay-leave-to .cover-overlay-content {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-enter-to,
|
||||||
|
.cover-overlay-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-enter-to .cover-overlay-content,
|
||||||
|
.cover-overlay-leave-from .cover-overlay-content {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive overlay */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cover-overlay {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-content {
|
||||||
|
width: calc(100vw - 24px);
|
||||||
|
height: calc(100vh - 24px);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-image {
|
||||||
|
width: calc(100vw - 48px);
|
||||||
|
height: calc(100vh - 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-metadata {
|
||||||
|
bottom: 16px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-close {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode kiosque - overlay adapté */
|
||||||
|
@media (max-height: 700px) and (orientation: landscape) {
|
||||||
|
.cover-overlay {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-content {
|
||||||
|
width: calc(100vw - 24px);
|
||||||
|
height: calc(100vh - 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-image {
|
||||||
|
width: calc(100vw - 48px);
|
||||||
|
height: calc(100vh - 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-metadata {
|
||||||
|
bottom: 12px;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-metadata .artist {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-overlay-metadata .album {
|
||||||
|
display: none; /* Masquer l'album en mode kiosque pour plus d'espace */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition pour les métadonnées */
|
||||||
|
.metadata-fade-enter-active,
|
||||||
|
.metadata-fade-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-fade-enter-from,
|
||||||
|
.metadata-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-fade-enter-to,
|
||||||
|
.metadata-fade-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
331
pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
Normal file
331
pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useMediaServers } from '@/composables/useMediaServers'
|
||||||
|
import { useRenderers } from '@/composables/useRenderers'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
|
import Breadcrumb from './Breadcrumb.vue'
|
||||||
|
import ContainerItem from './ContainerItem.vue'
|
||||||
|
import MediaItem from './MediaItem.vue'
|
||||||
|
import { Loader2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
serverId: string
|
||||||
|
containerId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const {
|
||||||
|
getBrowseCached,
|
||||||
|
browseContainer,
|
||||||
|
currentPath: breadcrumbPath,
|
||||||
|
loading,
|
||||||
|
error
|
||||||
|
} = useMediaServers()
|
||||||
|
|
||||||
|
const {
|
||||||
|
playContent,
|
||||||
|
addToQueue,
|
||||||
|
attachAndPlayPlaylist,
|
||||||
|
attachPlaylist,
|
||||||
|
} = useRenderers()
|
||||||
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
|
// Flags pour gérer le rechargement automatique avec debounce et cooldown
|
||||||
|
const isRefreshing = ref(false)
|
||||||
|
const refreshTimeoutId = ref<number | null>(null)
|
||||||
|
const lastRefreshTime = ref<number>(0)
|
||||||
|
const REFRESH_COOLDOWN_MS = 5000 // Ne pas recharger plus d'une fois toutes les 5 secondes
|
||||||
|
|
||||||
|
const browseData = computed(() =>
|
||||||
|
getBrowseCached(props.serverId, props.containerId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const containers = computed(() =>
|
||||||
|
browseData.value?.entries.filter((e) => e.is_container) || []
|
||||||
|
)
|
||||||
|
|
||||||
|
const items = computed(() =>
|
||||||
|
browseData.value?.entries.filter((e) => !e.is_container) || []
|
||||||
|
)
|
||||||
|
|
||||||
|
// Charger le container au montage et quand containerId change
|
||||||
|
watch(
|
||||||
|
() => props.containerId,
|
||||||
|
async (newContainerId) => {
|
||||||
|
if (newContainerId) {
|
||||||
|
await browseContainer(props.serverId, newContainerId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
|
||||||
|
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
|
||||||
|
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
|
||||||
|
// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations
|
||||||
|
// et un cooldown de 5 secondes pour éviter les rechargements successifs
|
||||||
|
watch(
|
||||||
|
() => browseData.value,
|
||||||
|
(data) => {
|
||||||
|
// Si browseData devient undefined alors que containerId est présent,
|
||||||
|
// et qu'on n'est pas déjà en train de charger, planifier un rechargement
|
||||||
|
if (!data && props.containerId && !loading.value) {
|
||||||
|
// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes
|
||||||
|
const timeSinceLastRefresh = Date.now() - lastRefreshTime.value
|
||||||
|
if (timeSinceLastRefresh < REFRESH_COOLDOWN_MS) {
|
||||||
|
console.log(
|
||||||
|
`[MediaBrowser] Cache invalidé mais cooldown actif (${Math.round((REFRESH_COOLDOWN_MS - timeSinceLastRefresh) / 1000)}s restantes), rechargement ignoré`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annuler tout timeout en cours
|
||||||
|
if (refreshTimeoutId.value !== null) {
|
||||||
|
clearTimeout(refreshTimeoutId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Planifier le rechargement après 3 secondes
|
||||||
|
// Cela permet de regrouper plusieurs événements SSE successifs
|
||||||
|
refreshTimeoutId.value = window.setTimeout(async () => {
|
||||||
|
if (!isRefreshing.value) {
|
||||||
|
console.log(
|
||||||
|
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`
|
||||||
|
)
|
||||||
|
isRefreshing.value = true
|
||||||
|
await browseContainer(props.serverId, props.containerId, false)
|
||||||
|
lastRefreshTime.value = Date.now() // Enregistrer le moment du rechargement
|
||||||
|
isRefreshing.value = false
|
||||||
|
refreshTimeoutId.value = null
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [containerId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleNavigate(containerId: string) {
|
||||||
|
emit('navigate', containerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrowseContainer(containerId: string) {
|
||||||
|
emit('navigate', containerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions handlers pour les containers (playlists/albums)
|
||||||
|
async function handlePlayContainer(containerId: string, rendererId: string) {
|
||||||
|
try {
|
||||||
|
await attachAndPlayPlaylist(rendererId, props.serverId, containerId)
|
||||||
|
uiStore.notifySuccess('Lecture de la playlist démarrée !')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
uiStore.notifyError(`Erreur lors de la lecture de la playlist: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQueueContainer(containerId: string, rendererId: string) {
|
||||||
|
try {
|
||||||
|
await attachPlaylist(rendererId, props.serverId, containerId)
|
||||||
|
uiStore.notifySuccess('Playlist attachée à la queue !')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
uiStore.notifyError(`Erreur lors de l'ajout de la playlist: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions handlers pour les items (tracks)
|
||||||
|
async function handlePlayItem(itemId: string, rendererId: string) {
|
||||||
|
try {
|
||||||
|
await playContent(rendererId, props.serverId, itemId)
|
||||||
|
uiStore.notifySuccess('Lecture démarrée !')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
uiStore.notifyError(`Erreur lors de la lecture: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQueueItem(itemId: string, rendererId: string) {
|
||||||
|
try {
|
||||||
|
await addToQueue(rendererId, props.serverId, itemId)
|
||||||
|
uiStore.notifySuccess('Ajouté à la queue !')
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
uiStore.notifyError(`Erreur lors de l'ajout à la queue: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="media-browser">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<Breadcrumb
|
||||||
|
:items="breadcrumbPath"
|
||||||
|
:serverId="serverId"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="browser-loading">
|
||||||
|
<Loader2 :size="32" class="spinner" />
|
||||||
|
<p>Chargement...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="error" class="browser-error">
|
||||||
|
<p class="error-message">{{ error }}</p>
|
||||||
|
<button class="btn btn-secondary" @click="browseContainer(serverId, containerId, false)">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else class="browser-content">
|
||||||
|
<!-- Containers section -->
|
||||||
|
<div v-if="containers.length" class="browser-section">
|
||||||
|
<h3 class="section-title">Dossiers et playlists</h3>
|
||||||
|
<div class="entries-list">
|
||||||
|
<ContainerItem
|
||||||
|
v-for="container in containers"
|
||||||
|
:key="container.id"
|
||||||
|
:entry="container"
|
||||||
|
:server-id="serverId"
|
||||||
|
@browse="handleBrowseContainer"
|
||||||
|
@play-now="handlePlayContainer"
|
||||||
|
@add-to-queue="handleQueueContainer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items section -->
|
||||||
|
<div v-if="items.length" class="browser-section">
|
||||||
|
<h3 class="section-title">Pistes</h3>
|
||||||
|
<div class="entries-list">
|
||||||
|
<MediaItem
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
:entry="item"
|
||||||
|
:server-id="serverId"
|
||||||
|
@play-now="handlePlayItem"
|
||||||
|
@add-to-queue="handleQueueItem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="!containers.length && !items.length" class="browser-empty">
|
||||||
|
<p>Ce dossier est vide</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.media-browser {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.browser-loading {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.browser-error {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--status-offline);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.browser-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
padding-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: var(--spacing-sm);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.browser-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.browser-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-content::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
185
pmoapp/webapp/src/components/pmocontrol/MediaItem.vue
Normal file
185
pmoapp/webapp/src/components/pmocontrol/MediaItem.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||||
|
import { Music } from 'lucide-vue-next'
|
||||||
|
import ActionMenu from './ActionMenu.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entry: ContainerEntry
|
||||||
|
serverId: string
|
||||||
|
showActions?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
playNow: [itemId: string, rendererId: string]
|
||||||
|
addToQueue: [itemId: string, rendererId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleImageError(event: Event) {
|
||||||
|
const img = event.target as HTMLImageElement
|
||||||
|
img.style.display = 'none'
|
||||||
|
const placeholder = img.nextElementSibling
|
||||||
|
if (placeholder && placeholder instanceof HTMLElement) {
|
||||||
|
placeholder.style.display = 'flex'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayNow(rendererId: string) {
|
||||||
|
emit('playNow', props.entry.id, rendererId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddToQueue(rendererId: string) {
|
||||||
|
emit('addToQueue', props.entry.id, rendererId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="media-item">
|
||||||
|
<!-- Cover miniature -->
|
||||||
|
<div class="media-cover">
|
||||||
|
<img
|
||||||
|
v-if="entry.album_art_uri"
|
||||||
|
:src="entry.album_art_uri"
|
||||||
|
:alt="entry.album || entry.title"
|
||||||
|
class="cover-image"
|
||||||
|
loading="lazy"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
<div class="cover-placeholder" :style="{ display: entry.album_art_uri ? 'none' : 'flex' }">
|
||||||
|
<Music :size="20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<div class="media-metadata">
|
||||||
|
<div class="media-title">{{ entry.title }}</div>
|
||||||
|
<div class="media-details">
|
||||||
|
<span v-if="entry.artist" class="media-artist">{{ entry.artist }}</span>
|
||||||
|
<span v-if="entry.album" class="media-album">{{ entry.album }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions menu -->
|
||||||
|
<div class="media-actions">
|
||||||
|
<ActionMenu
|
||||||
|
type="item"
|
||||||
|
:entry-id="entry.id"
|
||||||
|
:server-id="serverId"
|
||||||
|
@play-now="handlePlayNow"
|
||||||
|
@add-to-queue="handleAddToQueue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.media-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover */
|
||||||
|
.media-cover {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metadata */
|
||||||
|
.media-metadata {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-details {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-artist {
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-album {
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-album::before {
|
||||||
|
content: '•';
|
||||||
|
margin-right: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.media-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
162
pmoapp/webapp/src/components/pmocontrol/MediaServerCard.vue
Normal file
162
pmoapp/webapp/src/components/pmocontrol/MediaServerCard.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import type { MediaServerSummary } from '@/services/pmocontrol/types'
|
||||||
|
import { Server, Circle } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
server: MediaServerSummary
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const statusClass = computed(() => props.server.online ? 'online' : 'offline')
|
||||||
|
const statusLabel = computed(() => props.server.online ? 'En ligne' : 'Hors ligne')
|
||||||
|
|
||||||
|
function goToServer() {
|
||||||
|
if (props.server.online) {
|
||||||
|
router.push(`/server/${props.server.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['media-server-card', { offline: !server.online }]">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="server-icon">
|
||||||
|
<Server :size="40" />
|
||||||
|
</div>
|
||||||
|
<div class="header-content">
|
||||||
|
<h3 class="server-name">{{ server.friendly_name }}</h3>
|
||||||
|
<p class="server-model">{{ server.model_name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="card-status">
|
||||||
|
<Circle :size="12" :class="['status-indicator', statusClass]" fill="currentColor" />
|
||||||
|
<span :class="['status-label', statusClass]">{{ statusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Browse Button -->
|
||||||
|
<button
|
||||||
|
class="btn btn-primary card-browse-btn"
|
||||||
|
@click="goToServer"
|
||||||
|
:disabled="!server.online"
|
||||||
|
>
|
||||||
|
Parcourir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.media-server-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-server-card:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-server-card.offline {
|
||||||
|
opacity: 0.6;
|
||||||
|
filter: grayscale(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-model {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
.card-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.online {
|
||||||
|
color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.offline {
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label.online {
|
||||||
|
color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label.offline {
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Browse Button */
|
||||||
|
.card-browse-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-browse-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
182
pmoapp/webapp/src/components/pmocontrol/PlaylistBindingPanel.vue
Normal file
182
pmoapp/webapp/src/components/pmocontrol/PlaylistBindingPanel.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, toRef } from 'vue'
|
||||||
|
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
|
import { Link, Unlink } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rendererId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { binding } = useRenderer(toRef(props, 'rendererId'))
|
||||||
|
const { detachPlaylist } = useRenderers()
|
||||||
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
|
const isAttached = computed(() => !!binding.value)
|
||||||
|
|
||||||
|
async function handleDetach() {
|
||||||
|
try {
|
||||||
|
await detachPlaylist(props.rendererId)
|
||||||
|
uiStore.notifySuccess('Playlist détachée avec succès')
|
||||||
|
} catch (error) {
|
||||||
|
uiStore.notifyError(`Impossible de détacher la playlist: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="playlist-binding-panel">
|
||||||
|
<h4 class="panel-title">Synchronisation Playlist</h4>
|
||||||
|
|
||||||
|
<!-- État attaché -->
|
||||||
|
<div v-if="isAttached" class="binding-status attached">
|
||||||
|
<div class="status-info">
|
||||||
|
<Link :size="20" />
|
||||||
|
<div class="status-text">
|
||||||
|
<p class="status-label">Attachée à une playlist</p>
|
||||||
|
<p class="status-details">
|
||||||
|
<span class="detail-label">Serveur:</span>
|
||||||
|
{{ binding?.server_id }}
|
||||||
|
</p>
|
||||||
|
<p class="status-details">
|
||||||
|
<span class="detail-label">Container:</span>
|
||||||
|
{{ binding?.container_id }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" @click="handleDetach">
|
||||||
|
<Unlink :size="16" />
|
||||||
|
Détacher
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- État détaché -->
|
||||||
|
<div v-else class="binding-status detached">
|
||||||
|
<div class="status-info">
|
||||||
|
<Unlink :size="20" />
|
||||||
|
<div class="status-text">
|
||||||
|
<p class="status-label">Non attachée</p>
|
||||||
|
<p class="status-description">
|
||||||
|
La file d'attente n'est pas synchronisée avec une playlist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-section">
|
||||||
|
<p class="help-text">
|
||||||
|
<strong>📌 Playlist Dynamique</strong><br>
|
||||||
|
La queue se synchronisera automatiquement avec la playlist/album du serveur.
|
||||||
|
Les pistes ajoutées ou retirées sur le serveur apparaîtront ici en temps réel.
|
||||||
|
</p>
|
||||||
|
<p class="help-text">
|
||||||
|
<strong>💡 Comment activer ?</strong><br>
|
||||||
|
Naviguez vers un serveur de médias, trouvez une playlist/album,
|
||||||
|
et cliquez sur ⋮ puis "Lire maintenant". Le binding se fera automatiquement !
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.playlist-binding-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info > svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attached .status-info > svg {
|
||||||
|
color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detached .status-info > svg {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-description {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-details {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: var(--spacing-xs) 0 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border-left: 3px solid var(--status-transitioning);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text strong {
|
||||||
|
color: var(--color-text);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text-warning {
|
||||||
|
border-left-color: var(--status-paused);
|
||||||
|
background-color: var(--status-paused-bg);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
139
pmoapp/webapp/src/components/pmocontrol/QueueItem.vue
Normal file
139
pmoapp/webapp/src/components/pmocontrol/QueueItem.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Music, Play } from 'lucide-vue-next'
|
||||||
|
import type { QueueItem } from '@/services/pmocontrol/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
item: QueueItem
|
||||||
|
isCurrent: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [item: QueueItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleClick(item: QueueItem) {
|
||||||
|
console.log('[QueueItem] Click detected on item:', item.index, item.title)
|
||||||
|
emit('click', item)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['queue-item', { current: isCurrent }]" @click="handleClick(item)">
|
||||||
|
<!-- Indicateur piste en cours -->
|
||||||
|
<div class="current-indicator" v-if="isCurrent">
|
||||||
|
<Play :size="16" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Index (1-based pour l'affichage) -->
|
||||||
|
<span class="item-index">{{ item.index + 1 }}</span>
|
||||||
|
|
||||||
|
<!-- Cover miniature -->
|
||||||
|
<div class="item-cover">
|
||||||
|
<img
|
||||||
|
v-if="item.album_art_uri"
|
||||||
|
:src="item.album_art_uri"
|
||||||
|
:alt="item.album || 'Album cover'"
|
||||||
|
class="cover-image"
|
||||||
|
loading="lazy"
|
||||||
|
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||||
|
/>
|
||||||
|
<Music v-else :size="20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Métadonnées -->
|
||||||
|
<div class="item-metadata">
|
||||||
|
<div class="item-title">{{ item.title || 'Sans titre' }}</div>
|
||||||
|
<div class="item-artist">
|
||||||
|
{{ item.artist || 'Artiste inconnu' }}
|
||||||
|
<span v-if="item.album"> • {{ item.album }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.current {
|
||||||
|
background-color: var(--status-playing-bg);
|
||||||
|
border: 1px solid var(--status-playing);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-indicator {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--spacing-xs);
|
||||||
|
color: var(--status-playing);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-index {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.current .item-index {
|
||||||
|
margin-left: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-cover {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-metadata {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.current .item-title {
|
||||||
|
color: var(--status-playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-artist {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
159
pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
Normal file
159
pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch, nextTick, toRef } from 'vue'
|
||||||
|
import { useRenderer } from '@/composables/useRenderers'
|
||||||
|
import QueueItem from './QueueItem.vue'
|
||||||
|
import { Link } from 'lucide-vue-next'
|
||||||
|
import type { QueueItem as QueueItemType } from '@/services/pmocontrol/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rendererId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
clickItem: [item: QueueItemType]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { queue, binding } = useRenderer(toRef(props, 'rendererId'))
|
||||||
|
|
||||||
|
const isAttached = computed(() => !!binding.value)
|
||||||
|
|
||||||
|
const queueContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function handleItemClick(item: QueueItemType) {
|
||||||
|
emit('clickItem', item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll vers la piste courante lors de l'ouverture
|
||||||
|
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||||
|
if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
|
||||||
|
await nextTick()
|
||||||
|
const currentItem = queueContainer.value.querySelector('.queue-item.current')
|
||||||
|
if (currentItem) {
|
||||||
|
currentItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="queue-viewer">
|
||||||
|
<!-- Header avec indication de binding -->
|
||||||
|
<div class="queue-header">
|
||||||
|
<h3 class="queue-title">
|
||||||
|
File d'attente
|
||||||
|
<span class="queue-count" v-if="queue?.items.length">
|
||||||
|
({{ queue.items.length }})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Indicateur playlist attachée -->
|
||||||
|
<div v-if="isAttached" class="binding-indicator">
|
||||||
|
<Link :size="16" />
|
||||||
|
<span class="binding-text">
|
||||||
|
Attachée à une playlist
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste des items -->
|
||||||
|
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
||||||
|
<QueueItem
|
||||||
|
v-for="item in queue.items"
|
||||||
|
:key="item.index"
|
||||||
|
:item="item"
|
||||||
|
:is-current="item.index === queue.current_index"
|
||||||
|
@click="handleItemClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- État vide -->
|
||||||
|
<div v-else class="queue-empty">
|
||||||
|
<p>Aucun élément dans la file d'attente</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.queue-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-count {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background-color: var(--status-playing-bg);
|
||||||
|
color: var(--status-playing);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--status-playing);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-text {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.queue-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
332
pmoapp/webapp/src/components/pmocontrol/RendererCard.vue
Normal file
332
pmoapp/webapp/src/components/pmocontrol/RendererCard.vue
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import type {
|
||||||
|
RendererCapabilitiesSummary,
|
||||||
|
RendererSummary,
|
||||||
|
RendererState,
|
||||||
|
} from '@/services/pmocontrol/types'
|
||||||
|
import StatusBadge from './StatusBadge.vue'
|
||||||
|
import { Music, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
renderer: RendererSummary
|
||||||
|
state: RendererState | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Métadonnées proviennent directement de l'état du renderer (API + SSE)
|
||||||
|
const metadata = computed(() => props.state?.current_track)
|
||||||
|
|
||||||
|
const protocolLabel = computed(() => {
|
||||||
|
switch (props.renderer.protocol) {
|
||||||
|
case 'upnp':
|
||||||
|
return 'UPnP AV'
|
||||||
|
case 'openhome':
|
||||||
|
return 'OpenHome'
|
||||||
|
case 'hybrid':
|
||||||
|
return 'Hybrid (UPnP + OpenHome)'
|
||||||
|
default:
|
||||||
|
return 'Inconnu'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const protocolClass = computed(() => {
|
||||||
|
switch (props.renderer.protocol) {
|
||||||
|
case 'upnp':
|
||||||
|
return 'protocol-upnp'
|
||||||
|
case 'openhome':
|
||||||
|
return 'protocol-openhome'
|
||||||
|
case 'hybrid':
|
||||||
|
return 'protocol-hybrid'
|
||||||
|
default:
|
||||||
|
return 'protocol-unknown'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const capabilityBadges = computed(() => {
|
||||||
|
const caps = props.renderer.capabilities
|
||||||
|
if (!caps) return []
|
||||||
|
const mapping: Array<{ key: keyof RendererCapabilitiesSummary; label: string }> = [
|
||||||
|
{ key: 'has_avtransport', label: 'AVTransport' },
|
||||||
|
{ key: 'has_oh_playlist', label: 'OpenHome' },
|
||||||
|
{ key: 'has_linkplay_http', label: 'Hybrid' },
|
||||||
|
{ key: 'has_oh_volume', label: 'Vol' },
|
||||||
|
{ key: 'has_oh_time', label: 'Time' },
|
||||||
|
{ key: 'has_oh_info', label: 'Info' },
|
||||||
|
]
|
||||||
|
return mapping.filter(({ key }) => caps[key]).map(({ key, label }) => ({ key, label }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||||
|
|
||||||
|
function goToRenderer() {
|
||||||
|
router.push(`/renderer/${props.renderer.id}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['renderer-card', { offline: !renderer.online }]">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h3 class="renderer-name">{{ renderer.friendly_name }}</h3>
|
||||||
|
<p class="renderer-model">{{ renderer.model_name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="badges">
|
||||||
|
<span :class="['protocol-badge', protocolClass]">
|
||||||
|
{{ protocolLabel }}
|
||||||
|
</span>
|
||||||
|
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cover Art -->
|
||||||
|
<div v-if="capabilityBadges.length" class="capabilities">
|
||||||
|
<span v-for="badge in capabilityBadges" :key="badge.key" class="capability-badge">
|
||||||
|
{{ badge.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-cover">
|
||||||
|
<img
|
||||||
|
v-if="hasCover"
|
||||||
|
:src="metadata?.album_art_uri!"
|
||||||
|
:alt="metadata?.album || 'Album cover'"
|
||||||
|
class="cover-image"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div v-else class="cover-placeholder">
|
||||||
|
<Music :size="48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata (current track) -->
|
||||||
|
<div v-if="metadata" class="card-metadata">
|
||||||
|
<p class="track-title">{{ metadata.title || 'Sans titre' }}</p>
|
||||||
|
<p class="track-artist">{{ metadata.artist || 'Artiste inconnu' }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="card-metadata empty">
|
||||||
|
<p class="track-title">Aucun média</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Volume -->
|
||||||
|
<div v-if="state && state.volume !== null" class="card-volume">
|
||||||
|
<VolumeX v-if="state.mute" :size="16" class="volume-icon muted" />
|
||||||
|
<Volume2 v-else :size="16" class="volume-icon" />
|
||||||
|
<div class="volume-bar">
|
||||||
|
<div
|
||||||
|
class="volume-bar-fill"
|
||||||
|
:style="{ width: `${state.mute ? 0 : state.volume}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="volume-value">{{ state.volume }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Button -->
|
||||||
|
<button class="btn btn-primary card-control-btn" @click="goToRenderer">
|
||||||
|
Contrôler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.renderer-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.offline {
|
||||||
|
opacity: 0.6;
|
||||||
|
filter: grayscale(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-name {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-model {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capabilities {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-badge {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-upnp {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-openhome {
|
||||||
|
background-color: rgba(139, 92, 246, 0.1);
|
||||||
|
color: #8b5cf6;
|
||||||
|
border: 1px solid #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-hybrid {
|
||||||
|
background-color: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover */
|
||||||
|
.card-cover {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metadata */
|
||||||
|
.card-metadata {
|
||||||
|
min-height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 var(--spacing-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-metadata.empty .track-title {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume */
|
||||||
|
.card-volume {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-icon {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-icon.muted {
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
transition: width var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-value {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control Button */
|
||||||
|
.card-control-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive grid handled by parent (DashboardView) */
|
||||||
|
</style>
|
||||||
52
pmoapp/webapp/src/components/pmocontrol/StatusBadge.vue
Normal file
52
pmoapp/webapp/src/components/pmocontrol/StatusBadge.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
status: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN' | 'OFFLINE'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function getStatusClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'PLAYING':
|
||||||
|
return 'playing'
|
||||||
|
case 'PAUSED':
|
||||||
|
return 'paused'
|
||||||
|
case 'STOPPED':
|
||||||
|
case 'NO_MEDIA':
|
||||||
|
return 'stopped'
|
||||||
|
case 'TRANSITIONING':
|
||||||
|
return 'transitioning'
|
||||||
|
case 'OFFLINE':
|
||||||
|
return 'offline'
|
||||||
|
default:
|
||||||
|
return 'stopped'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'PLAYING':
|
||||||
|
return 'En lecture'
|
||||||
|
case 'PAUSED':
|
||||||
|
return 'En pause'
|
||||||
|
case 'STOPPED':
|
||||||
|
return 'Arrêté'
|
||||||
|
case 'NO_MEDIA':
|
||||||
|
return 'Aucun média'
|
||||||
|
case 'TRANSITIONING':
|
||||||
|
return 'Transition...'
|
||||||
|
case 'OFFLINE':
|
||||||
|
return 'Hors ligne'
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="['status-badge', getStatusClass(status)]">
|
||||||
|
{{ getStatusLabel(status) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Les styles sont définis globalement dans pmocontrol.css */
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user