Compare commits
179 Commits
b987decb8d
...
push-pwa-m
| Author | SHA1 | Date | |
|---|---|---|---|
| 453f427044 | |||
| 01f49670e9 | |||
| ed9b8046e4 | |||
| f5d9e2590e | |||
| ad45e40900 | |||
| cc27e4efca | |||
| b7285e14c4 | |||
| da4d9008ad | |||
| f757e10734 | |||
| 009545de40 | |||
| bfa6231b1d | |||
| 87bce3edfa | |||
| d49bb604ca | |||
| 429bc0d274 | |||
| d810e99a5b | |||
| 56a9f15cc1 | |||
| 0dab3077cf | |||
| 8179c0e239 | |||
| 319a54ee8a | |||
| 1d3a9379a1 | |||
| a30186485f | |||
| 0d4cf3cdae | |||
| bcfffa6df8 | |||
| e670e8e0d8 | |||
| c7f67e7c7c | |||
| a97bfe6148 | |||
| 7c10b22204 | |||
| 46e269a290 | |||
| 7bce0ffe19 | |||
| ba40d07f73 | |||
| 8bc0670de4 | |||
| 037435af20 | |||
| 2b3fae7778 | |||
| 06de4feb5e | |||
| 9b5a028f3f | |||
| 9ad7d7454d | |||
| 39d7c56a99 | |||
| 82197c8103 | |||
| 8ddee7d94f | |||
| 4b793cec59 | |||
| 9e447023a8 | |||
| 6285c8ccc8 | |||
| 3401ee839c | |||
| e8e33414f0 | |||
| b0e24c3b3c | |||
| b5b6becb25 | |||
| 353e54af76 | |||
| f2be1b9bc1 | |||
| 6e65558ba9 | |||
| 4c7c99f0a4 | |||
| 5c6e2d43ac | |||
| 6fd11ab450 | |||
| 57c2552247 | |||
| 9935a5e5af | |||
| 3979d346a5 | |||
| 6240179397 | |||
| 934bbaf4b8 | |||
| 35e2e037f0 | |||
| 9dee193947 | |||
| c128120697 | |||
| 4b7c482bf3 | |||
| 4c70a939cd | |||
| 77cfb7b3e3 | |||
| abe8c1a4b2 | |||
| 22b3a67417 | |||
| cba1c01b1b | |||
| 8be250b167 | |||
| 78c37e1732 | |||
| 164e3d9e5f | |||
| ac9cb3ef3f | |||
| 66ba31b12f | |||
| bb06fbe30e | |||
| 19451068f1 | |||
| 23b1ce7661 | |||
| 813fc97fa1 | |||
| 1459958978 | |||
| e055133e4f | |||
| e375d447d4 | |||
| fe2d755b4a | |||
| c13de9f46b | |||
| 32e3a18895 | |||
| 76a1c89137 | |||
| fe9dd9aba6 | |||
| 512dcd1803 | |||
| 623ce44774 | |||
| 6b0bfe86e3 | |||
| d5b1ed5635 | |||
| 546e8a782f | |||
| 340c69cb2b | |||
| 8924552696 | |||
| 33b279ff1e | |||
| 6fcecaab00 | |||
| 97a82f4e1d | |||
| e0ef485e04 | |||
| 21ea77eadc | |||
| ea5936717a | |||
| bdaee820ad | |||
| 718c0d2aed | |||
| ff699d220f | |||
| 77d0d73ed7 | |||
| 34260e7cbd | |||
| d81e7a9413 | |||
| 6352a43e27 | |||
| 54ea7b5813 | |||
| 5a9e156566 | |||
| 169e536011 | |||
| 5e3fdd1841 | |||
| bf03c56649 | |||
| 5d081654ef | |||
| 57995e0bdf | |||
| 3a3dc1727f | |||
| 0cb3cbe470 | |||
| 037ddc99ee | |||
| 226297510a | |||
| 2fd4c84e1a | |||
| 494cc3b8e1 | |||
| c0d1e1a3d5 | |||
| 843f474a98 | |||
| ec852c0002 | |||
| 8ca47953d2 | |||
| 0f20a11666 | |||
| 4fc42393d8 | |||
| 832068a61f | |||
| 5299ab9148 | |||
| e47c1f1a86 | |||
| 898db9a354 | |||
| 866bbc3270 | |||
| 36f0ac5122 | |||
| 1969a4080a | |||
| 069e9ee496 | |||
| bded788431 | |||
| 8005968ec5 | |||
| 04793da52c | |||
| 5095dd6964 | |||
| 09cc2dcf94 | |||
| dc062f8a3e | |||
| 0137a4675f | |||
| 402c3399e4 | |||
| f0dd028051 | |||
| 4083799c13 | |||
| 9598bbac4a | |||
| 95e833e7cf | |||
| b08112699e | |||
| 76b7c126c2 | |||
| 1164a11410 | |||
| b690420550 | |||
| 83f814c729 | |||
| 0f0a8d1c91 | |||
| 0cca9229ad | |||
| d1be71ff47 | |||
| 2b0def806a | |||
| f57754b5f8 | |||
| 3cb584646c | |||
| e33484b802 | |||
| 5b8642a70b | |||
| f9cd3063fb | |||
| de74c7b431 | |||
| ca8702fd7f | |||
| f9056f92e0 | |||
| eedb0b6a0e | |||
| 9017b70e90 | |||
| 18b9194530 | |||
| 6fe2f6be95 | |||
| 6f8a80a58a | |||
| 306e691c61 | |||
| 4b3e8a961c | |||
| 97879b4539 | |||
| ad7bdb7c5e | |||
| 4f3cc01e07 | |||
| a502e4fc3e | |||
| 32464c93fb | |||
| b3f1917441 | |||
| 86df772354 | |||
| fbe7291b43 | |||
| 03775f7574 | |||
| 58ec5f96f2 | |||
| cddd2afbd8 | |||
| 7276945b92 | |||
| 1e882ba6c3 |
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[registries]
|
||||
pmo = { index = "sparse+https://gargoton.petite-maison-orange.fr/api/packages/pmo/cargo/" }
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Lire les données JSON envoyées par Claude Code
|
||||
INPUT=$(cat)
|
||||
|
||||
# Extraire le nom de l'outil
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
|
||||
|
||||
# Pour les éditions de fichiers, forcer la demande de confirmation
|
||||
if [[ "$TOOL_NAME" == "Edit" ]] || [[ "$TOOL_NAME" == "MultiEdit" ]] || [[ "$TOOL_NAME" == "Write" ]]; then
|
||||
# Retourner une décision "ask" qui force la confirmation
|
||||
cat << EOF
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "ask",
|
||||
"permissionDecisionReason": "Validation requise pour toute édition de fichier"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Pour les autres outils, laisser passer normalement
|
||||
exit 0
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -16,13 +16,14 @@
|
||||
**/*.mp4
|
||||
**/*.mp3
|
||||
**/*.ogg
|
||||
|
||||
xxx
|
||||
/dcai/
|
||||
**/.pmomusic.yml
|
||||
**/.pmomusic_covers/**
|
||||
**/.pmomusic_audio/**
|
||||
/.pmomusic
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
/target
|
||||
**/target/
|
||||
/.pmomusic_covers
|
||||
@@ -30,6 +31,7 @@ xxx
|
||||
C/src/soxr-0.1.3/Release/tests
|
||||
**/Release/
|
||||
**/Debug/
|
||||
qobuz_debug
|
||||
OLD-GO-CODE/
|
||||
xxx
|
||||
xx
|
||||
@@ -38,7 +40,6 @@ pmo_src.txt
|
||||
upmpdcli/
|
||||
/*.xml
|
||||
test_upnp*.cargo/
|
||||
.cargo/
|
||||
setup-env.sh
|
||||
cache
|
||||
gupnp-tools
|
||||
@@ -46,3 +47,7 @@ pmo*_[0_9]*.txt
|
||||
webapp_[0_9]*.txt
|
||||
RF.json
|
||||
RF_old.json
|
||||
.claude/
|
||||
.claude.old
|
||||
Kilo-session.md
|
||||
pmomusic_logs.txt
|
||||
|
||||
380
.kilo/package-lock.json
generated
Normal file
380
.kilo/package-lock.json
generated
Normal file
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"name": ".kilo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "7.3.41"
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/plugin": {
|
||||
"version": "7.3.41",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.3.41.tgz",
|
||||
"integrity": "sha512-1Ku7BEzxAGtegjf86yuu28swVD/AFjngyjhjqHWjHcPOv57pg0G+kfQz9JInxjeGwGwwrZ/q93aBqMhdibUEvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "7.3.41",
|
||||
"effect": "4.0.0-beta.59",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.6",
|
||||
"@opentui/keymap": ">=0.2.6",
|
||||
"@opentui/solid": ">=0.2.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/keymap": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/sdk": {
|
||||
"version": "7.3.41",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.3.41.tgz",
|
||||
"integrity": "sha512-BVbsjOZTjyPcHGsiwJGRAwWcZyLx28BqdVC/JldZrtVH4f+eKsOXot/d0iYquu+zYUHFarAcF+QZGwOK5jc53Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.59",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz",
|
||||
"integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^6.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^1.11.9",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz",
|
||||
"integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz",
|
||||
"integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
405
.kilo/plans/1775285337131-neon-mountain.md
Normal file
405
.kilo/plans/1775285337131-neon-mountain.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Évaluation du plan : centraliser_base_url_axum_middleware
|
||||
|
||||
## Résumé de l'audit
|
||||
|
||||
Le plan est **bien pensé et cohérent**. Il identifie correctement le problème et la solution. Cependant, j'ai identifié plusieurs points nécessitant des amendements.
|
||||
|
||||
---
|
||||
|
||||
## Points validés (conformes au code actuel)
|
||||
|
||||
1. **Problème bien identifié** : URLs hardcodées avec IP locale (`PMO_SERVER_URL`) retournées au frontend via reverse proxy.
|
||||
|
||||
2. **`get_request_base_url` existe déjà** à `pmoserver/src/lib.rs:199` — pas besoin de la recréer.
|
||||
|
||||
3. **`covers_route_for` existe déjà** dans `pmocache/src/lib.rs:149`.
|
||||
|
||||
4. **`covers_absolute_url_for` utilisée dans les contextes UPnP** :
|
||||
- `pmoupnp/src/cache_registry.rs:57`
|
||||
- `pmoradiofrance/src/metadata_cache.rs:263`
|
||||
- `pmoparadise/src/source.rs:216`
|
||||
- `pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs:91`
|
||||
|
||||
5. **Route audio correcte** : `/audio/tracks/{pk}` (pas `/audio/flac/{pk}`).
|
||||
|
||||
6. **Architecture du Server** : Les routes sont construites dynamiquement via `Arc<RwLock<Router>>`. Le layer devra être ajouté dans la construction du router, pas après.
|
||||
|
||||
---
|
||||
|
||||
## Points à amender
|
||||
|
||||
### 1. Ajout du layer dans le Server
|
||||
|
||||
Le plan suggère d'ajouter le layer "dans `server.rs`" mais la structure du router est complexe :
|
||||
- Les routes sont dynamiques (`RwLock<Router>`)
|
||||
- Le router final est un fallback qui délègue
|
||||
|
||||
**Correction** : Ajouter le layer directement lors de la création du `registry_route` initial (ligne 120-122) :
|
||||
|
||||
```rust
|
||||
let registry_route = Router::new()
|
||||
.route("/api/registry", get(get_api_registry))
|
||||
.with_state(api_registry.clone())
|
||||
.layer(base_url_layer()); // ← ici
|
||||
```
|
||||
|
||||
### 2. Comportement requis pour LAN vs WAN
|
||||
|
||||
Le middleware doit supporter les deux cas d'usage :
|
||||
|
||||
- **LAN (sans reverse proxy)** : Pas de headers `X-Forwarded-*` → utiliser l'adresse IP locale du serveur (`PMO_SERVER_URL`)
|
||||
- **WAN (via reverse proxy)** : Headers `X-Forwarded-*` présents → utiliser l'URL publique du reverse proxy
|
||||
|
||||
**Important** : `get_request_base_url` dans `pmoserver/src/lib.rs:199` lit déjà ces headers. Le fallback doit être `PMO_SERVER_URL` qui est configuré au démarrage avec l'IP locale.
|
||||
|
||||
### 3. Chemin du middleware dans la pile
|
||||
|
||||
Le plan dit d'appliquer le layer "avant" les autres. En réalité, Tower/Acorn applique les couches dans l'ordre où elles sont ajoutées — le premier layer ajouté est le plus extérieur (exécuté en premier). Le `base_url_layer` doit donc être ajouté en **premier** (le plus intérieur) pour voir les headers nettoyés.
|
||||
|
||||
### 3. Les handlers n'ont PAS besoin de BaseUrl
|
||||
|
||||
Après analyse, **aucun handler** dans le codebase actuel n'appelle `covers_absolute_url_for()` directement pour le frontend. Les `album_art_uri` sont :
|
||||
- Soit **propagés** depuis les réponses UPnP des media servers (pas des URLs pmomusic)
|
||||
- Soit **construits en tâche de fond** dans les caches (RadioFrance, RadioParadise)
|
||||
|
||||
**Correction** : Le plan surestime le nombre de handlers à modifier. La vraie question est : d'où viennent les URLs incorrectes ?
|
||||
|
||||
### 4. Source du problème à clarifier
|
||||
|
||||
Les URLs incorrectes ne viennent pas des handlers REST classiques. Elles viennent probablement de :
|
||||
|
||||
**a) Tâches de fond** (background tasks) qui stockent des URLs complètes :
|
||||
- `pmoradiofrance/src/metadata_cache.rs:263` — construit `covers_absolute_url_for()` dans le cache
|
||||
- `pmoparadise/src/source.rs:216` — même problème
|
||||
|
||||
**b) API Qobuz** (`pmoqobuz/src/api_rest.rs:302`) — utilise `covers_route_for` (route relative, OK)
|
||||
|
||||
**c) Playlist** (`pmoplaylist/src/handle/read.rs:204,271,355`) — utilise `covers_route_for` (OK)
|
||||
|
||||
### 5. Correction du fallback
|
||||
|
||||
Le plan suggère `localhost:8080` ou `0.0.0.0:8080` comme fallback. Le port doit provenir de la configuration du serveur (`get_server_base_url()` existe déjà dans `pmoserver/src/lib.rs`).
|
||||
|
||||
**Correction** : Le fallback utilise `get_server_base_url()` (disponible via `GLOBAL_SERVER`) :
|
||||
- En LAN : pas de `X-Forwarded-*` → `get_server_base_url()` → URLs en IP locale
|
||||
- En WAN : `X-Forwarded-*` présents → URLs en URL publique du reverse proxy
|
||||
|
||||
### 6. Fonction `audio_route_for` pas nécessaire maintenant
|
||||
|
||||
Le plan propose d'ajouter `audio_route_for` dans `pmoaudiocache`. Mais :
|
||||
- Les fichiers audio sont servis par `pmoaudiocache` lui-même (routes internes)
|
||||
-Aucune URL audio n'est retournée au frontend via JSON
|
||||
|
||||
**Supprimer** cette étape du plan.
|
||||
|
||||
---
|
||||
|
||||
## Plan amendé
|
||||
|
||||
### Étape 0 — Audit spécifique (à faire avant implémentation)
|
||||
|
||||
```bash
|
||||
# Trouver les constructions d'URLs dans les tâches de fond (caches, sources)
|
||||
grep -rn "covers_absolute_url_for\|PMO_SERVER_URL" --include="*.rs" | grep -v "pmocontrol\|pmoplaylist\|pmoqobuz"
|
||||
|
||||
# Vérifier les URLs dans les réponses JSON des handlers
|
||||
grep -rn "album_art_uri" --include="*.rs" | grep -E "fn |->"
|
||||
```
|
||||
|
||||
Identifier spécifiquement quels endpoints REST retournent des URLs au frontend.
|
||||
|
||||
### Étape 1 — `pmoserver/src/lib.rs` : Ajouter `BaseUrl` + middleware
|
||||
|
||||
```rust
|
||||
use axum::{extract::Request, middleware::Next, response::Response};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BaseUrl(pub String);
|
||||
|
||||
impl BaseUrl {
|
||||
pub fn url_for(&self, route: &str) -> String {
|
||||
debug_assert!(route.starts_with('/'), "route must start with '/'");
|
||||
format!("{}{}", self.0.trim_end_matches('/'), route)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn base_url_middleware(mut request: Request, next: Next) -> Response {
|
||||
// Priorité : 1) X-Forwarded-* (reverse proxy), 2) get_server_base_url() (adresse configurée)
|
||||
let base = get_request_base_url(request.headers())
|
||||
.or_else(|| get_server_base_url())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"BaseUrl: impossible de déterminer l'URL de base.\n\
|
||||
Configurer PMO_SERVER_URL ou démarrer le serveur avant les handlers HTTP."
|
||||
);
|
||||
});
|
||||
tracing::debug!("BaseUrl calculée : {}", base);
|
||||
request.extensions_mut().insert(BaseUrl(base));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub fn base_url_layer() -> axum::middleware::FromFnLayer {
|
||||
axum::middleware::from_fn(base_url_middleware)
|
||||
}
|
||||
```
|
||||
|
||||
**Comportement** :
|
||||
- Accès LAN (pas de proxy) : `get_server_base_url()` → URLs en IP locale configurée
|
||||
- Accès WAN (reverse proxy) : `X-Forwarded-*` → URLs en URL publique
|
||||
|
||||
**Note** : Si ni les headers ni le serveur ne sont disponibles, le middleware panic (fail-fast) car c'est une erreur de configuration.
|
||||
|
||||
### Étape 2 — `pmoserver/src/server.rs` : Appliquer le layer
|
||||
|
||||
Dans `Server::new()`, ligne ~120-122 :
|
||||
|
||||
```rust
|
||||
let registry_route = Router::new()
|
||||
.route("/api/registry", get(get_api_registry))
|
||||
.with_state(api_registry.clone())
|
||||
.layer(base_url_layer()); // ← Ajouter ici (couche la plus intérieure)
|
||||
```
|
||||
|
||||
### Étape 3 — `pmocache/src/lib.rs` : Renommer sans déprecation
|
||||
|
||||
```rust
|
||||
// Rename direct - pas de déprecation (soft en cours de dev, pas une library)
|
||||
pub fn covers_absolute_url_for_upnp(pk: &str, param: Option<&str>) -> String {
|
||||
// PMO_SERVER_URL contient l'IP locale (LAN) - utilisé uniquement pour UPnP
|
||||
// Fallback sur get_server_base_url() si dispo, sinon erreur
|
||||
let base = std::env::var("PMO_SERVER_URL")
|
||||
.or_else(|_| pmoserver::get_server_base_url().ok_or("PMO_SERVER_URL not set"))
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("covers_absolute_url_for_upnp: {}", e);
|
||||
panic!("BaseUrl non disponible pour UPnP");
|
||||
});
|
||||
format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param))
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 4 — Mettre à jour les appels UPnP
|
||||
|
||||
```bash
|
||||
grep -rn "covers_absolute_url_for" --include="*.rs"
|
||||
```
|
||||
|
||||
Modifier `pmoupnp/src/cache_registry.rs:57` → `covers_absolute_url_for_upnp`
|
||||
|
||||
### Étape 5 — Tâches de fond : stocker la route, pas l'URL
|
||||
|
||||
**pmoradiofrance/src/metadata_cache.rs:263** :
|
||||
```rust
|
||||
// Avant :
|
||||
let public_url = pmocache::covers_absolute_url_for(&pk, None);
|
||||
|
||||
// Après : stocker la route relative
|
||||
let album_art_route = pmocache::covers_route_for(&pk, None);
|
||||
```
|
||||
|
||||
Le handler REST qui retourne ces métadonnées devra extraire `Extension<BaseUrl>` et appliquer `base_url.url_for()`.
|
||||
|
||||
**pmoparadise/src/source.rs:216** : Même traitement.
|
||||
|
||||
### Étape 6 — Vérification et tests
|
||||
|
||||
```bash
|
||||
# Plus d'appels à covers_absolute_url_for dans les contextes HTTP
|
||||
grep -rn "covers_absolute_url_for" --include="*.rs" | grep -v "pmocache\|pmoupnp"
|
||||
|
||||
# Tests du middleware
|
||||
cargo test base_url
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Questions en suspens
|
||||
|
||||
1. **Fallback avec panic** : Si ni les headers ni le serveur ne sont disponibles, le middleware panic au démarrage avec un message clair (ex: "BaseUrl: configurer PMO_SERVER_URL ou démarrer le serveur avant les handlers HTTP").
|
||||
→ **Décision utilisateur** : OK, panic avec message clair.
|
||||
|
||||
2. **Reverse proxy avec Authelia** : NPM ajoutera les headers `X-Forwarded-*`. Authelia gère l'authentification separately. Pas de vérification de header supplémentaire nécessaire pour le middleware BaseUrl.
|
||||
→ **Décision** : Pas de vérification supplémentaire.
|
||||
|
||||
---
|
||||
|
||||
## Problème complémentaire : URLs de covers des media servers externes
|
||||
|
||||
### Contexte
|
||||
|
||||
Quand le control point accède à un media server externe sur le LAN (autre que pmomusic), les URLs d'articles (`album_art_uri`) retournées par ce media server externe contiennent des IPs locales du LAN externe (ex: `http://192.168.1.100:8080/covers/...`).
|
||||
|
||||
Ces URLs ne passent pas par notre système de caching et ne peuvent pas être rewritées par le middleware `BaseUrl` car elles sont :
|
||||
1. Recues depuis le réseau UPnP (pas via HTTP)
|
||||
2. Propagées directement dans les réponses REST/SSE sans transformation
|
||||
|
||||
### Solution proposée : Proxy de covers avec cache
|
||||
|
||||
Créer un nouveau endpoint HTTP qui agit comme un proxy transparent :
|
||||
1. **Détection** : Si l'URL demandée est une URL LAN externe (pas une URL locale de pmomusic)
|
||||
2. **Caching** : Utiliser `cache.add_from_url()` qui gère déjà la déduplication (pas de double-cache)
|
||||
3. **Rewriting** : Retourner l'URL locale du cache (`/covers/image/{pk}`)
|
||||
|
||||
**Note importante** : `pmocache::add_from_url()` gère déjà :
|
||||
- La vérification si l'URL est déjà en cache (ligne 673-683)
|
||||
- Le calcul du pk basé sur le contenu (pas sur l'URL)
|
||||
- La déduplication automatique pour les mêmes contenus
|
||||
|
||||
### Implémentation
|
||||
|
||||
**Nouvel endpoint dans `pmocovers/src/lib.rs` ou nouveau fichier `pmocovers/src/proxy.rs`** :
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CoverProxyParams {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CoverProxyResponse {
|
||||
cached_url: String,
|
||||
pk: String,
|
||||
}
|
||||
|
||||
/// GET /covers/proxy?url=<encoded_url>
|
||||
/// Proxy transparent qui :
|
||||
/// 1. Détecte si l'URL est une URL LAN externe (pas déjà locale)
|
||||
/// 2. Ajoute à cache via add_from_url (déduplication automatique)
|
||||
/// 3. Retourne l'URL locale du cache
|
||||
pub async fn cover_proxy_handler(
|
||||
Query(params): Query<CoverProxyParams>,
|
||||
State(cache): State<CoverCache>,
|
||||
Extension(base_url): Extension<pmoserver::BaseUrl>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let external_url = ¶ms.url;
|
||||
|
||||
// Ignorer si déjà une URL locale (ne pas se cacher soi-même)
|
||||
if is_local_cover_url(external_url, &base_url) {
|
||||
return Err((StatusCode::BAD_REQUEST, "URL is already a local cover"));
|
||||
}
|
||||
|
||||
// Vérifier si c'est une URL LAN à proxyfier
|
||||
if !should_proxy_url(external_url) {
|
||||
return Err((StatusCode::BAD_REQUEST, "URL is not a LAN URL requiring proxy"));
|
||||
}
|
||||
|
||||
// Ajouter au cache (add_from_url gère la déduplication)
|
||||
let pk = cache.add_from_url(external_url, Some("external-covers"))
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
// Retourner l'URL locale
|
||||
let local_url = base_url.url_for(&pmocache::covers_route_for(&pk, None));
|
||||
Ok(Json(CoverProxyResponse { cached_url: local_url, pk }))
|
||||
}
|
||||
|
||||
/// Vérifie si l'URL est déjà une cover locale de NOTRE instance pmomusic
|
||||
/// Note: Les covers d'autres instances pmomusic sur le LAN DEVRAIENT être proxyfiées
|
||||
/// et mises en cache localement - c'est le comportement desired!
|
||||
fn is_local_cover_url(url: &str, base_url: &pmoserver::BaseUrl) -> bool {
|
||||
// Only skip if it's OUR instance's base URL
|
||||
// Covers from other pmomusic instances on LAN should be proxied and cached
|
||||
url.starts_with(&base_url.0)
|
||||
}
|
||||
|
||||
/// Vérifie si l'URL doit être proxyfiée (URL LAN externe)
|
||||
fn should_proxy_url(url: &str) -> bool {
|
||||
if let Ok(parsed) = url::Url::parse(url) {
|
||||
if let Some(host) = parsed.host_str() {
|
||||
// Proxy uniquement les URLs LAN (pas les URLs publiques)
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return ip.is_private() || ip.is_loopback();
|
||||
}
|
||||
// aussi les .local
|
||||
return host.ends_with(".local") || host == "localhost";
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- Utiliser `add_from_url()` pour bénéficier de la déduplication automatique
|
||||
- Vérifier `is_local_cover_url()` avec uniquement la comparaison de base_url pour éviter que notre instance ne se cache elle-même
|
||||
- Les covers d'autres instances pmomusic sur le LAN DEVRAIENT être proxyfiées (comportement souhaité!)
|
||||
- Le TTL sera celui par défaut du cache (configurable)
|
||||
|
||||
**Mise à jour des handlers REST** :
|
||||
|
||||
Dans `pmocontrol/src/pmoserver_ext.rs` et `pmocontrol/src/sse.rs`, transformer les `album_art_uri` LAN :
|
||||
|
||||
```rust
|
||||
fn transform_external_cover_url(url: &str) -> String {
|
||||
if is_lan_url(url) {
|
||||
// Remplacer par l'URL du proxy
|
||||
let encoded = urlencoding::encode(url);
|
||||
return format!("/covers/proxy?url={}", encoded);
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
```
|
||||
|
||||
**Appels dans les handlers** :
|
||||
|
||||
- `pmocontrol/src/pmoserver_ext.rs:2173` : `browse_container` → transformer `album_art_uri`
|
||||
- `pmocontrol/src/pmoserver_ext.rs:2424` : autre endpoint → même transformation
|
||||
- `pmocontrol/src/sse.rs:213` : `MetadataChanged` events → même transformation
|
||||
|
||||
### TTL
|
||||
|
||||
- Le TTL sera celui par défaut du cache `pmocovers`
|
||||
- C'est configurable via `pmoconfig` si besoin
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Limiter aux URLs LAN uniquement (`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`, `localhost`)
|
||||
- Vérifier que l'URL n'est pas déjà une cover locale de pmomusic (éviter le cacheception)
|
||||
- Ajouter un rate limiting pour éviter le flood de téléchargement
|
||||
- Timeout de téléchargement : 10 secondes max
|
||||
|
||||
### Résumé des fichiers à modifier
|
||||
|
||||
1. **Nouveau** : `pmocovers/src/proxy.rs` - Endpoint de proxy
|
||||
2. **Modifier** : `pmocontrol/src/pmoserver_ext.rs` - Transformer les album_art_uri
|
||||
3. **Modifier** : `pmocontrol/src/sse.rs` - Transformer les album_art_uri dans les événements
|
||||
|
||||
---
|
||||
|
||||
## Plan: Passer le SSE en mode Async
|
||||
|
||||
### Contexte
|
||||
|
||||
Le SSE de PMO Control est **déjà async** (fonctions `pub async fn`), mais le traitement des événements utilise des fonctions **synchrones** (`fn renderer_event_to_payload` → `transform_cover_url_sync`). Cela nécessite des workarounds (threads avec runtime tokio séparés).
|
||||
|
||||
### Problèmes actuels
|
||||
|
||||
1. **Nested runtime**: `std::thread::spawn` avec `tokio::runtime::Runtime::new()` dans chaque appel
|
||||
2. **Performance dégradée**: Création d'un thread par URL de cover
|
||||
3. **Code complexe**: Workarounds pour exécuter de l'async dans du sync
|
||||
|
||||
### Solution
|
||||
|
||||
Rendre le traitement des événements **entièrement async** :
|
||||
|
||||
1. **Modifier** `renderer_event_to_payload` → `async fn renderer_event_to_payload`
|
||||
2. **Modifier** `transform_cover_url_sync` → `transform_cover_url` (async) avec `.await` direct
|
||||
3. **Supprimer** le workaround `proxy_cover_url_sync` dans `pmocovers` (quand les .await fonctionnent)
|
||||
|
||||
### Avantages attendus
|
||||
|
||||
1. **Fluidité accrue**: Pas de thread par cover,真正的 async/await
|
||||
2. **Meilleure réactivité**: Pas de blocking sur les événements SSE
|
||||
3. **Code plus propre**: Plus de workarounds, plus de runtime imbriqué
|
||||
4. **Meilleure scalabilité**: Plus de création de thread
|
||||
|
||||
### Fichiers à modifier
|
||||
|
||||
1. `pmocontrol/src/sse.rs`:
|
||||
- `renderer_event_to_payload` → `async fn`
|
||||
- `media_server_event_to_payload` → `async fn`
|
||||
- `all_events_sse`: utiliser les versions async
|
||||
|
||||
2. `pmocontrol/src/pmoserver_ext.rs`:
|
||||
- Utiliser `transform_cover_url` (async) avec `.await` au lieu de `transform_cover_url_sync`
|
||||
|
||||
3. `pmocontrol/src/control_point.rs` (si nécessaire):
|
||||
- Adapter les appels aux fonctions async
|
||||
151
.kilo/plans/1775302116634-sunny-nebula.md
Normal file
151
.kilo/plans/1775302116634-sunny-nebula.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Plan: pmowebrenderer - Améliorations et Multi-client avec DSP
|
||||
|
||||
## Objectifs
|
||||
|
||||
1. **Améliorer l'intégration UPnP Control** - Meilleur fonctionnement des commandes Play/Pause/Seek
|
||||
- Contrôle piloté près de la sortie (streaming) plutôt qu'au début du pipeline
|
||||
- Pour Pause: latence actuelle trop importante
|
||||
- Play/Pause/Seek doivent fonctionner simultanément sur tous les clients
|
||||
|
||||
2. **Améliorer la performance - Latence** - Réduire le délai entre l'envoi et la lecture
|
||||
|
||||
3. **Ajouter le support multi-client avec DSP** - Chaque client peut avoir son propre pipeline DSP
|
||||
|
||||
## Comportement UPnP Control
|
||||
|
||||
### Mode Radio (flux infini)
|
||||
- Pas de pause possible, hanya next ou stop
|
||||
- Seek n'a pas de sens
|
||||
|
||||
|
||||
### Architecture
|
||||
```
|
||||
PlayerSource → ResamplingNode → ToI24Node
|
||||
├──→ [DSP Client 1] → StreamingOggFlacSink 1
|
||||
├──→ [DSP Client 2] → StreamingOggFlacSink 2
|
||||
└──→ ... (dynamique)
|
||||
```
|
||||
|
||||
Le control point UPnP voit UN seul Media Renderer. Les commandes Play/Pause/Seek affectent TOUTES les sorties client simultanément.
|
||||
|
||||
## État Actuel
|
||||
|
||||
Le pipeline actuel est linéaire pour un seul client:
|
||||
```
|
||||
PlayerSource → ResamplingNode (96kHz) → ToI24Node → StreamingOggFlacSink
|
||||
```
|
||||
|
||||
**Note importante:** Utiliser les crates pmoaudio et pmoaudio-ext existantes. Il est possible d'avoir plusieurs `StreamingOggFlacSink` consommant le même flux. Après ToI24Node, brancher en étoiles les différents DSP pour les différents clients.
|
||||
|
||||
## Plan d'Implémentation
|
||||
|
||||
### Phase 1: Amélioration UPnP Control
|
||||
|
||||
1. **Analyser les handlers existants** dans `handlers.rs`
|
||||
2. **Identifier les problèmes** avec Play/Pause/Seek:
|
||||
- Timing des transitions d'état
|
||||
- Gestion des erreurs
|
||||
- Synchronisation entre clients HTTP et état UPnP
|
||||
3. **Améliorer la fiabilité** des commandes
|
||||
- Piloter le contrôle près de la sortie (streaming)
|
||||
- Différerencier le comportement radio vs piste finie
|
||||
|
||||
### Phase 2: Amélioration Latence
|
||||
|
||||
1. **Réduire le buffer** dans `StreamingOggFlacSink`
|
||||
2. **Optimiser le pacing** (actuellement max 0.5s ahead)
|
||||
3. **Améliorer la directité** du chemin audio
|
||||
|
||||
### Phase 3: Architecture Multi-client avec DSP
|
||||
|
||||
1. **Refactorer le pipeline** pour supporter plusieurs clients comme décrit ci-dessus
|
||||
|
||||
2. **Créer un système de DSP** dans pmoaudio ou pmoaudio-ext:
|
||||
- Interface commune pour les effets audio
|
||||
- Config des DSP via PMOconfig
|
||||
- Room correction: equalizer, FIR filter, delay, gain
|
||||
|
||||
3. **Gérer le cycle de vie**:
|
||||
- Création du pipeline par client
|
||||
- Nettoyage lors de la déconnexion
|
||||
- Partage de la source commune entre clients
|
||||
|
||||
## Fichiers à Modifier
|
||||
|
||||
- `pipeline.rs` - Refactoring pour multi-client
|
||||
- `handlers.rs` - Amélioration UPnP control
|
||||
- `stream.rs` - Gestion multi-client
|
||||
- `state.rs` - État par client
|
||||
- pmoaudio ou pmoaudio-ext pour les mécanismes DSP
|
||||
|
||||
## Défis Potentiels
|
||||
|
||||
- Performance CPU avec plusieurs clients
|
||||
- Synchronisation des clients avec le même contenu
|
||||
- Gestion du gapless entre les pistes avec multi-client
|
||||
|
||||
### Gestion de la Pause
|
||||
|
||||
**Option recommandée: Silence (zéros)**
|
||||
- Pendant la pause, continuer à envoyer des zéros encodés en FLAC
|
||||
- Le client HTTP maintient sa connexion TCP alive
|
||||
- Pas de reconnexion nécessaire quand on reprend la lecture
|
||||
- Avantage: Seamless pour le client
|
||||
|
||||
**Pourquoi pas réduction du sample rate:**
|
||||
- Le header FLAC définit le sample rate en固定entête
|
||||
- Changer le sample rate en cours de flux invalidate le flux entier
|
||||
- Rebuild du flux serait plus complexe que le gain obtenu
|
||||
- FLAC compresse très bien les zéros de toute façon (beaucoup de répétitions)
|
||||
|
||||
**Autre option envisagée mais non recommandée:**
|
||||
- Suspendre l'envoi: Le client HTTP va timeout et se déconnecter
|
||||
- Segment OGG avec metadata: Complexe à implémenter, nécessite modification du client
|
||||
|
||||
### Phase 0: StreamingOggFlacSink avec contrôle Pause
|
||||
|
||||
**Distinction Radio vs Pistes finies:**
|
||||
|
||||
| Mode | Comportement pendant Pause |
|
||||
|------|---------------------------|
|
||||
| **Radio (flux infini)** | Les chunks qui arrivent sont ignorés/perdus. On envoie du silence. La source continue à produire mais on n'en tient pas compte. |
|
||||
| **Pistes finies** | On bloque la consommation des chunks. Par backpressure, le pipeline en amont s'arrête (TimerBufferNode arrête d'envoyer). La lecture est truly arrêtée. |
|
||||
|
||||
**Architecture actuelle analysée:**
|
||||
```
|
||||
AudioSegment → StreamingOggFlacSink → FLAC encoder → OGG wrapper → timed_broadcast → clients
|
||||
```
|
||||
|
||||
**Implémentation suggérée:**
|
||||
|
||||
1. **État de lecture distingué:**
|
||||
- `PlaybackMode::Radio` - ignore les chunks entrants pendant pause
|
||||
- `PlaybackMode::Track` - bloque la consommation (backpressure)
|
||||
|
||||
2. **Dans SharedSinkContext:**
|
||||
```rust
|
||||
pub enum PlaybackMode {
|
||||
Radio, // Flux infini - ignore chunks pendant pause
|
||||
Track, // Piste finie - block par backpressure
|
||||
}
|
||||
|
||||
pub playback_mode: PlaybackMode,
|
||||
pub is_paused: Arc<AtomicBool>,
|
||||
```
|
||||
|
||||
3. **Traitement différent selon le mode:**
|
||||
- **Radio**: Si `is_paused`, envoyer silence (zéros) mais perdre les chunks entrants
|
||||
- **Track**: Si `is_paused`, ne pas consommer les chunks → backpressure → arrêt du pipeline en amont
|
||||
|
||||
4. **Transition automatique:**
|
||||
- Détecter le type de contenu via les métadonnées du TrackBoundary
|
||||
- **Enrichir TrackBoundary** avec un champ `stream_type`:
|
||||
```rust
|
||||
pub enum StreamType {
|
||||
Continuous, // Radio/webcast - flux infini
|
||||
Finite, // Piste/album - flux avec fin définie
|
||||
}
|
||||
|
||||
pub stream_type: StreamType,
|
||||
```
|
||||
- Si durée inconnue = Radio (Continuous), si durée connue = Track (Finite)
|
||||
58
.kilo/plans/1775382570642-kind-falcon.md
Normal file
58
.kilo/plans/1775382570642-kind-falcon.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Refonte de `pmowebrenderer` – Élimination des redondances
|
||||
|
||||
## Objectif
|
||||
Réduire la duplication de code entre les fonctions de construction de services UPnP (`build_avtransport`, `build_renderingcontrol`, `build_connectionmanager`) et les macros d’ajout d’arguments (`add_arg_in!`, `add_arg_out!`).
|
||||
Cela améliore la maintenabilité, la lisibilité et diminue le risque d’incohérences.
|
||||
|
||||
## Étapes détaillées
|
||||
|
||||
1. **Création d’une fonction générique `build_service`**
|
||||
- Signature proposée:
|
||||
```rust
|
||||
fn build_service(
|
||||
name: &str,
|
||||
variables: Vec<Arc<Variable>>,
|
||||
actions: Vec<Action>,
|
||||
handlers: Vec<Handler>,
|
||||
) -> Result<Service, FactoryError>
|
||||
```
|
||||
- Implémentation unique de l’ajout de variables, d’actions et de handlers.
|
||||
- Chaque fonction existante (`build_avtransport`, `build_renderingcontrol`, `build_connectionmanager`) appelle `build_service` avec les paramètres spécifiques.
|
||||
|
||||
2. **Refactorisation des macros**
|
||||
- Remplacer `macro_rules! add_arg_in!` et `add_arg_out!` par des fonctions如此一来 :
|
||||
```rust
|
||||
fn add_arg_in(action: &mut Action, name: &str, var: Arc<Argument>) -> Result<(), FactoryError>
|
||||
fn add_arg_out(action: &mut Action, name: &str, var: Arc<Argument>) -> Result<(), FactoryError>
|
||||
```
|
||||
- Ces fonctions encapsulent la logique d’ajout d’arguments et centralisent la gestion d’erreur.
|
||||
|
||||
3. **Mise à jour des implémentations**
|
||||
- Modifier `build_avtransport`, `build_renderingcontrol`, `build_connectionmanager` pour déléguer à `build_service` et aux nouvelles fonctions d’argument.
|
||||
- Vérifier que les imports restent cohérents (ajouter `use` nécessaires pour `Variable`, `Handler`, etc.).
|
||||
|
||||
4. **Suppression des ancrés macros**
|
||||
- Retirer les declarations `macro_rules! add_arg_in!` et `macro_rules! add_arg_out!` du fichier `renderer.rs`.
|
||||
- Adapter le code appelant pour utiliser les fonctions concrètes.
|
||||
|
||||
5. **Tests et CI**
|
||||
- Ajouter des tests unitaires couvrant les nouvelles fonctions `build_service`, `add_arg_in`, `add_arg_out`.
|
||||
- Configurer le pipeline CI pour exécuter `cargo test` et `cargo clippy` afin de détecter d’éventuelles regressions.
|
||||
|
||||
6. **Documentation**
|
||||
- Mettre à jour les commentaires pour refléter les nouvelles abstractions.
|
||||
- Ajouter une section « Refactorisation » dans le `README` décrivant les changements.
|
||||
|
||||
## Impact attendu
|
||||
- **Réduction** : ~12 lignes de code redondantes éliminées.
|
||||
- **Maintenabilité** : modification centralisée de la logique de construction de services.
|
||||
- **Robustesse** : baisse du risque d’incohérences et de bugs liés à la duplication.
|
||||
- **Lisibilité** : code plus explicite et plus proche du modèle de domaine.
|
||||
|
||||
## Prochaines actions
|
||||
1. Implémenter les changements proposés dans les fichiers concernés.
|
||||
2. Exécuter la suite de tests pour valider la refonte.
|
||||
3. Commiter les modifications après revue.
|
||||
|
||||
---
|
||||
Plan finalisé.
|
||||
108
.kilo/plans/1775386308232-quick-orchid.md
Normal file
108
.kilo/plans/1775386308232-quick-orchid.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Plan: Suppression des duplications dans `@pmowebrenderer`
|
||||
|
||||
## Objectif
|
||||
Éliminer les redondances de code identifiées lors de l'audit.
|
||||
|
||||
---
|
||||
|
||||
## Duplication 1: Helper functions dupliquées dans `renderer.rs`
|
||||
|
||||
**Fichiers affectés**: `src/renderer.rs`
|
||||
|
||||
**Problème**:
|
||||
- Lignes 17-54: `add_arg_in`, `add_arg_out`, `add_var`, `add_action` définies
|
||||
- Lignes 150-181: Dans `build_avtransport()`, réimplémentation locale avec closures `|svc, var| { ... }`
|
||||
- Répétition de 20+ appels `add_var(&mut svc, &VAR)?` et `add_action(&mut svc, Arc::new(action))?`
|
||||
|
||||
**Solution**:
|
||||
1. Supprimer les closures locales redéclarées (lignes 150-181)
|
||||
2. Utiliser directement les fonctions helpers du haut du fichier
|
||||
3. Créer une macro ou fonction utilitaire pour les appels répétés:
|
||||
```rust
|
||||
macro_rules! add_vars {
|
||||
($svc:expr, $($var:expr),*) => { $({ add_var($svc, &$var)?; })* };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Duplication 2: Pattern handlers boilerplate dans `handlers.rs`
|
||||
|
||||
**Fichiers affectés**: `src/handlers.rs`
|
||||
|
||||
**Problème**:
|
||||
- `play_handler`, `stop_handler`, `pause_handler` (lignes 23-71): structure identique
|
||||
- `next_handler`, `previous_handler` (lignes 74-95): clones
|
||||
- Handlers GET (lignes 166-317): pattern `state.clone()` + `Box::pin(async move { ... set!() ... })` dupliqué
|
||||
|
||||
**Solution**:
|
||||
1. Créer un helper générique:
|
||||
```rust
|
||||
fn make_state_handler<F>(state: SharedState, f: F) -> ActionHandler
|
||||
where F: FnOnce(&mut ActionData, &RendererState) -> Result<ActionData, ActionError> + Send + 'static
|
||||
```
|
||||
2. Factoriser les closures `let state = state.clone()` dans chaque handler
|
||||
|
||||
---
|
||||
|
||||
## Duplication 3: Extraction metadata dupliquée
|
||||
|
||||
**Fichiers affectés**: `src/handlers.rs`
|
||||
|
||||
**Problème**:
|
||||
- Lignes 118-123: `set_uri_handler` extraction metadata
|
||||
- Lignes 146-151: `set_next_uri_handler` extraction metadata (identique)
|
||||
|
||||
**Solution**:
|
||||
1. Extraire en fonction utilitaire:
|
||||
```rust
|
||||
fn extract_metadata(data: &ActionData, key: &str) -> String { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Duplication 4: Méthodes pipeline dans `registry.rs`
|
||||
|
||||
**Fichiers affectés**: `src/registry.rs`
|
||||
|
||||
**Problème**:
|
||||
- `send_pipeline_command` (lignes 272-279) appelle `get_pipeline` (lignes 281-283)
|
||||
- `load_uri` (lignes 286-290), `send_play_command` (lignes 293-296), `send_pause_command` (lignes 299-301) sont des wrappers quasi-identiques
|
||||
|
||||
**Solution**:
|
||||
Consolider en méthodes génériques:
|
||||
```rust
|
||||
pub async fn send_command(&self, instance_id: &str, cmd: PipelineControl) {
|
||||
if let Some(pipeline) = self.get_pipeline(instance_id) {
|
||||
pipeline.send(cmd).await;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Duplication 5: Feature flags avec code dupliqué
|
||||
|
||||
**Fichiers affectés**: `src/registry.rs`
|
||||
|
||||
**Problème**:
|
||||
- Lignes 51-68 et 334-395: double impl de `create_instance` selon feature
|
||||
|
||||
**Solution**:
|
||||
- Extraire la logique commune dans une fonction privée
|
||||
- Utiliser `#[cfg]` seulement pour les différences (appel à pmoserver)
|
||||
|
||||
---
|
||||
|
||||
## Ordre de traitement suggéré
|
||||
|
||||
1. **Phase 1**: Helpers dans `renderer.rs` (les plus simples)
|
||||
2. **Phase 2**: Handlers dans `handlers.rs` (plus complexe, nécessite macro)
|
||||
3. **Phase 3**: Méthodes pipeline dans `registry.rs`
|
||||
4. **Phase 4**: Feature flags
|
||||
|
||||
## Vérification
|
||||
Après chaque phase, exécuter:
|
||||
```bash
|
||||
cargo check --package pmowebrenderer
|
||||
```
|
||||
225
.kilo/plans/1775631632458-happy-cactus.md
Normal file
225
.kilo/plans/1775631632458-happy-cactus.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Audit PMO Control - Optimisation Playlist OpenHome
|
||||
|
||||
## Résumé Exécutif
|
||||
|
||||
L'utilisateur rapporte des lenteurs significatives lors de la manipulation de playlists de ~1000 titres avec les renderers OpenHome. Les renderers Chromecast et UPnP (avec queue interne) ne sont pas affectés.
|
||||
|
||||
## État des Optimisations Deja Implémentées
|
||||
|
||||
Le precedent plan dans `Blackboard/Todo/enorme_playlist.md` a deja été partiellement implémenté:
|
||||
|
||||
| optimisation | Statut | Emplacement |
|
||||
|-------------|-------|------------|
|
||||
| MAX_BATCH = 256 pour ReadList | ✅ FAIT | `openhome.rs:1015` |
|
||||
| Élimination double queue_snapshot() | ✅ FAIT | `replace_queue_with_pivot()` et `replace_queue_standard_lcs()` |
|
||||
| LCS préfixe/suffixe (lcs_flags_optimized) | ✅ FAIT | `openhome.rs:869` |
|
||||
| Polling adaptatif (is_active) | ✅ FAIT | `musicrenderer.rs:325-374` |
|
||||
| Consolidation invalidation caches | ✅ FAIT | `openhome.rs:222-235` |
|
||||
|
||||
## Contraintes Protocolaires Découvertes
|
||||
|
||||
L'action `Insert` **ne supporte PAS l'insertion par lot** - chaque appel prend un seul Uri/Metadata.
|
||||
|
||||
## Nouvelles Optimisations (Contre-propositions Utilisateur)
|
||||
|
||||
### OPT-1: Fast Path pour 99% des cas de sync_queue
|
||||
|
||||
**Observation**: 99% des changements de playlist sont:
|
||||
- Insertion de nouvelles tracks en **fin de queue**
|
||||
- Délétion de tracks en **début de queue**
|
||||
- Rarement des changements nécessitant un vrai alignement LCS
|
||||
|
||||
**Solution**: Ajouter une détection de pattern avant d'appeler LCS:
|
||||
|
||||
```rust
|
||||
fn smart_sync(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
let current_ids = self.track_ids()?;
|
||||
|
||||
// Cas 1: Append only (insertion en fin)
|
||||
if items.starts_with(¤t_ids) {
|
||||
// Fast path: juste ajouter les nouveaux items
|
||||
return self.append_only(items.skip(current_ids.len()));
|
||||
}
|
||||
|
||||
// Cas 2: Delete from beginning
|
||||
if current_ids.starts_with(&items) {
|
||||
// Fast path: supprimer de la fin
|
||||
return self.delete_from_beginning(current_ids.len() - items.len());
|
||||
}
|
||||
|
||||
// Cas 3: Full LCS only for complex reorderings
|
||||
return self.replace_queue_standard_lcs(items);
|
||||
}
|
||||
```
|
||||
|
||||
**Impact**: 99% des sync_queue passent de O(N²) à O(N)
|
||||
|
||||
---
|
||||
|
||||
### OPT-2: Queue FIFO pour Opérations OpenHome (Thread Background)
|
||||
|
||||
**Concept**: Une file d'attente FIFO des opérations SOAP exécutée dans un thread dédié.
|
||||
|
||||
```rust
|
||||
pub struct OpenHomeOpQueue {
|
||||
queue: Arc<Mutex<Vec<OpenHomeOp>>>,
|
||||
worker_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
pub enum OpenHomeOp {
|
||||
Insert { uri: String, metadata: String, after_id: u32 },
|
||||
Delete { track_id: u32 },
|
||||
DeleteAll,
|
||||
SeekId { id: u32 },
|
||||
Play,
|
||||
Pause,
|
||||
Stop,
|
||||
SetVolume { volume: u16 },
|
||||
// Meta operations
|
||||
UpdateMetadata { track_id: u32, metadata: String },
|
||||
}
|
||||
|
||||
impl OpenHomeOpQueue {
|
||||
/// Push operation to the FIFO queue
|
||||
pub fn push(&self, op: OpenHomeOp) {
|
||||
self.queue.lock().unwrap().push(op);
|
||||
}
|
||||
|
||||
/// Push with priority (volume, play, stop - need fast response)
|
||||
pub fn push_first(&self, op: OpenHomeOp) {
|
||||
self.queue.lock().unwrap().push_front(op);
|
||||
}
|
||||
|
||||
/// Clear all pending operations (client can flush)
|
||||
pub fn clear(&self) {
|
||||
self.queue.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Worker thread consumes operations
|
||||
fn worker_loop(&self) {
|
||||
loop {
|
||||
let op = self.queue.lock().unwrap().pop_front();
|
||||
match op {
|
||||
Some(op) => self.execute(op),
|
||||
None => thread::sleep(Duration::from_millis(10)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- UI non-bloquante (les operations sont lancées et exec en background)
|
||||
- Batching naturel (plusieurs operations sont executes en sequence)
|
||||
- Priorité via `push_first()` pour play/stop/volume
|
||||
- `clear()` permet d'annuler les operations en attente (ex: playlist changée)
|
||||
|
||||
**Implémentation suggérée**:
|
||||
1. Créer `src/queue/openhome_op_queue.rs` avec la structure
|
||||
2. Intégrer dans `OpenHomeQueue` ou `OpenHomeRenderer`
|
||||
3. Thread de worker lancé au démarrage du control point
|
||||
|
||||
---
|
||||
|
||||
### OPT-3: Métadonnées en Tâche de Fond
|
||||
|
||||
**Observation**: L'appli utilise-t-elle vraiment les métadonnées de la queue OpenHome, ou un cache local?
|
||||
|
||||
Si le control-point maintient son propre cache (plus probable):
|
||||
- Les mises à jour de métadonnées peuvent être traitées en background
|
||||
- Pas besoin de sync immédiate des métadonnées
|
||||
|
||||
**Solution**: Queue séparée pour les operations de métadonnées:
|
||||
|
||||
```rust
|
||||
// Haute priorité (opérations critiques)
|
||||
let high_priority_queue: OpenHomeOpQueue;
|
||||
|
||||
// Basse priorité (métadonnées)
|
||||
let metadata_queue: OpenHomeOpQueue;
|
||||
```
|
||||
|
||||
**Implémentation**:
|
||||
1. Séparer les operations critiques (play/stop/seek/volume) de metadata
|
||||
2. Metadata update traités en background avec délais
|
||||
3. Le cache local du control-point est mis à jour indépendamment
|
||||
|
||||
---
|
||||
|
||||
### OPT-4: Connection Pooling HTTP
|
||||
|
||||
Chaque appel SOAP crée une nouvelle connexion. Avec 1000 insertions:
|
||||
- Overhead TCP: ~10-50ms par appel
|
||||
- Total: 10-50 secondes overhead réseau
|
||||
|
||||
**Solution**: Agent HTTP static avec connection reuse:
|
||||
|
||||
```rust
|
||||
// soap_client.rs
|
||||
static HTTP_AGENT: Lazy<ureq::Agent> = Lazy::new(|| {
|
||||
Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.build()
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plan d'Implémentation Proposé
|
||||
|
||||
### Phase 1: Fast Path LCS (Priorité Haute)
|
||||
|
||||
1. Ajouter `detect_sync_pattern()` dans `openhome.rs`
|
||||
2. Implémenter `append_only()` et `delete_from_beginning()`
|
||||
3. Tester avec playlists réelles
|
||||
|
||||
### Phase 2: Queue FIFO Opérations (Priorité Haute)
|
||||
|
||||
1. Créer `src/queue/openhome_op_queue.rs`
|
||||
2. Implémenter `push()`, `push_first()`, `clear()`
|
||||
3. Thread worker avec loop de consommation
|
||||
4. Intégrer dans `OpenHomeRenderer`
|
||||
|
||||
### Phase 3: Séparation Métadonnées (Priorité Moyenne)
|
||||
|
||||
1. Créer queue séparée pour metadata
|
||||
2. Implémenter batch processing
|
||||
|
||||
### Phase 4: Connection Pooling (Priorité Basse)
|
||||
|
||||
1. Modifier `soap_client.rs` pour agent static
|
||||
|
||||
---
|
||||
|
||||
## Questions pour Clarification
|
||||
|
||||
1. **Cache Métadonnées**: Le control-point utilise-t-il vraiment les métadonnées de la queue OpenHome, ou maintient-il son propre cache qui est alimenté indépendamment?
|
||||
|
||||
2. **Priorité des Opérations**: Pour `push_first()`, quelles opérations nécessitent une réponse rapide?
|
||||
- Volume (immédiat)
|
||||
- Play/Pause/Stop (immédiat)
|
||||
- Seek (rapide)
|
||||
- Insert (peut être différé)
|
||||
|
||||
3. **Comportement en cas de conflit**: Si le client fait `clear()` et que le worker est en train d'exécuter une opération:
|
||||
- Annuler l'opération en cours? ( risky - peut laisser le renderer dans un état inconsistent)
|
||||
- Laisser finir l'opération en cours? (plus sur)
|
||||
|
||||
---
|
||||
|
||||
## Tests Recommandés
|
||||
|
||||
```bash
|
||||
# Compiler
|
||||
cargo build -p pmocontrol
|
||||
|
||||
# Benchmark LCS fast paths
|
||||
# - Cas: append 100 tracks to 900 = O(N)
|
||||
# - Cas: delete 100 from 900 = O(N)
|
||||
# - Cas: reorder = O(N²) avec LCS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Plan mis à jour avec contre-propositions utilisateur*
|
||||
*Date: 2026-04-08*
|
||||
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
133
.serena/project.yml
Normal file
133
.serena/project.yml
Normal file
@@ -0,0 +1,133 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "pmomusic"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# svelte swift systemverilog terraform toml
|
||||
# typescript typescript_vts vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- rust
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
439
Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md
Normal file
439
Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# Améliorations pmoqobuz inspirées de qbz
|
||||
|
||||
Analyse comparative avec le projet [qbz](../../../qbz) (`crates/qbz-qobuz/`), client Qobuz Rust plus avancé.
|
||||
Les items sont classés par priorité et état d'avancement.
|
||||
|
||||
---
|
||||
|
||||
## 1. Streaming CMAF — **FAIT**
|
||||
|
||||
**Problème** : l'endpoint legacy `/track/getFileUrl` est en cours de dépréciation. Qobuz bascule vers
|
||||
CMAF (Common Media Application Format) : segments AES-CTR chiffrés sur CDN Akamai.
|
||||
|
||||
**Implémentation réalisée** :
|
||||
- `pmoqobuz::cmaf` — pipeline complet : dérivation HKDF, dérobage AES-CBC, déchiffrement AES-CTR par frame
|
||||
- `pmoqobuz::retry` — retry exponentiel avec classification Transient/Terminal
|
||||
- `QobuzClient::open_cmaf_stream()` — `AsyncRead` progressif via `tokio::io::duplex`, 3 segments en vol
|
||||
- `GET /qobuz/tracks/:id/flac` — endpoint REST local qui expose le flux ; `LazyProvider::get_url()`
|
||||
retourne cette URL locale (via `PMO_SERVER_URL`) → le progressive caching de pmocache est préservé sans
|
||||
modification
|
||||
|
||||
**Référence qbz** : `crates/qbz-qobuz/src/cmaf.rs`
|
||||
|
||||
---
|
||||
|
||||
## 2. Extraction du bundle Qobuz — **Fait**
|
||||
|
||||
**Problème** : `pmoqobuz` utilise un `app_id` et un `configvalue` statiques, hardcodés ou configurés
|
||||
manuellement. Qobuz peut les invalider à tout moment en changeant son bundle JS.
|
||||
|
||||
**Ce que fait qbz** (`bundle.rs`) :
|
||||
- Télécharge la page `https://play.qobuz.com/login`, extrait l'URL du bundle JS
|
||||
- Parse le bundle (~7 MB) avec des regex pour en extraire `app_id`, les secrets, et la `private_key` OAuth
|
||||
- Met en cache les tokens sur disque avec un hash de version du bundle (`bundle_version`)
|
||||
- Revalide automatiquement si la version change (rotation silencieuse de Qobuz)
|
||||
- Timeout de 45 s sur le fetch, 2 retry sur extraction
|
||||
|
||||
**Impact pour pmoqobuz** :
|
||||
- Le `Spoofer` actuel fait un fetch similaire mais sans cache disque ni détection de version
|
||||
- Ajouter `CachedBundle` (version + tokens + timestamp) dans `pmoconfig` ou dans le répertoire de données
|
||||
- Relire le cache au démarrage, re-extraire seulement si la version du bundle a changé
|
||||
|
||||
**Avantage** : ne jamais tomber en panne quand Qobuz rotate ses secrets sans préavis.
|
||||
|
||||
---
|
||||
|
||||
## 3. Chargement batch de tracks — `track/getList` — **FAIT**
|
||||
|
||||
**Problème** : les tracks retournées par `/playlist/get?extra=tracks` et
|
||||
`/favorite/getUserFavorites` ont des métadonnées incomplètes (parfois sans `performer`,
|
||||
jamais de `sample_rate`/`bit_depth`/`channels`). Cela déclenchait des appels individuels
|
||||
`get_track` lazy à la lecture.
|
||||
|
||||
**Implémentation réalisée** :
|
||||
- `signing::sign_track_get_list(ids_csv, timestamp, secret)` — signature MD5 pour `track/getList`
|
||||
- `QobuzApi::post_json_with_query` — POST avec auth headers + query sig + JSON body
|
||||
- `QobuzApi::get_tracks_batch(&[&str])` — fenêtres de 50 IDs, appels en série
|
||||
- `QobuzClient::get_tracks_batch` — wrapper avec cache (skip les IDs déjà en cache)
|
||||
- `get_playlist_tracks` : phase 1 pagination existante, phase 2 enrichissement si secret disponible
|
||||
- `get_favorite_tracks` : même enrichissement en phase 2
|
||||
- Fallback gracieux si le secret est absent ou si `track/getList` échoue
|
||||
|
||||
**Ce que fait qbz** (`get_tracks_batch`, l.1323) :
|
||||
```
|
||||
POST /track/getList
|
||||
{ "tracks_id": [id1, id2, ..., id50] }
|
||||
→ { "tracks": { "total": N, "items": [...Track] } }
|
||||
```
|
||||
- Fenêtre de 50 IDs max par appel (limite API Qobuz)
|
||||
- Les fenêtres supérieures à 50 sont découpées et appelées en série (respecte les quotas)
|
||||
|
||||
---
|
||||
|
||||
## 4. Pagination concurrente des playlists — **FAIT**
|
||||
|
||||
**Implémentation réalisée** dans `QobuzApi::get_playlist_tracks` :
|
||||
- Page size augmentée de 50 → **500** (réduit le nombre de pages de 10×)
|
||||
- Page 1 séquentielle pour obtenir `total`
|
||||
- Pages 2..N lancées en parallèle via `futures::try_join_all` + `Semaphore(3)`
|
||||
- Résultats triés par offset avant fusion — ordre playlist garanti
|
||||
- Suivi de phase 2 (`track/getList`) inchangé
|
||||
|
||||
**Impact** : playlist de 2 000 tracks (4 pages de 500) → 1 séquentielle + 3 parallèles ≈ 0,7 s
|
||||
au lieu de 4 séquentielles ≈ 1,6 s. Playlists ≤ 500 tracks : 1 seule requête.
|
||||
|
||||
---
|
||||
|
||||
## 5. Endpoint `release_watch` — **À FAIRE** (priorité basse)
|
||||
|
||||
**Problème** : pmoqobuz ne supporte pas les nouvelles sorties d'artistes suivis.
|
||||
|
||||
**Ce que fait qbz** (`get_release_watch`, l.809) :
|
||||
```
|
||||
GET /favorite/getNewReleases?type=album&limit=50&offset=0
|
||||
→ { has_more: bool, items: [...Album] }
|
||||
```
|
||||
- Types disponibles : `album`, `live`, `ep_single`
|
||||
- Pas de champ `total` dans la réponse — pagination via `has_more`
|
||||
|
||||
**Impact pour pmoqobuz** : permettre un "quoi de neuf" dans l'interface — albums des artistes favoris
|
||||
sortis récemment. Utile pour le catalogue de la webapp.
|
||||
|
||||
---
|
||||
|
||||
## 6. Chargement `playlist/get?extra=track_ids` — **À FAIRE** (priorité basse)
|
||||
|
||||
**Ce que fait qbz** (`get_playlist_track_ids`, l.1296) :
|
||||
- Variante légère de `playlist/get` qui retourne uniquement les IDs (pas les objets Track complets)
|
||||
- Utile pour vérifier si une playlist a changé sans tout recharger
|
||||
- Combiné avec `get_tracks_batch` pour un chargement optimal en deux passes :
|
||||
1. `playlist/get?extra=track_ids` → liste d'IDs
|
||||
2. `track/getList` par fenêtres de 50 → objets Track complets
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 7. Robustesse des requêtes et du parsing API
|
||||
|
||||
Analyse comparative approfondie (`qbz/crates/qbz-qobuz/src/`) révélant quatre gaps dans
|
||||
pmoqobuz par rapport à qbz.
|
||||
|
||||
---
|
||||
|
||||
### 7a. Signature générique — **À FAIRE** (priorité basse, effort très faible)
|
||||
|
||||
**Problème** : pmoqobuz a une fonction de signature dédiée par endpoint
|
||||
(`sign_track_get_file_url`, `sign_userlib_get_albums`, `sign_track_get_list`). Chaque nouvel
|
||||
endpoint signé nécessite une nouvelle fonction, avec risque de divergence silencieuse.
|
||||
|
||||
**Ce que fait qbz** (`auth.rs`, l.55-60) :
|
||||
```rust
|
||||
fn sign_request(method_name: &str, params: &[(&str, &str)], timestamp: u64, secret: &str) -> String {
|
||||
// Concatène method + pairs key+value triées alphabétiquement + timestamp + secret
|
||||
// MD5 du résultat
|
||||
}
|
||||
```
|
||||
Tous les endpoints partagent la même logique. Ajouter un endpoint = zéro code de signature.
|
||||
|
||||
**Pour pmoqobuz** : remplacer les 3 fonctions par une `sign_request` générique.
|
||||
Le tri alphabétique des paramètres est implicitement respecté par nos fonctions actuelles
|
||||
(vérifier que l'ordre de `sign_track_get_list` correspond bien à la convention qbz).
|
||||
|
||||
---
|
||||
|
||||
### 7b. Métadonnées audio dans `TrackResponse` — **À FAIRE** (priorité haute, effort faible)
|
||||
|
||||
**Problème** : `TrackResponse` (la struct de désérialisation interne) ne capte pas les champs
|
||||
de qualité audio retournés par `track/get` et `track/getList` :
|
||||
|
||||
```
|
||||
maximum_sampling_rate → absente de TrackResponse
|
||||
maximum_bit_depth → absente de TrackResponse
|
||||
hires_streamable → absente de TrackResponse
|
||||
```
|
||||
|
||||
Conséquence : après notre `get_tracks_batch`, les champs `Track.sample_rate` et
|
||||
`Track.bit_depth` restent `None` (ils sont `#[serde(skip)]` dans `models.rs`), alors que
|
||||
l'API les a retournés. La qualité audio n'est connue qu'après lecture effective via CMAF.
|
||||
|
||||
**Ce que fait qbz** (`types.rs`, l.204-215) :
|
||||
```rust
|
||||
pub struct Track {
|
||||
pub maximum_sampling_rate: Option<f64>, // 44100.0, 96000.0, 192000.0
|
||||
pub maximum_bit_depth: Option<u32>, // 16, 24
|
||||
pub hires_streamable: bool,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Pour pmoqobuz** :
|
||||
1. Ajouter `maximum_sampling_rate: Option<f64>`, `maximum_bit_depth: Option<u32>` à `TrackResponse`
|
||||
2. Les propager dans `Track` via `parse_track` (remplacer les `#[serde(skip)]`)
|
||||
3. Ces valeurs alimentent `AudioMetadata` dans `register_tracks_lazy` sans attendre la lecture
|
||||
|
||||
**Impact** : les métadonnées hi-res (24-bit/96kHz) sont disponibles dès le chargement de la
|
||||
playlist, pas seulement après la première lecture.
|
||||
|
||||
---
|
||||
|
||||
### 7c. Parsing des restrictions de stream — **À FAIRE** (priorité moyenne, effort moyen)
|
||||
|
||||
**Problème** : la réponse de `track/getFileUrl` contient un champ `restrictions[]` qui signale
|
||||
des blocages (ex: `"FormatRestrictedByFormatAvailability"`, `"SampleRestrictedByRightHolders"`).
|
||||
pmoqobuz ne le parse pas — un track restreint retourne une URL qui échoue silencieusement à
|
||||
la lecture.
|
||||
|
||||
**Ce que fait qbz** (`types.rs`, l.92-112, `client.rs`, l.1959-2012) :
|
||||
```rust
|
||||
pub struct StreamUrl {
|
||||
pub url: String,
|
||||
pub restrictions: Vec<StreamRestriction>,
|
||||
...
|
||||
}
|
||||
|
||||
pub fn has_restrictions(&self) -> bool {
|
||||
self.restrictions.iter().any(|r| {
|
||||
r.code == "FormatRestrictedByFormatAvailability"
|
||||
|| r.code == "SampleRestrictedByRightHolders"
|
||||
})
|
||||
}
|
||||
```
|
||||
Si `has_restrictions()`, qbz essaie la qualité inférieure suivante (voir 7d).
|
||||
|
||||
**Pour pmoqobuz** :
|
||||
- Ajouter `restrictions: Vec<StreamRestriction>` au parsing de `FileUrlResponse` dans `catalog.rs`
|
||||
- Retourner une erreur explicite (`QobuzError::TrackRestricted`) si restrictions présentes
|
||||
- Prépare la base pour le fallback de qualité (7d)
|
||||
|
||||
---
|
||||
|
||||
### 7d. Fallback automatique de qualité — **À FAIRE** (priorité moyenne, effort moyen)
|
||||
|
||||
**Problème** : si le format demandé (ex: Hi-Res 24-bit) n'est pas disponible pour un track,
|
||||
`get_file_url` échoue. pmoqobuz n'a pas de dégradation automatique.
|
||||
|
||||
**Ce que fait qbz** (`client.rs`, l.1959-2012) :
|
||||
```
|
||||
UltraHiRes (27) → HiRes (7) → Lossless (6) → MP3 (5)
|
||||
```
|
||||
Essaie chaque qualité jusqu'à obtenir une URL sans restrictions. Retourne
|
||||
`TrackUnavailable` seulement si toutes les qualités échouent.
|
||||
|
||||
**Pour pmoqobuz** : ajouter `get_file_url_with_fallback` dans `catalog.rs` qui itère sur
|
||||
`[format_id_configured, 6 (lossless), 5 (mp3)]` jusqu'à succès.
|
||||
Le path CMAF n'est pas concerné (format géré côté serveur).
|
||||
|
||||
---
|
||||
|
||||
### 7e. Respect du header `Retry-After` sur 429 — **À FAIRE** (priorité moyenne, effort moyen)
|
||||
|
||||
**Problème** : `retry.rs` classifie correctement les 429 comme transitoires, mais le backoff
|
||||
est fixe (250 ms → 500 ms → 1 s). Qobuz peut indiquer un délai précis via le header
|
||||
`Retry-After`. L'ignorer risque soit de retentar trop tôt (nouveau 429), soit d'attendre trop
|
||||
longtemps (backoff fixe parfois plus long que nécessaire).
|
||||
|
||||
**Ce que fait qbz** (`client.rs`, l.2497-2505) :
|
||||
```rust
|
||||
if status == StatusCode::TOO_MANY_REQUESTS {
|
||||
let retry_after = response.headers()
|
||||
.get(RETRY_AFTER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(2);
|
||||
return Err(ApiError::RateLimited(retry_after));
|
||||
}
|
||||
```
|
||||
Le délai est passé à la logique de retry qui dort exactement `retry_after` secondes.
|
||||
|
||||
**Pour pmoqobuz** : dans `mod.rs::handle_response`, sur 429, lire le header et
|
||||
propager la valeur via une variante `QobuzError::RateLimited(u64)`.
|
||||
`call_with_auth_repair` dans `client.rs` peut ensuite `tokio::time::sleep` ce délai
|
||||
avant de retenter, au lieu du backoff fixe.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 8. Recherche UPnP contextuelle — **À FAIRE** (priorité haute)
|
||||
|
||||
### 8a. Principe : le ContainerID détermine le scope et le type
|
||||
|
||||
L'action UPnP `Search(ContainerID, SearchCriteria)` passe déjà le container d'origine.
|
||||
On l'utilise pour décider quoi chercher et où, plutôt que de parser `SearchCriteria`.
|
||||
|
||||
**Mapping ContainerID → (scope, type)** :
|
||||
|
||||
| ContainerID | Scope | Type | Endpoint Qobuz |
|
||||
|---|---|---|---|
|
||||
| `qobuz`, `qobuz:discover`, `qobuz:discover:*`, `qobuz:genres`, `qobuz:genre:*` | Catalog | All | `/catalog/search` → containers groupés |
|
||||
| `qobuz:discover:artists` | Catalog | Artists | `/artist/search` |
|
||||
| `qobuz:discover:albums:*` | Catalog | Albums | `/album/search` |
|
||||
| `qobuz:favorites` | UserLibrary | All | filtre cache → containers groupés |
|
||||
| `qobuz:favorites:albums` | UserLibrary | Albums | filtre cache albums |
|
||||
| `qobuz:favorites:tracks` | UserLibrary | Tracks | filtre cache tracks |
|
||||
| `qobuz:favorites:artists` | UserLibrary | Artists | filtre cache artistes |
|
||||
| `qobuz:favorites:playlists` | UserLibrary | Playlists | filtre cache playlists |
|
||||
|
||||
**SearchCriteria** : extrait le texte brut — `dc:title contains "Pink Floyd"` → `"Pink Floyd"`,
|
||||
`upnp:artist contains "Miles"` → `"Miles"`, chaîne nue ou `*` → passé tel quel.
|
||||
|
||||
### 8b. Types dans `pmosource`
|
||||
|
||||
```rust
|
||||
pub enum SearchScope { Catalog, UserLibrary }
|
||||
|
||||
pub enum MediaSearchType { All, Tracks, Albums, Artists, Playlists }
|
||||
|
||||
pub struct SearchQuery {
|
||||
pub text: String,
|
||||
pub media_type: MediaSearchType,
|
||||
pub scope: SearchScope,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
```
|
||||
|
||||
Le trait `MusicSource::search()` passe de `&str` à `&SearchQuery`.
|
||||
|
||||
### 8c. Résultats groupés via containers virtuels navigables
|
||||
|
||||
Quand `media_type = All`, `search()` retourne des containers virtuels :
|
||||
|
||||
```
|
||||
BrowseResult::Containers([
|
||||
Container { id: "qobuz:search:catalog:Pink Floyd:albums", title: "Albums (12)", ... },
|
||||
Container { id: "qobuz:search:catalog:Pink Floyd:artists", title: "Artistes (3)", ... },
|
||||
Container { id: "qobuz:search:catalog:Pink Floyd:tracks", title: "Titres (47)", ... },
|
||||
Container { id: "qobuz:search:catalog:Pink Floyd:playlists",title: "Playlists (2)", ... },
|
||||
])
|
||||
```
|
||||
|
||||
**Format d'ID** : `qobuz:search:{scope}:{type}:{query}` — parsé avec `splitn(5, ':')` pour
|
||||
que la query puisse contenir des `:` sans ambiguïté.
|
||||
|
||||
Quand le control point browse dans `qobuz:search:catalog:Pink Floyd:albums`, `browse()` de
|
||||
`QobuzSource` reconnaît le pattern, re-exécute `/album/search?query=Pink+Floyd` (le cache API
|
||||
absorbe les appels redondants) et retourne les items directement.
|
||||
|
||||
### 8d. Recherche dans les favoris (UserLibrary)
|
||||
|
||||
Pas d'endpoint Qobuz — filtre client-side sur le cache. Pour chaque type :
|
||||
- `get_favorite_albums()`, `get_favorite_tracks()`, `get_favorite_artists()`, `get_user_playlists()`
|
||||
- Filtre : `title.to_lowercase().contains(&query.to_lowercase())` ou sur `artist.name`
|
||||
|
||||
Si le cache est chaud → instantané. Sinon charge les favoris avant de filtrer.
|
||||
|
||||
### 8e. Endpoints Qobuz utilisés
|
||||
|
||||
```
|
||||
GET /catalog/search?query=…&limit=… → All types (catalog scope)
|
||||
GET /album/search?query=… → Albums only
|
||||
GET /track/search?query=… → Tracks only
|
||||
GET /artist/search?query=… → Artists only
|
||||
GET /playlist/search?query=… → Playlists only
|
||||
```
|
||||
|
||||
Déjà partiellement implémentés : `QobuzApi::search(query, type_)` passe `type_` au param
|
||||
`type` de `/catalog/search`. Il faut ajouter les endpoints dédiés `/album/search` etc. pour
|
||||
les recherches typées — ils ont leur propre signature et des params de pagination corrects.
|
||||
|
||||
### 8f. Fichiers à modifier
|
||||
|
||||
| Fichier | Changement |
|
||||
|---|---|
|
||||
| `pmosource/src/lib.rs` | Ajouter `SearchQuery`, `SearchScope`, `MediaSearchType` ; changer signature `search()` |
|
||||
| `pmomediaserver/src/content_handler.rs` | Parser `container_id` → `SearchQuery` ; parser `SearchCriteria` |
|
||||
| `pmoqobuz/src/api/catalog.rs` | Ajouter `search_albums`, `search_tracks`, `search_artists`, `search_playlists` |
|
||||
| `pmoqobuz/src/client.rs` | Wrappers typés avec cache |
|
||||
| `pmoqobuz/src/source.rs` | Réécrire `search()` + étendre `browse()` pour les virtual containers |
|
||||
|
||||
---
|
||||
|
||||
## 9. Découverte (Discover) et playlists éditoriales — **À FAIRE** (priorité moyenne)
|
||||
|
||||
Les "Daily Q", "Weekly Q" et radios ne sont **pas** des endpoints API dynamiques distincts.
|
||||
Ce sont des playlists Qobuz standard (avec des IDs fixes par compte), accessibles via
|
||||
`/playlist/get`. Ce qui manque, c'est l'accès au catalogue de découverte éditorialisé.
|
||||
|
||||
### 9a. Endpoints Discover
|
||||
|
||||
```
|
||||
GET /discover/index?[genre_ids=112,119] ← tableau de bord
|
||||
GET /discover/playlists?[tags=…&genre_ids=…]&limit=…&offset=…
|
||||
GET /discover/newReleases?[genre_ids=…]&limit=…&offset=…
|
||||
GET /discover/mostStreamed?[genre_ids=…]&limit=…&offset=…
|
||||
GET /discover/albumOfTheWeek?[genre_ids=…]
|
||||
GET /discover/pressAward?[genre_ids=…]&limit=…&offset=…
|
||||
GET /discover/qobuzissims?[genre_ids=…]&limit=…&offset=…
|
||||
GET /discover/idealDiscography?[genre_ids=…]&limit=…&offset=…
|
||||
```
|
||||
|
||||
Tous authentifiés. Signature : `sign_request("discover{endpoint_slug}", params, ts, secret)`.
|
||||
|
||||
### 9b. Tags de playlists
|
||||
|
||||
```
|
||||
GET /playlist/getTags
|
||||
→ Vec<PlaylistTag { id, slug, name (localisé) }>
|
||||
```
|
||||
|
||||
Permet de filtrer `discover/playlists` par tag (`partner`, `label`, etc.).
|
||||
|
||||
### 9c. Albums mis en avant
|
||||
|
||||
```
|
||||
GET /album/getFeatured?type={new-releases|press-awards|most-streamed}[&genre_id=…]
|
||||
→ SearchResultsPage<Album>
|
||||
```
|
||||
|
||||
Alternative à `discover/newReleases` qui retourne des albums complets avec métadonnées.
|
||||
|
||||
### 9d. Structure `DiscoverResponse`
|
||||
|
||||
```rust
|
||||
pub struct DiscoverResponse {
|
||||
pub containers: DiscoverContainers,
|
||||
}
|
||||
pub struct DiscoverContainers {
|
||||
pub playlists: Option<DiscoverContainer<DiscoverPlaylist>>,
|
||||
pub new_releases: Option<DiscoverContainer<DiscoverAlbum>>,
|
||||
pub most_streamed: Option<DiscoverContainer<DiscoverAlbum>>,
|
||||
pub qobuzissims: Option<DiscoverContainer<DiscoverAlbum>>,
|
||||
pub album_of_the_week: Option<DiscoverContainer<DiscoverAlbum>>,
|
||||
pub press_awards: Option<DiscoverContainer<DiscoverAlbum>>,
|
||||
pub ideal_discography: Option<DiscoverContainer<DiscoverAlbum>>,
|
||||
pub playlists_tags: Option<DiscoverContainer<PlaylistTag>>,
|
||||
}
|
||||
```
|
||||
|
||||
### 9e. Daily Q / Weekly Q / Radio
|
||||
|
||||
Ces playlists sont des **playlists Qobuz standard** générées par Qobuz dans la bibliothèque
|
||||
utilisateur. Elles apparaissent dans `getUserPlaylists` avec des noms spéciaux. Il n'y a pas
|
||||
d'endpoint dédié — elles se chargent comme n'importe quelle playlist via `/playlist/get`.
|
||||
|
||||
Pour les exposer, il suffit de :
|
||||
1. Ajouter un filtre dans `get_user_playlists` pour identifier ces playlists (par propriétaire
|
||||
`qobuz` + nom pattern) et les exposer séparément dans l'API REST
|
||||
2. Ou laisser l'UI trier les playlists par propriétaire
|
||||
|
||||
---
|
||||
|
||||
## Résumé de priorités
|
||||
|
||||
| # | Amélioration | Effort | Impact | État |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Streaming CMAF | Élevé | Critique (pipeline futur) | **Fait** |
|
||||
| 2 | Bundle extraction avec cache disque | Moyen | Élevé (résilience) | **Fait** |
|
||||
| 3 | Batch `track/getList` | Faible | Élevé (performances) | **Fait** |
|
||||
| 4 | Pagination concurrente playlists | Faible | Moyen | **Fait** |
|
||||
| 5 | Release watch endpoint | Faible | Faible (catalogue) | À faire |
|
||||
| 6 | `extra=track_ids` + batch à deux passes | Faible | Faible (optimisation) | À faire |
|
||||
| 7a | Signature générique `sign_request` | Très faible | Maintenabilité | À faire |
|
||||
| 7b | Métadonnées audio dans TrackResponse | Faible | Élevé (qualité metadata) | À faire |
|
||||
| 7c | Parsing restrictions stream | Moyen | Moyen (robustesse) | À faire |
|
||||
| 7d | Fallback automatique de qualité | Moyen | Moyen (robustesse) | À faire |
|
||||
| 7e | Respect `Retry-After` 429 | Moyen | Moyen (résilience rate limit) | À faire |
|
||||
| 8 | Recherche (track/album/artist/catalog) | Moyen | Élevé (fonctionnalité manquante) | À faire |
|
||||
| 9 | Discover + playlists éditoriales | Moyen | Moyen (catalogue) | À faire |
|
||||
97
Blackboard/Architecture/webrenderer.md
Normal file
97
Blackboard/Architecture/webrenderer.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# WebRenderer UPnP privé par navigateur
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le crate `pmowebrenderer` transforme chaque navigateur connecté en un **MediaRenderer UPnP privé**. Quand un navigateur se connecte via WebSocket, le backend Rust crée dynamiquement un device UPnP dédié. Le ControlPoint envoie des commandes SOAP à ce device, et les action handlers les relaient au navigateur via WebSocket. Le navigateur joue l'audio via `<audio>` et renvoie l'état au backend.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (Vue.js) Rust Backend ControlPoint
|
||||
| | |
|
||||
|-- WS connect --------------->| |
|
||||
|<-- SessionCreated (token) ---| |
|
||||
|-- Init (capabilities) ------>| |
|
||||
| |-- register_device() -------->| (Server)
|
||||
| | (Device + Services custom) |
|
||||
| |-- push_renderer() ---------->| (CP registry)
|
||||
| | |
|
||||
| |<-- SOAP Play (control_handler)
|
||||
|<-- Command(Play, uri) -------| (action handler -> WS) |
|
||||
|-- StateUpdate(Playing) ----->| |
|
||||
| |-- update StateVarInstance -->| (evented -> SSE)
|
||||
| | |
|
||||
|-- WS disconnect ------------>| |
|
||||
| |-- device_says_byebye() ----->| (CP registry)
|
||||
```
|
||||
|
||||
## Flux de connexion
|
||||
|
||||
1. Le navigateur ouvre une WebSocket vers `/api/webrenderer/ws`
|
||||
2. Il envoie un message `Init` avec ses capabilities (user_agent, formats supportes)
|
||||
3. Le backend construit un `Device` UPnP avec des `Service` models custom :
|
||||
- AVTransport (Play, Stop, Pause, Seek, SetURI, GetPositionInfo, etc.)
|
||||
- RenderingControl (SetVolume, GetVolume, SetMute, GetMute)
|
||||
- ConnectionManager (GetProtocolInfo)
|
||||
4. Chaque Action a un handler qui capture le `mpsc::UnboundedSender<ServerMessage>` du WS
|
||||
5. Le device est enregistre via `Server::register_device()` (routes SOAP + DEVICE_REGISTRY)
|
||||
6. Un `RendererInfo` est pousse dans le `DeviceRegistry` du ControlPoint via `push_renderer()`
|
||||
7. Le backend renvoie un `SessionCreated` avec le token et les infos du renderer
|
||||
|
||||
## Decision cle : Services dynamiques (zero changement pmoupnp)
|
||||
|
||||
Plutot que de modifier pmoupnp pour permettre l'override de handlers post-creation, on **construit des `Service` models dynamiques** pour chaque session WebSocket :
|
||||
|
||||
- Les `StateVariable` statics de pmomediarenderer sont reutilisees via `Arc::clone(&*VAR)`
|
||||
- De nouvelles `Action` sont creees avec `Action::new()`, configurees avec `set_handler()` puis wrappees en `Arc`
|
||||
- Les handlers capturent le sender WS et le `SharedState` (clone a chaque appel via `Fn` closure)
|
||||
- Le `Device` est construit avec ces services custom, puis enregistre normalement
|
||||
|
||||
Cela reutilise toute l'infrastructure existante sans modification de pmoupnp ni pmomediarenderer.
|
||||
|
||||
## Propagation d'etat bidirectionnelle
|
||||
|
||||
### SOAP -> Navigateur (commandes)
|
||||
Les action handlers des services AVTransport/RenderingControl :
|
||||
1. Lisent les arguments SOAP depuis `ActionData` via la macro `get!()`
|
||||
2. Envoient un `ServerMessage::Command` ou `SetVolume`/`SetMute` via le canal mpsc
|
||||
3. Mettent a jour le `SharedState` local
|
||||
4. Retournent les arguments OUT via `set!()` si necessaire
|
||||
|
||||
### Navigateur -> UPnP (etats)
|
||||
Quand le navigateur envoie `StateUpdate`, `PositionUpdate`, `MetadataUpdate` ou `VolumeUpdate` :
|
||||
1. Le `SharedState` est mis a jour
|
||||
2. Les `StateVarInstance` du `DeviceInstance` sont mises a jour via `set_value(StateValue::...)`
|
||||
3. Les variables evented declenchent les notifications UPnP captees par le watcher du ControlPoint
|
||||
|
||||
## Cycle de vie
|
||||
|
||||
- **Connexion** : creation du Device, enregistrement aupres du Server et du ControlPoint
|
||||
- **Session active** : le `SessionManager` gere un timeout de 30 minutes d'inactivite
|
||||
- **Deconnexion WS** : appel a `device_says_byebye()` sur le registry du ControlPoint pour marquer offline
|
||||
- **Cleanup automatique** : le `SessionManager` verifie toutes les 60 secondes les sessions expirees
|
||||
- **Pas de SSDP** : les WebRenderers sont injectes directement, max_age de 86400s
|
||||
|
||||
## Structure des fichiers
|
||||
|
||||
| Fichier | Role |
|
||||
|---------|------|
|
||||
| `handlers.rs` | Action handlers SOAP->WS (play, stop, pause, seek, set_uri, get_*_info, volume, mute) |
|
||||
| `renderer.rs` | `WebRendererFactory` : construction dynamique Device/Services avec handlers |
|
||||
| `websocket.rs` | Handler WS : connexion, reception messages, creation device, propagation etat |
|
||||
| `session.rs` | `SessionManager` : gestion des sessions avec timeout |
|
||||
| `config.rs` | `WebRendererExt` trait pour `pmoserver::Server` (enregistrement route WS) |
|
||||
| `messages.rs` | Types de messages WS (ServerMessage, ClientMessage, etc.) |
|
||||
| `state.rs` | `RendererState` et `SharedState` (etat partage entre handlers et WS) |
|
||||
| `error.rs` | Types d'erreur du crate |
|
||||
| `lib.rs` | Exports publics |
|
||||
|
||||
## Points d'attention
|
||||
|
||||
1. **parking_lot::RwLockWriteGuard non-Send** : les guards de `SharedState` doivent etre dropes avant tout `.await` dans les handlers async. Utiliser des blocs `{ ... }` pour limiter la portee.
|
||||
|
||||
2. **Fn vs FnOnce** : les `ActionHandler` sont `Fn` (appeles plusieurs fois). Les closures doivent cloner `ws` et `state` a chaque appel, avant le `async move`.
|
||||
|
||||
3. **Routes Axum persistantes** : Axum ne supporte pas la suppression de routes. Les routes SOAP d'un device deconnecte persistent mais les handlers retournent des erreurs naturellement.
|
||||
|
||||
4. **Acces au Server** : le WebSocket handler utilise `pmoserver::get_server()` (singleton global) pour enregistrer les devices dynamiquement.
|
||||
651
Blackboard/Done/Frontend_Review.md
Normal file
651
Blackboard/Done/Frontend_Review.md
Normal file
@@ -0,0 +1,651 @@
|
||||
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le frontend de PMOMusic est une application Vue 3 + TypeScript avec Pinia, organisée autour
|
||||
de composables réactifs, d'un client SSE centralisé et d'un cache API à plusieurs niveaux.
|
||||
L'architecture générale est solide : séparation claire composables/services/vues, typage strict
|
||||
(`strict: true` dans `tsconfig.app.json`), reconnexion SSE avec backoff exponentiel.
|
||||
|
||||
Cette revue documente les bugs avérés, les fragilités de conception et les axes d'amélioration
|
||||
relevés lors d'une lecture complète des fichiers `pmoapp/webapp/src/`.
|
||||
|
||||
---
|
||||
|
||||
## Bugs
|
||||
|
||||
### 1. `apiCache.ts:130-133` — Mutation globale du TTL non réentrante
|
||||
|
||||
**Problème** : la méthode `fetch()` accepte un `ttl` optionnel par appel. Pour l'appliquer,
|
||||
elle modifie `this.options.ttl` globalement avant d'appeler `this.set()`, puis le restaure :
|
||||
|
||||
```typescript
|
||||
// apiCache.ts:130-133
|
||||
if (ttl) {
|
||||
const originalTtl = this.options.ttl;
|
||||
this.options.ttl = ttl; // (A) modification globale
|
||||
this.set(endpoint, data, params);
|
||||
this.options.ttl = originalTtl; // (B) restauration
|
||||
}
|
||||
```
|
||||
|
||||
**Cause** : `fetcher()` est `await`-é (ligne 128) avant ce bloc. Pendant cet await, d'autres
|
||||
microtasks peuvent s'intercaler et appeler `isFresh()` ou `set()`, qui lisent `this.options.ttl`.
|
||||
Si deux appels `fetch()` avec des `ttl` différents sont en vol simultanément, la restauration
|
||||
de (B) peut effacer la valeur posée par le second appel concurrent, ou (A) peut lire un TTL
|
||||
modifié par un autre appel.
|
||||
|
||||
**Solution** : passer le `ttl` directement à `set()` comme paramètre, sans modifier l'état
|
||||
partagé :
|
||||
|
||||
```typescript
|
||||
// Dans set() : ajouter un paramètre ttl optionnel
|
||||
set<T>(endpoint: string, data: T, params?: ..., etag?: string, ttl?: number): void {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
ttl: ttl ?? this.options.ttl, // TTL par entrée, pas global
|
||||
etag,
|
||||
});
|
||||
this.notifySubscribers(key, data);
|
||||
}
|
||||
|
||||
// Dans isFresh() : lire le ttl de l'entrée
|
||||
private isFresh(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
return Date.now() - entry.timestamp < (entry.ttl ?? this.options.ttl);
|
||||
}
|
||||
|
||||
// Dans fetch() : supprimer le bloc de mutation globale
|
||||
this.set(endpoint, data, params, undefined, ttl);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `useRenderers.ts:151 + 222` — Réassignation post-switch écrase le nouvel objet
|
||||
|
||||
**Problème** : le handler `onRendererEvent` termine par une ligne inconditionnelle :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:222
|
||||
snapshotState.snapshots.set(rendererId, snapshot);
|
||||
```
|
||||
|
||||
Cette ligne s'exécute pour **tous** les types d'événements après le `switch`, y compris pour
|
||||
`position_changed` et `metadata_changed` qui ont déjà créé et stocké un nouvel objet dans le
|
||||
Map à l'intérieur du switch :
|
||||
|
||||
```typescript
|
||||
// position_changed — ligne 151 : stocke newSnapshot
|
||||
snapshotState.snapshots.set(rendererId, newSnapshot);
|
||||
break;
|
||||
// → puis ligne 222 écrase avec snapshot (proxy d'origine)
|
||||
|
||||
// metadata_changed — ligne 176 : stocke un spread
|
||||
snapshotState.snapshots.set(rendererId, { ...snapshot, state: { ...snapshot.state } });
|
||||
break;
|
||||
// → puis ligne 222 écrase avec snapshot (proxy d'origine)
|
||||
```
|
||||
|
||||
**Cause** : `break` sort du `switch` mais pas de la fonction. La ligne 222 est atteinte dans
|
||||
tous les cas.
|
||||
|
||||
**Conséquence** : les objets créés pour forcer la détection de changement par Vue sont
|
||||
immédiatement écrasés. Le mécanisme de réactivité fonctionne malgré tout (le proxy muté est
|
||||
re-stocké), mais la logique est trompeuse et fragile : si Vue venait à optimiser la détection
|
||||
d'identité des objets réactifs, cette redondance deviendrait un bug visible.
|
||||
|
||||
**Solution** : supprimer la ligne 222 et s'assurer que chaque branche du switch stocke
|
||||
explicitement son résultat dans la Map. Les branches `volume_changed`, `mute_changed` et
|
||||
`binding_changed` qui mutent directement `snapshot` doivent aussi créer un nouvel objet :
|
||||
|
||||
```typescript
|
||||
case "volume_changed":
|
||||
snapshotState.snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, volume: event.volume },
|
||||
});
|
||||
break;
|
||||
|
||||
case "mute_changed":
|
||||
snapshotState.snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, mute: event.mute },
|
||||
});
|
||||
break;
|
||||
// idem pour binding_changed, stream_state_changed
|
||||
```
|
||||
|
||||
Supprimer la ligne 222. Chaque case devient responsable de son stockage, ce qui élimine aussi
|
||||
le besoin de `toRaw()`.
|
||||
|
||||
---
|
||||
|
||||
### 3. `useRenderers.ts:122` — `as any` sur `transport_state`
|
||||
|
||||
**Problème** :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:122
|
||||
snapshot.state.transport_state = event.state as any;
|
||||
```
|
||||
|
||||
**Cause** : `event.state` est typé `string` (type SSE générique), alors que
|
||||
`transport_state` est une union littérale (`"PLAYING" | "PAUSED" | "STOPPED" | ...`).
|
||||
Le cast `as any` contourne la vérification de type.
|
||||
|
||||
**Conséquence** : si le backend envoie une valeur non prévue (ex. `"TRANSITIONING"`), elle
|
||||
sera stockée sans validation. Les composants qui comparent `transport_state === "PLAYING"`
|
||||
ne matcheront pas et l'UI restera muette.
|
||||
|
||||
**Solution** : définir un guard de type ou une assertion dans `types.ts` :
|
||||
|
||||
```typescript
|
||||
// services/pmocontrol/types.ts
|
||||
export type TransportState = "PLAYING" | "PAUSED" | "STOPPED" | "NO_MEDIA" | "TRANSITIONING";
|
||||
|
||||
export function isTransportState(s: string): s is TransportState {
|
||||
return ["PLAYING", "PAUSED", "STOPPED", "NO_MEDIA", "TRANSITIONING"].includes(s);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts — case state_changed
|
||||
case "state_changed":
|
||||
if (isTransportState(event.state)) {
|
||||
snapshot.state.transport_state = event.state;
|
||||
} else {
|
||||
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `apiCache.ts:181-200` — `invalidate()` supprime silencieusement les subscriptions actives
|
||||
|
||||
**Problème** :
|
||||
|
||||
```typescript
|
||||
// apiCache.ts:197-200
|
||||
keysToDelete.forEach(key => {
|
||||
this.cache.delete(key);
|
||||
this.subscriptions.delete(key); // ← subscriptions perdues sans notification
|
||||
});
|
||||
```
|
||||
|
||||
**Cause** : lors d'une invalidation (ex. après un SSE event), les callbacks enregistrés via
|
||||
`subscribe()` sont supprimés de la Map. Les composants ne sont pas notifiés de la suppression
|
||||
et ne reçoivent plus les futures mises à jour même après un refetch.
|
||||
|
||||
**Conséquence** : un composant qui a appelé `apiCache.subscribe(...)` et qui survit à une
|
||||
invalidation devient « sourd » sans le savoir.
|
||||
|
||||
**Solution** : conserver les subscriptions lors d'une invalidation — seule la donnée en cache
|
||||
est périmée, pas les abonnés :
|
||||
|
||||
```typescript
|
||||
invalidate(pattern: string): void {
|
||||
const keysToDelete: string[] = [];
|
||||
// ... construction de keysToDelete inchangée ...
|
||||
keysToDelete.forEach(key => {
|
||||
this.cache.delete(key);
|
||||
// NE PAS supprimer this.subscriptions.get(key)
|
||||
// Les abonnés seront notifiés lors du prochain set()
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Si l'on veut notifier les abonnés d'une invalidation (pour qu'ils affichent un état de
|
||||
chargement), ajouter un callback optionnel `onInvalidate` dans l'interface de subscription.
|
||||
|
||||
---
|
||||
|
||||
## Fragilités de conception
|
||||
|
||||
### 5. `useRenderers.ts:7,143-151` — `toRaw()` comme contournement de réactivité Vue
|
||||
|
||||
**Problème** : le code utilise `toRaw()` pour extraire l'objet brut d'un proxy Vue avant de
|
||||
faire un spread, afin que la copie ne contienne pas de getters réactifs qui pointent vers
|
||||
l'objet original :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:143-151
|
||||
const rawState = toRaw(snapshot.state);
|
||||
const newState = { ...rawState };
|
||||
const newSnapshot = { ...snapshot, state: newState };
|
||||
snapshotState.snapshots.set(rendererId, newSnapshot);
|
||||
```
|
||||
|
||||
**Cause** : les Maps imbriquées dans un objet `reactive()` ont un comportement de réactivité
|
||||
peu prévisible dans Vue 3. Vue ne détecte pas les mutations d'éléments d'une Map réactive si
|
||||
la référence de la Map elle-même ne change pas.
|
||||
|
||||
**Solution recommandée** : remplacer `reactive(new Map())` par `shallowRef(new Map())` pour
|
||||
les Maps qui contiennent des données complexes. La réactivité se déclenche en remplaçant la
|
||||
Map entière (ou en forçant un `triggerRef`) :
|
||||
|
||||
```typescript
|
||||
// Au lieu de :
|
||||
const snapshotState = reactive<RendererSnapshotState>({ snapshots: reactive(new Map()), ... });
|
||||
|
||||
// Utiliser :
|
||||
const snapshots = shallowRef(new Map<string, FullRendererSnapshot>());
|
||||
|
||||
// Pour déclencher la réactivité après mutation :
|
||||
snapshots.value = new Map(snapshots.value); // ou triggerRef(snapshots)
|
||||
```
|
||||
|
||||
Cela rend la propagation de réactivité explicite et élimine le besoin de `toRaw()`.
|
||||
|
||||
---
|
||||
|
||||
### 6. `useRenderers.ts:552-570` — Timer de debounce non nettoyé dans `useRenderer()`
|
||||
|
||||
**Problème** : `useRenderer()` crée un timer de debounce local qui n'est jamais nettoyé si
|
||||
le composant parent est démonté :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:553-566
|
||||
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const REFRESH_DEBOUNCE_MS = 500;
|
||||
|
||||
async function refresh(force = true) {
|
||||
if (refreshDebounceTimer !== null) return;
|
||||
refreshDebounceTimer = setTimeout(() => {
|
||||
refreshDebounceTimer = null;
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
await Promise.all([...]);
|
||||
}
|
||||
```
|
||||
|
||||
**Cause** : pas d'appel à `clearTimeout` dans un `onUnmounted`. Si le composant est démonté
|
||||
pendant les 500 ms du debounce, le timer continue de s'exécuter.
|
||||
|
||||
**Conséquence** : fuite mémoire potentielle ; dans des cas extrêmes (navigation rapide), le
|
||||
callback peut tenter de déclencher un fetch sur un composant déjà démonté.
|
||||
|
||||
**Solution** :
|
||||
|
||||
```typescript
|
||||
import { onUnmounted } from 'vue';
|
||||
|
||||
// Dans useRenderer() :
|
||||
onUnmounted(() => {
|
||||
if (refreshDebounceTimer !== null) {
|
||||
clearTimeout(refreshDebounceTimer);
|
||||
refreshDebounceTimer = null;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. `useRenderers.ts:45-46` — Singleton SSE initialisé par flag de module non réinitialisable
|
||||
|
||||
**Problème** :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:45-46
|
||||
let sseInitialized = false;
|
||||
function ensureSSEInitialized() {
|
||||
if (sseInitialized) return;
|
||||
// ...
|
||||
sseInitialized = true;
|
||||
}
|
||||
```
|
||||
|
||||
**Cause** : ce flag de module est persistant pour toute la durée de vie de la page. Si la
|
||||
connexion SSE est perdue puis rétablie avec un nouvel objet `PMOControlSSE`, le handler
|
||||
`onRendererEvent` précédent peut ne plus être actif, mais `sseInitialized` empêche sa
|
||||
re-enregistration.
|
||||
|
||||
**Conséquence** : après une déconnexion et reconnexion SSE, les événements renderer peuvent
|
||||
ne plus être reçus par `useRenderers` jusqu'à un rechargement de page.
|
||||
|
||||
**Solution** : exposer une fonction `resetSSE()` qui remet `sseInitialized = false` et la
|
||||
connecter à l'événement de reconnexion du service SSE. Alternativement, utiliser le pattern
|
||||
`provide/inject` ou un store Pinia pour gérer le cycle de vie SSE explicitement, en lieu et
|
||||
place du flag de module.
|
||||
|
||||
---
|
||||
|
||||
## Qualité du code
|
||||
|
||||
### 8. `useTabs.ts` — Deep watch déclenchant une sérialisation localStorage à chaque mutation
|
||||
|
||||
**Problème** : le watch qui persiste l'état des onglets utilise `{ deep: true }` sur un
|
||||
tableau qui peut contenir jusqu'à 12 entrées avec des métadonnées :
|
||||
|
||||
```typescript
|
||||
// useTabs.ts:349-355
|
||||
watch(
|
||||
() => [state.tabs, state.activeTabId, state.tabHistory],
|
||||
() => { saveToLocalStorage(); },
|
||||
{ deep: true },
|
||||
);
|
||||
```
|
||||
|
||||
**Cause** : `{ deep: true }` traverse récursivement toutes les propriétés observées.
|
||||
`saveToLocalStorage()` appelle `JSON.stringify` sur l'ensemble des tabs à chaque mutation,
|
||||
même mineure (ex. changement de `activeTabId`).
|
||||
|
||||
**Solution** : surveiller les propriétés individuellement et sérialiser uniquement ce qui
|
||||
change, ou utiliser un computed pour construire la clé de changement :
|
||||
|
||||
```typescript
|
||||
// Watch séparés, sans deep
|
||||
watch(() => state.activeTabId, saveToLocalStorage);
|
||||
watch(() => state.tabHistory.length, saveToLocalStorage);
|
||||
watch(
|
||||
() => state.tabs.map(t => t.id + t.type + (t.metadata?.rendererId ?? '')).join('|'),
|
||||
saveToLocalStorage,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. `UnifiedControlView.vue:40-49` — Swipe : `clientX` final au lieu de la position initiale
|
||||
|
||||
**Problème** :
|
||||
|
||||
```typescript
|
||||
// UnifiedControlView.vue:40-49
|
||||
useSwipe(viewRef, {
|
||||
threshold: 50,
|
||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||
if (swipeDirection === "right" && !drawerOpen.value) {
|
||||
const touch = _e.changedTouches[0];
|
||||
if (touch && touch.clientX < 50) { // ← position finale du doigt
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Cause** : `onSwipeEnd` reçoit l'événement `touchend`. Dans `changedTouches`, `clientX`
|
||||
est la position **finale** du doigt (après le swipe), pas la position initiale. Un swipe
|
||||
commençant à `x=30` et terminant à `x=150` a `clientX=150` dans `touchend` — la condition
|
||||
`< 50` ne sera jamais vraie pour un swipe horizontal significatif.
|
||||
|
||||
**Conséquence** : le geste de swipe depuis le bord gauche ne fonctionne probablement pas
|
||||
sur les appareils tactiles.
|
||||
|
||||
**Solution** : capturer la position initiale dans `onSwipeStart` :
|
||||
|
||||
```typescript
|
||||
const swipeStartX = ref(0);
|
||||
|
||||
useSwipe(viewRef, {
|
||||
threshold: 50,
|
||||
onSwipeStart(e: TouchEvent) {
|
||||
swipeStartX.value = e.touches[0]?.clientX ?? 0;
|
||||
},
|
||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||
if (swipeDirection === "right" && !drawerOpen.value && swipeStartX.value < 50) {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. `api.ts:50` — Réponse JSON non validée avant le cast TypeScript
|
||||
|
||||
**Problème** :
|
||||
|
||||
```typescript
|
||||
// api.ts:50
|
||||
return response.json(); // retour typé T par inférence, sans validation
|
||||
```
|
||||
|
||||
**Cause** : `response.json()` retourne `Promise<any>`. TypeScript accepte le retour car la
|
||||
méthode `request<T>` promet `Promise<T>`, mais aucune validation de structure n'est effectuée.
|
||||
|
||||
**Conséquence** : si le backend renvoie un schéma légèrement différent (champ renommé, type
|
||||
changé), le bug se manifestera loin du point d'appel avec un message cryptique. En
|
||||
développement avec plusieurs instances en parallèle (`udn_prefix` différent), une requête
|
||||
dirigée vers la mauvaise instance peut retourner un format inattendu.
|
||||
|
||||
**Solution pragmatique** : ajouter une validation légère avec un type guard pour les réponses
|
||||
critiques, ou au minimum loguer la réponse brute en mode développement :
|
||||
|
||||
```typescript
|
||||
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
// ...
|
||||
const data = await response.json();
|
||||
if (import.meta.env.DEV && data == null) {
|
||||
console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
```
|
||||
|
||||
Pour les endpoints critiques (`getRendererFullSnapshot`, `getRenderers`), envisager un
|
||||
schéma de validation Zod ou une assertion runtime minimale.
|
||||
|
||||
---
|
||||
|
||||
## Accessibilité et feedback utilisateur
|
||||
|
||||
### 11. Absence de labels ARIA sur les contrôles transport
|
||||
|
||||
Les composants `TransportControls.vue` et `VolumeControl.vue` contiennent des boutons
|
||||
iconiques (play, pause, stop, volume) sans attributs `aria-label`. Les lecteurs d'écran
|
||||
ne peuvent pas identifier la fonction de ces contrôles.
|
||||
|
||||
**Correction minimale** :
|
||||
|
||||
```html
|
||||
<!-- TransportControls.vue -->
|
||||
<button @click="play" aria-label="Lecture">
|
||||
<PlayIcon />
|
||||
</button>
|
||||
<button @click="pause" aria-label="Pause">
|
||||
<PauseIcon />
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12. Erreurs réseau silencieuses sans feedback utilisateur
|
||||
|
||||
Plusieurs appels critiques sont lancés en fire-and-forget sans propagation vers l'UI :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:86
|
||||
void fetchRenderers(true); // erreur loggée en console uniquement
|
||||
|
||||
// useRenderers.ts:89
|
||||
void fetchRendererSnapshot(rendererId, { force: true }); // idem
|
||||
```
|
||||
|
||||
Le store `ui.ts` dispose d'un système de notifications toast (`addNotification`). Les erreurs
|
||||
de réseau devraient y être propagées pour informer l'utilisateur :
|
||||
|
||||
```typescript
|
||||
import { useUIStore } from '@/stores/ui';
|
||||
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Dans le handler SSE :
|
||||
try {
|
||||
await fetchRenderers(true);
|
||||
} catch {
|
||||
uiStore.addNotification({
|
||||
message: 'Impossible de rafraîchir la liste des renderers',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Points forts à conserver
|
||||
|
||||
Ces patterns sont bien conçus et ne doivent pas être modifiés dans les corrections ci-dessus :
|
||||
|
||||
- **Déduplication des requêtes en vol** (`apiCache.ts:100-112`) : évite les appels réseau
|
||||
redondants quand plusieurs composants demandent la même ressource simultanément.
|
||||
- **Watch sélectif sur les IDs** (`UnifiedControlView.vue:139-150`) : calcule une clé
|
||||
synthétique `ids.join(',')` au lieu d'un deep watch sur le tableau de renderers.
|
||||
- **Backoff exponentiel SSE** (`sse.ts`) : reconnexion progressive 1s→2s→4s→8s→16s→30s
|
||||
avec cap. Implémentation robuste.
|
||||
- **Fetch batch contrôlé** (`useRenderers.ts:351-383`) : `fetchBatchSnapshots()` avec
|
||||
concurrence limitée (défaut 3) et délai inter-batches. Évite de saturer le réseau au
|
||||
démarrage.
|
||||
- **`filterRenderers` par UDN** (`UnifiedControlView.vue:107-120`) : filtre les WebRenderers
|
||||
étrangers en comparant le UDN normalisé. Logique correcte avec gestion du préfixe `uuid:`.
|
||||
|
||||
---
|
||||
|
||||
## État d'avancement — corrections appliquées (commit 2026-04-06)
|
||||
|
||||
Les 12 points de la revue ont été traités. Le tableau ci-dessous récapitule ce qui a été
|
||||
fait et ce qui reste à finir.
|
||||
|
||||
| # | Problème | État |
|
||||
|---|----------|------|
|
||||
| 1 | `apiCache` — TTL mutation globale | ✅ Corrigé (`ttl` par entrée dans `CacheEntry`, `isFresh()` lit `entry.ttl`) |
|
||||
| 2 | `useRenderers` — double réassignation post-switch | ✅ Corrigé (chaque `case` responsable, ligne 222 supprimée) |
|
||||
| 3 | `useRenderers` — `as any` sur `transport_state` | ✅ Corrigé (`isTransportState` guard dans `types.ts`) |
|
||||
| 4 | `apiCache` — `invalidate()` détruisait les subscriptions | ✅ Corrigé (`this.subscriptions.delete` supprimé) |
|
||||
| 5 | `useRenderers` — `toRaw()` / `reactive(Map)` fragile | ✅ Migré vers `shallowRef` + helpers `triggerSnapshotReactivity()` / `triggerLoadingReactivity()` |
|
||||
| 6 | `useRenderer()` — timer debounce non nettoyé | ✅ Corrigé (`onUnmounted` + `clearTimeout`) |
|
||||
| 7 | Singleton SSE non réinitialisable | ✅ Corrigé (`resetSSE()` exposé dans le retour de `useRenderers()`) |
|
||||
| 8 | `useTabs` — deep watch coûteux | ✅ Corrigé (watches séparés sans `deep: true`) |
|
||||
| 9 | Swipe — `clientX` final au lieu d'initial | ✅ Corrigé (`swipeStartX` capturé dans `onSwipeStart`) |
|
||||
| 10 | `api.ts` — JSON non validé | ✅ Corrigé (log dev-mode pour réponse nulle) |
|
||||
| 11 | Absence de labels ARIA | ✅ Corrigé (4 boutons transport + bouton mute + slider volume) |
|
||||
| 12 | Erreurs réseau silencieuses | ✅ Corrigé (`uiStore.notifyError()` dans `fetchRenderers` et `fetchRendererSnapshot`) |
|
||||
|
||||
---
|
||||
|
||||
## Tâches restantes — ✅ Toutes corrigées (commit 2026-04-06)
|
||||
|
||||
| # | Problème résiduel | État |
|
||||
|---|-------------------|------|
|
||||
| A | `state_changed` — mutation directe sans trigger réactivité | ✅ Corrigé (spread + `triggerSnapshotReactivity()`) |
|
||||
| B | `queue_refreshing`/`queue_updated` — `queueRefreshingIds` sans trigger | ✅ Corrigé (`triggerQueueReactivity()` ajoutée et appelée) |
|
||||
| C | `toRaw` import obsolète, `position_changed` simplifié | ✅ Corrigé (`toRaw` supprimé de l'import, spread direct) |
|
||||
|
||||
---
|
||||
|
||||
### A. `useRenderers.ts` — `state_changed` : mutation directe sans déclenchement de réactivité
|
||||
|
||||
**Problème** : avec la migration vers `shallowRef`, les objets dans la Map ne sont plus des
|
||||
proxies Vue. La mutation directe de `snapshot.state.transport_state` ne déclenche aucune
|
||||
réactivité — les composants ne se mettront pas à jour quand l'état de transport change :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts — case state_changed (code actuel)
|
||||
case "state_changed":
|
||||
if (isTransportState(event.state)) {
|
||||
snapshot.state.transport_state = event.state; // ← mutation directe, pas de trigger
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
**Solution** : créer un nouvel objet, comme pour `volume_changed` et `mute_changed` :
|
||||
|
||||
```typescript
|
||||
case "state_changed":
|
||||
if (isTransportState(event.state)) {
|
||||
snapshots.value.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, transport_state: event.state },
|
||||
});
|
||||
} else {
|
||||
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### B. `useRenderers.ts` — `queue_refreshing` / `queue_updated` : mutations de `queueRefreshingIds` sans trigger
|
||||
|
||||
**Problème** : `queueRefreshingIds` est un `shallowRef<Set>`. Les appels `.add()` et
|
||||
`.delete()` sur `.value` ne déclenchent pas la réactivité de `shallowRef` :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts — case queue_refreshing (code actuel)
|
||||
case "queue_refreshing":
|
||||
queueRefreshingIds.value.add(rendererId); // ← pas de trigger
|
||||
break;
|
||||
|
||||
case "queue_updated":
|
||||
queueRefreshingIds.value.delete(rendererId); // ← pas de trigger
|
||||
break;
|
||||
```
|
||||
|
||||
Le composable `isQueueRefreshing(id)` retourne `queueRefreshingIds.value.has(id)`. Sans
|
||||
trigger, les templates qui dépendent de cette valeur ne se recalculeront pas.
|
||||
|
||||
**Solution** : ajouter une fonction `triggerQueueReactivity()` analogue à
|
||||
`triggerLoadingReactivity()` et l'appeler après chaque mutation :
|
||||
|
||||
```typescript
|
||||
function triggerQueueReactivity() {
|
||||
queueRefreshingIds.value = new Set(queueRefreshingIds.value);
|
||||
}
|
||||
|
||||
// Dans le switch :
|
||||
case "queue_refreshing":
|
||||
queueRefreshingIds.value.add(rendererId);
|
||||
triggerQueueReactivity();
|
||||
break;
|
||||
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.value.delete(rendererId);
|
||||
triggerQueueReactivity();
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C. `useRenderers.ts` — import `toRaw` et commentaire obsolètes
|
||||
|
||||
**Problème** : avec `shallowRef`, les objets stockés dans `snapshots.value` sont de simples
|
||||
objets JavaScript (jamais des proxies Vue). L'appel `toRaw(snapshot.state)` dans
|
||||
`position_changed` est devenu un no-op, et le commentaire qui le justifie est trompeur :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:160-163 (commentaire et import obsolètes)
|
||||
// IMPORTANT: Utiliser toRaw() pour obtenir l'objet brut non-réactif avant de copier
|
||||
// sinon Vue copie les getters réactifs qui continuent à pointer vers l'objet d'origine
|
||||
const rawState = toRaw(snapshot.state);
|
||||
const newState = { ...rawState };
|
||||
```
|
||||
|
||||
**Solution** : supprimer le `toRaw()`, simplifier en spread direct, retirer `toRaw` de
|
||||
l'import ligne 7 :
|
||||
|
||||
```typescript
|
||||
// Remplacer :
|
||||
import { ref, shallowRef, computed, toRaw, type Ref, onUnmounted } from "vue";
|
||||
|
||||
// Par :
|
||||
import { ref, shallowRef, computed, type Ref, onUnmounted } from "vue";
|
||||
|
||||
// Dans position_changed :
|
||||
const newSnapshot = {
|
||||
...snapshot,
|
||||
state: {
|
||||
...snapshot.state,
|
||||
position_ms: positionMs ?? 0,
|
||||
duration_ms: durationMs,
|
||||
},
|
||||
};
|
||||
snapshots.value.set(rendererId, newSnapshot);
|
||||
```
|
||||
|
||||
Cette simplification rend aussi le cas `position_changed` cohérent avec les autres cases
|
||||
(`volume_changed`, `mute_changed`, etc.) qui construisent directement l'objet final sans
|
||||
passer par une variable intermédiaire.
|
||||
19
Blackboard/Report/enorme_playlist.md
Normal file
19
Blackboard/Report/enorme_playlist.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Rapport : Optimisation performance OpenHome playlist
|
||||
|
||||
## Résumé
|
||||
Les optimizations implementées réduisent significativement le temps de synchronisation des playlists OpenHome de ~1000 titres. Les principales améliorations : passage du batch ReadList de 64 à 256 (−75% appels SOAP), elimination des doubles appels queue_snapshot() (−50% appels SOAP), et introduction du polling adaptatif avec intervalle long en veille (5s vs 500ms).
|
||||
|
||||
## Fichiers modifies
|
||||
|
||||
1. `pmocontrol/src/queue/openhome.rs`
|
||||
- Batch ReadList augmente de 64 a 256
|
||||
- Signature de replace_queue_with_pivot et replace_queue_standard_lcs modifiee pour accepter snapshot et current_track_ids
|
||||
- Appel a sync_queue mis a jour pour passer les donnees deja disponibles
|
||||
-Nouvelle fonction lcs_flags_optimized avec elimination pre/suffixe communs
|
||||
|
||||
2. `pmocontrol/src/music_renderer/watcher.rs`
|
||||
- Ajout du champ is_active dans WatchedState pour le polling adaptatif
|
||||
|
||||
3. `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
- Boucle watcher avec intervalle adaptatif (500ms actif, 5000ms veille)
|
||||
- Marqueurs is_active=true dans play(), stop(), seek_rel_time(), sync_queue()
|
||||
323
Blackboard/ToDiscuss/play_from_url.md
Normal file
323
Blackboard/ToDiscuss/play_from_url.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Play From URL — source `UrlSource`
|
||||
|
||||
Inspiré par BubbleUPnP : recevoir n'importe quelle URL (lien de partage Qobuz,
|
||||
flux radio, playlist M3U, page web contenant de l'audio…) et la jouer
|
||||
immédiatement sur le renderer actif.
|
||||
|
||||
---
|
||||
|
||||
## Vision architecturale
|
||||
|
||||
`UrlSource` est une **source musicale ordinaire** qui implémente `MusicSource`,
|
||||
exactement comme Qobuz, RadioFrance ou RadioParadise. Elle apparaît dans le
|
||||
drawer gauche au même titre que les autres sources du serveur PMO.
|
||||
|
||||
Sa particularité : sa **barre de recherche est le champ URL**. L'utilisateur
|
||||
colle ou tape une URL, appuie sur Entrée — la source résout l'URL et retourne
|
||||
le contenu jouable comme un `BrowseResult` normal.
|
||||
|
||||
```
|
||||
Drawer gauche
|
||||
└─ PMO Music Server
|
||||
├─ Qobuz
|
||||
├─ Radio Paradise
|
||||
├─ Radio France
|
||||
└─ URL / Partage ← nouvelle source
|
||||
└─ [barre de recherche = champ URL]
|
||||
└─ coller une URL + Entrée
|
||||
└─ résolution → BrowseResult → queue + play
|
||||
```
|
||||
|
||||
Avantages de cette approche :
|
||||
- **Zéro nouvelle UI** : la barre de recherche existante du drawer gère tout
|
||||
- **Zéro nouvel endpoint REST** : browse/search existants suffisent
|
||||
- **Zéro cas particulier** dans le drawer ou le content directory handler
|
||||
- `browse()` du container racine peut afficher un **historique** des URLs jouées
|
||||
|
||||
---
|
||||
|
||||
## Trait `UrlHandler` (dans `pmosource`)
|
||||
|
||||
Chaque source (et un handler générique) peut revendiquer les URLs qu'elle sait
|
||||
résoudre.
|
||||
|
||||
```rust
|
||||
pub enum ResolvedContent {
|
||||
/// Référence à un container d'une source existante
|
||||
/// → la UrlSource délègue le browse à cette source
|
||||
SourceContainer {
|
||||
source_id: String, // "qobuz", "radiofrance", …
|
||||
container_id: String, // "qobuz:album:l46fxnqnxp5vs"
|
||||
},
|
||||
/// Liste de tracks (M3U, PLS, XSPF, RSS/podcast…)
|
||||
Playlist {
|
||||
title: Option<String>,
|
||||
items: Vec<ResolvedTrack>,
|
||||
},
|
||||
/// Track unique ou flux continu
|
||||
Track {
|
||||
uri: String,
|
||||
metadata: TrackMetadata,
|
||||
},
|
||||
Stream {
|
||||
uri: String,
|
||||
metadata: StreamMetadata,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UrlHandler: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
/// Priorité : plus grand = essayé en premier (défaut 50)
|
||||
fn priority(&self) -> u8 { 50 }
|
||||
/// Test rapide sans I/O (regex sur l'URL)
|
||||
fn can_handle(&self, url: &str) -> bool;
|
||||
/// Résolution effective (I/O autorisé)
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handlers spécifiques aux sources
|
||||
|
||||
### `QobuzUrlHandler` (dans `pmoqobuz`) — priorité 90
|
||||
|
||||
URLs reconnues. Les IDs sont potentiellement alphanumériques pour tous les
|
||||
types (pas seulement les albums) :
|
||||
|
||||
| Forme d'URL | Exemple |
|
||||
|---|---|
|
||||
| `open.qobuz.com/album/<id>` | `https://open.qobuz.com/album/l46fxnqnxp5vs` |
|
||||
| `play.qobuz.com/album/<id>` | `https://play.qobuz.com/album/l46fxnqnxp5vs` |
|
||||
| `open.qobuz.com/track/<id>` | `https://open.qobuz.com/track/48471123` |
|
||||
| `open.qobuz.com/playlist/<id>` | `https://open.qobuz.com/playlist/63246908` |
|
||||
| `open.qobuz.com/artist/<id>` | `https://open.qobuz.com/artist/125709` |
|
||||
|
||||
Regex d'extraction : `[a-zA-Z0-9]+` pour tous les types sans exception.
|
||||
|
||||
Résolution sans appel API — l'ID est directement mappé sur un container_id :
|
||||
|
||||
```
|
||||
open.qobuz.com/album/l46fxnqnxp5vs
|
||||
→ ResolvedContent::SourceContainer {
|
||||
source_id: "qobuz",
|
||||
container_id: "qobuz:album:l46fxnqnxp5vs",
|
||||
}
|
||||
```
|
||||
|
||||
### `RadioFranceUrlHandler` (dans `pmoradiofrance`) — priorité 90
|
||||
|
||||
URLs `radiofrance.fr/*`, `francemusique.fr/*`, `fip.fr/*`, etc.
|
||||
→ `ResolvedContent::Stream`
|
||||
|
||||
### `RadioParadiseUrlHandler` (dans `pmoparadise`) — priorité 90
|
||||
|
||||
URLs `radioparadise.com/*`
|
||||
→ `ResolvedContent::Stream`
|
||||
|
||||
---
|
||||
|
||||
## Handler générique (dans `pmourlresolver`, nouveau crate) — priorité 10
|
||||
|
||||
Dernier recours. Pipeline interne :
|
||||
|
||||
```
|
||||
URL
|
||||
│
|
||||
├─ Garde-fou SSRF : rejeter si IP résolue est privée/locale
|
||||
│ (RFC-1918 : 10/8, 172.16/12, 192.168/16 ; loopback : 127/8, ::1 ;
|
||||
│ link-local : 169.254/16, fe80::/10)
|
||||
│ → aucun cas d'usage légitime pour une URL interne ici
|
||||
│
|
||||
├─ HEAD request → Content-Type audio/* ?
|
||||
│ └─ → ResolvedContent::Stream / Track (URI directe)
|
||||
│
|
||||
├─ Extension ou Content-Type playlist ?
|
||||
│ ├─ .m3u / .m3u8 / application/vnd.apple.mpegurl → parse M3U
|
||||
│ ├─ .pls / audio/x-scpls → parse PLS
|
||||
│ └─ .xspf / application/xspf+xml → parse XSPF
|
||||
│
|
||||
├─ application/rss+xml / application/xml ?
|
||||
│ └─ → parse RSS, extraire les <enclosure> audio → Playlist
|
||||
│
|
||||
└─ text/html ?
|
||||
└─ GET + parse HTML
|
||||
├─ <audio src="...">
|
||||
├─ <link type="application/rss+xml"> → RSS/Podcast
|
||||
├─ og:audio
|
||||
└─ JSON-LD @type MusicRecording / MusicAlbum
|
||||
```
|
||||
|
||||
Pas de yt-dlp ni de dépendance Python externe — hors scope.
|
||||
|
||||
---
|
||||
|
||||
## `UrlSource` — implémentation de `MusicSource`
|
||||
|
||||
```rust
|
||||
pub struct UrlSource {
|
||||
resolver: UrlResolver, // registre des handlers
|
||||
history: Arc<RwLock<VecDeque<HistoryEntry>>>, // dernières URLs
|
||||
}
|
||||
```
|
||||
|
||||
### `name()` / `id()`
|
||||
|
||||
```rust
|
||||
fn name(&self) -> &str { "URL / Partage" }
|
||||
fn id(&self) -> &str { "url" }
|
||||
```
|
||||
|
||||
### `root_container()`
|
||||
|
||||
Retourne un container dont le contenu (`browse("url")`) est l'historique des
|
||||
dernières URLs résolues avec succès (titre, source résolue, date).
|
||||
|
||||
### `search(query)` — cœur de la fonctionnalité
|
||||
|
||||
`query.text` est l'URL collée par l'utilisateur.
|
||||
|
||||
```
|
||||
search(url)
|
||||
│
|
||||
├─ resolver.resolve(url)
|
||||
│
|
||||
└─ match ResolvedContent
|
||||
├─ SourceContainer { source_id, container_id }
|
||||
│ → get_source(source_id) → source.browse(container_id)
|
||||
│ → retourner le BrowseResult tel quel
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
├─ Playlist { items }
|
||||
│ → construire un BrowseResult::Items depuis les tracks
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
├─ Track / Stream
|
||||
│ → BrowseResult::Items avec un seul item
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
└─ Err → BrowseResult vide + log
|
||||
```
|
||||
|
||||
Pour la délégation `SourceContainer`, `UrlSource` accède au `SOURCE_REGISTRY`
|
||||
global (déjà disponible dans `pmosource`). Elle est enregistrée après les autres
|
||||
sources donc elles sont toutes présentes au moment de la résolution.
|
||||
|
||||
### `browse(container_id)`
|
||||
|
||||
- `"url"` → liste de l'historique (containers/items)
|
||||
- `"url:history:<n>"` → détail d'une entrée historique (si Playlist)
|
||||
|
||||
---
|
||||
|
||||
## Initialisation dans `pmomediaserver`
|
||||
|
||||
```rust
|
||||
// Après enregistrement de Qobuz, RadioFrance, RadioParadise…
|
||||
|
||||
let mut resolver = UrlResolver::new();
|
||||
resolver.register(Arc::new(QobuzUrlHandler::new()));
|
||||
resolver.register(Arc::new(RadioFranceUrlHandler::new()));
|
||||
resolver.register(Arc::new(RadioParadiseUrlHandler::new()));
|
||||
resolver.register(Arc::new(GenericUrlHandler::new())); // toujours en dernier
|
||||
|
||||
let url_source = Arc::new(UrlSource::new(resolver));
|
||||
register_source(url_source).await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Points d'entrée
|
||||
|
||||
Le pipeline de résolution (`UrlResolver`) est le même quel que soit le point
|
||||
d'entrée. Deux modes complémentaires :
|
||||
|
||||
### Mode "pull" — le drawer
|
||||
|
||||
1. L'utilisateur ouvre le drawer gauche → voit "URL / Partage" dans la liste
|
||||
2. Il entre dedans → voit l'historique et la barre avec placeholder "Coller une URL…"
|
||||
3. Il colle `https://open.qobuz.com/album/l46fxnqnxp5vs` + Entrée
|
||||
4. Le drawer affiche les tracks de l'album (délégation Qobuz transparente)
|
||||
5. Il clique ▶ sur un track ou l'album entier → lecture normale
|
||||
|
||||
Aucune modification du drawer nécessaire.
|
||||
|
||||
### Mode "push" — endpoint REST + Web Share Target (Android)
|
||||
|
||||
Endpoint REST dans `pmocontrol` :
|
||||
|
||||
```
|
||||
POST /api/play-url
|
||||
{ "url": "https://open.qobuz.com/album/l46fxnqnxp5vs" }
|
||||
```
|
||||
|
||||
Résout l'URL via `UrlResolver` → ajoute au renderer actif → lecture immédiate.
|
||||
Pas de navigation dans le drawer, pas de clic supplémentaire.
|
||||
|
||||
**Web Share Target (PWA)** — intégration dans le share sheet Android :
|
||||
|
||||
```json
|
||||
// manifest.json
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
"method": "GET",
|
||||
"params": { "url": "url" }
|
||||
}
|
||||
```
|
||||
|
||||
La page `/share?url=...` appelle l'endpoint REST et se ferme. Depuis n'importe
|
||||
quelle application Android (Qobuz, navigateur, Spotify…) : menu "Partager" →
|
||||
choisir PMOMusic → l'album/track joue immédiatement sur le renderer courant,
|
||||
exactement comme BubbleUPnP.
|
||||
|
||||
Le renderer "courant" est celui qui est sélectionné dans la session active.
|
||||
Pour une PWA installée sur Android, c'est la session de l'utilisateur
|
||||
qui a installé l'app. Si plusieurs renderers sont disponibles, l'endpoint
|
||||
peut prendre un paramètre optionnel `renderer_id` pour cibler explicitement.
|
||||
|
||||
---
|
||||
|
||||
## Plan d'implémentation
|
||||
|
||||
### Étape 1 — Trait + QobuzUrlHandler + UrlSource minimale
|
||||
|
||||
- [ ] Ajouter `UrlHandler`, `ResolvedContent`, `UrlResolver` dans `pmosource`
|
||||
- [ ] Implémenter `QobuzUrlHandler` dans `pmoqobuz` (regex + mapping container_id)
|
||||
- [ ] Implémenter `UrlSource` avec `search()` gérant `SourceContainer`
|
||||
- [ ] Enregistrer dans `pmomediaserver`
|
||||
- [ ] Tester : coller un lien Qobuz → album joue
|
||||
|
||||
### Étape 2 — Formats de playlist directs
|
||||
|
||||
- [ ] Nouveau crate `pmourlresolver` avec `GenericUrlHandler`
|
||||
- [ ] Garde-fou SSRF (`is_safe_url()`)
|
||||
- [ ] Détection Content-Type + parse M3U, PLS, XSPF
|
||||
- [ ] `UrlSource::search()` gère `Playlist` et `Track/Stream`
|
||||
|
||||
### Étape 3 — Scraper HTML + historique
|
||||
|
||||
- [ ] Parse HTML : `<audio>`, og:audio, JSON-LD, RSS
|
||||
- [ ] Historique dans `UrlSource::browse()`
|
||||
- [ ] Placeholder adapté dans la barre de recherche du drawer
|
||||
|
||||
---
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
**Q1 — Barre de recherche : placeholder contextuel**
|
||||
Quand l'utilisateur est dans "URL / Partage", le placeholder devrait afficher
|
||||
"Coller une URL…" plutôt que "Rechercher…". Le drawer peut-il adapter le
|
||||
placeholder selon la source active ? À voir si c'est utile en pratique (le
|
||||
titre de la source dans le header est déjà indicatif).
|
||||
|
||||
**Q2 — Redirections**
|
||||
Les liens de partage mobiles Qobuz peuvent être des URLs raccourcies. Suivre
|
||||
les redirections automatiquement (reqwest le fait avec
|
||||
`redirect::Policy::limited(5)`).
|
||||
|
||||
**Q3 — Validation Qobuz**
|
||||
Le `QobuzUrlHandler` retourne un `SourceContainer` sans vérifier que l'album
|
||||
existe ou est accessible. L'erreur éventuelle sera levée au moment du browse
|
||||
délégué à `QobuzSource`. C'est acceptable : l'erreur arrivera rapidement avec
|
||||
un message clair.
|
||||
1193
Blackboard/ToThinkAbout/plant_webrenderer_server_side_streaming.md
Normal file
1193
Blackboard/ToThinkAbout/plant_webrenderer_server_side_streaming.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,139 +1,443 @@
|
||||
Parfait. Voici un **schéma fonctionnel minimal** pour un **MediaRenderer UPnP privé par navigateur** avec **token**. L’idée est de rester fidèle à ton backend Rust existant et à la webapp Vue.js. En s'appuyant sur l'architecture de PMOMusic, j'aimerais que tu proposes un plan détaillé pour implémenter un tel système de Média Renderer.
|
||||
# Web Media Renderer - Architecture
|
||||
|
||||
- L'application web se trouve dans: [@webapp](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoapp/webapp)
|
||||
- Tu as un prototype de Média Renderer dans: [@pmomediarenderer](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmomediarenderer)
|
||||
- Le contrôle point est dans : [@pmocontrol](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol)
|
||||
- Tu implémenteras ce nouveau système de Média Renderer dans la CRATe pmowebrenderer
|
||||
## Vision
|
||||
|
||||
Tu mettras une version du plan en Markdown dans le répertoire Architecture.
|
||||
Système de Media Renderer pilotable à distance via UPnP, exposant un flux audio vers différents types de lecteurs physiques.
|
||||
|
||||
---
|
||||
## Architecture globale en 4 parties
|
||||
|
||||
## 1. Flow général
|
||||
|
||||
```
|
||||
Browser (Vue.js Control Point)
|
||||
┌───────────────┐
|
||||
│ UI / audio │
|
||||
│ WebSocket │
|
||||
└───────▲───────┘
|
||||
│ token
|
||||
│
|
||||
▼
|
||||
Rust backend (UPnP MediaRenderer)
|
||||
┌───────────────────────────┐
|
||||
│ Token → Renderer mapping │
|
||||
│ Device XML / SOAP endpoints│
|
||||
│ Play/Pause/Stop → WS → Browser │
|
||||
└───────────────────────────┘
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Media Server] -->|flux audio| B[Control Point]
|
||||
B -->|commandes| C[Web Media Renderer]
|
||||
C -->|flux + contrôles| D[Device physique]
|
||||
|
||||
subgraph Devices physiques
|
||||
D1[Browser]
|
||||
D2[Android Auto]
|
||||
D3[Apple CarPlay]
|
||||
D4[Sonos multipoint]
|
||||
D5[Chromecast]
|
||||
end
|
||||
|
||||
D --> D1
|
||||
D --> D2
|
||||
D --> D3
|
||||
D --> D4
|
||||
D --> D5
|
||||
```
|
||||
|
||||
---
|
||||
### Rôles
|
||||
|
||||
## 2. Étapes détaillées
|
||||
1. **Media Server** - Source audio (le flux OGG-FLAC existant)
|
||||
2. **Control Point** - Interface UI qui envoie les commandes (pause, play, seek, next, prev)
|
||||
3. **Web Media Renderer** - Hub qui expose le flux et traduit les commandes selon le device
|
||||
4. **Physical Device** - Lecteur final (browser, voiture, Sonos, Chromecast...)
|
||||
|
||||
### a) Création du renderer
|
||||
## Web Media Renderer - Rôle central
|
||||
|
||||
1. Le navigateur se connecte via WebSocket ou HTTP.
|
||||
2. Rust génère un token unique pour ce client :
|
||||
|
||||
```rust
|
||||
use uuid::Uuid;
|
||||
let token = Uuid::new_v4().to_string();
|
||||
```
|
||||
3. Rust crée une instance MediaRenderer **privée**, associée à ce token :
|
||||
|
||||
* Device description XML : `/renderer/<token>/desc.xml`
|
||||
* AVTransport SOAP : `/renderer/<token>/avtransport`
|
||||
* RenderingControl SOAP : `/renderer/<token>/renderingcontrol`
|
||||
|
||||
---
|
||||
|
||||
### b) Control Point
|
||||
|
||||
* La webapp Vue.js reçoit le token et la “déclare” au Control Point :
|
||||
|
||||
```js
|
||||
const renderer = {
|
||||
token: "abcd-1234-efgh",
|
||||
name: "Browser Renderer"
|
||||
};
|
||||
|
||||
// Ajout au control point local
|
||||
controlPoint.addRenderer(renderer);
|
||||
```
|
||||
|
||||
* Toutes les commandes Play/Pause/Stop incluent ce token :
|
||||
|
||||
```js
|
||||
ws.send(JSON.stringify({
|
||||
token: renderer.token,
|
||||
action: "play",
|
||||
uri: "http://localhost:8080/media.mp3"
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### c) Backend Rust : dispatcher les commandes
|
||||
|
||||
* Rust reçoit le JSON avec le token.
|
||||
* Vérifie que le token correspond à un renderer actif.
|
||||
* Transmet la commande au navigateur via WebSocket (ou HTTP push) :
|
||||
|
||||
```rust
|
||||
match msg.action.as_str() {
|
||||
"play" => send_ws_to_browser(&token, format!("play:{}", msg.uri)),
|
||||
"pause" => send_ws_to_browser(&token, "pause".to_string()),
|
||||
"stop" => send_ws_to_browser(&token, "stop".to_string()),
|
||||
_ => (),
|
||||
```mermaid
|
||||
blockdiag
|
||||
{
|
||||
block = Commandes UPnP
|
||||
block -> "Web Media Renderer" -> Adaptation selon device
|
||||
"Web Media Renderer" -> Device-specific protocols
|
||||
}
|
||||
```
|
||||
|
||||
* Rust met à jour l’état du renderer (AVTransport/RenderingControl) pour le Control Point.
|
||||
### Rôle central: Adaptateur
|
||||
|
||||
---
|
||||
Le Web Media Renderer est un **adaptateur** qui:
|
||||
- **Reçoit le flux** du Media Server (OGG-FLAC)
|
||||
- **Reçoit les commandes** du Control Point (UPnP)
|
||||
- **Les traduit** vers les devices physiques
|
||||
- **Expose une API de contrôle** commune
|
||||
|
||||
### d) Lecture côté navigateur
|
||||
### Ce qui est COMMUN (factorisé)
|
||||
|
||||
* Le navigateur reçoit la commande via WebSocket et pilote `<audio>` :
|
||||
| Layer | Description |
|
||||
|-------|-------------|
|
||||
| **API contrôle** | pause, resume, seek, next, prev, flush, stop |
|
||||
| **Métadonnées** | /nowplaying, /metadata, /state |
|
||||
| **Flux audio** | OGG-FLAC (identique pour tous) |
|
||||
| **StreamType** | Continuous vs Finite |
|
||||
|
||||
```js
|
||||
ws.onmessage = (evt) => {
|
||||
const msg = evt.data;
|
||||
if(msg.startsWith("play:")) {
|
||||
audio.src = msg.split(":")[1];
|
||||
audio.play();
|
||||
} else if(msg === "pause") {
|
||||
audio.pause();
|
||||
} else if(msg === "stop") {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
};
|
||||
### Ce qui est SPÉCIFIQUE (par device)
|
||||
|
||||
| Device | Transport | Buffer Management | Sync |
|
||||
|--------|-----------|-------------------|------|
|
||||
| Browser | HTTP/WebSocket | JS flush | N/A |
|
||||
| Android Auto | AA API | native | varies |
|
||||
| CarPlay | CP API | native | varies |
|
||||
| Sonos | UPnP | none | UPnP |
|
||||
| Chromecast | Cast API | none | Cast |
|
||||
|
||||
### Problème du buffer (Browser)
|
||||
|
||||
Le browser buffer cause des delais de reaction:
|
||||
- **Pause**: delai de 5 secondes
|
||||
- **Seek**: cherche dans le buffer, pas dans le nouveau flux
|
||||
- **Next/Prev**: changement reporte
|
||||
|
||||
**Solutions**:
|
||||
1. Web Audio API - `audioContext.suspend()/resume()` - plus petit buffer (~50ms)
|
||||
2. Frontend flush buffer
|
||||
3. Chaque client manage son propre buffer, Web Media Renderer juste expose API
|
||||
|
||||
## Implémentation actuelle
|
||||
|
||||
### Faits
|
||||
|
||||
- Flux audio OGG-FLAC ✓
|
||||
- Pause/Resume ✓ (via `OggFlacStreamHandle`)
|
||||
- TrackBoundary pour OGG segments
|
||||
- StreamType (Continuous vs Finite)
|
||||
|
||||
### À faire
|
||||
|
||||
- seek/next/prev API
|
||||
- WebSocket pour temps réel
|
||||
- Metadata endpoint (/nowplaying JSON)
|
||||
- MPV integration (multi-point/multi-room)
|
||||
|
||||
## Code actuel - Pause/Resume
|
||||
|
||||
```rust
|
||||
// OggFlacStreamHandle - méthodes de contrôle
|
||||
pub fn pause(&self) {
|
||||
self.inner.is_paused.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn resume(&self) {
|
||||
self.inner.is_paused.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.inner.is_paused.load(Ordering::SeqCst)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### Différences Continuous vs Finite
|
||||
|
||||
### e) Fermeture / cleanup
|
||||
- **Continuous** (radio): pause -> sends silence, drops incoming chunks
|
||||
- **Finite** (tracks): pause -> don't receive chunks (backpressure), loops sending silence
|
||||
|
||||
* Quand le navigateur se déconnecte :
|
||||
## Schéma d'intégration
|
||||
|
||||
* Rust supprime le renderer associé au token
|
||||
* Émet un **byebye virtuel** pour le Control Point (si nécessaire)
|
||||
* Libère toutes les ressources
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CP as Control Point
|
||||
participant WMR as Web Media Renderer
|
||||
participant FS as Flux Server
|
||||
participant D as Device
|
||||
|
||||
CP->>WMR: pause()
|
||||
WMR->>FS: commande pause
|
||||
FS->>FS: pause state change
|
||||
FS->>WMR: silence (continuous) / blocked (finite)
|
||||
WMR->>D: flux avec silence
|
||||
D-->>CP: audio joué (avec delay si buffer)
|
||||
```
|
||||
|
||||
---
|
||||
## Le Web Media Renderer - Adaptateur
|
||||
|
||||
## 3. Points clés
|
||||
Le rôle central du Web Media Renderer est de **convertir des ordres UPnP en actions spécifiques** selon le device cible:
|
||||
|
||||
1. **Token unique** = session privée + sécurité
|
||||
2. **Pas besoin de SSDP / annonce** : le renderer est dédié à un navigateur connu
|
||||
3. **Control Point Vue.js** sait exactement quel renderer utiliser
|
||||
4. **Rust backend** reste seul responsable de l’implémentation UPnP
|
||||
5. **Lecture réelle** = navigateur via `<audio>` ou `<video>`
|
||||
```
|
||||
UPnP orders → [Web Media Renderer] → Device-specific actions
|
||||
```
|
||||
|
||||
---
|
||||
## Browser Player - Composant web invisible
|
||||
|
||||
💡 Ce modèle est très proche de ce que font **Mopidy avec Iris**, **Kodi Remote**, ou **Chromecast / local cast** : le device est connu et dédié, pas besoin de découverte réseau.
|
||||
Pour s'entraîner, on peut se focaliser sur un composant web qui:
|
||||
- Est **complètement invisible** (pas de UI)
|
||||
- Est **télécommandable** par le Web Media Renderer
|
||||
- Joue la musique dans le navigateur
|
||||
|
||||
### Specifications
|
||||
|
||||
| Requirement | Description |
|
||||
|-------------|-------------|
|
||||
| Invisible | Pas de UI, pas de controls, pas de visuel |
|
||||
| Remote control | Reçoit commandes via WebSocket/HTTP |
|
||||
| Auto-reconnect | Reconnection si stream coupé |
|
||||
| Buffer management | Flush commandée |
|
||||
| Audio format | OGG-FLAC stream |
|
||||
|
||||
### Architecture en 2 parties
|
||||
|
||||
| Partie | Langage | Rôle |
|
||||
|--------|---------|------|
|
||||
| Backend | Rust (pmoaudio-ext) | Contrôle, flux OGG-FLAC |
|
||||
| Frontend | JavaScript | Player invisible dans le browser |
|
||||
|
||||
#### Backend (Rust)
|
||||
|
||||
- Expose le flux audio (`/stream`)
|
||||
- API contrôle (`/pause`, `/resume`, `/seek`, `/flush`, `/stop`)
|
||||
- WebSocket pour temps réel (`/ws`)
|
||||
- **Reçoit les rapports de position/state**
|
||||
|
||||
```rust
|
||||
// Endpoints existants
|
||||
POST /pause
|
||||
POST /resume
|
||||
POST /seek?t={timestamp}
|
||||
POST /flush
|
||||
POST /stop
|
||||
|
||||
// Stream
|
||||
GET /stream
|
||||
|
||||
// WebSocket messages REÇUS du player:
|
||||
{
|
||||
"type": "position",
|
||||
"position_sec": 125.5,
|
||||
"duration_sec": 240.0,
|
||||
"state": "playing"
|
||||
}
|
||||
{
|
||||
"type": "track",
|
||||
"id": "...",
|
||||
"title": "...",
|
||||
"artist": "..."
|
||||
}
|
||||
{
|
||||
"type": "ready_state",
|
||||
"ready_state": "canplay"
|
||||
}
|
||||
```
|
||||
|
||||
#### Frontend (JavaScript)
|
||||
|
||||
Composant minimal (~100 lignes):
|
||||
|
||||
```javascript
|
||||
class RemotePlayer {
|
||||
constructor(wsUrl) {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.audio = new Audio();
|
||||
this.ac = new AudioContext();
|
||||
|
||||
this.ws.onmessage = (e) => this.handle(e.data);
|
||||
}
|
||||
|
||||
handle(msg) {
|
||||
switch(msg.type) {
|
||||
case 'stream': this.load(msg.url); break;
|
||||
case 'play': this.play(); break;
|
||||
case 'pause': this.pause(); break;
|
||||
case 'seek': this.seek(msg.timestamp); break;
|
||||
case 'flush': this.flush(); break;
|
||||
case 'stop': this.stop(); break;
|
||||
}
|
||||
}
|
||||
|
||||
load(url) {
|
||||
this.audio.src = url;
|
||||
}
|
||||
|
||||
play() {
|
||||
this.audio.play();
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
seek(ts) {
|
||||
this.audio.currentTime = ts;
|
||||
}
|
||||
|
||||
flush() {
|
||||
// Flush buffer immediatement
|
||||
this.audio.pause();
|
||||
this.audio.currentTime = 0;
|
||||
this.audio.src = '';
|
||||
this.ac.suspend();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```html
|
||||
<script src="pmo-player.js"></script>
|
||||
<script>
|
||||
const player = new PMOPlayer('ws://localhost:8080/ws');
|
||||
</script>
|
||||
```
|
||||
|
||||
### Fichier à créer
|
||||
|
||||
`pmoapp/webapp/src/services/PMOPlayer.ts`
|
||||
|
||||
### Endpoints HTTP
|
||||
|
||||
| Endpoint | Methode | Description |
|
||||
|----------|---------|-------------|
|
||||
| `/api/webrenderer/register` | POST | Enregistre instance |
|
||||
| `/api/webrenderer/{id}/stream` | GET | Flux audio OGG-FLAC |
|
||||
| `/api/webrenderer/{id}/position` | POST | Rapporte position |
|
||||
| `/api/webrenderer/{id}/report` | POST | Rapporte etat player |
|
||||
| `/api/webrenderer/{id}/command` | GET | Recupere commande pending |
|
||||
| `/api/webrenderer/{id}` | DELETE | Desenregistre |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Player (Browser) <--HTTP--> Backend
|
||||
- Report: position/state via POST /report
|
||||
- Poll: command via GET /command (500ms)
|
||||
- Stream: GET /stream
|
||||
```
|
||||
|
||||
### Réactivité
|
||||
|
||||
Pour maximiser la réactivité:
|
||||
|
||||
| Technique | Impact |
|
||||
|-----------|--------|
|
||||
| WebSocket | Temps réel vs HTTP polling |
|
||||
| AudioContext.suspend() | Buffer ~50ms au lieu de ~5s |
|
||||
| Flush command | Vide le buffer immediatement |
|
||||
| Native HTML5 audio | Le plus simple = le plus stable |
|
||||
|
||||
### Schéma
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant WMR as Web Media Renderer
|
||||
participant BP as Browser Player
|
||||
|
||||
WMR->>BP: stream(url)
|
||||
BP->>BP: audio.src = url; play()
|
||||
|
||||
WMR->>BP: pause()
|
||||
BP->>BP: audio.pause()
|
||||
|
||||
WMR->>BP: seek(timestamp)
|
||||
BP->>BP: audio.currentTime = timestamp
|
||||
|
||||
WMR->>BP: flush()
|
||||
BP->>BP: audioContext.suspend()
|
||||
```
|
||||
|
||||
### Mapping orders → actions par device
|
||||
|
||||
| UPnP order | Browser | Android Auto | CarPlay | Sonos | Chromecast |
|
||||
|-----------|---------|-------------|--------|------|-------------|
|
||||
| Play | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
|
||||
| Pause | `audio.pause()` | AA pause | CP pause | UPnP Pause | Cast pause |
|
||||
| Resume | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
|
||||
| Seek | `audio.currentTime=t` | AA seek | CP seek | UPnP Seek | Cast seek |
|
||||
| Next | fetch new stream | AA next | CP next | UPnP Next | Cast next |
|
||||
| Prev | fetch new stream | AA prev | CP prev | UPnP Prev | Cast prev |
|
||||
| Flush | JS `audioContext.suspend()` | AA flush | CP flush | N/A | Cast load |
|
||||
| Stop | `audio.stop()` | AA stop | CP stop | UPnP Stop | Cast stop |
|
||||
|
||||
### Protocole de contrôle
|
||||
|
||||
Le Web Media Renderer expose une API de contrôle uniforme qui est traduite selon le device:
|
||||
|
||||
### Commandes
|
||||
|
||||
| Commande | Description |
|
||||
|---------|-------------|
|
||||
| `play` | Lecture |
|
||||
| `pause` | Pause (silence ou backpressure) |
|
||||
| `resume` | Reprise |
|
||||
| `seek(t)` | Seek vers timestamp t |
|
||||
| `next` | Track suivante |
|
||||
| `prev` | Track précédente |
|
||||
| `flush` | **Flush buffer** - ordre critique pour reponse rapide |
|
||||
| `stop` | Arrêt total |
|
||||
|
||||
### Métadonnées
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `/nowplaying` | Track actuelle, timestamp, is_paused |
|
||||
| `/metadata` | TITLE, ARTIST, ALBUM, COVER |
|
||||
| `/state` | État complet (position, duration, volume...) |
|
||||
|
||||
### Ordres spéciaux pour devices avec buffer
|
||||
|
||||
Pour les devices типа Android Auto, CarPlay, Browser:
|
||||
- `flush` = vide le buffer immédiatement
|
||||
- `stop` = arrête + flush
|
||||
- Ces ordres doivent être traités en priorité
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CP as Control Point
|
||||
participant WMR as Web Media Renderer
|
||||
participant D as Device (Android Auto, Browser...)
|
||||
|
||||
CP->>WMR: flush()
|
||||
Note over WMR: Priorité haute - immédiat
|
||||
WMR->>D: FLUSH order
|
||||
D-->>WMR: ack
|
||||
WMR-->>CP: flushed
|
||||
|
||||
CP->>WMR: pause()
|
||||
Note over WMR: Standard
|
||||
WMR->>D: flux with silence
|
||||
```
|
||||
|
||||
## multipoint/multi-room
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
WMR[Web Media Renderer] -->|flux| R1[Renderer 1]
|
||||
WMR -->|flux| R2[Renderer 2]
|
||||
WMR -->|flux| R3[Renderer N]
|
||||
|
||||
R1 -->|sync| R2
|
||||
R2 -->|sync| R3
|
||||
```
|
||||
|
||||
Possibilités:
|
||||
- UPnP pour renderers UPnP
|
||||
- Cast API pour Chromecast
|
||||
- Serveur temps réel pour sync
|
||||
|
||||
## Notes techniques
|
||||
|
||||
### AudioChunk::silence()
|
||||
|
||||
```rust
|
||||
impl AudioChunk {
|
||||
pub fn silence(frames: usize, sample_rate: u32) -> Self {
|
||||
AudioChunk::I32(AudioChunkData::<i32>::silence(frames, sample_rate))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Sample> AudioChunkData<T> {
|
||||
pub fn silence(frames: usize, sample_rate: u32) -> Arc<Self> {
|
||||
Self::new(vec![[T::ZERO; 2]; frames], sample_rate, 0.0)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### StreamType
|
||||
|
||||
```rust
|
||||
pub enum StreamType {
|
||||
Continuous, // radio - silence pendant pause
|
||||
Finite, // tracks - backpressure pendant pause
|
||||
}
|
||||
```
|
||||
|
||||
### TrackBoundary avec StreamType
|
||||
|
||||
```rust
|
||||
SyncMarker::TrackBoundary {
|
||||
metadata: ...,
|
||||
stream_type: StreamType
|
||||
}
|
||||
```
|
||||
202
Blackboard/ToThinkAbout/webrenderer_server_side_streaming.md
Normal file
202
Blackboard/ToThinkAbout/webrenderer_server_side_streaming.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# WebRenderer : Streaming Audio Côté Serveur
|
||||
|
||||
## Problème actuel
|
||||
|
||||
Le webrenderer actuel délègue la lecture audio au navigateur : le serveur envoie une URL
|
||||
de fichier via WebSocket (`SetUri`), et le navigateur charge cette URL dans un élément
|
||||
`<audio>`. Cette approche a plusieurs limitations :
|
||||
|
||||
- Les URLs sont internes (IP locale + port) → inaccessibles depuis l'extérieur
|
||||
- Les fichiers sur partage Samba ont des chemins locaux → jamais accessibles au navigateur
|
||||
- La `base_url` doit être configurée statiquement → pas de solution propre local/externe
|
||||
|
||||
## Solution proposée : flux HTTP serveur
|
||||
|
||||
Le serveur génère un flux audio continu par instance de webrenderer, servi sur un endpoint
|
||||
HTTP dédié. Le navigateur n'écoute que ce flux — une URL fixe, toujours accessible.
|
||||
|
||||
```
|
||||
Avant : ControlPoint → SetAVTransportURI(url_interne) → WebSocket → Browser(<audio src=url_interne>)
|
||||
Après : ControlPoint → SetAVTransportURI(url_interne) → Serveur(ouvre+stream) → Browser(<audio src=/api/webrenderer/{id}/stream>)
|
||||
```
|
||||
|
||||
## Architecture cible
|
||||
|
||||
### Cycle de vie d'une instance
|
||||
|
||||
```
|
||||
1. Navigateur ouvre la page
|
||||
↓
|
||||
2. POST /api/webrenderer/register {instance_id, user_agent}
|
||||
↓
|
||||
3. Serveur crée le device UPnP + pipeline audio
|
||||
Annonce SSDP → ControlPoints découvrent le renderer
|
||||
Répond : { stream_url }
|
||||
↓
|
||||
4. Navigateur ouvre GET /api/webrenderer/{id}/stream (flux FLAC)
|
||||
↓
|
||||
5. ControlPoint → SetAVTransportURI + Play → pipeline démarre
|
||||
Navigateur écoute le flux FLAC en continu
|
||||
SSE global existant → métadonnées et état vers l'interface
|
||||
↓
|
||||
6. Navigateur ferme la page → flux FLAC se coupe
|
||||
Serveur détecte → SSDP byebye → pipeline stoppé
|
||||
Device UPnP retiré
|
||||
```
|
||||
|
||||
Le lecteur web est complètement invisible — l'interface est pilotée par le SSE global
|
||||
existant du ControlPoint. Le WebSocket est supprimé. Pas de SSE dédié au webrenderer.
|
||||
|
||||
### Endpoints HTTP
|
||||
|
||||
```
|
||||
POST /api/webrenderer/register
|
||||
Body: { instance_id, user_agent }
|
||||
Réponse: { stream_url }
|
||||
|
||||
GET /api/webrenderer/{id}/stream
|
||||
Content-Type: audio/flac
|
||||
Cache-Control: no-store, no-transform
|
||||
[Flux FLAC continu — déconnexion = fin de session]
|
||||
|
||||
DELETE /api/webrenderer/{id}
|
||||
Désenregistrement explicite (optionnel, fallback sur coupure du flux)
|
||||
```
|
||||
|
||||
La `stream_url` est une URL relative (`/api/webrenderer/{id}/stream`) — le navigateur
|
||||
la résout lui-même, toujours correcte en local et via proxy externe, sans reconstruction
|
||||
depuis les headers `X-Forwarded-*`.
|
||||
|
||||
### Composants nécessaires
|
||||
|
||||
#### 1. Pipeline audio par instance
|
||||
|
||||
Chaque instance possède :
|
||||
|
||||
- Un **`StreamingFlacSink`** — infrastructure existante dans `pmoaudio-ext`
|
||||
- Un **`StreamHandle`** — exposé via l'endpoint `/stream`
|
||||
- Un canal de contrôle **`PipelineControl`** — alimenté par les actions UPnP
|
||||
|
||||
Le pipeline est créé au `POST /register` et détruit à la coupure du flux FLAC.
|
||||
|
||||
#### 2. Enregistrement et création du device UPnP
|
||||
|
||||
```
|
||||
POST /api/webrenderer/register
|
||||
→ créer DeviceInstance UPnP (même factory qu'aujourd'hui)
|
||||
→ annoncer via SSDP (nouveau : aujourd'hui pas de SSDP pour le webrenderer)
|
||||
→ créer StreamingFlacSink + pipeline
|
||||
→ enregistrer dans le RendererRegistry
|
||||
→ retourner stream_url
|
||||
```
|
||||
|
||||
L'`instance_id` vient du `localStorage` du navigateur — stable entre les reloads,
|
||||
garantit que le même renderer UPnP est retrouvé à la reconnexion.
|
||||
|
||||
#### 3. Modification de `SetAVTransportURI`
|
||||
|
||||
Au lieu d'envoyer l'URL au navigateur, le handler UPnP :
|
||||
|
||||
1. Reçoit l'URI source (fichier cache, Samba, URL externe...)
|
||||
2. Envoie `PipelineControl::LoadUri(uri)` au pipeline de l'instance
|
||||
3. Le pipeline ouvre la source côté serveur et alimente le `StreamingFlacSink`
|
||||
4. Le navigateur reçoit un event SSE `state_changed: Transitioning` puis `Playing`
|
||||
|
||||
#### 4. Gestion des transitions (gapless)
|
||||
|
||||
Le `StreamingFlacSink` diffuse un flux FLAC continu. À la frontière de piste, le pipeline
|
||||
enchaîne les sources sans interruption du flux HTTP.
|
||||
|
||||
`SetNextAVTransportURI` → `PipelineControl::LoadNextUri(uri)` → pré-chargé dans le pipeline
|
||||
→ transition seamless, le navigateur ne recharge pas l'URL.
|
||||
|
||||
#### 5. Métadonnées et état
|
||||
|
||||
Tout passe par le SSE existant — titre, artiste, artwork, position, état de lecture.
|
||||
Pas de nouveau mécanisme nécessaire.
|
||||
|
||||
#### 6. Seek
|
||||
|
||||
Flux HTTP live → pas de Range requests.
|
||||
|
||||
Pour les fichiers (non-live) :
|
||||
- `PipelineControl::Seek(position_sec)` → pipeline repart depuis la nouvelle position
|
||||
- Légère interruption du flux FLAC (rebuffering navigateur ~1s) — acceptable
|
||||
|
||||
#### 7. Sources supportées
|
||||
|
||||
Le pipeline réutilise `pmoaudio-ext` et `pmoflac`. La seule source actuellement
|
||||
déclarée dans `pmoaudio-ext` fonctionne à partir d'une `pmoplaylist` — c'est le
|
||||
modèle à suivre pour construire dans `source_loader.rs` une source ad-hoc capable
|
||||
d'ouvrir des URIs arbitraires (URL HTTP externe, fichier local/Samba) qui ne passent
|
||||
pas par le cache.
|
||||
|
||||
### État partagé par instance
|
||||
|
||||
```rust
|
||||
pub struct WebRendererServerState {
|
||||
pub playback_state: PlaybackState,
|
||||
pub current_uri: Option<String>,
|
||||
pub volume: u16,
|
||||
pub mute: bool,
|
||||
pub stream_handle: SharedStreamHandle, // Handle vers le flux FLAC
|
||||
pub pipeline_tx: mpsc::Sender<PipelineControl>, // Contrôle du pipeline
|
||||
}
|
||||
|
||||
pub enum PipelineControl {
|
||||
LoadUri(String),
|
||||
LoadNextUri(String),
|
||||
Play,
|
||||
Pause,
|
||||
Stop,
|
||||
Seek(f64),
|
||||
SetVolume(u16),
|
||||
}
|
||||
```
|
||||
|
||||
## Fichiers à créer / modifier
|
||||
|
||||
### Nouveaux fichiers
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `pmowebrenderer/src/stream.rs` | Handler HTTP du flux FLAC |
|
||||
| `pmowebrenderer/src/pipeline.rs` | Pipeline audio serveur par instance |
|
||||
| `pmowebrenderer/src/source_loader.rs` | Ouverture des sources (cache, HTTP, fichier local) |
|
||||
| `pmowebrenderer/src/register.rs` | Handler `POST /register` + `DELETE /{id}` |
|
||||
|
||||
### Fichiers à modifier
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|--------------|
|
||||
| `pmowebrenderer/src/state.rs` | Ajouter `stream_handle` et `pipeline_tx` |
|
||||
| `pmowebrenderer/src/handlers.rs` | `set_uri_handler` → `PipelineControl::LoadUri` |
|
||||
| `pmowebrenderer/src/websocket.rs` | **Supprimer** — remplacé par `register.rs` |
|
||||
| `pmowebrenderer/src/config.rs` | Enregistrer les nouvelles routes, supprimer WS |
|
||||
| `pmowebrenderer/src/messages.rs` | **Supprimer** — plus de WebSocket |
|
||||
| `pmowebrenderer/src/session.rs` | Adapter : session liée au flux FLAC, pas au WS |
|
||||
| `pmoapp/webapp/src/` | Remplacer WS par `POST /register` + `<audio src=stream_url>` |
|
||||
|
||||
## Infrastructure existante réutilisable
|
||||
|
||||
Tout le travail difficile est déjà fait :
|
||||
|
||||
- **`StreamingFlacSink`** (`pmoaudio-ext/src/sinks/streaming_flac_sink.rs`) — broadcast multi-clients, gestion backpressure, ICY metadata
|
||||
- **`timed_broadcast`** (`pmoaudio-ext/src/sinks/timed_broadcast.rs`) — diffusion multi-clients avec pacing
|
||||
- **`pmoflac`** — décodage/encodage FLAC temps réel
|
||||
- Pattern HTTP streaming (`pmomediaserver/src/paradise_streaming.rs`) — exemple complet à suivre
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
1. **Volume côté serveur ou navigateur** ? Actuellement le navigateur gère le volume.
|
||||
Côté serveur, on pourrait appliquer un gain DSP dans le pipeline.
|
||||
|
||||
3. **Plusieurs onglets simultanés** : chaque onglet a son propre pipeline et flux ?
|
||||
Ou partage d'un flux broadcast si même contenu ? (Complexe, probablement un pipeline par instance.)
|
||||
|
||||
4. **Reconnexion** : si le navigateur se reconnecte (reload), le pipeline continue de
|
||||
tourner ? Ou on le stoppe et recrée ? La `StreamHandle` permet plusieurs souscripteurs,
|
||||
donc la reconnexion peut se faire sans interruption.
|
||||
|
||||
5. **Latence** : le pipeline serveur introduit une latence (buffering + encodage).
|
||||
À mesurer et potentiellement configurer via `max_lead_seconds`.
|
||||
327
Blackboard/Todo/Frontend_Review.md
Normal file
327
Blackboard/Todo/Frontend_Review.md
Normal file
@@ -0,0 +1,327 @@
|
||||
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||
|
||||
## Contexte
|
||||
|
||||
Ce document est le résultat d'une revue de code complète du frontend Vue.js/TypeScript du Control Point
|
||||
(`pmoapp/webapp/src/`). L'application est fonctionnelle mais présente plusieurs classes de problèmes
|
||||
qui peuvent causer des fuites mémoire, des incohérences de réactivité Vue 3, et des difficultés de
|
||||
maintenance à mesure que l'app grandit.
|
||||
|
||||
**Périmètre** : uniquement `pmoapp/webapp/src/` (composants, services, composables, stores, utils, CSS).
|
||||
|
||||
---
|
||||
|
||||
## Problèmes identifiés
|
||||
|
||||
### P0 — Fuites mémoire via listeners SSE jamais nettoyés
|
||||
|
||||
**Fichiers** : `src/composables/useSSE.ts`, `src/composables/useRenderers.ts`,
|
||||
`src/composables/useMediaServers.ts`
|
||||
|
||||
Les abonnements aux événements SSE sont créés lors du premier appel de chaque composable, mais
|
||||
jamais nettoyés si le composable est réutilisé ou le composant détruit. Dans `useSSE.ts`, la
|
||||
fonction `onRendererEvent()` retourne une fonction de cleanup, mais `useRendererEvents()` ignore
|
||||
ce retour — l'abonnement reste actif indefiniment.
|
||||
|
||||
De plus, dans `imageCache.ts`, un `setInterval` de cleanup s'exécute toutes les 5 minutes sans
|
||||
jamais être annulé si l'app est détruite.
|
||||
|
||||
### P1 — Réactivité Vue incohérente avec `shallowRef` + Maps
|
||||
|
||||
**Fichier** : `src/composables/useRenderers.ts` (L23-37)
|
||||
|
||||
`snapshots` et `loadingIds` sont déclarés en `shallowRef<Map<...>>()`. Vue ne détecte pas les
|
||||
mutations d'objets à l'intérieur d'un `shallowRef`. La solution actuelle — `triggerSnapshotReactivity()`
|
||||
qui crée une nouvelle Map à chaque appel — force une re-render complète de tous les composants
|
||||
qui dépendent de `snapshots`, même si seul un renderer a changé.
|
||||
|
||||
### P2 — Race condition à l'initialisation (main.ts)
|
||||
|
||||
**Fichier** : `src/main.ts`
|
||||
|
||||
Le UIStore est initialisé après le montage de l'app et l'appel à `sse.connect()`. Des événements
|
||||
SSE peuvent arriver avant que `useUIStore()` soit appelé dans les composants, et les notifications
|
||||
correspondantes peuvent être perdues.
|
||||
|
||||
### P3 — SSE singleton sans garantie formelle
|
||||
|
||||
**Fichiers** : `src/composables/useRenderers.ts`, `src/composables/useMediaServers.ts`
|
||||
|
||||
Chaque composable maintient son propre flag `sseInitialized` pour éviter les double-abonnements.
|
||||
Le mécanisme repose sur une convention implicite fragile : si deux composables s'abonnent au même
|
||||
type d'événement SSE dans des contextes différents, les callbacks s'accumulent sans être
|
||||
dédupliqués.
|
||||
|
||||
Dans `useSSE.ts`, `setupConnectionListener()` vérifie `connectionCallbacks.size === 0` mais
|
||||
sans lock — deux appels simultanés peuvent installer deux listeners.
|
||||
|
||||
### P4 — Pas de timeout ni retry sur les requêtes `fetch`
|
||||
|
||||
**Fichier** : `src/services/pmocontrol/api.ts`
|
||||
|
||||
Toutes les requêtes `fetch()` sont émises sans `AbortController`. Si le serveur ne répond pas,
|
||||
la promesse pend indéfiniment, bloquant potentiellement les composants qui attendent le résultat.
|
||||
Il n'y a ni timeout configurable ni retry automatique au niveau du service.
|
||||
|
||||
### P5 — Validation absente des réponses API
|
||||
|
||||
**Fichiers** : `src/services/audioCache.ts` (L76), `src/services/coverCache.ts`,
|
||||
`src/services/playlists.ts`, `src/services/pmocontrol/api.ts`
|
||||
|
||||
Les réponses JSON sont acceptées sans vérification de structure. Une assertion de type comme
|
||||
`metadata as { origin_url?: unknown }` ne protège pas contre un changement d'API côté Rust. Si
|
||||
l'API retourne une structure inattendue, le crash survient au runtime, pas à la compilation.
|
||||
|
||||
### P6 — Type assertions dangereuses dans PMOPlayer
|
||||
|
||||
**Fichier** : `src/services/pmosource.ts` / PMOPlayer (L197-214)
|
||||
|
||||
Les messages de commande sont typés `Record<string, unknown>`, puis les propriétés sont castées
|
||||
directement : `msg.url as string`, `msg.timestamp as number`. Si une propriété est absente ou
|
||||
d'un type différent, TypeScript ne le détecte pas.
|
||||
|
||||
### P7 — `useTabs` : watch multiples sans debounce, flag de restauration non-réinitialisé
|
||||
|
||||
**Fichier** : `src/composables/useTabs.ts` (L44, L350-361)
|
||||
|
||||
Trois `watch()` séparées écrivent dans `localStorage`. Sans debounce commun, si 3 onglets
|
||||
changent d'état simultanément, `localStorage` est écrit 3 fois de suite.
|
||||
|
||||
Le flag `isRestoringFromStorage` (L44) empêche la boucle de sauvegarde pendant la restauration,
|
||||
mais sans timeout : si `restoreFromLocalStorage()` lance une exception non-catchée, le flag reste
|
||||
`true` et toutes les sauvegardes futures sont silencieusement ignorées.
|
||||
|
||||
### P8 — Routes de debug exposées en production, pas de lazy loading
|
||||
|
||||
**Fichier** : `src/router/index.ts`
|
||||
|
||||
Les routes debug (CoversCache, AudioCache, UPnP Explorer, etc.) sont accessibles en production
|
||||
sans contrôle d'accès. Par ailleurs, tous les composants sont importés statiquement, augmentant
|
||||
le bundle initial inutilement — les vues debug notamment ne sont jamais utilisées en prod.
|
||||
|
||||
### P9 — `formatMsToShortTime` est un alias inutile
|
||||
|
||||
**Fichier** : `src/utils/time.ts` (L58-59)
|
||||
|
||||
```typescript
|
||||
// Actuellement
|
||||
export function formatMsToShortTime(ms: number | null): string {
|
||||
return formatMsToTime(ms);
|
||||
}
|
||||
```
|
||||
|
||||
Fonction identique à `formatMsToTime`. Tous les appelants peuvent utiliser directement
|
||||
`formatMsToTime`.
|
||||
|
||||
### P10 — `truncate()` dans `string.ts` peut dépasser `maxLength`
|
||||
|
||||
**Fichier** : `src/utils/string.ts` (L46-48)
|
||||
|
||||
```typescript
|
||||
// Actuellement
|
||||
export function truncate(str: string, maxLength: number, suffix = '…'): string {
|
||||
return str.length > maxLength ? str.slice(0, maxLength - suffix.length) + suffix : str;
|
||||
}
|
||||
```
|
||||
|
||||
Si `suffix.length >= maxLength`, `str.slice(0, maxLength - suffix.length)` retourne une chaîne
|
||||
de longueur négative (comportement silencieux en JS, retourne `''`), et le résultat final est
|
||||
plus long que `maxLength`.
|
||||
|
||||
### P11 — `DEFAULT_COVER_SVG` inline dans coverCache.ts
|
||||
|
||||
**Fichier** : `src/services/coverCache.ts` (L206-226)
|
||||
|
||||
Un SVG inline de ~20 lignes est inclus dans chaque bundle qui importe `coverCache`. Il devrait
|
||||
être un fichier `src/assets/default-cover.svg` importé nativement par Vite (ce qui permet le
|
||||
tree-shaking et le caching HTTP séparé).
|
||||
|
||||
### P12 — `animations` CSS sans `prefers-reduced-motion`
|
||||
|
||||
**Fichiers** : `src/assets/styles/glass-theme.css` (L384-401),
|
||||
`src/assets/styles/pmocontrol.css` (L82)
|
||||
|
||||
Les animations `glassShimmer` (2s infini) et le `pulse` du badge de statut `Transitioning`
|
||||
s'exécutent sans tenir compte de `prefers-reduced-motion: reduce`. Sur certains systèmes ou
|
||||
pour des utilisateurs sensibles au mouvement, ces animations sont gênantes.
|
||||
|
||||
### P13 — CSS dupliqué dans drawers.css
|
||||
|
||||
**Fichier** : `src/assets/styles/drawers.css` (L105-149)
|
||||
|
||||
`drawer-close-btn` et `drawer-back-btn` partagent 90% des styles. Un TODO présent en L167
|
||||
("remplacer par la classe globale .section-title") confirme cette dette. La variable
|
||||
`var(--opacity-disabled)` est utilisée mais non définie dans `variables.css`.
|
||||
|
||||
### P14 — `browseContainer` : clés de cache fragiles et pas de pagination
|
||||
|
||||
**Fichier** : `src/composables/useMediaServers.ts` (L145, L239)
|
||||
|
||||
Les clés de cache sont construites comme `${serverId}/${containerId}`. Si un `containerId`
|
||||
contient un slash (séparateur d'URL), la clé est ambigüe. Par exemple, `server1/a/b` peut
|
||||
correspondre à serverId=`server1`, containerId=`a/b` ou serverId=`server1/a`, containerId=`b`.
|
||||
|
||||
La pagination n'est pas implémentée côté composable : `browseContainer` charge toujours
|
||||
offset=0, limit=50. Pour les containers avec 500+ items, les items au-delà de 50 ne sont
|
||||
jamais accessibles.
|
||||
|
||||
### P15 — Notifications sans limite de taille dans `ui.ts`
|
||||
|
||||
**Fichier** : `src/stores/ui.ts` (L50, L55-57)
|
||||
|
||||
Un bug ou une boucle d'erreur peut générer des centaines de notifications. Le tableau
|
||||
`notifications` n'est pas limité. Chaque notification crée un `setTimeout` individuel, et
|
||||
si le store est détruit avant l'expiration, ces callbacks persistent (ghosts).
|
||||
|
||||
---
|
||||
|
||||
## Plan d'exécution
|
||||
|
||||
Les corrections sont groupées par effort et impact. Les P0–P3 concernent la fiabilité
|
||||
(fuites mémoire, réactivité), les P4–P8 la robustesse et maintenabilité, les P9–P15 la
|
||||
qualité et la dette technique.
|
||||
|
||||
### Étape 1 — Corriger les fuites mémoire SSE (P0)
|
||||
|
||||
Dans `useSSE.ts`, stocker et appeler les fonctions de cleanup retournées par `onRendererEvent` /
|
||||
`onMediaServerEvent` :
|
||||
|
||||
```typescript
|
||||
// useSSE.ts – useRendererEvents()
|
||||
onMounted(() => {
|
||||
const cleanup = onRendererEvent(rendererId(), handler);
|
||||
onUnmounted(cleanup); // ← actuellement ignoré
|
||||
});
|
||||
```
|
||||
|
||||
Dans `imageCache.ts`, exporter une fonction `destroyImageCache()` qui appelle `clearInterval`
|
||||
sur le timer de cleanup, et l'appeler dans le `onUnmounted` de l'app root.
|
||||
|
||||
### Étape 2 — Stabiliser la réactivité des snapshots (P1)
|
||||
|
||||
Remplacer `shallowRef<Map<...>>` + `triggerSnapshotReactivity` par `reactive(new Map<...>)`.
|
||||
Vue 3 rend les Maps réactives nativement. Les composants qui lisent `snapshots.get(id)`
|
||||
seront notifiés uniquement si ce `id` change.
|
||||
|
||||
```typescript
|
||||
// Avant
|
||||
const snapshots = shallowRef<Map<string, FullRendererSnapshot>>(new Map());
|
||||
function triggerSnapshotReactivity() {
|
||||
snapshots.value = new Map(snapshots.value);
|
||||
}
|
||||
|
||||
// Après
|
||||
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
|
||||
// Les modifications directes (snapshots.set/delete) déclenchent la réactivité
|
||||
```
|
||||
|
||||
### Étape 3 — Timeout fetch + AbortController (P4)
|
||||
|
||||
Ajouter un helper dans `api.ts` :
|
||||
|
||||
```typescript
|
||||
function fetchWithTimeout(url: string, options?: RequestInit, timeoutMs = 10_000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return fetch(url, { ...options, signal: controller.signal })
|
||||
.finally(() => clearTimeout(id));
|
||||
}
|
||||
```
|
||||
|
||||
Utiliser `fetchWithTimeout` pour toutes les requêtes dans le service API.
|
||||
|
||||
### Étape 4 — Corriger `useTabs` watchs et flag de restauration (P7)
|
||||
|
||||
Fusionner les trois `watch()` en un seul `watchEffect` avec un debounce unique (100ms).
|
||||
Encadrer `isRestoringFromStorage` dans un bloc `try/finally` :
|
||||
|
||||
```typescript
|
||||
async function restoreFromLocalStorage() {
|
||||
isRestoringFromStorage = true;
|
||||
try {
|
||||
// ... logique de restauration
|
||||
} catch (e) {
|
||||
console.error('Tab restore failed:', e);
|
||||
} finally {
|
||||
isRestoringFromStorage = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 5 — Limit de notifications et nettoyage timers (P15)
|
||||
|
||||
```typescript
|
||||
const MAX_NOTIFICATIONS = 5;
|
||||
|
||||
function addNotification(notif: Omit<Notification, 'id'>): void {
|
||||
if (notifications.value.length >= MAX_NOTIFICATIONS) {
|
||||
notifications.value.shift(); // supprimer la plus ancienne
|
||||
}
|
||||
const id = nextId++;
|
||||
const timer = setTimeout(() => removeNotification(id), notif.duration ?? 5000);
|
||||
notificationTimers.set(id, timer);
|
||||
notifications.value.push({ ...notif, id });
|
||||
}
|
||||
|
||||
function $dispose() {
|
||||
notificationTimers.forEach(clearTimeout);
|
||||
notificationTimers.clear();
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 6 — Lazy loading des routes et protection debug (P8)
|
||||
|
||||
```typescript
|
||||
// router/index.ts
|
||||
const DebugView = () => import('../views/DebugView.vue');
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
const routes = [
|
||||
// ... routes normales
|
||||
...(isDev ? [{ path: '/debug', component: DebugView }] : []),
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' }, // wildcard 404
|
||||
];
|
||||
```
|
||||
|
||||
### Étape 7 — Corrections mineures (P9, P10, P11, P12, P13)
|
||||
|
||||
- **P9** : Supprimer `formatMsToShortTime`, remplacer tous les appels par `formatMsToTime`
|
||||
- **P10** : Ajouter un guard dans `truncate` : `if (suffix.length >= maxLength) return str.slice(0, maxLength)`
|
||||
- **P11** : Déplacer le SVG dans `src/assets/default-cover.svg` et l'importer avec `import defaultCover from '../assets/default-cover.svg?raw'`
|
||||
- **P12** : Entourer les animations CSS avec `@media (prefers-reduced-motion: no-preference) { ... }`
|
||||
- **P13** : Factoriser `drawer-close-btn` / `drawer-back-btn` avec une classe `.drawer-icon-btn`. Définir `--opacity-disabled: 0.4` dans `variables.css`
|
||||
|
||||
### Étape 8 — Clés de cache et pagination (P14)
|
||||
|
||||
Encoder les IDs dans les clés de cache :
|
||||
|
||||
```typescript
|
||||
const cacheKey = `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`;
|
||||
```
|
||||
|
||||
Utiliser `:` comme séparateur (absent de l'encoding) pour éviter toute ambigüité.
|
||||
|
||||
Pour la pagination, ajouter une propriété `hasMore: boolean` et `loadMore()` au résultat de
|
||||
`browseContainer`, incrementant offset à chaque appel.
|
||||
|
||||
### Ordre d'exécution
|
||||
|
||||
1. Étape 1 — fuites SSE (P0) — fiabilité critique
|
||||
2. Étape 2 — réactivité Map (P1) — fiabilité
|
||||
3. Étape 3 — timeout fetch (P4) — robustesse réseau
|
||||
4. Étape 4 — useTabs (P7) — fiabilité des onglets
|
||||
5. Étape 5 — notifications (P15) — stabilité UI
|
||||
6. Étape 6 — router (P8) — sécurité + performance bundle
|
||||
7. Étape 7 — corrections mineures (P9–P13)
|
||||
8. Étape 8 — cache keys + pagination (P14)
|
||||
|
||||
## Règle après ces corrections
|
||||
|
||||
**Interdit** : créer un abonnement SSE (`onRendererEvent`, `onMediaServerEvent`) sans stocker
|
||||
et appeler la fonction de cleanup retournée dans `onUnmounted`.
|
||||
|
||||
**Interdit** : utiliser `shallowRef<Map<...>>` avec mutation directe — utiliser `reactive(new Map())`
|
||||
pour les Maps qui doivent déclencher la réactivité Vue sur leurs entrées.
|
||||
|
||||
**Obligatoire** : toute requête `fetch()` dans un service doit utiliser `fetchWithTimeout`
|
||||
avec un AbortController.
|
||||
459
Blackboard/Todo/centraliser_base_url_axum_middleware.md
Normal file
459
Blackboard/Todo/centraliser_base_url_axum_middleware.md
Normal file
@@ -0,0 +1,459 @@
|
||||
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||
|
||||
## Problème
|
||||
|
||||
Quand le site est accédé via une adresse externe (reverse proxy, ex: `orbis.petite-maison-orange.fr`),
|
||||
les URLs d'images et de ressources générées par le backend contiennent l'IP locale hardcodée
|
||||
(ex: `http://192.168.0.32:80/covers/image/...`). Ces URLs sont inaccessibles depuis l'extérieur.
|
||||
|
||||
## Cause racine
|
||||
|
||||
Il existe deux contextes distincts de construction d'URL dans pmomusic :
|
||||
|
||||
**A. Contexte UPnP / réseau local** : les URLs doivent être absolues avec l'IP locale, car les
|
||||
renderers UPnP accèdent directement aux ressources sur le réseau local.
|
||||
→ Elles utilisent correctement `PMO_SERVER_URL` / `covers_absolute_url_for()`.
|
||||
|
||||
**B. Contexte HTTP / frontend webapp** : les URLs retournées au navigateur doivent refléter l'hôte
|
||||
vu par le client (local ou via proxy). Elles utilisent actuellement aussi `PMO_SERVER_URL` —
|
||||
c'est le bug.
|
||||
|
||||
`pmoserver` dispose déjà de `request_base_url(headers)` (`pmoserver/src/server.rs:633`) qui lit
|
||||
`X-Forwarded-Proto` / `X-Forwarded-Host` / `Host` et retourne la base URL correcte par requête.
|
||||
Mais **aucun handler HTTP ne l'utilise** pour construire les URLs retournées au frontend.
|
||||
|
||||
## Solution : Middleware Axum BaseUrl
|
||||
|
||||
Ajouter un middleware Axum au niveau de la racine du routeur qui enrichit chaque requête d'une
|
||||
`Extension<BaseUrl>` calculée depuis les headers. Tous les handlers HTTP qui retournent des URLs
|
||||
au frontend extraient cette extension — **un seul point de calcul, zéro gestion ad hoc**.
|
||||
|
||||
### 1. Nouveau type `BaseUrl` dans `pmoserver/src/lib.rs`
|
||||
|
||||
```rust
|
||||
/// URL de base effective pour la requête courante.
|
||||
/// Calculée depuis X-Forwarded-Proto/Host ou Host header.
|
||||
/// Injectée par `base_url_layer` dans toutes les requêtes Axum.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BaseUrl(pub String);
|
||||
```
|
||||
|
||||
### 2. Middleware `base_url_layer` dans `pmoserver/src/lib.rs`
|
||||
|
||||
```rust
|
||||
/// Middleware Axum : injecte BaseUrl dans chaque requête.
|
||||
/// À appliquer sur le routeur racine via `.layer(base_url_layer())`.
|
||||
pub fn base_url_layer() -> axum::middleware::FromFnLayer<...> {
|
||||
axum::middleware::from_fn(|request: Request, next: Next| async move {
|
||||
// Fallback sur PMO_SERVER_URL (valeur de démarrage avec la vraie IP/port),
|
||||
// pas sur localhost:8080 hardcodé.
|
||||
let base = get_request_base_url(request.headers())
|
||||
.or_else(|| std::env::var("PMO_SERVER_URL").ok())
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("BaseUrl: aucun header Host/X-Forwarded-Host ni PMO_SERVER_URL — fallback localhost:8080");
|
||||
"http://localhost:8080".to_string()
|
||||
});
|
||||
let mut request = request;
|
||||
tracing::debug!("BaseUrl calculée : {}", base);
|
||||
request.extensions_mut().insert(BaseUrl(base));
|
||||
next.run(request).await
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Application du layer dans `pmoserver/src/server.rs`
|
||||
|
||||
Dans Axum, le dernier `.layer()` appliqué est le plus extérieur (exécuté en premier sur la
|
||||
requête entrante). Pour que `base_url_layer` voie les headers **après** tout layer de nettoyage,
|
||||
il doit être **intérieur** — donc appliqué **avant** dans le code :
|
||||
|
||||
```rust
|
||||
router
|
||||
.layer(header_clean_layer()) // extérieur → exécuté en premier, nettoie les headers
|
||||
.layer(base_url_layer()) // intérieur → voit les headers nettoyés
|
||||
```
|
||||
|
||||
Les endpoints UPnP (SSDP, description XML, control, event) ne doivent pas appeler
|
||||
`covers_absolute_url_for_upnp()` via `BaseUrl` — l'injection du middleware ne les affecte pas
|
||||
puisqu'ils n'extraient pas `Extension<BaseUrl>`.
|
||||
|
||||
Si des routes non-HTTP sont ajoutées ultérieurement (métriques internes, health checks sans
|
||||
contexte client), les isoler dans un sous-routeur dédié sans `base_url_layer()`.
|
||||
|
||||
### 4. Utilisation dans les handlers
|
||||
|
||||
Tous les handlers qui retournent des URLs au frontend ajoutent :
|
||||
|
||||
```rust
|
||||
Extension(base_url): Extension<BaseUrl>,
|
||||
```
|
||||
|
||||
Et utilisent `base_url.url_for(&pmocovers::covers_route_for(pk, None))` à la place de
|
||||
`covers_absolute_url_for()` (voir section 5 pour le pattern complet).
|
||||
|
||||
Handlers REST concernés (liste non exhaustive) :
|
||||
- `pmocontrol/src/pmoserver_ext.rs` : `get_renderer_full_snapshot` (album_art_uri dans snapshot)
|
||||
- `pmocontrol/src/pmoserver_ext.rs` : handler browse (ContainerEntry.album_art_uri)
|
||||
- `pmoradiofrance/src/api_rest.rs` : endpoints playlist/metadata
|
||||
- `pmoplaylist/src/handle/read.rs` : album art dans les réponses playlist
|
||||
|
||||
**Handlers SSE** (`pmocontrol/src/sse.rs`) : cas particulier. Le stream SSE est long-lived —
|
||||
après le `stream!` block, on n'est plus dans le contexte du handler Axum. `BaseUrl` doit être
|
||||
clonée dans une variable locale **avant** le `stream!`, puis `move`-ée dans la closure :
|
||||
|
||||
```rust
|
||||
pub async fn renderer_events_sse(
|
||||
State(control_point): State<Arc<ControlPoint>>,
|
||||
Extension(base_url): Extension<BaseUrl>, // ← extraite à la connexion
|
||||
) -> impl IntoResponse {
|
||||
let base_url = base_url.clone(); // clone avant le stream! pour le move
|
||||
// ...
|
||||
let stream = stream! {
|
||||
while let Some(event) = rx_tokio.recv().await {
|
||||
// base_url est disponible ici par move
|
||||
let payload = renderer_event_to_payload(event, &base_url);
|
||||
yield Ok(Event::default()...);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
### 5. Méthode `url_for` sur `BaseUrl` + fonctions `route_for` dans chaque crate
|
||||
|
||||
La combinaison `base_url + route` est identique pour tous les types de ressources. Elle est
|
||||
factorisée en une méthode sur `BaseUrl` dans `pmoserver/src/lib.rs` :
|
||||
|
||||
```rust
|
||||
impl BaseUrl {
|
||||
/// Construit une URL absolue en combinant la base URL de la requête avec une route relative.
|
||||
/// Usage : base_url.url_for(&pmocovers::covers_route_for(pk, None))
|
||||
pub fn url_for(&self, route: &str) -> String {
|
||||
debug_assert!(route.starts_with('/'), "route must start with '/'");
|
||||
format!("{}{}", self.0.trim_end_matches('/'), route)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Chaque crate spécialisée expose uniquement sa **route** (chemin relatif), pas l'URL complète :
|
||||
|
||||
**`pmocovers/src/lib.rs`** — déplacer depuis `pmocache` :
|
||||
```rust
|
||||
/// Route relative d'une cover : `/covers/image/{pk}[/{param}]`
|
||||
pub fn covers_route_for(pk: &str, param: Option<&str>) -> String { ... }
|
||||
```
|
||||
|
||||
**`pmoaudiocache/src/lib.rs`** :
|
||||
```rust
|
||||
/// Route relative d'un fichier audio : `/audio/flac/{pk}`
|
||||
pub fn audio_route_for(pk: &str) -> String {
|
||||
format!("/audio/flac/{}", pk)
|
||||
}
|
||||
```
|
||||
|
||||
Usage dans les handlers :
|
||||
```rust
|
||||
base_url.url_for(&pmocovers::covers_route_for(pk, None))
|
||||
base_url.url_for(&pmoaudiocache::audio_route_for(pk))
|
||||
```
|
||||
|
||||
### 6. Renommage de `covers_absolute_url_for` → `covers_absolute_url_for_upnp`
|
||||
|
||||
Pour rendre le contexte d'usage explicite et décourager l'appel depuis les handlers HTTP,
|
||||
renommer dans `pmocache/src/lib.rs` :
|
||||
|
||||
```rust
|
||||
// Ancien nom — marqué deprecated pour faciliter la migration (warnings à la compilation)
|
||||
#[deprecated(note = "Utiliser covers_absolute_url_for_upnp() dans les contextes UPnP uniquement")]
|
||||
pub fn covers_absolute_url_for(pk: &str, param: Option<&str>) -> String { ... }
|
||||
|
||||
// Nouveau nom — usage UPnP uniquement
|
||||
pub fn covers_absolute_url_for_upnp(pk: &str, param: Option<&str>) -> String { ... }
|
||||
```
|
||||
|
||||
Mettre à jour tous les appels existants (contextes UPnP/DIDL uniquement) via un grep :
|
||||
`grep -rn "covers_absolute_url_for" src/ --include="*.rs"`
|
||||
|
||||
À terme, `covers_route_for` et `covers_absolute_url_for_upnp` devraient migrer de `pmocache`
|
||||
vers `pmocovers`, mais ce n'est pas le périmètre de ce ticket.
|
||||
|
||||
## URLs dans les documents DIDL et SSE
|
||||
|
||||
Les documents DIDL bruts (`<res>`, `<upnp:albumArtURI>`) ne transitent jamais vers le frontend —
|
||||
pmocontrol les parse côté serveur et n'envoie que des champs extraits (JSON) via REST et SSE.
|
||||
Il n'y a donc pas de "rebasage XML" : les champs extraits (`album_art_uri`, etc.) passent tous
|
||||
par des handlers qui ont accès à `BaseUrl`.
|
||||
|
||||
**SSE est per-client** : chaque connexion SSE crée son propre receiver (`subscribe_events()`).
|
||||
La `BaseUrl` est figée à l'établissement de la connexion et ne sera pas mise à jour si le client
|
||||
change de réseau en cours de stream — comportement attendu et documenté.
|
||||
Il n'y a ni canal partagé, ni duplication LAN/WAN. Le handler SSE capture `Extension<BaseUrl>`
|
||||
à l'établissement de la connexion et applique `base_url.url_for()` à toutes les URLs des événements
|
||||
émis vers ce client.
|
||||
|
||||
Les DIDL servis directement aux renderers UPnP (hors HTTP webapp) gardent l'IP locale — c'est
|
||||
correct, les renderers sont sur le réseau local.
|
||||
|
||||
## Audit préalable à l'implémentation
|
||||
|
||||
Avant de modifier les handlers, faire un audit exhaustif de tous les endroits qui construisent
|
||||
des URLs absolues dans des réponses JSON au frontend :
|
||||
|
||||
```bash
|
||||
# Appels directs aux fonctions URL connues
|
||||
grep -rn "covers_absolute_url_for\|audio/flac\|cache/audio" src/ --include="*.rs"
|
||||
|
||||
# Constructions format! utilisant PMO_SERVER_URL ou des littéraux http://
|
||||
grep -rn "PMO_SERVER_URL\|format!.*base_url\|format!.*server_url" src/ --include="*.rs"
|
||||
grep -rn 'format!.*"http' src/ --include="*.rs"
|
||||
```
|
||||
|
||||
Note : les PKs de covers et audio sont des hashes hex (`[0-9a-f]+`) — ils ne peuvent pas
|
||||
contenir de caractères spéciaux nécessitant un encodage URL. La concaténation `format!` est
|
||||
donc sûre ; pas besoin de `url::Url::join`.
|
||||
|
||||
## Tests à écrire
|
||||
|
||||
- **Middleware** : `BaseUrl` correctement extraite depuis `X-Forwarded-Host`, `Host`, et en
|
||||
leur absence (fallback sur `PMO_SERVER_URL`)
|
||||
- **`url_for`** : assertion que toutes les routes commencent par `/` ; pas de double slash ;
|
||||
trailing slash sur la base géré par `trim_end_matches`
|
||||
- **Handlers REST** : `album_art_uri` rebased dans `FullRendererSnapshot` et `BrowseResponse`
|
||||
- **SSE** : URLs rebased dans les événements `TrackChanged`
|
||||
- **UPnP** : vérifier que les URLs servies aux renderers UPnP restent en IP locale (non affectées
|
||||
par `BaseUrl`)
|
||||
- **Intégration** : appeler **chaque endpoint frontend** avec un client HTTP de test pour
|
||||
vérifier (a) qu'aucune panique ne se produit (middleware bien appliqué) et (b) que les URLs
|
||||
produites utilisent l'hôte du header `X-Forwarded-Host` simulé et non l'IP locale.
|
||||
La panique sur `Extension<BaseUrl>` manquante est un comportement voulu — elle doit être
|
||||
détectée par ces tests et non silencieusement masquée par un `Option`.
|
||||
|
||||
## Sécurité : headers X-Forwarded-*
|
||||
|
||||
Les headers `X-Forwarded-Proto` / `X-Forwarded-Host` peuvent être forgés par n'importe quel client
|
||||
si le reverse proxy ne les filtre pas. Dans le contexte de déploiement de pmomusic (usage domestique,
|
||||
proxy Nginx/Caddy unique), le risque est faible et hors périmètre de ce ticket.
|
||||
|
||||
À surveiller si le déploiement évolue : restreindre la lecture de ces headers aux requêtes venant
|
||||
de l'IP du proxy (liste blanche de proxies de confiance côté Axum ou côté proxy).
|
||||
|
||||
## Périmètre : ce qui ne change PAS
|
||||
|
||||
- `covers_absolute_url_for()` dans `pmocache` : conservée pour les contextes UPnP
|
||||
- `PMO_SERVER_URL` env var : conservée pour UPnP et les processus non-HTTP
|
||||
- URLs dans les DIDL servis aux renderers UPnP : inchangées (doivent rester en IP locale)
|
||||
- `server_base_url` passé aux sources (RadioFrance, RadioParadise, Qobuz) : inchangé
|
||||
(ces sources construisent des URLs pour les renderers réseau)
|
||||
|
||||
## Plan d'exécution
|
||||
|
||||
### Corrections d'audit préalables
|
||||
|
||||
Divergences entre le document et le code réel :
|
||||
|
||||
- **Route audio** : `/audio/tracks/{pk}` (PAS `/audio/flac/{pk}`)
|
||||
- **`covers_route_for`** existe déjà dans `pmocache/src/lib.rs:149` — à copier vers `pmocovers`
|
||||
- **`album_art_uri`** dans les handlers est propagé depuis des caches amont ; le point de
|
||||
construction réel est `pmoradiofrance/src/metadata_cache.rs:263` (tâche de fond, pas un handler)
|
||||
- **`pmoqobuz/src/source.rs:1943`** construit des URLs audio avec `self.base_url` → contexte
|
||||
UPnP/renderer, hors périmètre de ce ticket
|
||||
|
||||
### Étape 0 — Audit exhaustif (avant tout changement)
|
||||
|
||||
```bash
|
||||
grep -rn "covers_absolute_url_for\|audio/tracks\|cache/audio" --include="*.rs"
|
||||
grep -rn "PMO_SERVER_URL\|format!.*base_url\|format!.*server_url" --include="*.rs"
|
||||
grep -rn 'format!.*"http' --include="*.rs"
|
||||
```
|
||||
|
||||
Identifier tous les call sites dans les contextes HTTP (handlers, caches de métadonnées servant
|
||||
le frontend). Distinguer des contextes UPnP/renderer (hors périmètre).
|
||||
|
||||
### Étape 1 — `pmoserver/src/lib.rs` : ajouter `BaseUrl` + `base_url_layer`
|
||||
|
||||
`get_request_base_url(headers)` existe déjà à la ligne 199. Ajouter :
|
||||
|
||||
```rust
|
||||
use axum::{extract::Request, middleware::Next, response::Response};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BaseUrl(pub String);
|
||||
|
||||
impl BaseUrl {
|
||||
pub fn url_for(&self, route: &str) -> String {
|
||||
debug_assert!(route.starts_with('/'), "route must start with '/'");
|
||||
format!("{}{}", self.0.trim_end_matches('/'), route)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn base_url_middleware(mut request: Request, next: Next) -> Response {
|
||||
let base = get_request_base_url(request.headers())
|
||||
.unwrap_or_else(|| {
|
||||
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| {
|
||||
tracing::warn!(
|
||||
"BaseUrl: aucun header Host/X-Forwarded-Host ni PMO_SERVER_URL \
|
||||
— fallback localhost:8080"
|
||||
);
|
||||
"http://localhost:8080".to_string()
|
||||
})
|
||||
});
|
||||
tracing::debug!("BaseUrl calculée : {}", base);
|
||||
request.extensions_mut().insert(BaseUrl(base));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub fn base_url_layer() -> axum::middleware::FromFnLayer<...> {
|
||||
axum::middleware::from_fn(base_url_middleware)
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 2 — `pmoserver/src/server.rs` : appliquer le layer
|
||||
|
||||
Trouver la construction du routeur principal. Ajouter `base_url_layer()` avant les layers
|
||||
existants (= intérieur dans la pile Tower) :
|
||||
|
||||
```rust
|
||||
router
|
||||
.layer(some_existing_layer()) // extérieur → exécuté en premier
|
||||
.layer(base_url_layer()) // intérieur → voit les headers après nettoyage
|
||||
```
|
||||
|
||||
### Étape 3 — `pmocovers/src/lib.rs` : ajouter `covers_route_for`
|
||||
|
||||
Copier depuis `pmocache/src/lib.rs:149` :
|
||||
|
||||
```rust
|
||||
/// Route relative d'une cover : `/covers/image/{pk}[/{param}]`
|
||||
pub fn covers_route_for(pk: &str, param: Option<&str>) -> String {
|
||||
if let Some(p) = param {
|
||||
format!("/covers/image/{}/{}", pk, p)
|
||||
} else {
|
||||
format!("/covers/image/{}", pk)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 4 — `pmoaudiocache/src/lib.rs` : ajouter `audio_route_for`
|
||||
|
||||
```rust
|
||||
/// Route relative d'un fichier audio : `/audio/tracks/{pk}`
|
||||
pub fn audio_route_for(pk: &str) -> String {
|
||||
format!("/audio/tracks/{}", pk)
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 5 — `pmocache/src/lib.rs` : renommer `covers_absolute_url_for`
|
||||
|
||||
```rust
|
||||
#[deprecated(note = "Utiliser covers_absolute_url_for_upnp() dans les contextes UPnP uniquement")]
|
||||
pub fn covers_absolute_url_for(pk: &str, param: Option<&str>) -> String {
|
||||
covers_absolute_url_for_upnp(pk, param)
|
||||
}
|
||||
|
||||
pub fn covers_absolute_url_for_upnp(pk: &str, param: Option<&str>) -> String {
|
||||
let base = std::env::var("PMO_SERVER_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param))
|
||||
}
|
||||
```
|
||||
|
||||
Mettre à jour l'appel UPnP connu : `pmoupnp/src/cache_registry.rs:57`
|
||||
→ `covers_absolute_url_for(` → `covers_absolute_url_for_upnp(`
|
||||
|
||||
### Étape 6 — `pmoradiofrance/src/metadata_cache.rs:263` : stocker la route, pas l'URL
|
||||
|
||||
**Contexte** : tâche de fond — ne peut pas extraire `Extension<BaseUrl>`.
|
||||
**Principe** : stocker la route relative dans le cache ; le handler rebase au moment de la réponse.
|
||||
|
||||
```rust
|
||||
// Avant :
|
||||
let public_url = pmocache::covers_absolute_url_for(&pk, None);
|
||||
|
||||
// Après :
|
||||
let public_url = pmocovers::covers_route_for(&pk, None); // route relative
|
||||
```
|
||||
|
||||
Le handler REST dans `pmoradiofrance/src/api_rest.rs` qui retourne ces métadonnées doit :
|
||||
1. Ajouter `Extension(base_url): Extension<BaseUrl>` à sa signature
|
||||
2. Construire l'URL : `base_url.url_for(&metadata.album_art_uri)`
|
||||
|
||||
Lire `api_rest.rs` pour identifier le handler exact qui inclut `album_art_uri` dans la réponse.
|
||||
|
||||
### Étape 7 — Handlers REST `pmocontrol/src/pmoserver_ext.rs`
|
||||
|
||||
`get_renderer_full_snapshot` (l.170) et `browse_container` (l.2080) propagent `album_art_uri`
|
||||
depuis les résultats DIDL des media servers UPnP — ces URLs pointent vers l'IP du media server,
|
||||
pas de pmomusic.
|
||||
|
||||
**Action** : après l'audit, vérifier si ces URLs passent par `covers_absolute_url_for`.
|
||||
Si oui → même traitement qu'étape 6. Sinon → pas de changement.
|
||||
|
||||
### Étape 8 — Handlers SSE `pmocontrol/src/sse.rs`
|
||||
|
||||
Pour `renderer_events_sse`, `media_server_events_sse`, `all_events_sse` :
|
||||
|
||||
```rust
|
||||
pub async fn renderer_events_sse(
|
||||
State(control_point): State<Arc<ControlPoint>>,
|
||||
Extension(base_url): Extension<BaseUrl>, // ← ajouter
|
||||
) -> impl IntoResponse {
|
||||
let base_url = base_url.clone(); // avant le stream!
|
||||
let stream = stream! {
|
||||
while let Some(event) = rx.recv().await {
|
||||
// base_url.url_for(...) pour les URLs dans les événements
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Vérifier si les événements SSE contiennent des `album_art_uri` construits avec
|
||||
`covers_absolute_url_for` ou propagés depuis le cache.
|
||||
Si propagation → même traitement qu'étape 6.
|
||||
|
||||
### Étape 9 — Vérification finale
|
||||
|
||||
```bash
|
||||
# Ne doit retourner aucun appel dans les handlers HTTP
|
||||
grep -rn "covers_absolute_url_for[^_]" --include="*.rs"
|
||||
|
||||
# Ne doit retourner aucun résultat dans les handlers HTTP
|
||||
grep -rn "PMO_SERVER_URL" --include="*.rs" | grep -v "pmocache\|pmoserver\|test"
|
||||
|
||||
# Warnings deprecated
|
||||
cargo build 2>&1 | grep "deprecated"
|
||||
```
|
||||
|
||||
### Étape 10 — Tests
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn url_for_combines_base_and_route() {
|
||||
let b = BaseUrl("https://example.com".to_string());
|
||||
assert_eq!(b.url_for("/covers/image/abc"), "https://example.com/covers/image/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_for_trims_trailing_slash() {
|
||||
let b = BaseUrl("https://example.com/".to_string());
|
||||
assert_eq!(b.url_for("/covers/image/abc"), "https://example.com/covers/image/abc");
|
||||
}
|
||||
// + tests middleware X-Forwarded-Host, fallback PMO_SERVER_URL, fallback localhost
|
||||
// + test intégration : chaque endpoint frontend avec X-Forwarded-Host simulé
|
||||
```
|
||||
|
||||
### Ordre d'exécution
|
||||
|
||||
1. Étape 0 — audit (confirmer la liste des call sites)
|
||||
2. Étapes 3, 4 — ajouter `covers_route_for` / `audio_route_for` (sans breaking change)
|
||||
3. Étape 5 — renommer + `#[deprecated]` (les warnings guident la suite)
|
||||
4. Étape 1 — `BaseUrl` + `base_url_layer` dans `pmoserver`
|
||||
5. Étape 2 — appliquer le layer dans `server.rs`
|
||||
6. Étapes 6, 7, 8 — migrer les handlers (guidés par les warnings de compilation)
|
||||
7. Étapes 9, 10 — vérification + tests
|
||||
|
||||
## Règle après cette modification
|
||||
|
||||
**Interdit** : appeler `covers_absolute_url_for_upnp()`, lire `PMO_SERVER_URL`, ou utiliser
|
||||
`format!("{}/audio/flac/{}", base_url, pk)` dans un handler HTTP qui retourne du JSON au frontend.
|
||||
|
||||
**Obligatoire** : extraire `Extension<BaseUrl>` et utiliser :
|
||||
- `base_url.url_for(&pmocovers::covers_route_for(pk, None))` pour les images
|
||||
- `base_url.url_for(&pmoaudiocache::audio_route_for(pk))` pour les fichiers audio
|
||||
426
Blackboard/Todo/enorme_playlist.md
Normal file
426
Blackboard/Todo/enorme_playlist.md
Normal file
@@ -0,0 +1,426 @@
|
||||
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||
|
||||
## Contexte et symptôme
|
||||
|
||||
Le control point PMOMusic est lent lorsqu'un renderer **OpenHome** manipule des playlists
|
||||
d'environ 1 000 titres. Les renderers Chromecast et UPnP pur ne sont pas affectés : ils
|
||||
utilisent une `InternalQueue` entièrement locale, sans appels SOAP. Le problème est
|
||||
spécifique à `OpenHomeQueue` (`pmocontrol/src/queue/openhome.rs`).
|
||||
|
||||
Le code a été généré par IA : il peut contenir des redondances, mais **chaque comportement
|
||||
est intentionnel**. L'objectif est d'optimiser sans rien supprimer.
|
||||
|
||||
## Causes racines identifiées
|
||||
|
||||
### P0 — Double appel à `queue_snapshot()` dans `sync_queue()`
|
||||
|
||||
**Fichiers** : `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
`sync_queue()` (ligne 1151) appelle `queue_snapshot()` pour obtenir l'état courant.
|
||||
Puis elle délègue à l'une de ces deux sous-fonctions qui appellent **à nouveau**
|
||||
`queue_snapshot()` :
|
||||
|
||||
- `replace_queue_with_pivot()` (ligne 555) : 2e appel `queue_snapshot()` + 1 appel
|
||||
`track_ids()` séparé (alors que `queue_snapshot()` appelle déjà `track_ids()` en interne)
|
||||
- `replace_queue_standard_lcs()` (ligne 647) : 2e appel `queue_snapshot()`
|
||||
|
||||
Seule `replace_queue_preserve_current()` n'a pas ce défaut (elle appelle uniquement
|
||||
`track_ids()`).
|
||||
|
||||
**Impact pour 1 000 titres :**
|
||||
|
||||
Chaque `queue_snapshot()` exécute :
|
||||
- 1 appel SOAP `IdArray` (liste des IDs)
|
||||
- 16 appels SOAP `ReadList` (lots de 64 items)
|
||||
|
||||
Soit **34 appels SOAP** pour une seule opération `sync_queue()` au lieu de 17.
|
||||
|
||||
Le cache `ReadList` (TTL 500 ms) atténue partiellement mais ne supprime pas le problème
|
||||
car la durée d'un `sync_queue` sur 1 000 titres peut dépasser 500 ms.
|
||||
|
||||
### P1 — Algorithme LCS de complexité quadratique O(m × n)
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs:851`
|
||||
|
||||
La fonction `lcs_flags()` alloue une table DP de taille `(m+1) × (n+1)` :
|
||||
|
||||
```rust
|
||||
let mut dp = vec![vec![0u32; n + 1]; m + 1];
|
||||
```
|
||||
|
||||
Pour 1 000 titres en entrée : 1 000 × 1 000 = **1 000 000 entrées** (≈ 4 MB), et
|
||||
1 000 000 comparaisons. Elle est appelée **jusqu'à 3 fois** dans un seul `sync_queue` :
|
||||
- 2 fois dans `replace_queue_with_pivot()` (avant et après le pivot, lignes 579 et 582)
|
||||
- 1 fois dans `replace_queue_standard_lcs()` (ligne 661)
|
||||
|
||||
Dans le cas courant (ajout de titres en fin de liste, ou liste déjà synchronisée),
|
||||
la quasi-totalité de la table DP est inutile : les préfixe et suffixe communs
|
||||
représentent souvent 90 % ou plus de la liste.
|
||||
|
||||
### P2 — Taille de lot `ReadList` = 64
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs:986`
|
||||
|
||||
```rust
|
||||
const MAX_BATCH: usize = 64;
|
||||
```
|
||||
|
||||
Pour 1 000 titres : 1 000 ÷ 64 = **16 appels SOAP `ReadList`** par `queue_snapshot()`.
|
||||
La latence réseau typique par appel SOAP (50–200 ms) implique 0,8 à 3,2 secondes
|
||||
uniquement pour la lecture des métadonnées.
|
||||
|
||||
La norme OpenHome Playlist ne fixe pas de limite de payload. La valeur 64 est
|
||||
conservatrice. Augmenter à 256 réduit à **4 appels** (−75 %).
|
||||
|
||||
Le mécanisme de fallback one-by-one (lignes 1007–1019) assure la rétrocompatibilité
|
||||
avec les devices qui refuseraient un payload plus large.
|
||||
|
||||
### P3 — Polling à 500 ms indépendant de l'activité
|
||||
|
||||
**Fichier** : `pmocontrol/src/music_renderer/watcher.rs`
|
||||
|
||||
Chaque renderer OpenHome tourne un thread watcher toutes les 500 ms, même en veille.
|
||||
Avec plusieurs renderers actifs, les appels de polling et les opérations `sync_queue`
|
||||
se chevauchent sur le même device réseau, créant de la contention.
|
||||
|
||||
### P4 — Redondances de code (nettoyage conservatif)
|
||||
|
||||
**a. Invalidation des caches dupliquée** (`openhome.rs`)
|
||||
|
||||
La séquence d'invalidation apparaît en 3 endroits distincts (lignes 1134–1136,
|
||||
454–456, 634–635) :
|
||||
|
||||
```rust
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
// parfois aussi :
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
```
|
||||
|
||||
**b. Protection durée stream dupliquée** (`openhome.rs` et `interne.rs`)
|
||||
|
||||
La logique de protection de durée pour les flux continus (radio) est implémentée :
|
||||
- Dans `cache_metadata()` de `OpenHomeQueue` (`openhome.rs:250–351`)
|
||||
- Dans `protect_stream_durations()` de `InternalQueue` (`interne.rs:92–143`)
|
||||
- Dans `merge_metadata_protecting_streams()` de `InternalQueue` (`interne.rs:148–215`)
|
||||
|
||||
**c. `parse_duration()` défini 3 fois**
|
||||
|
||||
La conversion `HH:MM:SS` → secondes apparaît dans `openhome.rs`, `interne.rs`,
|
||||
et dans `time_utils::parse_hhmmss_u32()` (déjà publique).
|
||||
|
||||
## Ce qui fonctionne déjà correctement
|
||||
|
||||
**Pagination du Browse** : La boucle de pagination est correctement implémentée dans
|
||||
`control_point.rs:1610–1653` avec `browse_children()` + offset incrémental.
|
||||
|
||||
**Fallback ReadList one-by-one** : Si un batch échoue, le retry unitaire (lignes 1007–1019)
|
||||
assure la robustesse sur les devices stricts.
|
||||
|
||||
**Protection multi-control-point** : `delete_id_if_exists()` gère proprement le cas où
|
||||
un autre control point a déjà supprimé un titre.
|
||||
|
||||
**Stratégie double-LCS avec pivot** : La logique de `replace_queue_with_pivot()` est
|
||||
correcte et importante pour ne pas interrompre la lecture en cours.
|
||||
|
||||
**Cache métadonnées stream** : La protection de durée décroissante pour les flux radio
|
||||
est un comportement essentiel à préserver scrupuleusement.
|
||||
|
||||
## Plan d'exécution
|
||||
|
||||
### Crate concernée : `pmocontrol`
|
||||
|
||||
---
|
||||
|
||||
### Étape 1 — Augmenter le batch `ReadList` à 256
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs:986`
|
||||
|
||||
```rust
|
||||
// Avant
|
||||
const MAX_BATCH: usize = 64;
|
||||
|
||||
// Après
|
||||
const MAX_BATCH: usize = 256;
|
||||
```
|
||||
|
||||
Le fallback one-by-one (lignes 1007–1019) reste intact. Si un renderer refuse
|
||||
un payload de 256 IDs, il retombe automatiquement sur le mode unitaire.
|
||||
|
||||
---
|
||||
|
||||
### Étape 2 — Éliminer le double appel à `queue_snapshot()`
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
Le snapshot calculé dans `sync_queue()` contient déjà les items **et** leurs IDs
|
||||
backend (`backend_id: usize`). Il n'est pas nécessaire de le recalculer dans les
|
||||
sous-fonctions.
|
||||
|
||||
#### 2a. Passer le snapshot à `replace_queue_with_pivot()`
|
||||
|
||||
Signature actuelle (ligne 548) :
|
||||
```rust
|
||||
fn replace_queue_with_pivot(
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
pivot_idx_new: usize,
|
||||
pivot_id: usize,
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
Nouvelle signature :
|
||||
```rust
|
||||
fn replace_queue_with_pivot(
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
pivot_idx_new: usize,
|
||||
pivot_id: usize,
|
||||
snapshot: &QueueSnapshot, // ← ajouté
|
||||
current_track_ids: &[u32], // ← ajouté (évite aussi le 2e appel track_ids())
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
À l'intérieur de `replace_queue_with_pivot()`, supprimer :
|
||||
```rust
|
||||
// Supprimer ces deux lignes (ligne 555–556)
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current_track_ids = self.track_ids()?;
|
||||
```
|
||||
|
||||
Et utiliser directement les paramètres `snapshot` et `current_track_ids`.
|
||||
|
||||
Appel depuis `sync_queue()` (ligne 1221) :
|
||||
```rust
|
||||
// Avant
|
||||
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
|
||||
|
||||
// Après — passer le snapshot et les IDs déjà disponibles
|
||||
let current_ids_for_pivot: Vec<u32> = snapshot.items
|
||||
.iter()
|
||||
.map(|i| i.backend_id as u32)
|
||||
.collect();
|
||||
self.replace_queue_with_pivot(items, pivot_idx, playing_id, &snapshot, ¤t_ids_for_pivot)?;
|
||||
```
|
||||
|
||||
**Note importante** : dans `sync_queue()`, le snapshot est pris APRÈS
|
||||
`ensure_playlist_source_selected()` (ligne 1115) et APRÈS la résolution du `playing_info`.
|
||||
Cet ordre est correct et doit être conservé.
|
||||
|
||||
#### 2b. Passer le snapshot à `replace_queue_standard_lcs()`
|
||||
|
||||
Signature actuelle (ligne 641) :
|
||||
```rust
|
||||
fn replace_queue_standard_lcs(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
Nouvelle signature :
|
||||
```rust
|
||||
fn replace_queue_standard_lcs(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
snapshot: &QueueSnapshot, // ← ajouté
|
||||
current_track_ids: &[u32], // ← ajouté
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
À l'intérieur, supprimer :
|
||||
```rust
|
||||
// Supprimer ces deux lignes (lignes 647–648)
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current_track_ids = self.track_ids()?;
|
||||
```
|
||||
|
||||
Appel depuis `sync_queue()` (ligne 1253) :
|
||||
```rust
|
||||
// Avant
|
||||
self.replace_queue_standard_lcs(items, Some(0))?;
|
||||
|
||||
// Après
|
||||
let current_ids_for_lcs: Vec<u32> = snapshot.items
|
||||
.iter()
|
||||
.map(|i| i.backend_id as u32)
|
||||
.collect();
|
||||
self.replace_queue_standard_lcs(items, &snapshot, ¤t_ids_for_lcs)?;
|
||||
```
|
||||
|
||||
**Cas particulier à préserver** (ligne 1237–1246) : le guard sur `snapshot.items.is_empty()`
|
||||
dans `sync_queue()` est exécuté **avant** l'appel à `replace_queue_standard_lcs`, donc
|
||||
le snapshot vide ne peut pas atteindre la sous-fonction — le comportement est préservé.
|
||||
|
||||
---
|
||||
|
||||
### Étape 3 — Optimiser LCS par élagage du préfixe/suffixe communs
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
La fonction `lcs_flags()` (ligne 851) reste inchangée. L'optimisation s'applique
|
||||
**aux appels** dans `replace_queue_with_pivot()` et `replace_queue_standard_lcs()`.
|
||||
|
||||
#### Principe
|
||||
|
||||
Avant de calculer le LCS DP, éliminer les éléments identiques en tête et en queue :
|
||||
|
||||
```rust
|
||||
/// Wrapper autour de lcs_flags() qui élimine préfixe et suffixe communs
|
||||
/// avant d'appeler l'algorithme DP O(m×n).
|
||||
///
|
||||
/// Cas optimisés : ajout en fin de liste → O(n), liste déjà synchro → O(n),
|
||||
/// suppression en fin → O(n). LCS complet uniquement pour les vrais réordonnements.
|
||||
fn lcs_flags_optimized(
|
||||
current: &[PlaybackItem],
|
||||
desired: &[PlaybackItem],
|
||||
) -> (Vec<bool>, Vec<bool>) {
|
||||
// Préfixe commun
|
||||
let leading = current
|
||||
.iter()
|
||||
.zip(desired.iter())
|
||||
.take_while(|(c, d)| items_match(c, d))
|
||||
.count();
|
||||
|
||||
// Suffixe commun (sur les portions restantes uniquement)
|
||||
let c_tail = ¤t[leading..];
|
||||
let d_tail = &desired[leading..];
|
||||
let trailing = c_tail
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(d_tail.iter().rev())
|
||||
.take_while(|(c, d)| items_match(c, d))
|
||||
.count();
|
||||
|
||||
let c_mid = &c_tail[..c_tail.len() - trailing];
|
||||
let d_mid = &d_tail[..d_tail.len() - trailing];
|
||||
|
||||
// Si rien à faire (listes identiques ou préfixe/suffixe couvrent tout)
|
||||
if c_mid.is_empty() && d_mid.is_empty() {
|
||||
return (vec![true; current.len()], vec![true; desired.len()]);
|
||||
}
|
||||
|
||||
// LCS DP sur le delta central uniquement
|
||||
let (keep_c_mid, keep_d_mid) = lcs_flags(c_mid, d_mid);
|
||||
|
||||
// Reconstituer les vecteurs complets
|
||||
let mut keep_current = vec![true; leading];
|
||||
keep_current.extend(keep_c_mid);
|
||||
keep_current.extend(vec![true; trailing]);
|
||||
|
||||
let mut keep_desired = vec![true; leading];
|
||||
keep_desired.extend(keep_d_mid);
|
||||
keep_desired.extend(vec![true; trailing]);
|
||||
|
||||
(keep_current, keep_desired)
|
||||
}
|
||||
```
|
||||
|
||||
Remplacer les 3 appels à `lcs_flags()` (lignes 579, 582, 661) par `lcs_flags_optimized()`.
|
||||
|
||||
La fonction `lcs_flags()` originale est **conservée** (utilisée en interne par
|
||||
`lcs_flags_optimized()`).
|
||||
|
||||
---
|
||||
|
||||
### Étape 4 — Polling adaptatif selon l'activité
|
||||
|
||||
**Fichier** : `pmocontrol/src/music_renderer/watcher.rs`
|
||||
|
||||
Ajouter un flag partagé `is_active` dans `MusicRenderer` (ou `WatchedState`) pour
|
||||
signaler si le renderer est en activité récente.
|
||||
|
||||
Le renderer met `is_active = true` lors de chaque opération (play, sync, seek, stop).
|
||||
Le watcher revient à l'intervalle long (5 000 ms) après 10 s sans activité.
|
||||
|
||||
```rust
|
||||
// Dans la boucle du watcher :
|
||||
let interval = if is_active.load(Ordering::Relaxed) {
|
||||
Duration::from_millis(500)
|
||||
} else {
|
||||
Duration::from_millis(5_000)
|
||||
};
|
||||
thread::sleep(interval);
|
||||
```
|
||||
|
||||
**Fonctionnalités à préserver** :
|
||||
- Détection de fin de piste (auto-advance) : délai max 5 s en idle — acceptable
|
||||
- Sleep timer countdown : reste actif au polling suivant
|
||||
- Synchronisation auto sur mise à jour de playlist : déclenchée par événement externe,
|
||||
pas par le polling — non affectée
|
||||
|
||||
---
|
||||
|
||||
### Étape 5 — Consolider les redondances (nettoyage conservatif)
|
||||
|
||||
**À réaliser uniquement après validation fonctionnelle des étapes 1–4.**
|
||||
|
||||
#### 5a. Méthode `invalidate_all_caches()` sur `OpenHomeQueue`
|
||||
|
||||
```rust
|
||||
fn invalidate_all_caches(&self) {
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
}
|
||||
|
||||
fn invalidate_track_caches(&self) {
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
}
|
||||
```
|
||||
|
||||
Remplacer les séquences d'invalidation en 3 endroits (lignes 1134–1136, 454–456, 634–635).
|
||||
Garder les appels sélectifs là où seulement 2 caches sont invalidés.
|
||||
|
||||
#### 5b. Factoriser `parse_duration()`
|
||||
|
||||
Supprimer les définitions locales de `parse_duration` dans `openhome.rs` et `interne.rs`.
|
||||
Utiliser `crate::music_renderer::time_utils::parse_hhmmss_u32()` (déjà publique).
|
||||
La sémantique est identique : conversion `HH:MM:SS` → u64 secondes.
|
||||
|
||||
#### 5c. Factoriser la protection durée stream
|
||||
|
||||
Extraire la logique commune de protection (« ne jamais diminuer la durée d'un flux
|
||||
continu pour le même titre/artiste ») dans une fonction privée dans `openhome.rs`,
|
||||
et y référencer depuis `interne.rs` via le module `queue`.
|
||||
|
||||
**Règle absolue** : ne pas modifier la sémantique de détection de stream continu
|
||||
(`is_continuous_stream_url()`) ni la logique de comparaison titre/artiste. Uniquement
|
||||
factoriser le code existant.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'exécution
|
||||
|
||||
1. **Étape 1** — Batch ReadList 256 (changement trivial, gain immédiat −75 % appels)
|
||||
2. **Étape 2** — Élimination double `queue_snapshot()` (−50 % appels SOAP totaux)
|
||||
3. **Étape 3** — Optimisation LCS préfixe/suffixe (gain CPU, cas courants en O(n))
|
||||
4. **Étape 4** — Polling adaptatif (réduction contention réseau en veille)
|
||||
5. **Étape 5** — Consolidation redondances (nettoyage, après validation)
|
||||
|
||||
## Périmètre : ce qui ne change pas
|
||||
|
||||
- La logique à 3 cas de `sync_queue()` (avec pivot, préserver courant, LCS standard)
|
||||
- La protection durée décroissante pour les flux radio (cache stream)
|
||||
- Le mécanisme `delete_id_if_exists()` pour la robustesse multi-control-point
|
||||
- Le fallback `ReadList` one-by-one en cas d'erreur batch
|
||||
- La pagination Browse dans `control_point.rs` (déjà correcte)
|
||||
- Le comportement des queues `InternalQueue` (Chromecast, UPnP) — non affectées
|
||||
- Les TTL des caches existants (1 s, 500 ms, 250 ms)
|
||||
- Tous les logs de diagnostic (`tracing::warn!`, `debug!`) — à conserver
|
||||
|
||||
## Tests recommandés
|
||||
|
||||
Demander à l'humain de compiler et tester :
|
||||
|
||||
```
|
||||
cargo build -p pmocontrol
|
||||
```
|
||||
|
||||
Puis tester avec un renderer OpenHome physique :
|
||||
- Playlist de 1 000 titres : mesurer le temps de `sync_queue` avant/après
|
||||
- Ajout de titres en fin de liste : vérifier que LCS optimisé ne fait que des insertions
|
||||
- Lecture en cours + refresh playlist : vérifier que la piste courante n'est pas interrompue
|
||||
- Flux radio : vérifier que la durée ne régresse pas pour un même titre/artiste
|
||||
- Renderer Chromecast : vérifier l'absence de régression (queue interne)
|
||||
235
Blackboard/Todo/enorme_playlist_step2.md
Normal file
235
Blackboard/Todo/enorme_playlist_step2.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Optimisation Playlist OpenHome — Étape 2
|
||||
|
||||
**Contexte**: Suite de `enorme_playlist.md`. Les optimisations de base (MAX_BATCH=256, LCS prefixe/suffixe, polling adaptatif, caches TTL) sont faites. Les lenteurs persistent sur les grandes playlists (~1000 titres). Les renderers Chromecast et UPnP sont moins affectés mais bénéficieront également de certaines optimisations.
|
||||
|
||||
**Contrainte architecturale fondamentale**: La queue OpenHome est la **source de vérité unique**. Un miroir local persistent a été tenté et abandonné — impossible à maintenir en sync quand d'autres control points (BubbleUPnP, Linn, etc.) modifient la queue. Toute optimisation doit respecter cette contrainte.
|
||||
|
||||
**Note sur le LCS**: `lcs_flags_optimized` (openhome.rs:869) gère déjà le cas dominant (préfixe/suffixe communs). Pour un append de 100 tracks à 900 existants, le LCS est O(1) — ce n'est pas le goulot. Le coût réel est les appels SOAP : connexions TCP × (RTT + handshake) et les `ReadList` pour reconstruire le snapshot.
|
||||
|
||||
---
|
||||
|
||||
## Analyse des Goulots Réels
|
||||
|
||||
Pour un `sync_queue` "append 100 tracks à 900 existants" aujourd'hui :
|
||||
|
||||
| Étape | Appels SOAP | Coût estimé |
|
||||
|-------|------------|-------------|
|
||||
| `id_array()` | 1 | ~5ms |
|
||||
| `read_list()` — 4 batches × 256 | 4 | ~20ms |
|
||||
| 100 × `insert()` | 100 | ~100 × (RTT + **TCP handshake**) |
|
||||
| **Total TCP handshakes** | 105 | **105 × 10-50ms = 1-5 secondes** |
|
||||
|
||||
Les deux leviers : (1) éliminer les handshakes TCP, (2) éliminer les `ReadList` quand inutiles.
|
||||
|
||||
---
|
||||
|
||||
## Plan d'Implémentation
|
||||
|
||||
### Phase 1 — Connection Pooling (1-2h, PRIORITÉ MAXIMALE)
|
||||
|
||||
**Fichier**: `pmocontrol/src/soap_client.rs`
|
||||
|
||||
**Problème**: lignes 65-72 créent un nouvel `ureq::Agent` à chaque appel SOAP = nouvelle connexion TCP à chaque fois.
|
||||
|
||||
**Solution**: Agent statique partagé via `OnceLock`.
|
||||
|
||||
```rust
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static SOAP_AGENT: OnceLock<ureq::Agent> = OnceLock::new();
|
||||
|
||||
fn get_soap_agent() -> &'static ureq::Agent {
|
||||
SOAP_AGENT.get_or_init(|| {
|
||||
ureq::Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.build()
|
||||
.into()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Dans `invoke_upnp_action_with_timeout()`, remplacer la construction de l'agent par :
|
||||
|
||||
```rust
|
||||
// Cas normal : réutiliser l'agent partagé (keep-alive, connection pooling)
|
||||
// Cas custom timeout : agent dédié (rare — timeout global suffit en pratique)
|
||||
let agent_owned;
|
||||
let agent: &ureq::Agent = if timeout.is_some() {
|
||||
agent_owned = ureq::Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(timeout)
|
||||
.build()
|
||||
.into();
|
||||
&agent_owned
|
||||
} else {
|
||||
get_soap_agent()
|
||||
};
|
||||
```
|
||||
|
||||
**Vérifier** que `ureq::Agent` maintient bien un pool de connexions HTTP/1.1 keep-alive entre les appels (comportement documenté de ureq v3 — l'agent est conçu pour être réutilisé).
|
||||
|
||||
**Impact**: Élimine les TCP handshakes répétés pour tous les renderers (OpenHome, UPnP, Chromecast). Pour 100 inserts : 99 handshakes économisés × 10-50ms = **1-5 secondes récupérées**.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Fast Path Session (2-3h, PRIORITÉ HAUTE)
|
||||
|
||||
**Concept**: Sans miroir persistant (abandonné), on peut quand même éviter les `ReadList` dans le cas dominant en maintenant un état **éphémère de session** — valable uniquement entre deux `sync_queue` consécutifs, et invalidé dès qu'on détecte une incohérence.
|
||||
|
||||
**Principe**: Après chaque `sync_queue`, mémoriser :
|
||||
- la liste d'IDs résultante (déjà dans `track_ids_cache` TTL 1s)
|
||||
- le `after_id` du dernier insert (pour pouvoir appender sans `id_array`)
|
||||
|
||||
Au prochain `sync_queue`, tenter de détecter le pattern sans `ReadList` :
|
||||
|
||||
```rust
|
||||
fn try_fast_path(&self, new_items: &[PlaybackItem]) -> FastPathResult {
|
||||
// Récupérer les IDs actuels (cache ou 1 appel id_array)
|
||||
let current_ids = self.track_ids()?;
|
||||
let current_len = current_ids.len();
|
||||
let new_len = new_items.len();
|
||||
|
||||
// Fast path 1: append only
|
||||
// Condition: new_items a plus d'items, et les current_len premiers de new_items
|
||||
// ont les mêmes didl_id que les items actuels (vérifiable depuis id_array + metadata_cache local)
|
||||
if new_len > current_len {
|
||||
let prefix_matches = self.check_prefix_matches(¤t_ids, &new_items[..current_len]);
|
||||
if prefix_matches {
|
||||
return FastPathResult::AppendOnly { items: &new_items[current_len..] };
|
||||
}
|
||||
}
|
||||
|
||||
// Fast path 2: delete from end
|
||||
if new_len < current_len {
|
||||
let prefix_matches = self.check_prefix_matches(¤t_ids[..new_len], new_items);
|
||||
if prefix_matches {
|
||||
let to_delete = ¤t_ids[new_len..];
|
||||
return FastPathResult::DeleteFromEnd { ids: to_delete };
|
||||
}
|
||||
}
|
||||
|
||||
// Cas général: fallback ReadList + LCS
|
||||
FastPathResult::NeedFullSync
|
||||
}
|
||||
```
|
||||
|
||||
**`check_prefix_matches`** : compare `current_ids[i]` avec `new_items[i]` en utilisant le `metadata_cache` local (déjà en mémoire) pour résoudre les URIs/didl_ids des IDs connus. Si un ID n'est pas en cache → fast path impossible → fallback.
|
||||
|
||||
**Clé**: Cette vérification utilise uniquement le `metadata_cache` local (HashMap en mémoire, nano-secondes) et `id_array()` (déjà caché TTL 1s). Zéro appel `ReadList` dans le cas heureux. Si la vérification échoue (incohérence détectée, cache manquant) → fallback propre vers `ReadList` + LCS, source de vérité OpenHome préservée.
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/openhome.rs`, ajouter `try_fast_path()` et l'intégrer en début de `sync_queue()`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Queue FIFO Async (8-12h, PRIORITÉ MOYENNE)
|
||||
|
||||
**Prérequis**: Phases 1 et 2 complétées.
|
||||
|
||||
**Concept**: Exécuter les opérations SOAP dans un thread dédié pour rendre `sync_queue()` non-bloquant du point de vue de l'appelant.
|
||||
|
||||
**Contrainte technique**: Les `insert()` sont chaînés — chaque appel retourne un `new_id` utilisé comme `after_id` du suivant. Le worker doit maintenir cet état interne.
|
||||
|
||||
**Fichier à créer**: `pmocontrol/src/queue/openhome_op_queue.rs`
|
||||
|
||||
```rust
|
||||
pub enum OpenHomeOp {
|
||||
/// Insert séquentiel — after_id géré en interne (last_inserted_id)
|
||||
InsertAtEnd { uri: String, metadata: String, didl_id: String },
|
||||
/// Insert après un ID connu (ex: après le pivot)
|
||||
InsertAfter { after_id: u32, uri: String, metadata: String, didl_id: String },
|
||||
DeleteId { track_id: u32 },
|
||||
DeleteAll,
|
||||
SeekId { id: u32 },
|
||||
Play,
|
||||
Pause,
|
||||
Stop,
|
||||
SetVolume { volume: u16 },
|
||||
}
|
||||
|
||||
pub struct OpenHomeOpQueue {
|
||||
sender: mpsc::Sender<OpenHomeOp>,
|
||||
last_error: Arc<Mutex<Option<ControlPointError>>>,
|
||||
completion: Arc<(Mutex<bool>, Condvar)>,
|
||||
}
|
||||
|
||||
impl OpenHomeOpQueue {
|
||||
pub fn push(&self, op: OpenHomeOp) { ... }
|
||||
/// Opérations critiques passent devant (play/stop/volume)
|
||||
pub fn push_priority(&self, op: OpenHomeOp) { ... }
|
||||
/// Vider la file d'attente (ex: nouvelle playlist demandée avant fin de sync)
|
||||
pub fn clear_pending(&self) { ... }
|
||||
/// Attendre que toutes les opérations soient exécutées
|
||||
pub fn wait_completion(&self) { ... }
|
||||
/// Récupérer la dernière erreur (non-bloquant)
|
||||
pub fn take_error(&self) -> Option<ControlPointError> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Comportement en cas d'erreur**: vider la file d'attente, signaler l'erreur via `last_error`, invalider les caches OpenHome (forcer re-sync depuis source de vérité au prochain appel).
|
||||
|
||||
**Comportement en cas de `clear_pending()` pendant exécution**: laisser l'opération en cours se terminer (plus sûr — évite de laisser le renderer dans un état inconsistant), vider le reste.
|
||||
|
||||
**Intégration dans `OpenHomeQueue`**: remplacer les appels directs `playlist_client.insert()` / `playlist_client.delete_id()` par des `op_queue.push()`. Les opérations qui ont besoin d'une réponse synchrone (ex: `current_track()`, `queue_snapshot()`) continuent d'appeler directement le `playlist_client` — mais doivent d'abord attendre la complétion de la file (`wait_completion()`).
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Throttle des replace_item (2-3h, PRIORITÉ BASSE)
|
||||
|
||||
**Contexte**: `replace_item()` (openhome.rs:1315) fait `delete_id` + `insert` pour mettre à jour une piste. Sur un stream radio qui change de morceau, la durée est mise à jour fréquemment → paires SOAP inutiles car le `metadata_cache` local est déjà la source pour l'UI.
|
||||
|
||||
**Solution**: Ne pas envoyer le `delete_id` + `insert` OpenHome si une mise à jour pour ce `track_id` a déjà été envoyée dans les N dernières secondes. Le `metadata_cache` local est mis à jour immédiatement (pour l'UI), et l'opération OpenHome est différée ou ignorée.
|
||||
|
||||
```rust
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
let track_id = self.track_ids()?[index];
|
||||
|
||||
// Toujours mettre à jour le cache local immédiatement (pour l'UI)
|
||||
self.cache_metadata(track_id, item.metadata.clone());
|
||||
|
||||
// Throttle: si replace OpenHome récent pour ce track, sauter l'opération SOAP
|
||||
if self.is_recent_replace(track_id, Duration::from_secs(5)) {
|
||||
return Ok(());
|
||||
}
|
||||
self.mark_replace_time(track_id);
|
||||
|
||||
// Opération SOAP (delete + insert)
|
||||
// ... code existant ...
|
||||
}
|
||||
```
|
||||
|
||||
Ajouter `last_replace_times: Mutex<HashMap<u32, SystemTime>>` dans `OpenHomeQueue`.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'Implémentation
|
||||
|
||||
```
|
||||
Phase 1 → mesurer gain TCP → Phase 2 → mesurer gain ReadList → Phase 3 → Phase 4
|
||||
```
|
||||
|
||||
Tester chaque phase avec des playlists réelles (~1000 tracks) avant de poursuivre. Ne pas combiner les phases pour pouvoir isoler les régressions.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Phase 1 — vérifier connexions TCP réutilisées
|
||||
# tcpdump -i lo port 60000 -c 200 (ou port du renderer)
|
||||
# Avant: SYN à chaque appel SOAP
|
||||
# Après: SYN unique, flux keep-alive
|
||||
|
||||
# Phase 2 — vérifier fast path activé
|
||||
# RUST_LOG=debug cargo run ... 2>&1 | grep "fast path"
|
||||
# Cas append 100 → "fast path: AppendOnly, 100 inserts"
|
||||
# Cas delete 100 → "fast path: DeleteFromEnd, 100 deletes"
|
||||
# Cas reorder → "fast path: NeedFullSync, falling back to ReadList+LCS"
|
||||
|
||||
# Phase 3 — vérifier non-blocage
|
||||
# sync_queue() doit retourner en <1ms (les 100 inserts continuent en background)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Contrainte architecturale intégrée: miroir local abandonné (désynchronisation avec autres control points). Toutes les phases respectent OpenHome comme source de vérité unique.*
|
||||
|
||||
*Date: 2026-04-08*
|
||||
642
Blackboard/Todo/enorme_playlist_step3.md
Normal file
642
Blackboard/Todo/enorme_playlist_step3.md
Normal file
@@ -0,0 +1,642 @@
|
||||
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||
|
||||
# Async Queue Refresh — Étape 3
|
||||
|
||||
**Contexte**: `refresh_attached_queue_for()` dans `control_point.rs` est appelé de 3 endroits
|
||||
et bloque son thread pendant toute la synchronisation (browse media server + 100+ opérations
|
||||
SOAP/mémoire). L'objectif est de factoriser le mécanisme async au niveau de la couche queue
|
||||
(`MusicQueue`), qui est déjà l'abstraction agnostique du backend. `control_point.rs` ne doit
|
||||
plus connaître les threads ni les tokens d'annulation.
|
||||
|
||||
**Principe architectural**: la couche queue sait *comment* syncer (mémoire ou SOAP) et donc
|
||||
aussi *comment* annuler et *quand* signaler que la lecture peut démarrer. `control_point.rs`
|
||||
sait seulement *quoi* syncer (browse + conversion PlaybackItem). Les deux responsabilités
|
||||
restent séparées.
|
||||
|
||||
---
|
||||
|
||||
## Vue d'ensemble des changements
|
||||
|
||||
```
|
||||
AVANT
|
||||
control_point.rs
|
||||
refresh_attached_queue_for()
|
||||
→ browse()
|
||||
→ sync_queue(items) ← bloquant 1-5s
|
||||
|
||||
APRÈS
|
||||
control_point.rs
|
||||
do_queue_refresh_work() ← interne, fait le browse + conversion
|
||||
MusicQueue (couche queue)
|
||||
schedule_sync(items, callbacks) ← non-bloquant, retourne immédiatement
|
||||
→ thread "queue-sync-{renderer_id}"
|
||||
→ QueueBackend::sync_queue(items, cancel_token, on_ready)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fichiers à modifier / créer
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `pmocontrol/src/errors.rs` | Ajouter variante `SyncCancelled` |
|
||||
| `pmocontrol/src/queue/backend.rs` | Modifier signature `sync_queue()` |
|
||||
| `pmocontrol/src/queue/interne.rs` | Adapter signature `sync_queue()` |
|
||||
| `pmocontrol/src/queue/openhome.rs` | Adapter + points de vérification cancel + on_ready |
|
||||
| `pmocontrol/src/queue/music_queue.rs` | Ajouter champs async + méthode `schedule_sync()` |
|
||||
| `pmocontrol/src/queue/mod.rs` | Exporter `SyncScheduleOutcome` |
|
||||
| `pmocontrol/src/model.rs` | Ajouter événements `QueueReadyToPlay`, `QueueSyncCancelled` |
|
||||
| `pmocontrol/src/sse.rs` | Sérialiser les deux nouveaux événements |
|
||||
| `pmocontrol/src/control_point.rs` | Remplacer les 3 call sites bloquants |
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 — Nouvelle variante d'erreur (`errors.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/errors.rs`
|
||||
|
||||
Ajouter après la ligne 51 (`ControlPoint`) :
|
||||
|
||||
```rust
|
||||
#[error("Queue sync cancelled (superseded by a newer request)")]
|
||||
SyncCancelled,
|
||||
```
|
||||
|
||||
Cette variante est retournée par `sync_queue()` quand le `cancel_token` passe à `true`.
|
||||
Elle est **non-fatale** — le coordinator la traite comme un comportement normal, pas une
|
||||
erreur à logger en `warn!`.
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — Modifier la signature de `sync_queue()` dans le trait (`backend.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/backend.rs`, ligne 110
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
|
||||
|
||||
// APRÈS
|
||||
use std::sync::{Arc, atomic::AtomicBool};
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError>;
|
||||
```
|
||||
|
||||
**Sémantique des paramètres** :
|
||||
- `cancel_token` : si `true` au moment d'une opération, retourner `Err(SyncCancelled)` immédiatement
|
||||
- `on_ready` : callback one-shot appelé quand la lecture peut démarrer (voir logique ci-dessous)
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 — Adapter `InternalQueue::sync_queue()` (`interne.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/interne.rs`
|
||||
|
||||
Trouver la méthode `sync_queue()` et adapter la signature. Le corps reste identique,
|
||||
avec deux ajouts :
|
||||
|
||||
**1. Vérification du pivot (early start)** : si une piste est en cours de lecture
|
||||
(un `current_index` est défini dans le snapshot courant), appeler `on_ready` immédiatement
|
||||
avant toute opération — la piste courante sera préservée.
|
||||
|
||||
**2. Si pas de pivot** (queue vide ou aucune piste en cours) : appeler `on_ready` après
|
||||
avoir inséré le premier item.
|
||||
|
||||
**3. Vérification cancel** : après chaque item inséré/supprimé (en pratique `InternalQueue`
|
||||
est rapide mais le principe doit être cohérent) :
|
||||
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
// Early start si pivot présent
|
||||
let has_current = self.current_index()?.is_some();
|
||||
if has_current {
|
||||
if let Some(f) = on_ready.take() { f(); }
|
||||
}
|
||||
|
||||
// ... logique existante de sync_queue() ...
|
||||
// Dans la boucle d'insertions, après le 1er insert :
|
||||
if on_ready.is_some() {
|
||||
if let Some(f) = on_ready.take() { f(); }
|
||||
}
|
||||
// Après chaque opération :
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 — Adapter `OpenHomeQueue::sync_queue()` (`openhome.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
### 4.1 Signature (ligne ~1277)
|
||||
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
### 4.2 Early start — logique pivot
|
||||
|
||||
Au début de `sync_queue()`, **avant** toute opération SOAP, détecter si un pivot est présent :
|
||||
|
||||
```rust
|
||||
// Après la récupération du snapshot (ligne ~1323), avant les branches if/else :
|
||||
let has_pivot = playing_info.is_some();
|
||||
if has_pivot {
|
||||
// Le pivot sera préservé — on peut démarrer la lecture immédiatement
|
||||
if let Some(f) = on_ready.take() { f(); }
|
||||
}
|
||||
```
|
||||
|
||||
Si pas de pivot (nouvelle playlist via `delete_all` + inserts depuis 0) : appeler `on_ready`
|
||||
après le **1er insert réussi** dans `replace_queue()` et dans `replace_queue_standard_lcs()`.
|
||||
|
||||
### 4.3 Points de vérification cancel
|
||||
|
||||
Ajouter `if cancel_token.load(SeqCst) { return Err(SyncCancelled); }` aux endroits suivants :
|
||||
|
||||
- Dans `delete_marked_items()` (ligne ~458) : après chaque `delete_id_if_exists()`
|
||||
- Dans `rebuild_playlist_section()` (ligne ~494) : après chaque `insert()`
|
||||
- Dans `replace_queue_preserve_current()` (ligne ~419) : après chaque `delete_id_if_exists()` et `insert()`
|
||||
- Dans `replace_queue_standard_lcs()` (ligne ~693-745) : après chaque delete et insert
|
||||
- Dans `replace_queue()` (ligne ~1118) : après chaque insert dans la boucle
|
||||
- Dans le fast path `AppendOnly` (ligne ~1318) : après chaque insert
|
||||
- Dans le fast path `DeleteFromEnd` (ligne ~1336) : après chaque delete
|
||||
|
||||
### 4.4 Propagation du cancel_token aux helpers
|
||||
|
||||
Les méthodes helper privées qui font des boucles doivent recevoir le token :
|
||||
|
||||
```rust
|
||||
fn delete_marked_items(
|
||||
&mut self,
|
||||
old_ids: &[u32],
|
||||
keep_flags: &[bool],
|
||||
position_label: &str,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
) -> Result<(), ControlPointError>
|
||||
|
||||
fn rebuild_playlist_section(
|
||||
&mut self,
|
||||
// ... params existants ...
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<u32, ControlPointError>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 5 — Adapter `MusicQueue` (dispatch enum) (`music_queue.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/music_queue.rs`
|
||||
|
||||
### 5.1 Adapter le dispatch `sync_queue()` (ligne ~98)
|
||||
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||
MusicQueue::OpenHome(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Ajouter l'état async dans `MusicQueue`
|
||||
|
||||
`MusicQueue` passe de simple enum de dispatch à une struct qui **encapsule** l'enum backend
|
||||
et l'état de synchronisation async :
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
pub enum MusicQueue {
|
||||
Internal(InternalQueue),
|
||||
OpenHome(OpenHomeQueue),
|
||||
}
|
||||
|
||||
// APRÈS
|
||||
pub struct MusicQueue {
|
||||
backend: MusicQueueBackend,
|
||||
// État async de synchronisation
|
||||
sync_in_progress: Arc<AtomicBool>,
|
||||
sync_pending: Arc<AtomicBool>,
|
||||
sync_cancel_token: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
// L'enum devient privée
|
||||
enum MusicQueueBackend {
|
||||
Internal(InternalQueue),
|
||||
OpenHome(OpenHomeQueue),
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: le changement de `enum` en `struct` implique de mettre à jour toutes les
|
||||
utilisations de `MusicQueue::Internal(...)` et `MusicQueue::OpenHome(...)` dans le reste
|
||||
du code (essentiellement `music_queue.rs` lui-même et `mod.rs`). Les callers externes
|
||||
utilisent `MusicQueue` via `QueueBackend` et `QueueFromRendererInfo` — ils ne sont pas
|
||||
impactés si l'API publique est préservée.
|
||||
|
||||
### 5.3 Enum résultat et méthode `schedule_sync()`
|
||||
|
||||
```rust
|
||||
pub enum SyncScheduleOutcome {
|
||||
/// Thread spawné, sync en cours.
|
||||
Scheduled,
|
||||
/// Sync déjà en cours — annulée et nouvelle sync programmée en pending.
|
||||
AlreadyRunning,
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
impl MusicQueue {
|
||||
/// Lance une synchronisation asynchrone de la queue.
|
||||
///
|
||||
/// - Si aucune sync n'est en cours : spawne un thread, retourne `Scheduled`.
|
||||
/// - Si une sync est en cours : l'annule, note une sync pending, retourne `AlreadyRunning`.
|
||||
/// Le thread en cours finira l'opération courante, détectera le cancel, puis
|
||||
/// relancera la sync avec les nouveaux items via `pending_items_fn`.
|
||||
///
|
||||
/// `pending_items_fn` : closure appelée dans le worker pour re-fetcher les items
|
||||
/// en cas de pending. Elle doit être Send + 'static car elle s'exécute dans un thread.
|
||||
///
|
||||
/// `on_ready` : appelé dès que la lecture peut démarrer (pivot préservé ou 1er insert).
|
||||
///
|
||||
/// `renderer_id` : utilisé uniquement pour nommer le thread de travail.
|
||||
pub fn schedule_sync(
|
||||
&self, // &self car l'état async est derrière Arc<AtomicBool>
|
||||
renderer_id: &str,
|
||||
items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
) -> SyncScheduleOutcome
|
||||
```
|
||||
|
||||
**Problème de `&mut self` vs `&self`** : `sync_queue()` dans le trait prend `&mut self`
|
||||
car les backends mutent leur état. Mais `schedule_sync()` veut spawner un thread qui détient
|
||||
le backend. Solution : le backend est déjà derrière `Arc<Mutex<MusicQueue>>` dans
|
||||
`MusicRenderer` (ligne 124 de musicrenderer.rs — c'est le `queue` field). Le thread worker
|
||||
clone cet `Arc` et acquiert le lock pour appeler `sync_queue()`.
|
||||
|
||||
**Signature révisée** :
|
||||
|
||||
```rust
|
||||
/// Doit être appelé avec un Arc<Mutex<Self>> pour permettre le spawn du thread worker.
|
||||
pub fn schedule_sync(
|
||||
queue_arc: &Arc<Mutex<MusicQueue>>,
|
||||
renderer_id: &str,
|
||||
items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
) -> SyncScheduleOutcome {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
let (sync_in_progress, sync_pending, sync_cancel_token) = {
|
||||
let q = queue_arc.lock().unwrap();
|
||||
(
|
||||
Arc::clone(&q.sync_in_progress),
|
||||
Arc::clone(&q.sync_pending),
|
||||
Arc::clone(&q.sync_cancel_token),
|
||||
)
|
||||
};
|
||||
|
||||
if sync_in_progress.swap(true, SeqCst) {
|
||||
// Sync en cours : annuler et noter pending
|
||||
sync_cancel_token.store(true, SeqCst);
|
||||
sync_pending.store(true, SeqCst);
|
||||
return SyncScheduleOutcome::AlreadyRunning;
|
||||
}
|
||||
|
||||
// Pas de sync en cours : initialiser et spawner
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
sync_pending.store(false, SeqCst);
|
||||
|
||||
let queue_arc = Arc::clone(queue_arc);
|
||||
let thread_name = format!("queue-sync-{}", renderer_id);
|
||||
|
||||
thread::Builder::new()
|
||||
.name(thread_name)
|
||||
.spawn(move || {
|
||||
// Guard: libère in_progress à la sortie même en cas de panic
|
||||
struct Guard(Arc<AtomicBool>);
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) { self.0.store(false, SeqCst); }
|
||||
}
|
||||
let _guard = Guard(Arc::clone(&sync_in_progress));
|
||||
|
||||
let mut current_items = items;
|
||||
let mut current_on_ready = Some(on_ready);
|
||||
|
||||
loop {
|
||||
sync_pending.store(false, SeqCst);
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
|
||||
let result = {
|
||||
let mut q = queue_arc.lock().unwrap();
|
||||
q.backend.sync_queue(
|
||||
current_items,
|
||||
&sync_cancel_token,
|
||||
current_on_ready.take().flatten(),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Err(ControlPointError::SyncCancelled) => {
|
||||
// Annulé normalement — vérifier si pending
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("queue-sync error: {}", e);
|
||||
}
|
||||
Ok(()) => {}
|
||||
}
|
||||
|
||||
if !sync_pending.load(SeqCst) {
|
||||
break; // Pas de nouvelle sync en attente → terminer
|
||||
}
|
||||
|
||||
// Nouvelle sync demandée pendant l'exécution → re-fetcher et relancer
|
||||
match pending_items_fn() {
|
||||
Ok(new_items) => {
|
||||
current_items = new_items;
|
||||
current_on_ready = Some(None); // pas de on_ready pour les re-syncs
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("queue-sync pending re-fetch error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// _guard libère sync_in_progress = false
|
||||
})
|
||||
.expect("Failed to spawn queue-sync thread");
|
||||
|
||||
SyncScheduleOutcome::Scheduled
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 6 — Nouveaux événements SSE
|
||||
|
||||
### 6.1 `model.rs`
|
||||
|
||||
Localiser l'enum `RendererEvent` et ajouter :
|
||||
|
||||
```rust
|
||||
/// Émis dès que la queue peut être lue (pivot préservé ou 1er track inséré).
|
||||
QueueReadyToPlay {
|
||||
id: DeviceId,
|
||||
},
|
||||
/// Émis quand une sync est annulée car une nouvelle a été demandée.
|
||||
QueueSyncCancelled {
|
||||
id: DeviceId,
|
||||
},
|
||||
```
|
||||
|
||||
### 6.2 `sse.rs`
|
||||
|
||||
Localiser le match de sérialisation des `RendererEvent` et ajouter les deux variantes.
|
||||
Suivre le pattern existant de `QueueRefreshing` (ligne ~152) :
|
||||
|
||||
```rust
|
||||
RendererEvent::QueueReadyToPlay { id } => {
|
||||
// sérialiser avec type = "queue_ready_to_play"
|
||||
}
|
||||
RendererEvent::QueueSyncCancelled { id } => {
|
||||
// sérialiser avec type = "queue_sync_cancelled"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 7 — Modifier `control_point.rs`
|
||||
|
||||
### 7.1 Extraire la logique de browse
|
||||
|
||||
Renommer `refresh_attached_queue_for()` en deux fonctions :
|
||||
|
||||
**`fetch_queue_items_for()`** (nouvelle, interne) : fait le browse + conversion, retourne
|
||||
`Vec<PlaybackItem>`. C'est la `pending_items_fn` passée à `schedule_sync()`.
|
||||
|
||||
**`schedule_queue_refresh_for()`** (remplace l'ancienne) : appelle `fetch_queue_items_for()`,
|
||||
puis `MusicQueue::schedule_sync()`.
|
||||
|
||||
```rust
|
||||
fn fetch_queue_items_for(
|
||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||
renderer_id: &DeviceId,
|
||||
) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||
// Browse media server + conversion PlaybackItem
|
||||
// (logique actuellement dans refresh_attached_queue_for() lignes ~1593-1670)
|
||||
}
|
||||
|
||||
fn schedule_queue_refresh_for(
|
||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||
renderer_id: &DeviceId,
|
||||
event_bus: &RendererEventBus,
|
||||
auto_play_cb: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>>,
|
||||
) -> SyncScheduleOutcome {
|
||||
let items = match fetch_queue_items_for(registry, renderer_id) {
|
||||
Ok(items) => items,
|
||||
Err(e) => { warn!(...); return SyncScheduleOutcome::Scheduled; /* ou erreur */ }
|
||||
};
|
||||
|
||||
// on_ready : déclenche auto_play si demandé + émet QueueReadyToPlay SSE
|
||||
let rid = renderer_id.clone();
|
||||
let bus = event_bus.clone();
|
||||
let on_ready: Option<Box<dyn FnOnce() + Send + 'static>> = Some(Box::new(move || {
|
||||
bus.broadcast(RendererEvent::QueueReadyToPlay { id: rid.clone() });
|
||||
if let Some(cb) = auto_play_cb {
|
||||
if let Err(e) = cb(&rid) {
|
||||
warn!("auto-play callback failed: {}", e);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// pending_items_fn : re-fetcher depuis le media server si pending
|
||||
let registry2 = Arc::clone(registry);
|
||||
let rid2 = renderer_id.clone();
|
||||
let pending_fn = Box::new(move || fetch_queue_items_for(®istry2, &rid2));
|
||||
|
||||
// Récupérer l'Arc<Mutex<MusicQueue>> du renderer depuis le registry
|
||||
let queue_arc = {
|
||||
let reg = registry.read().unwrap();
|
||||
reg.get_renderer_queue_arc(renderer_id)? // méthode à ajouter dans DeviceRegistry
|
||||
};
|
||||
|
||||
// Émettre QueueRefreshing avant de lancer
|
||||
event_bus.broadcast(RendererEvent::QueueRefreshing { id: renderer_id.clone() });
|
||||
|
||||
let outcome = MusicQueue::schedule_sync(&queue_arc, &renderer_id.0, items, pending_fn, on_ready);
|
||||
|
||||
if matches!(outcome, SyncScheduleOutcome::AlreadyRunning) {
|
||||
event_bus.broadcast(RendererEvent::QueueSyncCancelled { id: renderer_id.clone() });
|
||||
}
|
||||
|
||||
outcome
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Call site 1 — thread "cp-media-server-event-worker" (l.270)
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
let _ = refresh_attached_queue_for(®istry, &renderer_id, &event_bus, None);
|
||||
// APRÈS
|
||||
schedule_queue_refresh_for(®istry, &renderer_id, &event_bus, None);
|
||||
```
|
||||
|
||||
### 7.3 Call site 2 — thread "cp-playlist-periodic-refresh" (l.340)
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
let _ = refresh_attached_queue_for(®istry_for_periodic, &renderer_id, &event_bus_for_periodic, None);
|
||||
// APRÈS
|
||||
schedule_queue_refresh_for(®istry_for_periodic, &renderer_id, &event_bus_for_periodic, None);
|
||||
```
|
||||
|
||||
### 7.4 Call site 3 — `attach_queue_to_playlist_async()` (l.1264)
|
||||
|
||||
```rust
|
||||
pub async fn attach_queue_to_playlist_async(
|
||||
&self, renderer_id: &DeviceId, server_id: &DeviceId, container_id: &str, auto_play: bool,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// 1. Enregistrer la liaison (synchrone, ~1ms)
|
||||
self.registry.write().unwrap()
|
||||
.set_playlist_binding(renderer_id, server_id, container_id);
|
||||
|
||||
// 2. Construire le callback auto-play si demandé
|
||||
let cp = self.clone();
|
||||
let rid = renderer_id.clone();
|
||||
let auto_play_cb: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>> =
|
||||
if auto_play {
|
||||
Some(Box::new(move |id| cp.play_current_from_queue(id)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 3. Lancer le refresh async — retourne immédiatement
|
||||
schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, auto_play_cb);
|
||||
|
||||
Ok(()) // La webapp sera notifiée via SSE (QueueRefreshing → QueueReadyToPlay → QueueUpdated)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 Fin de sync — émettre `QueueUpdated`
|
||||
|
||||
L'événement `QueueUpdated` (avec `queue_length`) est actuellement émis à la ligne ~1718
|
||||
dans `refresh_attached_queue_for()`. Il doit être émis à la fin du worker thread dans
|
||||
`MusicQueue::schedule_sync()`, après le `sync_queue()` réussi.
|
||||
|
||||
Passer un `on_complete` callback à `schedule_sync()` (en plus de `on_ready`) :
|
||||
|
||||
```rust
|
||||
pub fn schedule_sync(
|
||||
queue_arc: &Arc<Mutex<MusicQueue>>,
|
||||
renderer_id: &str,
|
||||
items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
on_complete: Box<dyn Fn(usize) + Send + 'static>, // NOUVEAU — reçoit queue_length
|
||||
) -> SyncScheduleOutcome
|
||||
```
|
||||
|
||||
Dans le worker, après `Ok(())` du `sync_queue()` :
|
||||
|
||||
```rust
|
||||
Ok(()) => {
|
||||
let queue_len = queue_arc.lock().unwrap().len().unwrap_or(0);
|
||||
on_complete(queue_len);
|
||||
}
|
||||
```
|
||||
|
||||
Dans `schedule_queue_refresh_for()` :
|
||||
|
||||
```rust
|
||||
let bus3 = event_bus.clone();
|
||||
let rid3 = renderer_id.clone();
|
||||
let on_complete = Box::new(move |queue_len: usize| {
|
||||
bus3.broadcast(RendererEvent::QueueUpdated {
|
||||
id: rid3.clone(),
|
||||
queue_length: queue_len,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 8 — Accès à `Arc<Mutex<MusicQueue>>` depuis le registry
|
||||
|
||||
`schedule_queue_refresh_for()` a besoin d'accéder à l'`Arc<Mutex<MusicQueue>>` du renderer.
|
||||
Localiser dans `DeviceRegistry` comment les renderers et leurs queues sont stockés, et ajouter
|
||||
une méthode :
|
||||
|
||||
```rust
|
||||
pub fn get_renderer_queue_arc(
|
||||
&self,
|
||||
renderer_id: &DeviceId,
|
||||
) -> Result<Arc<Mutex<MusicQueue>>, ControlPointError>
|
||||
```
|
||||
|
||||
(Ou équivalent selon la structure réelle du registry.)
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'implémentation
|
||||
|
||||
```
|
||||
Étape 1 (errors.rs) ← 5 min
|
||||
Étape 2 (backend.rs) ← 10 min, casse la compilation → à faire avant les autres
|
||||
Étape 3 (interne.rs) ← 30 min
|
||||
Étape 4 (openhome.rs) ← 1-2h (nombreux points de vérification cancel)
|
||||
Étape 5 (music_queue.rs) ← 2-3h (changement struct + schedule_sync)
|
||||
Étape 6 (model.rs + sse.rs) ← 30 min
|
||||
Étape 7 (control_point.rs) ← 1h
|
||||
Étape 8 (registry) ← 30 min selon la structure
|
||||
```
|
||||
|
||||
Après l'étape 2, `cargo build` cassera jusqu'à l'étape 5 incluse — c'est attendu.
|
||||
Faire les étapes 3, 4, 5 dans la même session sans interrompre.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cargo build -p pmocontrol
|
||||
|
||||
# Vérifier les scénarios :
|
||||
# 1. Attach playlist → QueueRefreshing SSE immédiat, QueueReadyToPlay après 1er insert,
|
||||
# QueueUpdated après fin complète
|
||||
# 2. Attach 2e playlist pendant sync en cours → QueueSyncCancelled + nouveau refresh repart
|
||||
# 3. Piste en cours de lecture pendant sync → QueueReadyToPlay immédiat (pivot préservé)
|
||||
# 4. Refresh périodique (60s) ne bloque plus son thread
|
||||
|
||||
RUST_LOG=debug cargo run 2>&1 | grep -E "queue.sync|SyncCancelled|Scheduled|AlreadyRunning|on_ready|on_complete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Date: 2026-04-09*
|
||||
338
Blackboard/Todo/frontend_enorme_playlist.md
Normal file
338
Blackboard/Todo/frontend_enorme_playlist.md
Normal file
@@ -0,0 +1,338 @@
|
||||
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||
|
||||
## Contexte et symptôme
|
||||
|
||||
Le frontend Vue.js du control point PMOMusic (`pmoapp/webapp`) présente des problèmes de
|
||||
performance significatifs avec des playlists de ~1000 titres. La plupart des lenteurs sont
|
||||
côté UI, indépendamment des optimisations déjà réalisées côté Rust/backend.
|
||||
|
||||
Les trois manifestations observées :
|
||||
- **Freeze au scroll** dans la file d'attente (QueueViewer) dès ~200 items
|
||||
- **Blocage UI temporaire** à l'ouverture d'une playlist dans PlayListManager
|
||||
- **Refetches JSON répétés** déclenchés par les événements SSE `queue_updated`
|
||||
|
||||
## Causes racines identifiées
|
||||
|
||||
### P0 — Pas de virtualisation dans `QueueViewer.vue`
|
||||
|
||||
**Fichier** : `src/components/pmocontrol/QueueViewer.vue:85-93`
|
||||
|
||||
```vue
|
||||
<!-- Tous les items rendus en DOM simultanément -->
|
||||
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
||||
<QueueItem
|
||||
v-for="item in queue.items"
|
||||
:key="item.index"
|
||||
...
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
Pour 1 000 titres : 1 000 nœuds DOM permanents, chacun contenant une image, un composable
|
||||
réactif (`useCoverImage`), et des computed properties. Le scroll devient impossible.
|
||||
|
||||
**Ironie** : `vue-virtual-scroller@^2.0.0-beta.8` est présent dans `package.json` mais
|
||||
**n'est utilisé nulle part** dans la codebase — manifestement prévu puis abandonné.
|
||||
|
||||
### P1 — Pas de virtualisation dans `PlayListManager.vue`
|
||||
|
||||
**Fichier** : `src/components/PlayListManager.vue:562-724`
|
||||
|
||||
```vue
|
||||
<div class="track-grid" v-if="sortedTracks.length > 0">
|
||||
<article v-for="track in sortedTracks" :key="..." class="track-card">
|
||||
<!-- image + title + artist + album + durée + bitrate + samplerate -->
|
||||
</article>
|
||||
</div>
|
||||
```
|
||||
|
||||
Même problème : grid CSS avec 1 000 articles et leurs images (`loading="lazy"`).
|
||||
|
||||
Aggravé par la computed `sortedTracks` (ligne 883-890) :
|
||||
```typescript
|
||||
const sortedTracks = computed(() => {
|
||||
return [...detail.tracks].sort( // copie complète du tableau
|
||||
(a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime()
|
||||
);
|
||||
});
|
||||
```
|
||||
Et `lazyTracksCount` (ligne 892-897) qui filtre les 1 000 items à chaque re-render.
|
||||
|
||||
### P2 — `queue_updated` force un refetch JSON complet
|
||||
|
||||
**Fichier** : `src/composables/useRenderers.ts:200-206`
|
||||
|
||||
```typescript
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
// Pour la queue complète, on doit refetch
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
```
|
||||
|
||||
`fetchRendererSnapshot()` appelle `api.getRendererFullSnapshot(rendererId)` qui retourne
|
||||
le snapshot complet incluant **tous les items de la queue avec leurs métadonnées**.
|
||||
|
||||
Pour 1 000 titres, le payload JSON peut atteindre plusieurs centaines de Ko. Si l'utilisateur
|
||||
charge une playlist de 1 000 titres depuis un serveur qui émet les items par batch (ex. 64
|
||||
par 64 côté Rust), l'événement `queue_updated` est émis plusieurs fois de suite, déclenchant
|
||||
autant de refetches consécutifs du même JSON complet.
|
||||
|
||||
**Note** : `loadingIds.has(rendererId)` (ligne 354) déduplique les requêtes simultanées,
|
||||
mais pas les requêtes consécutives rapprochées.
|
||||
|
||||
### P3 — Infinite scroll accumule toutes les pages en mémoire (`MediaBrowser`)
|
||||
|
||||
**Fichier** : `src/composables/useMediaServers.ts:200-204`
|
||||
|
||||
```typescript
|
||||
// Accumuler les nouvelles entrées — jamais purgées
|
||||
state.entries.push(...data.entries)
|
||||
```
|
||||
|
||||
En parcourant un serveur contenant 1 000 titres, toutes les pages de 50 items
|
||||
s'accumulent dans `browseCache` sans jamais être libérées. Résultat : après un scroll
|
||||
complet, 1 000 entrées sont en mémoire ET en DOM simultanément.
|
||||
|
||||
### P4 — `scrollIntoView` sur 1 000 nœuds DOM non virtualisés
|
||||
|
||||
**Fichier** : `src/components/pmocontrol/QueueViewer.vue:27-48`
|
||||
|
||||
```typescript
|
||||
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
await nextTick();
|
||||
const currentItem = queueContainer.value.querySelector(".queue-item.current");
|
||||
if (currentItem) {
|
||||
currentItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}, { immediate: true });
|
||||
```
|
||||
|
||||
`querySelector` sur un conteneur de 1 000 nœuds + animation CSS `smooth` provoque un
|
||||
layout thrashing. Ce watcher est aussi déclenché au montage (`immediate: true`), ce qui
|
||||
peut provoquer un re-layout au chargement initial de la page.
|
||||
|
||||
## Ce qui fonctionne déjà correctement
|
||||
|
||||
- **Déduplication des snapshots simultanés** : `loadingIds.has(rendererId)` évite les
|
||||
requêtes parallèles pour le même renderer — à préserver.
|
||||
- **`loading="lazy"` sur les images** dans PlayListManager — efficace une fois que le DOM
|
||||
est virtualisé.
|
||||
- **Cache du browse** avec invalidation par conteneur SSE — architecture correcte.
|
||||
- **`useCoverImage`** avec retry exponentiel et cleanup — à conserver tel quel dans
|
||||
`QueueItem.vue`.
|
||||
- **Connexion SSE unique** partagée entre tous les composants — bonne architecture.
|
||||
|
||||
## Plan d'exécution
|
||||
|
||||
### Répertoire concerné : `pmoapp/webapp`
|
||||
|
||||
---
|
||||
|
||||
### Étape 1 — Virtualiser la file d'attente dans `QueueViewer.vue`
|
||||
|
||||
**Fichier** : `src/components/pmocontrol/QueueViewer.vue`
|
||||
|
||||
`vue-virtual-scroller` est déjà installé. Remplacer le `v-for` nu par `<RecycleScroller>` :
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { RecycleScroller } from 'vue-virtual-scroller';
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
// ... imports existants inchangés
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Remplacer le div.queue-list + v-for par : -->
|
||||
<RecycleScroller
|
||||
v-if="queue?.items.length"
|
||||
class="queue-list"
|
||||
:items="queue.items"
|
||||
:item-size="64"
|
||||
key-field="index"
|
||||
v-slot="{ item }"
|
||||
ref="queueContainer"
|
||||
>
|
||||
<QueueItem
|
||||
:item="item"
|
||||
:is-current="item.index === queue.current_index"
|
||||
@click="handleItemClick"
|
||||
/>
|
||||
</RecycleScroller>
|
||||
</template>
|
||||
```
|
||||
|
||||
`item-size="64"` correspond à la hauteur CSS actuelle de `.queue-item` (padding +
|
||||
cover 48px + gap). À ajuster si le CSS change.
|
||||
|
||||
**Adapter `scrollIntoView`** : `RecycleScroller` expose une méthode `scrollToItem(index)`.
|
||||
Remplacer le `querySelector` + `scrollIntoView` par :
|
||||
|
||||
```typescript
|
||||
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
|
||||
await nextTick();
|
||||
queueContainer.value.scrollToItem(currentIndex);
|
||||
}
|
||||
}, { immediate: true });
|
||||
```
|
||||
|
||||
**Fonctionnalité préservée** : `QueueItem.vue` reste inchangé — `RecycleScroller` recycle
|
||||
les nœuds DOM au lieu de les créer tous, mais les props passées à chaque item sont
|
||||
identiques.
|
||||
|
||||
---
|
||||
|
||||
### Étape 2 — Débouncer les refetches `queue_updated`
|
||||
|
||||
**Fichier** : `src/composables/useRenderers.ts:200-206`
|
||||
|
||||
Le problème : `queue_updated` arrive N fois de suite pendant le chargement d'une grande
|
||||
playlist, déclenchant N refetches.
|
||||
|
||||
Ajouter un debounce par renderer sur l'appel à `fetchRendererSnapshot` :
|
||||
|
||||
```typescript
|
||||
// Map des timers de debounce par renderer (à déclarer en module scope)
|
||||
const queueUpdateDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const QUEUE_UPDATE_DEBOUNCE_MS = 300;
|
||||
|
||||
// Dans le case "queue_updated" :
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
|
||||
// Annuler le timer précédent pour ce renderer
|
||||
const existingTimer = queueUpdateDebounceTimers.get(rendererId);
|
||||
if (existingTimer) clearTimeout(existingTimer);
|
||||
|
||||
// Programmer un seul fetch après stabilisation
|
||||
queueUpdateDebounceTimers.set(rendererId, setTimeout(() => {
|
||||
queueUpdateDebounceTimers.delete(rendererId);
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
}, QUEUE_UPDATE_DEBOUNCE_MS));
|
||||
break;
|
||||
```
|
||||
|
||||
**Fonctionnalité préservée** : Si un seul `queue_updated` arrive (cas normal), le refetch
|
||||
est simplement retardé de 300 ms — imperceptible. Si N arrivent en rafale (chargement
|
||||
d'une grande playlist), un seul refetch est déclenché à la fin.
|
||||
|
||||
**Contrainte** : Ne pas dépasser 500 ms de debounce — l'indicateur `queueRefreshing` dans
|
||||
l'UI doit se désactiver rapidement après la fin du chargement.
|
||||
|
||||
---
|
||||
|
||||
### Étape 3 — Virtualiser la grille dans `PlayListManager.vue`
|
||||
|
||||
**Fichier** : `src/components/PlayListManager.vue`
|
||||
|
||||
La grille CSS ne peut pas être virtualisée directement avec `RecycleScroller` (liste 1D).
|
||||
Remplacer la grid par une liste virtualisée, ou introduire une pagination côté client :
|
||||
|
||||
```typescript
|
||||
const PAGE_SIZE = 100;
|
||||
const currentPage = ref(0);
|
||||
|
||||
const paginatedTracks = computed(() =>
|
||||
sortedTracks.value.slice(
|
||||
currentPage.value * PAGE_SIZE,
|
||||
(currentPage.value + 1) * PAGE_SIZE
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
Avec des boutons de navigation Précédent / Suivant et un indicateur de page.
|
||||
|
||||
**Optimiser `sortedTracks`** : mémoriser le résultat par `playlist.id` pour éviter
|
||||
la copie+tri à chaque re-render non lié à la playlist :
|
||||
|
||||
```typescript
|
||||
const sortedTracksCache = new Map<string, TrackEntry[]>();
|
||||
|
||||
const sortedTracks = computed(() => {
|
||||
const detail = selectedPlaylist.value;
|
||||
if (!detail) return [];
|
||||
const cached = sortedTracksCache.get(detail.id);
|
||||
if (cached && cached.length === detail.tracks.length) return cached;
|
||||
const sorted = [...detail.tracks].sort(
|
||||
(a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime()
|
||||
);
|
||||
sortedTracksCache.set(detail.id, sorted);
|
||||
return sorted;
|
||||
});
|
||||
```
|
||||
|
||||
**Simplifier `lazyTracksCount`** : le dériver de `sortedTracks` pour ne pas parcourir
|
||||
le tableau original en parallèle :
|
||||
|
||||
```typescript
|
||||
const lazyTracksCount = computed(() =>
|
||||
sortedTracks.value.filter(isLazyTrack).length
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Étape 4 — Limiter l'accumulation dans le browse infini (`MediaBrowser`)
|
||||
|
||||
**Fichier** : `src/composables/useMediaServers.ts:183-212`
|
||||
|
||||
Implémenter une fenêtre glissante dans `browseCache` : conserver seulement les 200
|
||||
derniers items en mémoire :
|
||||
|
||||
```typescript
|
||||
const BROWSE_WINDOW_SIZE = 200;
|
||||
|
||||
async function loadMoreBrowse(serverId: string, containerId: string) {
|
||||
// ... code existant jusqu'à la récupération de data ...
|
||||
|
||||
// Remplacer : state.entries.push(...data.entries)
|
||||
// Par :
|
||||
const combined = [...state.entries, ...data.entries];
|
||||
state.entries = combined.slice(-BROWSE_WINDOW_SIZE);
|
||||
state.total_count = data.total_count;
|
||||
state.currentOffset = (state.currentOffset ?? 0) + data.entries.length;
|
||||
state.hasMore = state.currentOffset < state.total_count;
|
||||
browseCache.value.set(key, { ...state });
|
||||
}
|
||||
```
|
||||
|
||||
**Invariant à préserver** : `state.currentOffset` et `state.hasMore` doivent continuer
|
||||
de refléter la position réelle dans la liste serveur, indépendamment de ce qui est
|
||||
affiché — leur logique ne change pas.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'exécution
|
||||
|
||||
1. **Étape 1** — Virtualisation `QueueViewer` (impact le plus visible, composant le plus simple)
|
||||
2. **Étape 2** — Debounce `queue_updated` (élimine les refetches en cascade, changement minimal)
|
||||
3. **Étape 3** — Optimisation `PlayListManager` (plus complexe, composant de 2039 lignes)
|
||||
4. **Étape 4** — Fenêtre glissante `MediaBrowser` (amélioration mémoire, moins critique)
|
||||
|
||||
## Périmètre : ce qui ne change pas
|
||||
|
||||
- `QueueItem.vue` : aucune modification (recycling géré par le parent)
|
||||
- `useCoverImage.ts` : aucune modification (lazy loading + retry déjà corrects)
|
||||
- `useSSE.ts` : aucune modification (connexion unique, bonne architecture)
|
||||
- `loadingIds` dans `fetchRendererSnapshot` : déduplication conservée
|
||||
- `api.getRendererFullSnapshot` : le payload reste complet, pas de pagination API
|
||||
- Tous les événements SSE autres que `queue_updated` : aucune modification
|
||||
|
||||
## Tests recommandés
|
||||
|
||||
Demander à l'humain de :
|
||||
|
||||
```bash
|
||||
cd pmoapp/webapp
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Puis tester manuellement :
|
||||
- Ouvrir la file d'attente d'un renderer OpenHome avec 1 000 titres : scroll fluide ?
|
||||
- Vérifier que la piste courante est visible au changement de piste (`scrollToItem`)
|
||||
- Charger une playlist de 1 000 titres via PlayListManager : absence de blocage ?
|
||||
- Observer les requêtes réseau dans DevTools lors du chargement d'une grande playlist :
|
||||
un seul `GET /renderers/{id}/full` doit être émis après la fin du chargement
|
||||
306
Blackboard/Todo/mdns_sd_migration.md
Normal file
306
Blackboard/Todo/mdns_sd_migration.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Migration `mdns` → `mdns-sd`
|
||||
|
||||
## Contexte
|
||||
|
||||
La crate `mdns 3.0.0` (dernière version, 2021, abandonnée) s'appuie sur `dns-parser 0.8.0`
|
||||
qui rejette les labels DNS non-ASCII avec `LabelIsNotAscii`. Les appareils Apple utilisent
|
||||
U+2019 (RIGHT SINGLE QUOTATION MARK) dans leurs noms (ex : "Sophie's MacBook Air"), ce qui
|
||||
spamme les logs en WARN à chaque paquet mDNS reçu.
|
||||
|
||||
`mdns-sd 0.19` (avril 2026, activement maintenu) gère l'UTF-8 correctement, fournit une API
|
||||
de plus haut niveau (service pré-assemblé), et ne nécessite pas `async-std`.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Deux fichiers à modifier, un fichier à nettoyer :
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `pmocontrol/Cargo.toml` | Dépendances |
|
||||
| `pmocontrol/src/control_point.rs` | Thread de découverte mDNS (lignes 145–195) |
|
||||
| `pmocontrol/src/discovery/chromecast_discovery.rs` | Parsing des réponses mDNS |
|
||||
| `pmoserver/src/logs/mod.rs` | Filtre de bruit `mdns=error` devenu inutile |
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 — `pmocontrol/Cargo.toml`
|
||||
|
||||
### Supprimer
|
||||
```toml
|
||||
mdns = "3.0"
|
||||
```
|
||||
|
||||
Vérifier si `async-std` et `futures-util` sont utilisés **ailleurs** que dans le thread mDNS.
|
||||
D'après l'analyse :
|
||||
- `async_std` : uniquement `control_point.rs:161` → **supprimer**
|
||||
- `futures-util` : uniquement `control_point.rs:150,167` → **supprimer**
|
||||
|
||||
### Ajouter
|
||||
```toml
|
||||
mdns-sd = "0.19"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — `pmocontrol/src/control_point.rs`
|
||||
|
||||
### Code actuel (lignes 145–195) à remplacer intégralement
|
||||
|
||||
```rust
|
||||
// Thread de découverte mDNS pour Chromecast
|
||||
let registry_for_mdns = Arc::clone(®istry);
|
||||
let udn_cache_for_mdns = Arc::clone(&udn_cache);
|
||||
thread::spawn(move || {
|
||||
use crate::discovery::ChromecastDiscoveryManager;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut discovery_manager =
|
||||
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
|
||||
|
||||
debug!("Starting mDNS discovery thread for Chromecast devices");
|
||||
|
||||
const SERVICE_NAME: &str = "_googlecast._tcp.local";
|
||||
|
||||
async_std::task::block_on(async {
|
||||
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) {
|
||||
Ok(discovery) => {
|
||||
let stream = discovery.listen();
|
||||
futures_util::pin_mut!(stream);
|
||||
debug!("mDNS discovery stream started for Chromecast devices");
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
debug!("Received mDNS response with {} records", response.records().count());
|
||||
discovery_manager.handle_mdns_response(response);
|
||||
}
|
||||
Err(e) => { warn!("mDNS discovery error: {}", e); }
|
||||
}
|
||||
}
|
||||
warn!("mDNS discovery stream ended unexpectedly");
|
||||
}
|
||||
Err(e) => { error!("Failed to start mDNS discovery: {}", e); }
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Nouveau code
|
||||
|
||||
```rust
|
||||
// Thread de découverte mDNS pour Chromecast
|
||||
let registry_for_mdns = Arc::clone(®istry);
|
||||
let udn_cache_for_mdns = Arc::clone(&udn_cache);
|
||||
thread::spawn(move || {
|
||||
use crate::discovery::ChromecastDiscoveryManager;
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
|
||||
let mut discovery_manager =
|
||||
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
|
||||
|
||||
debug!("Starting mDNS discovery thread for Chromecast devices");
|
||||
|
||||
// Note: mdns-sd requires the trailing dot in the service type
|
||||
const SERVICE_TYPE: &str = "_googlecast._tcp.local.";
|
||||
|
||||
let daemon = match ServiceDaemon::new() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("Failed to create mDNS daemon: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let receiver = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Failed to start mDNS browse for {}: {}", SERVICE_TYPE, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("mDNS discovery started for Chromecast devices");
|
||||
|
||||
while let Ok(event) = receiver.recv() {
|
||||
match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
debug!(
|
||||
fullname = info.get_fullname(),
|
||||
host = info.get_hostname(),
|
||||
port = info.get_port(),
|
||||
"mDNS Chromecast service resolved"
|
||||
);
|
||||
discovery_manager.handle_service_resolved(&info);
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_service_type, fullname) => {
|
||||
debug!(fullname = %fullname, "mDNS Chromecast service removed");
|
||||
// Pas de retrait actif du registre : le timeout habituel s'en charge
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("mDNS discovery receiver closed");
|
||||
});
|
||||
```
|
||||
|
||||
### Points d'attention
|
||||
- Le point final `.` dans `"_googlecast._tcp.local."` est **obligatoire** pour `mdns-sd`.
|
||||
- `receiver.recv()` est bloquant synchrone — pas besoin d'async runtime.
|
||||
- `ServiceDaemon` gère son propre thread interne ; inutile de relancer manuellement.
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 — `pmocontrol/src/discovery/chromecast_discovery.rs`
|
||||
|
||||
### Supprimer l'import `mdns`
|
||||
|
||||
```rust
|
||||
// Supprimer ces uses implicites via le type dans la signature
|
||||
use std::collections::HashMap; // <- plus nécessaire si on passe par TxtProperties
|
||||
```
|
||||
|
||||
### Ajouter l'import `mdns-sd`
|
||||
|
||||
```rust
|
||||
use mdns_sd::ServiceInfo;
|
||||
```
|
||||
|
||||
### Remplacer `handle_mdns_response` par `handle_service_resolved`
|
||||
|
||||
#### Code actuel (lignes 39–179) — ~80 lignes de parsing manuel
|
||||
|
||||
Toute la logique d'extraction PTR / A / AAAA / SRV / TXT disparaît.
|
||||
|
||||
#### Nouveau code
|
||||
|
||||
```rust
|
||||
/// Traite un service Chromecast résolu par mDNS-SD.
|
||||
///
|
||||
/// `ServiceInfo` arrive pré-assemblé : plus besoin de jointure manuelle
|
||||
/// des enregistrements PTR / A / SRV / TXT.
|
||||
pub fn handle_service_resolved(&mut self, info: &ServiceInfo) {
|
||||
let fullname = info.get_fullname().to_string();
|
||||
|
||||
debug!("Processing resolved Chromecast service: {}", fullname);
|
||||
|
||||
// Adresse IP : préférer IPv4
|
||||
let host = match info
|
||||
.get_addresses()
|
||||
.iter()
|
||||
.find(|a| a.is_ipv4())
|
||||
.or_else(|| info.get_addresses().iter().next())
|
||||
{
|
||||
Some(addr) => addr.to_string(),
|
||||
None => {
|
||||
warn!("No IP address for Chromecast service: {}", fullname);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let port = info.get_port();
|
||||
|
||||
// TXT records — API directe par clé
|
||||
let uuid = info
|
||||
.get_property_val_str("id")
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let uuid = if uuid.is_empty() {
|
||||
format!("chromecast-{}-{}", host, port)
|
||||
} else {
|
||||
uuid
|
||||
};
|
||||
|
||||
let model = info.get_property_val_str("md").map(|s| s.to_string());
|
||||
|
||||
let friendly_name = info
|
||||
.get_property_val_str("fn")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback : extraire depuis le fullname, supprimer le suffixe de service
|
||||
fullname
|
||||
.split("._googlecast._tcp.local")
|
||||
.next()
|
||||
.unwrap_or("Unknown Chromecast")
|
||||
.split('-')
|
||||
.take_while(|part| part.len() != 32)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
||||
friendly_name, host, port, uuid, model
|
||||
);
|
||||
|
||||
let udn = format!("uuid:{}", uuid);
|
||||
let default_max_age = 1800u64;
|
||||
|
||||
if !UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, default_max_age) {
|
||||
debug!("Chromecast {} recently seen, skipping", udn);
|
||||
return;
|
||||
}
|
||||
|
||||
let renderer_info = build_renderer_info(
|
||||
&uuid,
|
||||
&friendly_name,
|
||||
&host,
|
||||
port,
|
||||
model.as_deref(),
|
||||
Some("Google Inc."),
|
||||
);
|
||||
|
||||
self.device_registry
|
||||
.write()
|
||||
.expect("DeviceRegistry mutex lock failed")
|
||||
.push_renderer(&renderer_info, default_max_age as u32);
|
||||
}
|
||||
```
|
||||
|
||||
### Supprimer
|
||||
- L'import `use std::collections::HashMap` (plus utilisé)
|
||||
- Tout le bloc `handle_mdns_response` (lignes 39–179)
|
||||
|
||||
### Conserver sans modification
|
||||
- `build_renderer_info` (lignes 182–234)
|
||||
- `extract_host_from_location` / `extract_port_from_location` (lignes 239–258)
|
||||
- Les tests (lignes 260–287)
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 — `pmoserver/src/logs/mod.rs`
|
||||
|
||||
Le filtre de bruit `mdns=error` injecté dans `build_filter_with_noise_suppressions` n'est
|
||||
plus nécessaire. Deux options :
|
||||
|
||||
**Option A (recommandée)** — Supprimer l'entrée du tableau :
|
||||
```rust
|
||||
const NOISE_FILTERS: &[(&str, &str)] = &[
|
||||
// ("mdns", "mdns=error"), // supprimé : migration vers mdns-sd
|
||||
];
|
||||
```
|
||||
Ou supprimer `build_filter_with_noise_suppressions` entièrement si aucun autre bruit n'est
|
||||
à filtrer, et revenir à `EnvFilter::try_new(base)` direct.
|
||||
|
||||
**Option B** — Laisser en place. La directive `mdns=error` ne cause aucun dommage si la
|
||||
crate `mdns` n'est plus dans le build (elle sera simplement ignorée).
|
||||
|
||||
---
|
||||
|
||||
## Résumé des diffs
|
||||
|
||||
| Fichier | Lignes supprimées | Lignes ajoutées |
|
||||
|---|---|---|
|
||||
| `Cargo.toml` | `mdns`, `async-std`, `futures-util` | `mdns-sd` |
|
||||
| `control_point.rs` | ~50 (async block) | ~35 (sync recv loop) |
|
||||
| `chromecast_discovery.rs` | ~80 (parsing manuel) | ~50 (lecture ServiceInfo) |
|
||||
| `logs/mod.rs` | ~5 (filtre bruit) | 0 |
|
||||
|
||||
## Vérification
|
||||
|
||||
Après implémentation :
|
||||
1. `cargo check -p pmocontrol` sans erreurs ni `use of undeclared crate mdns`
|
||||
2. `cargo check -p pmoserver` sans erreurs
|
||||
3. Tester la découverte d'un Chromecast en réseau local
|
||||
4. Vérifier l'absence de `LabelIsNotAscii` dans les logs avec des appareils Apple présents
|
||||
323
Blackboard/Todo/refactoring_pmocontrol.md
Normal file
323
Blackboard/Todo/refactoring_pmocontrol.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Refactoring Plan - Crate `pmocontrol`
|
||||
|
||||
## Contexte
|
||||
|
||||
La crate `pmocontrol` implémente un control point UPnP multiprotocole pour contrôler des renderers audio (UPnP/DLNA, OpenHome, LinkPlay, Arylic TCP, Chromecast). Une refactorisation récente avait pour objectif de monter la logique vers les couches abstraites, mais des duplications et des problèmes de conception subsistent.
|
||||
|
||||
---
|
||||
|
||||
## Structure analysée
|
||||
|
||||
- `music_renderer/` : Implémentations concrètes + façade `MusicRenderer`
|
||||
- `queue/` : Gestion abstraite et concrète des files de lecture
|
||||
- `discovery/` : Découverte SSDP et gestion des appareils
|
||||
- `upnp_clients/` : Clients SOAP pour services UPnP
|
||||
- `control_point.rs` : Point de contrôle principal
|
||||
|
||||
Backends : `UpnpRenderer`, `OpenHomeRenderer`, `LinkPlayRenderer`, `ArylicTcpRenderer`, `ChromecastRenderer`, `HybridUpnpArylicRenderer`
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P0 : BUGS LOGIQUES (à corriger immédiatement)
|
||||
|
||||
### BUG-1 : `sync_queue` dans UpnpRenderer ignore le cancel_token
|
||||
|
||||
**Fichier :** `src/music_renderer/upnp_renderer.rs` (lignes ~354-364)
|
||||
|
||||
**Description :** Le paramètre `cancel_token` est reçu comme `_cancel_token` (ignoré) et remplacé par un `Arc::new(AtomicBool::new(false))` fraîchement créé. Les demandes d'annulation de synchronisation de queue sont silencieusement ignorées pour le backend UPnP.
|
||||
|
||||
**Correction :**
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>, // utiliser le param, pas _cancel_token
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, cancel_token, on_ready) // passer le vrai token
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Vérifier également les autres backends (OpenHome, LinkPlay, Arylic, Chromecast) s'ils propagent correctement le cancel_token.
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P1 : DUPLICATIONS MAJEURES (à traiter en priorité)
|
||||
|
||||
### DUP-1 : Implémentation de `QueueBackend` répétée dans les 5+ renderers
|
||||
|
||||
**Fichiers :**
|
||||
- `src/music_renderer/upnp_renderer.rs` (~310-381)
|
||||
- `src/music_renderer/arylic_tcp.rs` (~368-450)
|
||||
- `src/music_renderer/linkplay_renderer.rs` (~246-330)
|
||||
- `src/music_renderer/chromecast_renderer.rs` (~866+)
|
||||
- `src/music_renderer/openhome_renderer.rs` (~629+)
|
||||
|
||||
**Description :** Chaque renderer implémente `QueueBackend` de manière identique : chaque méthode verrouille `self.queue` et délègue à la file sous-jacente. ~150+ lignes de boilerplate.
|
||||
|
||||
**Approche recommandée — Trait délégateur :**
|
||||
```rust
|
||||
// Dans queue/mod.rs ou music_renderer/mod.rs
|
||||
pub trait HasQueue {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>>;
|
||||
}
|
||||
|
||||
// Impl automatique pour QueueBackend si le type implémente HasQueue
|
||||
impl<T: HasQueue> QueueBackend for T {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue().lock().unwrap().len()
|
||||
}
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue().lock().unwrap().track_ids()
|
||||
}
|
||||
// ... toutes les méthodes déléguantes
|
||||
}
|
||||
|
||||
// Dans chaque renderer : une seule ligne
|
||||
impl HasQueue for UpnpRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> { &self.queue }
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Définir le trait `HasQueue`, implémenter `QueueBackend for T where T: HasQueue`, supprimer les implémentations manuelles dans chaque renderer.
|
||||
|
||||
---
|
||||
|
||||
### DUP-2 : Logique commune de `play_from_queue` dupliquée dans 4+ renderers
|
||||
|
||||
**Fichiers :**
|
||||
- `src/music_renderer/upnp_renderer.rs` (~184-256)
|
||||
- `src/music_renderer/linkplay_renderer.rs` (~189-212)
|
||||
- `src/music_renderer/arylic_tcp.rs` (~311-334)
|
||||
- `src/music_renderer/openhome_renderer.rs` (~partie similaire)
|
||||
|
||||
**Description :** Les 10-12 premières lignes de `play_from_queue` sont identiques dans tous les renderers : verrouillage de queue, gestion de l'index courant, fallback sur index 0 si non défini, récupération de l'item. Seule la partie terminale (play effectif sur le backend) diffère.
|
||||
|
||||
**Approche recommandée — Méthode par défaut dans un trait :**
|
||||
```rust
|
||||
pub trait QueueTransportControl: HasQueue + HasContinuousStream {
|
||||
// Primitive spécifique au backend
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError>;
|
||||
|
||||
// Implémentation commune par défaut
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue().lock().unwrap();
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
let item = queue.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
drop(queue);
|
||||
|
||||
let is_stream = is_continuous_stream_url(&item.uri);
|
||||
*self.continuous_stream().lock().unwrap() = is_stream;
|
||||
self.play_item(&item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Créer `QueueTransportControl` avec une méthode par défaut, implémenter `play_item` dans chaque renderer, supprimer la logique commune dupliquée.
|
||||
|
||||
---
|
||||
|
||||
### DUP-3 : Initialisation redondante des champs partagés dans tous les renderers
|
||||
|
||||
**Fichiers :** Constructeurs dans tous les fichiers renderer
|
||||
|
||||
**Description :** Chaque renderer répète la même construction :
|
||||
```rust
|
||||
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
|
||||
// ...
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
```
|
||||
|
||||
**Approche recommandée :**
|
||||
```rust
|
||||
pub struct SharedRendererState {
|
||||
pub queue: Arc<Mutex<MusicQueue>>,
|
||||
pub continuous_stream: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl SharedRendererState {
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
Ok(Self {
|
||||
queue: Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?)),
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Créer `SharedRendererState`, l'utiliser dans tous les constructeurs de renderers.
|
||||
|
||||
---
|
||||
|
||||
### DUP-4 : `parse_didl_duration` implémentée deux fois différemment
|
||||
|
||||
**Fichiers :**
|
||||
- `src/music_renderer/upnp_renderer.rs` (~383-420) : parsing manuel par string search (fragile)
|
||||
- `src/music_renderer/musicrenderer.rs` (~2089-2117) : via parser DIDL-Lite structuré (robuste)
|
||||
|
||||
**Description :** Deux implémentations divergentes. L'une risque de mal parser du DIDL là où l'autre réussit.
|
||||
|
||||
**Tâche :** Conserver uniquement la version via `DIDLLite::parse`, l'exporter depuis `music_renderer/mod.rs`, supprimer la version par string search dans `upnp_renderer.rs`.
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P2 : ALGORITHMES COMPLEXES ET ABSTRACTIONS MAL PLACÉES
|
||||
|
||||
### ALGO-1 : `schedule_sync` dans `music_queue.rs` — logique intriquée
|
||||
|
||||
**Fichier :** `src/queue/music_queue.rs` (~97-200)
|
||||
|
||||
**Description :** La méthode crée un thread worker avec :
|
||||
- Des `AtomicBool` pour synchronisation (sync_in_progress, sync_pending, sync_cancel_token)
|
||||
- Une boucle infinie interne qui re-tente si un nouveau job arrive
|
||||
- Un Guard RAII basé sur `Drop` pour le cleanup
|
||||
- Des closures capturées mêlant synchronisation et logique métier
|
||||
|
||||
Difficile à tester, à observer de l'extérieur, pas de timeout.
|
||||
|
||||
**Tâche :**
|
||||
1. Extraire la logique du worker dans une fonction `sync_worker_loop` avec signature claire
|
||||
2. Documenter le protocole de synchronisation avec les AtomicBool
|
||||
3. Ajouter une stratégie de timeout ou de sortie en cas de blocage
|
||||
|
||||
---
|
||||
|
||||
### ALGO-2 : Enum dispatch sprawl dans `MusicRendererBackend`
|
||||
|
||||
**Fichier :** `src/music_renderer/musicrenderer.rs` (~2134-2495)
|
||||
|
||||
**Description :** L'enum a 6 variantes. Chaque trait implémenté pour l'enum (`TransportControl`, `PlaybackStatus`, `PlaybackPosition`, `RendererBackend`, `QueueBackend`, etc.) contient un `match` sur les 6 variantes. Estimation : 200+ lignes de boilerplate purement mécanique. Ajouter une 7e variante requiert des mises à jour dans 25+ endroits.
|
||||
|
||||
**Approche recommandée — Macro de dispatch :**
|
||||
```rust
|
||||
macro_rules! dispatch {
|
||||
($self:expr, $method:ident($($arg:expr),*)) => {
|
||||
match $self {
|
||||
MusicRendererBackend::Upnp(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::OpenHome(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::LinkPlay(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::ArylicTcp(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::Chromecast(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.$method($($arg),*),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for MusicRendererBackend {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
dispatch!(self, play_uri(uri, meta))
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Définir la macro `dispatch!`, remplacer les match statements redondants, valider les cas où HybridUpnpArylic a une logique spéciale.
|
||||
|
||||
---
|
||||
|
||||
### ALGO-3 : Logique de protection des durées de streams dupliquée dans 3 endroits
|
||||
|
||||
**Fichiers :**
|
||||
- `src/queue/interne.rs` (~74-126) : `protect_stream_durations`
|
||||
- `src/queue/openhome.rs` (~400+) : logique similaire pour playlists OpenHome
|
||||
- `src/music_renderer/musicrenderer.rs` (~488-537) : dans `poll_and_emit_changes`
|
||||
|
||||
**Description :** La logique "refuser la diminution de durée pour un stream continu" est réimplémentée trois fois. Si la définition de "diminution acceptable" change, il faut modifier 3 fichiers.
|
||||
|
||||
**Tâche :** Créer `music_renderer/stream_utils.rs` (ou équivalent) avec une fonction `protect_stream_duration(old, new, is_stream) -> Option<String>` et l'utiliser dans les 3 endroits.
|
||||
|
||||
---
|
||||
|
||||
### ALGO-4 : Détection de flux continu fragmentée
|
||||
|
||||
**Fichiers :** `stream_detection.rs`, `musicrenderer.rs`, `queue/interne.rs`, `queue/openhome.rs`
|
||||
|
||||
**Description :** La détection "est-ce un stream continu?" passe par plusieurs chemins non unifiés :
|
||||
1. `TrackMetadata::is_continuous_stream`
|
||||
2. Appel `is_continuous_stream_url(uri)` (réseau)
|
||||
3. Absence de durée dans les métadonnées
|
||||
|
||||
Un stream peut être marqué continu dans une couche mais pas l'autre.
|
||||
|
||||
**Tâche :** Créer une fonction canonique unique :
|
||||
```rust
|
||||
pub fn is_continuous_stream(metadata: Option<&TrackMetadata>, uri: &str) -> bool {
|
||||
metadata.map(|m| m.is_continuous_stream).unwrap_or(false)
|
||||
|| is_continuous_stream_url(uri)
|
||||
}
|
||||
```
|
||||
Faire passer tous les codepaths par cette fonction.
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P3 : BONNES PRATIQUES (amélioration continue)
|
||||
|
||||
### BP-1 : `.unwrap()` sur mutex locks (>50 occurrences)
|
||||
|
||||
**Problème :** Si un mutex est empoisonné (panique dans une autre tâche), `.unwrap()` propage la panique. Aucun code ne gère ce cas.
|
||||
|
||||
**Tâche :** Remplacer `.unwrap()` par `.expect("message contextuel")` à court terme. À long terme, envisager `parking_lot::Mutex` (pas de concept de poison).
|
||||
|
||||
---
|
||||
|
||||
### BP-2 : Absence de gestion d'erreur dans les threads watcher et sync
|
||||
|
||||
**Fichiers :** `musicrenderer.rs` (watcher_loop), `music_queue.rs` (schedule_sync)
|
||||
|
||||
**Tâche :** Ajouter `error!` logs dans les threads et décider explicitement de la politique de redémarrage (continuer vs arrêter).
|
||||
|
||||
---
|
||||
|
||||
### BP-3 : Champs `pub` au lieu de `pub(crate)` dans `PlaylistBinding`
|
||||
|
||||
**Fichier :** `src/music_renderer/musicrenderer.rs` (struct `PlaylistBinding`)
|
||||
|
||||
**Tâche :** Rendre les champs `pub` → `pub(crate)` ou privés avec accesseurs.
|
||||
|
||||
---
|
||||
|
||||
### BP-4 : Documentation manquante sur les contrats des traits
|
||||
|
||||
**Fichiers :** `src/music_renderer/capabilities.rs`, `src/queue/backend.rs`
|
||||
|
||||
**Tâche :** Ajouter des doc-comments sur les traits clés (`TransportControl`, `PlaybackStatus`, `QueueBackend`) décrivant les invariants, les pré/post-conditions, et le comportement attendu.
|
||||
|
||||
---
|
||||
|
||||
## PLAN D'EXÉCUTION
|
||||
|
||||
### Phase 1 — Bugs (immédiat)
|
||||
- [ ] **BUG-1** : Corriger le cancel_token ignoré dans `upnp_renderer.rs::sync_queue`
|
||||
- [ ] Vérifier les autres renderers pour le même bug
|
||||
|
||||
### Phase 2 — Éliminer les duplications majeures (1-2 semaines)
|
||||
- [ ] **DUP-1** : Trait `HasQueue` + impl automatique de `QueueBackend`
|
||||
- [ ] **DUP-4** : Unifier `parse_didl_duration` sur la version DIDL-Lite
|
||||
- [ ] **DUP-3** : Créer `SharedRendererState` pour l'init commune
|
||||
- [ ] **DUP-2** : Trait `QueueTransportControl` avec `play_from_queue` par défaut
|
||||
|
||||
### Phase 3 — Simplifier les algorithmes (2-4 semaines)
|
||||
- [ ] **ALGO-2** : Macro `dispatch!` pour `MusicRendererBackend`
|
||||
- [ ] **ALGO-3** : Centraliser la protection des durées de stream
|
||||
- [ ] **ALGO-4** : Unifier la détection de flux continu
|
||||
- [ ] **ALGO-1** : Refactoriser `schedule_sync` (extraire `sync_worker_loop`)
|
||||
|
||||
### Phase 4 — Qualité continue
|
||||
- [ ] **BP-1** : Remplacer les `.unwrap()` critiques
|
||||
- [ ] **BP-2** : Gestion d'erreur dans les threads
|
||||
- [ ] **BP-3** : Visibilité des champs `PlaylistBinding`
|
||||
- [ ] **BP-4** : Documentation des traits
|
||||
108
Blackboard/Todo/search_navigation_bug.md
Normal file
108
Blackboard/Todo/search_navigation_bug.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Bug : Navigation dans les résultats de recherche Qobuz
|
||||
|
||||
## Symptôme
|
||||
|
||||
1. On tape "Camille" dans la barre de recherche → spinner → 4 containers s'affichent : "Albums (1000+)", "Artistes (1000+)", "Titres (1000+)", "Playlists (1000+)". ✓
|
||||
2. On clique sur "Artistes (1000+)" → on retombe sur les **mêmes 4 containers** au lieu de la liste des artistes. ✗
|
||||
3. La breadcrumb en haut montre bien qu'on est dans "Artistes (1000+)" — donc la navigation a eu lieu, mais le contenu affiché est wrong.
|
||||
|
||||
## Cause racine identifiée (côté frontend)
|
||||
|
||||
Dans `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue` :
|
||||
|
||||
```typescript
|
||||
const isSearchMode = computed(() => searchQuery.value !== '');
|
||||
|
||||
const browseData = computed(() =>
|
||||
isSearchMode.value
|
||||
? searchResults.value // ← toujours ça quand on est en search mode
|
||||
: getBrowseCached(props.serverId, props.containerId),
|
||||
);
|
||||
```
|
||||
|
||||
Quand `isSearchMode` est true (après une recherche), `browseData` retourne TOUJOURS `searchResults` (les 4 groupes), peu importe le `containerId` courant. Donc cliquer sur "Artistes" change `containerId` → le watcher charge bien les artistes du serveur dans le cache → mais `browseData` ignore le cache et re-affiche `searchResults`.
|
||||
|
||||
Le serveur de son côté fonctionne correctement :
|
||||
- Browse de `qobuz:search:catalog:artists:camille` → appelle `execute_search(Artists, "camille")` → retourne la liste des artistes
|
||||
- Le log DIDL confirme que les bons artistes sont retournés
|
||||
|
||||
## Ce qui a été tenté (et raté)
|
||||
|
||||
### Tentative : stocker les résultats dans browseCache
|
||||
|
||||
`searchServer()` dans `useMediaServers.ts` modifié pour ne plus écrire dans `searchResults` mais directement dans `browseCache` sous la clé `search:camille`, puis naviguer vers cet ID.
|
||||
|
||||
Résultat : `isSearchMode` devient toujours false (searchQuery jamais set), donc `browseData` utilise `getBrowseCached`. Mais les 4 groupes n'apparaissent plus. Cause non confirmée — probablement un problème de réactivité Vue ou de timing entre le navigate et le watcher.
|
||||
|
||||
**État actuel du code** : ce fix a été partiellement appliqué (voir commits récents). `handleSearch` appelle encore l'ancienne `searchServer()`. Le code est dans un état incohérent — voir diff.
|
||||
|
||||
## Architecture correcte (UPnP)
|
||||
|
||||
L'utilisateur a clarifié l'architecture attendue :
|
||||
|
||||
1. **Media server** : implémente correctement l'action UPnP `Search` — retourne un DIDL contenant des containers virtuels navigables (les 4 groupes). Les IDs de ces containers (`qobuz:search:catalog:artists:camille`, etc.) sont opaques pour le control point.
|
||||
|
||||
2. **GetSearchCapabilities** : doit retourner des caps non vides pour que les control points (BubbleUPnP, PMOMusic frontend) reconnaissent le serveur comme searchable. **BubbleUPnP ne reconnaissait pas PMOMusic comme searchable avant les modifications récentes.**
|
||||
|
||||
3. **Control point / Frontend** : envoie `Search(ContainerID, SearchCriteria)` → reçoit DIDL avec des containers → les navigue via Browse normalement. Le control point ne connaît RIEN des IDs internes Qobuz.
|
||||
|
||||
4. **Pas de endpoint `/search` spécifique Qobuz** dans le control point — c'est l'action UPnP standard `Search` qui fait tout.
|
||||
|
||||
## Fix correct à implémenter
|
||||
|
||||
### Côté frontend (`MediaBrowser.vue` + `useMediaServers.ts`)
|
||||
|
||||
Supprimer `isSearchMode`, `searchResults`, `searchQuery`. Remplacer par :
|
||||
|
||||
```typescript
|
||||
// browseData devient simplement :
|
||||
const browseData = computed(() =>
|
||||
getBrowseCached(props.serverId, props.containerId)
|
||||
);
|
||||
```
|
||||
|
||||
`searchServer()` doit stocker dans `browseCache` sous l'ID retourné par le serveur et naviguer vers cet ID. Quand l'utilisateur clique ensuite sur un sous-container (Artistes, Albums…), `browseContainer` est appelé avec l'ID correct, le serveur retourne les bons résultats, le cache est peuplé, `browseData` l'affiche.
|
||||
|
||||
La clé : **sortir du search mode dès que la navigation a eu lieu**. Ce que `isSearchMode` empêche actuellement.
|
||||
|
||||
### Côté serveur (`pmocontrol/src/pmoserver_ext.rs`)
|
||||
|
||||
L'endpoint REST `/servers/{id}/search` appelle `server.search("0", query, 0, 200)` via UPnP Search. Il retourne actuellement `container_id: "search"` (fictif).
|
||||
|
||||
Il devrait retourner le vrai `container_id` issu du DIDL (ex: `qobuz:search:catalog:all:camille`) pour que le frontend puisse le mettre dans le cache et naviguer vers un ID que le serveur reconnaît lors d'un Browse ultérieur.
|
||||
|
||||
**Mais** : mettre la logique de construction de cet ID dans le control point viole la séparation des couches. La bonne approche est que le serveur retourne dans le DIDL des containers avec des IDs navigables, et que le control point les utilise tels quels.
|
||||
|
||||
### Vérifier aussi
|
||||
|
||||
- `GetSearchCapabilities` dans `pmomediaserver/src/content_handler.rs` retourne `"dc:title,dc:creator,upnp:artist,upnp:album,upnp:genre"` — vérifier que c'est bien annoncé dans le service descriptor UPnP (sinon BubbleUPnP ne propose pas la recherche).
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue` | Bug `isSearchMode` / `browseData` |
|
||||
| `pmoapp/webapp/src/composables/useMediaServers.ts` | `searchServer()`, `browseCache` |
|
||||
| `pmoapp/webapp/src/services/pmocontrol/api.ts` | Appel REST `/search` |
|
||||
| `pmocontrol/src/pmoserver_ext.rs` | Handler REST `search_server()` |
|
||||
| `pmomediaserver/src/contentdirectory/handlers.rs` | UPnP `search_handler()`, logs `━━━ SEARCH ━━━` |
|
||||
| `pmomediaserver/src/content_handler.rs` | `ContentHandler::search()` |
|
||||
| `pmoqobuz/src/source.rs` | `search_grouped()`, `execute_search()`, `parse_object_id()` |
|
||||
|
||||
## Format des IDs virtuels Qobuz
|
||||
|
||||
```
|
||||
qobuz:search:{scope}:{type}:{query}
|
||||
scope : catalog | favorites
|
||||
type : all | albums | artists | tracks | playlists
|
||||
query : texte libre (peut contenir ':' — splitn(5) utilisé)
|
||||
```
|
||||
|
||||
Exemples :
|
||||
- `qobuz:search:catalog:all:camille` → Browse → 4 containers groupés
|
||||
- `qobuz:search:catalog:artists:camille` → Browse → liste d'artistes
|
||||
- `qobuz:search:favorites:albums:bach` → Browse → albums favoris
|
||||
|
||||
## État du code à la fin de la session
|
||||
|
||||
Les logs de debug (`━━━ BROWSE ━━━`, `━━━ SEARCH ━━━`, preview DIDL) ont été ajoutés dans `handlers.rs` au niveau `warn`. Le Makefile a été fixé pour propager `RUST_LOG` à travers `osascript`. La logique serveur Qobuz fonctionne. Seul le frontend est cassé.
|
||||
1156
Blackboard/Todo/webrenderer_architecture_evolution.md
Normal file
1156
Blackboard/Todo/webrenderer_architecture_evolution.md
Normal file
File diff suppressed because it is too large
Load Diff
710
Cargo.lock
generated
710
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
10
Cargo.toml
@@ -4,9 +4,9 @@ members = [
|
||||
"PMOMusic",
|
||||
"pmoupnp",
|
||||
"pmomediarenderer",
|
||||
"pmowebrenderer",
|
||||
"pmomediaserver",
|
||||
"pmoconfig",
|
||||
"pmoutils",
|
||||
"pmodidl",
|
||||
"pmoserver",
|
||||
"pmoapp",
|
||||
@@ -14,6 +14,7 @@ members = [
|
||||
"pmocovers",
|
||||
"pmoaudiocache",
|
||||
"pmoaudio",
|
||||
"pmoaudio-ext",
|
||||
"pmoqobuz",
|
||||
"pmoparadise",
|
||||
"pmoradiofrance",
|
||||
@@ -22,6 +23,7 @@ members = [
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
"pmocontrol",
|
||||
"pmourlsource",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -37,7 +39,7 @@ async-trait = "0.1"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0" # ⚠️ Unifier sur 2.0 (vous avez 1.0 et 2.0)
|
||||
thiserror = "2.0"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1.41"
|
||||
@@ -46,7 +48,7 @@ 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
|
||||
quick-xml = { version = "0.38", features = ["serialize"] }
|
||||
axum = "0.8.4"
|
||||
futures = "0.3"
|
||||
|
||||
@@ -57,4 +59,4 @@ crossbeam-channel = "0.5"
|
||||
rand = "0.9"
|
||||
|
||||
# Testing
|
||||
tokio-test = "0.4"
|
||||
tokio-test = "0.4"
|
||||
@@ -32,8 +32,9 @@ RUN apt-get update && apt-get install -y \
|
||||
cmake \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy Cargo workspace files
|
||||
# Copy Cargo workspace files and registry configuration
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY .cargo/ ./.cargo/
|
||||
|
||||
# Copy all crates
|
||||
COPY PMOMusic/ ./PMOMusic/
|
||||
@@ -41,7 +42,6 @@ COPY pmoupnp/ ./pmoupnp/
|
||||
COPY pmomediarenderer/ ./pmomediarenderer/
|
||||
COPY pmomediaserver/ ./pmomediaserver/
|
||||
COPY pmoconfig/ ./pmoconfig/
|
||||
COPY pmoutils/ ./pmoutils/
|
||||
COPY pmodidl/ ./pmodidl/
|
||||
COPY pmoserver/ ./pmoserver/
|
||||
COPY pmocache/ ./pmocache/
|
||||
@@ -51,11 +51,13 @@ COPY pmoaudio/ ./pmoaudio/
|
||||
COPY pmoqobuz/ ./pmoqobuz/
|
||||
COPY pmoparadise/ ./pmoparadise/
|
||||
COPY pmoradiofrance/ ./pmoradiofrance/
|
||||
COPY pmowebrenderer/ ./pmowebrenderer/
|
||||
COPY pmosource/ ./pmosource/
|
||||
COPY pmoplaylist/ ./pmoplaylist/
|
||||
COPY pmoflac/ ./pmoflac/
|
||||
COPY pmometadata/ ./pmometadata/
|
||||
COPY pmocontrol/ ./pmocontrol/
|
||||
COPY pmourlsource/ ./pmourlsource/
|
||||
COPY pmoaudio-ext/ ./pmoaudio-ext/
|
||||
COPY pmoapp/ ./pmoapp/
|
||||
|
||||
|
||||
16
Makefile
16
Makefile
@@ -161,15 +161,19 @@ watch:
|
||||
ci: fmt-check clippy test doc-build webapp
|
||||
@echo "$(GREEN)✓ Toutes les vérifications CI passées$(NC)"
|
||||
|
||||
## run: Lance le binaire en mode debug
|
||||
## run: Compile en debug et lance via Terminal.app (requis pour le multicast sur macOS Sequoia+)
|
||||
run: debug
|
||||
@echo "$(YELLOW)→ Lancement de l'application...$(NC)"
|
||||
./target/debug/$(BINARY_NAME)
|
||||
@echo "$(YELLOW)→ Lancement de l'application via Terminal.app...$(NC)"
|
||||
@echo "$(BLUE) (Terminal.app est nécessaire pour le multicast sur macOS Sequoia+)$(NC)"
|
||||
@osascript -e 'tell application "Terminal" to do script "cd \"$(CURDIR)\" && ./target/debug/$(BINARY_NAME) 2>&1 | tee pmomusic.log; exit"'
|
||||
|
||||
## run-release: Lance le binaire en mode release
|
||||
## run-release: Compile en release et lance via Terminal.app (requis pour le multicast sur macOS Sequoia+)
|
||||
run-release: release
|
||||
@echo "$(YELLOW)→ Lancement de l'application (release)...$(NC)"
|
||||
./$(RUST_TARGET)/$(BINARY_NAME)
|
||||
@echo "$(YELLOW)→ Lancement de l'application (release) via Terminal.app...$(NC)"
|
||||
@echo "$(BLUE) (Terminal.app est nécessaire pour le multicast sur macOS Sequoia+)$(NC)"
|
||||
@RUST_LOG_PREFIX=""; \
|
||||
if [ -n "$(RUST_LOG)" ]; then RUST_LOG_PREFIX="export RUST_LOG='$(RUST_LOG)' && "; fi; \
|
||||
osascript -e "tell application \"Terminal\" to do script \"cd '$(CURDIR)' && $${RUST_LOG_PREFIX}./$(RUST_TARGET)/$(BINARY_NAME) 2>&1 | tee pmomusic.log; exit\""
|
||||
|
||||
## size: Affiche la taille du binaire
|
||||
size:
|
||||
|
||||
BIN
PMOMusic-A-192.png
Normal file
BIN
PMOMusic-A-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
PMOMusic-A-512.png
Normal file
BIN
PMOMusic-A-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.20"
|
||||
version = "0.3.59"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmoupnp = { path = "../pmoupnp"}
|
||||
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "radiofrance", "api"] }
|
||||
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "radiofrance", "urlsource", "api"] }
|
||||
pmosource = { path = "../pmosource", features = ["server"] }
|
||||
pmoserver = { path = "../pmoserver" }
|
||||
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||
@@ -15,6 +15,7 @@ pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
|
||||
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
|
||||
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
|
||||
pmowebrenderer = { path = "../pmowebrenderer", features = ["pmoserver"] }
|
||||
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use pmoapp::{WebAppExt, Webapp};
|
||||
use pmocontrol::ControlPointExt;
|
||||
use pmomediarenderer::MEDIA_RENDERER;
|
||||
use pmomediaserver::{
|
||||
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
|
||||
};
|
||||
use pmoserver::Server;
|
||||
use pmosource::MusicSourceExt;
|
||||
use pmoupnp::UpnpServerExt;
|
||||
use pmowebrenderer::WebRendererExt;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -60,6 +60,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing::warn!("⚠️ Failed to register Radio France source: {}", e);
|
||||
}
|
||||
|
||||
// Enregistrer la source URL / Partage
|
||||
info!("🔗 Registering URL source...");
|
||||
if let Err(e) = server.write().await.register_urlsource().await {
|
||||
tracing::warn!("⚠️ Failed to register URL source: {}", e);
|
||||
}
|
||||
|
||||
// Lister toutes les sources enregistrées
|
||||
let sources = server.read().await.list_music_sources().await;
|
||||
info!("✅ {} music source(s) registered", sources.len());
|
||||
@@ -70,21 +76,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 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");
|
||||
|
||||
let base_url = renderer_instance.base_url();
|
||||
let desc_route = renderer_instance.description_route();
|
||||
info!("✅ MediaRenderer ready at {}{}", base_url, desc_route);
|
||||
|
||||
let server_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_SERVER.clone())
|
||||
.register_device(MEDIA_SERVER.clone(), true)
|
||||
.await
|
||||
.expect("Failed to register MediaServer");
|
||||
|
||||
@@ -104,13 +99,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Enregistrer le Control Point (découverte renderers/serveurs + API REST + SSE)
|
||||
info!("🎛️ Registering Control Point...");
|
||||
let _control_point = server
|
||||
let control_point = server
|
||||
.write()
|
||||
.await
|
||||
.register_control_point(5)
|
||||
.await
|
||||
.expect("Failed to register Control Point");
|
||||
|
||||
// Enregistrer le WebRenderer (endpoint WebSocket pour renderers navigateur)
|
||||
info!("🌐 Registering WebRenderer...");
|
||||
server
|
||||
.write()
|
||||
.await
|
||||
.register_web_renderer(control_point)
|
||||
.await
|
||||
.expect("Failed to register WebRenderer");
|
||||
|
||||
// Ajouter la webapp via le trait WebAppExt
|
||||
info!("📡 Registering Web application...");
|
||||
server
|
||||
@@ -127,8 +131,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("✅ PMOMusic is ready!");
|
||||
info!("Press Ctrl+C to stop...");
|
||||
|
||||
// Attendre le signal Ctrl+C et l'arrêt du serveur HTTP
|
||||
server.write().await.wait().await;
|
||||
// Extraire le join_handle AVANT de libérer le write lock,
|
||||
// pour pouvoir l'awaiter sans tenir le write lock du serveur global.
|
||||
// (Tenir le write lock pendant wait() bloquerait register_device() dynamique)
|
||||
let join_handle = server.write().await.take_join_handle();
|
||||
if let Some(h) = join_handle {
|
||||
let _ = h.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
|
||||
|
||||
20
Report/frontend_enorme_playlist.md
Normal file
20
Report/frontend_enorme_playlist.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Rapport : Optimisation performances frontend playlists ~1000 titres
|
||||
|
||||
## Résumé
|
||||
Implémentation de quatre optimisations pour traiter les lenteurs UI observées avec de grandes playlists : virtualisation de la file d'attente avec RecycleScroller, debounce des refetches queue_updated, pagination + cache dans PlayListManager, et fenêtre glissante dans MediaBrowser. Ces changements éliminent les freezes au scroll, bloquages UI et refetches JSON répétés.
|
||||
|
||||
## Fichiers modifiés
|
||||
1. `pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue`
|
||||
- Remplacement du v-for natif par RecycleScroller de vue-virtual-scroller
|
||||
- Migration de querySelector+scrollIntoView vers scrollToItem() exposé par RecycleScroller
|
||||
|
||||
2. `pmoapp/webapp/src/composables/useRenderers.ts`
|
||||
- Ajout d'un debounce de 300ms sur les refetches queue_updated pour éviter les refetches en cascade
|
||||
|
||||
3. `pmoapp/webapp/src/components/PlayListManager.vue`
|
||||
- Ajout pagination client (100 items/page) avec navigation Précédent/Suivant
|
||||
-Ajout cache mémorisé pour sortedTracks par playlist ID
|
||||
- Simplification lazyTracksCount derivé de sortedTracks
|
||||
|
||||
4. `pmoapp/webapp/src/composables/useMediaServers.ts`
|
||||
- Ajout fenêtre glissante limitant le cache browse aux 200 derniers items
|
||||
24
Report/webrenderer_architecture_evolution.md
Normal file
24
Report/webrenderer_architecture_evolution.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Rapport : Évolution architecture webrenderer
|
||||
|
||||
## Résumé
|
||||
Implémentation des phases 0-4 du plan d'évolution : correction du bug P0 (play_handler sans URI), introduction du typage fort avec DeviceCommand/VegearAdapter, integration flac_handle pause/resume, ajout AudioContext avec reconnexion automatique dans PMOPlayer.ts, correction du format de position UPnP, et endpoints JSON nowplaying/state.
|
||||
|
||||
## Fichiers modifiés
|
||||
1. `pmowebrenderer/src/handlers.rs` - Fix P0 + pause/resume flac_handle
|
||||
2. `pmowebrenderer/src/adapter.rs` - Nouveau fichier avec DeviceCommand et DeviceAdapter
|
||||
3. `pmowebrenderer/src/state.rs` - VecDeque<DeviceCommand> au lieu de Option<Value>
|
||||
4. `pmowebrenderer/src/pipeline.rs` - Ajout flac_handle dans PipelineHandle
|
||||
5. `pmowebrenderer/src/registry.rs` - Format position + get_state + get_state_and_udn
|
||||
6. `pmowebrenderer/src/register.rs` - Handlers nowplaying et state
|
||||
7. `pmowebrenderer/src/config.rs` - Nouvelles routes
|
||||
8. `pmowebrenderer/src/lib.rs` - Exports adapter
|
||||
9. `pmoapp/webapp/src/services/PMOPlayer.ts` - AudioContext + reconnexion
|
||||
|
||||
## Modifications sémantiques
|
||||
- **P0** : play_handler vérifie URI avant de changement d'état (plus de Transitioning bloqué)
|
||||
- **P1-P2** : DeviceCommand typé compile-time (plus de serde_json Value non typé)
|
||||
- **P3** : pause_handler/stop_handler/play_handler appellent flac_handle.pause()/resume()
|
||||
- **P5** : AudioContext avec createMediaElementSource() et suspend() dans flush()
|
||||
- **P6** : Reconnexion automatique avec backoff exponentiel
|
||||
- **P7** : Format position统一 en HH:MM:SS (seconds_to_upnp_time)
|
||||
- **P8** : Endpoints /nowplaying et /state en JSON
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -2,5 +2,9 @@
|
||||
"name": "pmomusic",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pmomusic"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "pmoapp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rust-embed = "8.5.0"
|
||||
@@ -10,6 +10,10 @@ rust-embed = "8.5.0"
|
||||
path = "../pmoserver"
|
||||
optional = true
|
||||
|
||||
[dependencies.async-trait]
|
||||
optional = true
|
||||
version = "0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
pmoserver = ["dep:pmoserver"]
|
||||
pmoserver = ["dep:pmoserver", "dep:async-trait"]
|
||||
|
||||
@@ -237,6 +237,8 @@
|
||||
//! - [Vue.js Documentation](https://vuejs.org/)
|
||||
//! - [Vite Documentation](https://vitejs.dev/)
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use async_trait::async_trait;
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
/// Structure représentant l'application web embarquée.
|
||||
@@ -285,6 +287,8 @@ pub struct Webapp;
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[async_trait]
|
||||
pub trait WebAppExt {
|
||||
/// Ajoute une Single Page Application au serveur.
|
||||
///
|
||||
|
||||
@@ -27,10 +27,14 @@
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use async_trait::async_trait;
|
||||
use crate::WebAppExt;
|
||||
use pmoserver::Server;
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[async_trait]
|
||||
impl WebAppExt for Server {
|
||||
async fn add_webapp<W>(&mut self, path: &str)
|
||||
where
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webapp</title>
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/app/icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/app/icons/icon-192.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="PMOMusic" />
|
||||
<title>PMOMusic</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
5321
pmoapp/webapp/package-lock.json
generated
5321
pmoapp/webapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vue-tsc": "^3.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
pmoapp/webapp/public/icons/icon-192.png
Normal file
BIN
pmoapp/webapp/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
pmoapp/webapp/public/icons/icon-512.png
Normal file
BIN
pmoapp/webapp/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
21
pmoapp/webapp/src/assets/default-cover.svg
Normal file
21
pmoapp/webapp/src/assets/default-cover.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgGrad)"/>
|
||||
<g transform="translate(200, 200)">
|
||||
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
|
||||
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
</g>
|
||||
<text x="200" y="360" text-anchor="middle"
|
||||
font-family="system-ui, -apple-system, sans-serif"
|
||||
font-size="20" fill="white" opacity="0.6">
|
||||
No Image Available
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
274
pmoapp/webapp/src/assets/styles/drawers.css
Normal file
274
pmoapp/webapp/src/assets/styles/drawers.css
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Styles communs pour les drawers (ServerDrawer, RendererDrawer)
|
||||
*
|
||||
* Chaque drawer ajoute uniquement ce qui le différencie :
|
||||
* - position (left/right)
|
||||
* - border (border-right / border-left)
|
||||
* - box-shadow (direction)
|
||||
* - backdrop spécifique (position)
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
BACKDROP
|
||||
======================================== */
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.drawer-backdrop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
PANNEAU DRAWER
|
||||
======================================== */
|
||||
|
||||
.app-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 50vw;
|
||||
/* Fond opaque — évite la transparence empilée sur d'autres éléments glass */
|
||||
background: rgba(22, 22, 32, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.app-drawer {
|
||||
background: rgba(245, 245, 250, 0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.app-drawer {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
HEADER
|
||||
======================================== */
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.drawer-header {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-title.small {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
ICONS COMMUNS POUR DRAWERS
|
||||
======================================== */
|
||||
|
||||
/* Classe de base pour les boutons icônes des drawers */
|
||||
.drawer-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast) ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.drawer-icon-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Bouton fermer */
|
||||
.drawer-close-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Bouton retour (ServerDrawer navigation) */
|
||||
.drawer-back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.drawer-back-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
CONTENU
|
||||
======================================== */
|
||||
|
||||
/* Add class="scrollbar-thin" on elements needing thin scrollbar (see pmocontrol.css) */
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.drawer-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* TODO: remplacer par la classe globale .section-title de pmocontrol.css */
|
||||
.drawer-section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
padding: 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Liste d'items générique */
|
||||
.drawer-item-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all var(--transition-fast) ease;
|
||||
}
|
||||
|
||||
.drawer-item.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drawer-item.clickable:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.drawer-item.clickable:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.drawer-item.offline {
|
||||
opacity: var(--opacity-disabled, 0.5);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Icône dans un item */
|
||||
.drawer-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Info texte dans un item */
|
||||
.drawer-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-item-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-item-sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 2px 0 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
FOOTER
|
||||
======================================== */
|
||||
|
||||
.drawer-footer {
|
||||
padding: var(--spacing-md);
|
||||
/* On mobile, extend into the safe-area so the footer doesn't sit under
|
||||
the system navigation bar / home indicator. */
|
||||
padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.drawer-footer {
|
||||
border-top-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Les animations d'entrée/sortie sont définies dans chaque composant (scoped)
|
||||
car elles diffèrent selon la direction (gauche/droite/bas) */
|
||||
@@ -381,24 +381,27 @@
|
||||
ANIMATIONS
|
||||
======================================== */
|
||||
|
||||
@keyframes glassShimmer {
|
||||
0% {
|
||||
background-position: -200% center;
|
||||
/* Respecte prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@keyframes glassShimmer {
|
||||
0% {
|
||||
background-position: -200% center;
|
||||
}
|
||||
100% {
|
||||
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;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
|
||||
@@ -79,7 +79,14 @@ body {
|
||||
background-color: var(--status-transitioning-bg);
|
||||
color: var(--status-transitioning);
|
||||
border: 1px solid var(--status-transitioning);
|
||||
animation: pulse 2s infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.status-badge.transitioning {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
@@ -226,52 +233,81 @@ body {
|
||||
/* ========================================
|
||||
Animations
|
||||
======================================== */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
|
||||
/* Respecte prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-opacity {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge.transitioning {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.shuffle-button.loading {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.event-badge {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
/* Default state (animations disabled) */
|
||||
.status-badge.transitioning,
|
||||
.shuffle-button.loading,
|
||||
.event-badge,
|
||||
.loading-state {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@@ -434,3 +470,161 @@ input[type="range"]::-moz-range-thumb:hover {
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
UTILITIES
|
||||
======================================== */
|
||||
|
||||
/* Section title (used in drawers and panels) */
|
||||
.section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
/* Icon-only circular button */
|
||||
.icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
transition: background var(--transition-fast) ease, transform var(--transition-fast) ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.icon-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.icon-btn {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
PROTOCOL BADGES (global, used in BottomTabBar, RendererDrawer, RendererCard)
|
||||
======================================== */
|
||||
|
||||
.protocol-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.protocol-chromecast {
|
||||
background-color: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
SCROLLBAR (webkit)
|
||||
======================================== */
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
ANIMATIONS (Keyframes for no-preference media query)
|
||||
======================================== */
|
||||
|
||||
@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 {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn var(--transition-base);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-opacity {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
@@ -78,6 +78,8 @@
|
||||
/* Primary color (brand) */
|
||||
--color-primary: #667eea;
|
||||
--color-primary-hover: #5568d3;
|
||||
/* Primary color RGB components (for rgba() usage) */
|
||||
--color-primary-rgb: 102, 126, 234;
|
||||
|
||||
/* ========================================
|
||||
Transitions
|
||||
@@ -87,13 +89,18 @@
|
||||
--transition-base: 300ms ease-in-out;
|
||||
--transition-slow: 500ms ease-in-out;
|
||||
|
||||
/* ========================================
|
||||
Z-index layers
|
||||
======================================== */
|
||||
/* ========================================
|
||||
Z-index layers
|
||||
======================================== */
|
||||
--z-dropdown: 100;
|
||||
--z-modal: 200;
|
||||
--z-toast: 300;
|
||||
--z-tooltip: 400;
|
||||
|
||||
/* ========================================
|
||||
Opacity states
|
||||
======================================== */
|
||||
--opacity-disabled: 0.4;
|
||||
}
|
||||
|
||||
/* Dark mode support (optionnel pour l'avenir) */
|
||||
|
||||
@@ -981,7 +981,14 @@ button.active {
|
||||
padding: 3rem;
|
||||
color: #569cd6;
|
||||
font-size: 1.1rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.loading-state {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -559,12 +559,24 @@
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Pagination controls -->
|
||||
<div v-if="totalPages > 1" class="pagination-controls">
|
||||
<button @click="prevPage" :disabled="currentPage === 0">
|
||||
← Previous
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ currentPage + 1 }} of {{ totalPages }}
|
||||
</span>
|
||||
<button @click="nextPage" :disabled="currentPage >= totalPages - 1">
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="track-grid"
|
||||
v-if="sortedTracks.length > 0"
|
||||
v-if="paginatedTracks.length > 0"
|
||||
>
|
||||
<article
|
||||
v-for="track in sortedTracks"
|
||||
v-for="track in paginatedTracks"
|
||||
:key="`${track.cache_pk}-${track.added_at}`"
|
||||
class="track-card"
|
||||
:class="{ lazy: isLazyTrack(track) }"
|
||||
@@ -880,20 +892,51 @@ const sortedPlaylists = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const currentPage = ref(0);
|
||||
const sortedTracksCache = new Map<string, PlaylistTrack[]>();
|
||||
|
||||
const sortedTracks = computed(() => {
|
||||
const detail = selectedPlaylist.value;
|
||||
if (!detail) return [];
|
||||
return [...detail.tracks].sort(
|
||||
const playlistId = detail.summary.id;
|
||||
const cached = sortedTracksCache.get(playlistId);
|
||||
if (cached && cached.length === detail.tracks.length) return cached;
|
||||
const sorted = [...detail.tracks].sort(
|
||||
(a, b) =>
|
||||
new Date(b.added_at).getTime() - new Date(a.added_at).getTime(),
|
||||
);
|
||||
sortedTracksCache.set(playlistId, sorted);
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const paginatedTracks = computed(() =>
|
||||
sortedTracks.value.slice(
|
||||
currentPage.value * PAGE_SIZE,
|
||||
(currentPage.value + 1) * PAGE_SIZE
|
||||
)
|
||||
);
|
||||
|
||||
const totalPages = computed(() => Math.ceil(sortedTracks.value.length / PAGE_SIZE));
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value - 1) {
|
||||
currentPage.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 0) {
|
||||
currentPage.value--;
|
||||
}
|
||||
}
|
||||
|
||||
watch(sortedTracks, () => {
|
||||
currentPage.value = 0;
|
||||
});
|
||||
|
||||
const lazyTracksCount = computed(() =>
|
||||
selectedPlaylist.value
|
||||
? selectedPlaylist.value.tracks.filter((track) => isLazyTrack(track))
|
||||
.length
|
||||
: 0,
|
||||
sortedTracks.value.filter((track) => isLazyTrack(track)).length
|
||||
);
|
||||
|
||||
const updateCoverPreview = computed(
|
||||
@@ -1412,17 +1455,17 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.playlist-manager {
|
||||
padding: 1rem;
|
||||
padding: var(--spacing-md);
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
color: #f5f5f5;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.playlist-manager {
|
||||
padding: 0.5rem;
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1430,56 +1473,63 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #2b2b2b;
|
||||
background: #151515;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #61dafb;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #b0b0b0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-stats span {
|
||||
background: #232323;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #ddd;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: #61dafb;
|
||||
color: #0b0b0b;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.header-actions button:disabled {
|
||||
opacity: 0.6;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panels {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: 340px 1fr;
|
||||
gap: var(--spacing-md);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -1490,28 +1540,28 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #2f2f2f;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
border: 1px dashed #3a3a3a;
|
||||
border-radius: 8px;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: #a8a8a8;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.list-section {
|
||||
@@ -1522,95 +1572,107 @@ onMounted(() => {
|
||||
.playlist-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
background: #242424;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.playlist-card:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.playlist-card.selected {
|
||||
border-color: #61dafb;
|
||||
box-shadow: 0 0 0 1px rgba(97, 218, 251, 0.4);
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.card-metrics,
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #bdbdbd;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.card-artist {
|
||||
margin-top: 0.25rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.artist-label {
|
||||
font-size: 0.85rem;
|
||||
color: #90caf9;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-primary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #111;
|
||||
border: 1px solid #2b2b2b;
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-md);
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr auto;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 100px 1fr auto;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-header-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-header-main h3 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 10px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #2f2f2f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.detail-cover img {
|
||||
@@ -1620,23 +1682,29 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.detail-id {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #9c9c9c;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.detail-header-actions button {
|
||||
background: #232323;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
color: #f5f5f5;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.4rem 0.8rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.detail-header-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.detail-header {
|
||||
grid-template-columns: 100px 1fr;
|
||||
grid-template-columns: 80px 1fr;
|
||||
}
|
||||
|
||||
.detail-header-actions {
|
||||
@@ -1651,9 +1719,8 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1b1b1b;
|
||||
color: #666;
|
||||
font-size: 2rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.cover-placeholder.small {
|
||||
@@ -1662,18 +1729,18 @@ onMounted(() => {
|
||||
|
||||
.cover-preview-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cover-preview {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 8px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid #2d2d2d;
|
||||
background: #0f0f0f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1686,27 +1753,28 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.cover-preview.small {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.btn-compact {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.95rem;
|
||||
gap: var(--spacing-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.cover-stat code {
|
||||
background: #0f0f0f;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-right: 0.4rem;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.detail-messages .error,
|
||||
@@ -1715,32 +1783,34 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.section-subcard {
|
||||
background: #181818;
|
||||
border: 1px solid #2b2b2b;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.section-subcard h4 {
|
||||
margin: 0;
|
||||
color: #e7e7e7;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.field,
|
||||
.checkbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
gap: 0.3rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
@@ -1751,11 +1821,12 @@ form {
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #3f3f3f;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
color: #f5f5f5;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.45rem var(--spacing-sm);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
@@ -1764,14 +1835,14 @@ form {
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.form-messages {
|
||||
@@ -1779,13 +1850,15 @@ form {
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #4b9ed0;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.primary:disabled {
|
||||
@@ -1794,19 +1867,35 @@ form {
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2c2c2c;
|
||||
border: 1px solid #3d3d3d;
|
||||
color: #f5f5f5;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #c0392b;
|
||||
border: 1px solid #d35400;
|
||||
background: rgba(192, 57, 43, 0.8);
|
||||
border: 1px solid rgba(211, 84, 0, 0.6);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(192, 57, 43, 1);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -1815,68 +1904,109 @@ button:disabled {
|
||||
}
|
||||
|
||||
.pill {
|
||||
background: #2f2f2f;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.1rem 0.55rem;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
color: #a5a5a5;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #8a8a8a;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tracks .section-header {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.pagination-controls button {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-controls button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-controls button:hover:not(:disabled) {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.track-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.track-card {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr auto;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #2a2a2a;
|
||||
background: #16181f;
|
||||
grid-template-columns: 80px 1fr auto;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
align-items: center;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.track-card:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.track-card {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: 80px 1fr;
|
||||
}
|
||||
|
||||
.cover-wrapper {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
.track-actions {
|
||||
grid-column: span 2;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.track-card.lazy {
|
||||
border-color: #d4a017;
|
||||
box-shadow: 0 0 0 1px rgba(212, 160, 23, 0.4);
|
||||
border-color: rgba(212, 160, 23, 0.5);
|
||||
background: rgba(212, 160, 23, 0.05);
|
||||
}
|
||||
|
||||
.cover-wrapper {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 10px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: #1f1f1f;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cover-wrapper img {
|
||||
@@ -1886,90 +2016,101 @@ button:disabled {
|
||||
}
|
||||
|
||||
.track-card .cover-placeholder {
|
||||
font-size: 2rem;
|
||||
color: #6f6f6f;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.cover-lazy-pill {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
background: #d4a017;
|
||||
color: #1a1a1a;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 1rem;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-artist,
|
||||
.track-album {
|
||||
font-size: 0.9rem;
|
||||
color: #bbbbbb;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #a0a0a0;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.pk-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
word-break: break-word;
|
||||
gap: 0.2rem;
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.pk-line {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pk-line .label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pk-line code {
|
||||
background: #0f0f0f;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.added-info {
|
||||
font-size: 0.8rem;
|
||||
color: #909090;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff7a7a;
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #6adf8b;
|
||||
color: var(--status-playing);
|
||||
}
|
||||
</style>
|
||||
.detail-header-main { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
@@ -13,6 +13,7 @@ const props = defineProps<{
|
||||
const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const uiStore = useUIStore();
|
||||
const metadata = computed(() => state.value?.current_track);
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
const isSeeking = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const dragProgress = ref(0);
|
||||
@@ -100,14 +101,7 @@ function formatTime(ms: number | null | undefined): string {
|
||||
}
|
||||
|
||||
const currentTime = computed(() => formatTime(state.value?.position_ms));
|
||||
const totalTime = computed(() => {
|
||||
const duration = state.value?.duration_ms;
|
||||
const transport = state.value?.transport_state;
|
||||
console.log(
|
||||
`[CurrentTrack] rendererId=${props.rendererId}, duration_ms=${duration}, transport=${transport}, title=${state.value?.current_track?.title}`,
|
||||
);
|
||||
return formatTime(duration);
|
||||
});
|
||||
const totalTime = computed(() => formatTime(state.value?.duration_ms));
|
||||
|
||||
const hasCover = computed(
|
||||
() => !!metadata.value?.album_art_uri && !imageError.value,
|
||||
@@ -156,7 +150,7 @@ function handleProgressBarMouseDown(event: MouseEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
// En cas d'erreur, réinitialiser
|
||||
seekTargetMs.value = null;
|
||||
@@ -195,7 +189,7 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
// En cas d'erreur, réinitialiser
|
||||
seekTargetMs.value = null;
|
||||
@@ -258,7 +252,7 @@ function handleOverlayProgressBarTouchStart(event: TouchEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
seekTargetMs.value = null;
|
||||
dragProgress.value = 0;
|
||||
@@ -357,20 +351,14 @@ const swipeOpacity = computed(() => {
|
||||
@click="openCoverOverlay"
|
||||
>
|
||||
<img
|
||||
v-if="cacheBustedUrl"
|
||||
ref="coverImageRef"
|
||||
:style="{
|
||||
opacity:
|
||||
cacheBustedUrl && imageLoaded && !imageError ? 1 : 0,
|
||||
visibility:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'visible'
|
||||
: 'hidden',
|
||||
position:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'relative'
|
||||
: 'absolute',
|
||||
opacity: imageLoaded && !imageError ? 1 : 0,
|
||||
visibility: imageLoaded && !imageError ? 'visible' : 'hidden',
|
||||
position: imageLoaded && !imageError ? 'relative' : 'absolute',
|
||||
}"
|
||||
:src="cacheBustedUrl || ''"
|
||||
:src="cacheBustedUrl"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
@load="handleImageLoad"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, 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";
|
||||
import { Loader2, Search, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string;
|
||||
@@ -16,17 +16,37 @@ const props = defineProps<{
|
||||
const {
|
||||
getBrowseCached,
|
||||
browseContainer,
|
||||
loadMoreBrowse,
|
||||
hasMore,
|
||||
currentPath: breadcrumbPath,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
searchServer,
|
||||
} = useMediaServers();
|
||||
|
||||
const searchInput = ref('');
|
||||
|
||||
async function handleSearch() {
|
||||
if (searchInput.value.trim()) {
|
||||
const virtualId = await searchServer(props.serverId, searchInput.value.trim());
|
||||
if (virtualId) {
|
||||
emit("navigate", virtualId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearSearch() {
|
||||
searchInput.value = '';
|
||||
}
|
||||
|
||||
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
|
||||
useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Flag pour gérer le rechargement automatique
|
||||
const isRefreshing = ref(false);
|
||||
const sentinelRef = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const browseData = computed(() =>
|
||||
getBrowseCached(props.serverId, props.containerId),
|
||||
@@ -40,6 +60,28 @@ const items = computed(
|
||||
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
|
||||
);
|
||||
|
||||
const canLoadMore = computed(() => hasMore(props.serverId, props.containerId));
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect();
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
|
||||
loadMoreBrowse(props.serverId, props.containerId);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
if (sentinelRef.value) observer.observe(sentinelRef.value);
|
||||
}
|
||||
|
||||
onMounted(() => setupObserver());
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
|
||||
watch(sentinelRef, (el) => {
|
||||
if (el) setupObserver();
|
||||
});
|
||||
|
||||
// Charger le container au montage et quand containerId change
|
||||
watch(
|
||||
() => props.containerId,
|
||||
@@ -51,15 +93,10 @@ watch(
|
||||
{ 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
|
||||
// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
|
||||
// Recharger si le cache est invalidé (ContainersUpdated SSE)
|
||||
watch(
|
||||
() => browseData.value,
|
||||
async (data) => {
|
||||
// Si browseData devient undefined alors que containerId est présent,
|
||||
// et qu'on n'est pas déjà en train de charger, recharger immédiatement
|
||||
if (
|
||||
!data &&
|
||||
props.containerId &&
|
||||
@@ -144,6 +181,31 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="search-bar">
|
||||
<div class="search-input-wrapper">
|
||||
<Search :size="16" class="search-icon" />
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher..."
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="searchInput"
|
||||
class="search-clear"
|
||||
@click="handleClearSearch"
|
||||
title="Effacer"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary search-btn" @click="handleSearch">
|
||||
Rechercher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="browser-loading">
|
||||
<Loader2 :size="32" class="spinner" />
|
||||
@@ -199,7 +261,15 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
v-if="!containers.length && !items.length"
|
||||
class="browser-empty"
|
||||
>
|
||||
<p>Ce dossier est vide</p>
|
||||
<p>{{ 'Ce dossier est vide' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sentinel infinite scroll -->
|
||||
<div ref="sentinelRef" class="scroll-sentinel" />
|
||||
|
||||
<!-- Spinner load more -->
|
||||
<div v-if="loadingMore" class="load-more-spinner">
|
||||
<Loader2 :size="20" class="spinner" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,14 +298,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
/* @keyframes spin is now global in pmocontrol.css */
|
||||
|
||||
/* Error */
|
||||
.browser-error {
|
||||
@@ -295,6 +358,75 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.scroll-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: var(--spacing-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-xs) var(--spacing-xl) var(--spacing-xs) calc(var(--spacing-sm) + 20px);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: var(--spacing-xs);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
white-space: nowrap;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.browser-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick, toRef } from "vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import { useRenderer } from "@/composables/useRenderers";
|
||||
import QueueItem from "./QueueItem.vue";
|
||||
import { Link, Radio } from "lucide-vue-next";
|
||||
import { Link, Radio, RefreshCw } from "lucide-vue-next";
|
||||
import type { QueueItem as QueueItemType } from "@/services/pmocontrol/types";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -13,11 +15,11 @@ const emit = defineEmits<{
|
||||
clickItem: [item: QueueItemType];
|
||||
}>();
|
||||
|
||||
const { queue, binding, isStream } = useRenderer(toRef(props, "rendererId"));
|
||||
const { queue, binding, isStream, queueRefreshing } = useRenderer(toRef(props, "rendererId"));
|
||||
|
||||
const isAttached = computed(() => !!binding.value);
|
||||
|
||||
const queueContainer = ref<HTMLElement | null>(null);
|
||||
const queueContainer = ref<any>(null);
|
||||
|
||||
function handleItemClick(item: QueueItemType) {
|
||||
emit("clickItem", item);
|
||||
@@ -33,15 +35,7 @@ watch(
|
||||
queueContainer.value
|
||||
) {
|
||||
await nextTick();
|
||||
const currentItem = queueContainer.value.querySelector(
|
||||
".queue-item.current",
|
||||
);
|
||||
if (currentItem) {
|
||||
currentItem.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
queueContainer.value.scrollToItem(currentIndex);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -72,19 +66,32 @@ watch(
|
||||
<Radio :size="16" />
|
||||
<span class="stream-text"> Web Radio </span>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur de mise à jour de queue -->
|
||||
<div v-if="queueRefreshing" class="refresh-indicator">
|
||||
<RefreshCw :size="14" class="refresh-icon" />
|
||||
<span class="refresh-text"> Mise à jour... </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des items -->
|
||||
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
||||
<!-- Liste des items virtualisée -->
|
||||
<RecycleScroller
|
||||
v-if="queue?.items.length"
|
||||
class="queue-list"
|
||||
:items="queue.items"
|
||||
:item-size="64"
|
||||
key-field="index"
|
||||
v-slot="{ item }"
|
||||
ref="queueContainer"
|
||||
:min-item-size="64"
|
||||
>
|
||||
<QueueItem
|
||||
v-for="item in queue.items"
|
||||
:key="item.index"
|
||||
:item="item"
|
||||
:is-current="item.index === queue.current_index"
|
||||
@click="handleItemClick"
|
||||
/>
|
||||
</div>
|
||||
</RecycleScroller>
|
||||
|
||||
<!-- État vide -->
|
||||
<div v-else class="queue-empty">
|
||||
@@ -99,6 +106,8 @@ watch(
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
@@ -162,21 +171,39 @@ watch(
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.refresh-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding-right: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--color-border);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* Ajoute un espace de scroll en bas pour ne pas cacher les derniers items sous la barre */
|
||||
.queue-list::after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 80px; /* Espace pour la barre fixe en bas */
|
||||
flex-shrink: 0;
|
||||
.refresh-icon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.refresh-text {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--spacing-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
|
||||
@@ -253,23 +253,7 @@ function goToRenderer() {
|
||||
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;
|
||||
}
|
||||
/* Protocol badge colors (.protocol-upnp, .protocol-openhome, etc.) → pmocontrol.css */
|
||||
|
||||
/* Cover */
|
||||
.card-cover {
|
||||
|
||||
@@ -76,7 +76,14 @@ async function handleShuffle() {
|
||||
}
|
||||
|
||||
.shuffle-button.loading {
|
||||
animation: pulse 1s infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.shuffle-button.loading {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -20,12 +20,14 @@ const isStopped = computed(
|
||||
state.value?.transport_state === "NO_MEDIA",
|
||||
);
|
||||
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
|
||||
async function handlePlay() {
|
||||
try {
|
||||
await resumeOrPlayFromQueue(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +37,7 @@ async function handlePause() {
|
||||
await pause(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +47,7 @@ async function handleStop() {
|
||||
await stop(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +57,7 @@ async function handleNext() {
|
||||
await next(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +70,7 @@ async function handleNext() {
|
||||
:disabled="isPlaying"
|
||||
@click="handlePlay"
|
||||
title="Lecture"
|
||||
aria-label="Lecture"
|
||||
>
|
||||
<Play :size="20" />
|
||||
</button>
|
||||
@@ -77,6 +80,7 @@ async function handleNext() {
|
||||
:disabled="isPaused || isStopped"
|
||||
@click="handlePause"
|
||||
title="Pause"
|
||||
aria-label="Pause"
|
||||
>
|
||||
<Pause :size="20" />
|
||||
</button>
|
||||
@@ -86,6 +90,7 @@ async function handleNext() {
|
||||
:disabled="isStopped"
|
||||
@click="handleStop"
|
||||
title="Stop"
|
||||
aria-label="Arrêter"
|
||||
>
|
||||
<Square :size="20" />
|
||||
</button>
|
||||
@@ -95,6 +100,7 @@ async function handleNext() {
|
||||
:disabled="!state?.queue_len"
|
||||
@click="handleNext"
|
||||
title="Suivant"
|
||||
aria-label="Morceau suivant"
|
||||
>
|
||||
<SkipForward :size="20" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, toRef } from "vue";
|
||||
import { ref, watch, computed, toRef } from "vue";
|
||||
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { Volume2, VolumeX } from "lucide-vue-next";
|
||||
@@ -12,6 +12,7 @@ const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const { setVolume, toggleMute } = useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
const localVolume = ref(state.value?.volume ?? 50);
|
||||
|
||||
// Synchroniser localVolume avec le state
|
||||
@@ -41,7 +42,7 @@ function handleVolumeChange(event: Event) {
|
||||
await setVolume(props.rendererId, localVolume.value);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
debounceTimer = null;
|
||||
@@ -53,7 +54,7 @@ async function handleToggleMute() {
|
||||
await toggleMute(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de couper/rétablir le son: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +66,7 @@ async function handleToggleMute() {
|
||||
class="btn btn-icon"
|
||||
@click="handleToggleMute"
|
||||
:title="state?.mute ? 'Réactiver le son' : 'Couper le son'"
|
||||
:aria-label="state?.mute ? 'Réactiver le son' : 'Couper le son'"
|
||||
>
|
||||
<VolumeX v-if="state?.mute" :size="20" />
|
||||
<Volume2 v-else :size="20" />
|
||||
@@ -78,6 +80,7 @@ async function handleToggleMute() {
|
||||
@input="handleVolumeChange"
|
||||
class="volume-slider"
|
||||
:disabled="state?.mute ?? false"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
|
||||
<span class="volume-value">{{ localVolume }}</span>
|
||||
|
||||
@@ -147,19 +147,22 @@ function handleRendererDrawerClick() {
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
height: 72px;
|
||||
padding: 0 var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.1);
|
||||
/* Extend background into the system navigation bar area (iOS home indicator,
|
||||
Android gesture bar). Content stays in the 72px zone; only the visual
|
||||
background bleeds into the safe-area below. */
|
||||
padding: 0 var(--spacing-md) env(safe-area-inset-bottom, 0px);
|
||||
background: rgba(22, 22, 32, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@media (prefers-color-scheme: light) {
|
||||
.bottom-bar {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(245, 245, 250, 0.97);
|
||||
border-top-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,40 +296,7 @@ function handleRendererDrawerClick() {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Protocol badge */
|
||||
.protocol-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.protocol-chromecast {
|
||||
background-color: rgba(244, 114, 182, 0.15);
|
||||
color: #f472b6;
|
||||
border: 1px solid rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
/* Protocol badge classes (.protocol-badge, .protocol-upnp, etc.) are now global in pmocontrol.css */
|
||||
|
||||
.status-badge {
|
||||
flex-shrink: 0;
|
||||
@@ -356,7 +326,7 @@ function handleRendererDrawerClick() {
|
||||
@media (max-width: 768px) {
|
||||
.bottom-bar {
|
||||
height: 64px;
|
||||
padding: 0 var(--spacing-sm);
|
||||
padding: 0 var(--spacing-sm) env(safe-area-inset-bottom, 0px);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -153,14 +153,7 @@ async function handleRefresh() {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
/* @keyframes spin is now global in pmocontrol.css */
|
||||
|
||||
/* Responsive mobile */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ArrowRightLeft,
|
||||
} from "lucide-vue-next";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useWebRenderer } from "@/composables/useWebRenderer";
|
||||
import { useRouter } from "vue-router";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import type { RendererSummary } from "@/services/pmocontrol/types";
|
||||
@@ -27,8 +28,27 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||
const webRenderer = useWebRenderer();
|
||||
const router = useRouter();
|
||||
|
||||
// Filtre pour n'afficher que notre WebRenderer (pas les autres onglets/navigateurs)
|
||||
const myUdn = computed(() => webRenderer.rendererUdn.value);
|
||||
|
||||
const filteredRenderers = computed(() => {
|
||||
const udn = myUdn.value;
|
||||
const normalizedUdn = udn?.replace(/^uuid:/, '') ?? null;
|
||||
return allRenderers.value.filter((r: RendererSummary) => {
|
||||
// WebRenderers have "Web Audio – " as friendly_name prefix
|
||||
const isWebRenderer = r.friendly_name?.startsWith("Web Audio – ") ?? false;
|
||||
if (isWebRenderer) {
|
||||
if (normalizedUdn === null) return false;
|
||||
const normalizedId = r.id.replace(/^uuid:/, '');
|
||||
return normalizedId === normalizedUdn;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// Gestion du menu déroulant
|
||||
const openMenuId = ref<string | null>(null);
|
||||
|
||||
@@ -110,12 +130,21 @@ watch(
|
||||
);
|
||||
|
||||
const onlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => r.online),
|
||||
filteredRenderers.value.filter((r: RendererSummary) => r.online),
|
||||
);
|
||||
const offlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => !r.online),
|
||||
filteredRenderers.value.filter((r: RendererSummary) => !r.online),
|
||||
);
|
||||
|
||||
// Noms de renderers qui apparaissent plus d'une fois (online + offline)
|
||||
const duplicateNames = computed(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const r of filteredRenderers.value) {
|
||||
counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1);
|
||||
}
|
||||
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
@@ -129,7 +158,7 @@ function handleRendererClick(renderer: RendererSummary) {
|
||||
}
|
||||
|
||||
function handleSettingsClick() {
|
||||
router.push("/debug/api-dashboard");
|
||||
router.push("/debug");
|
||||
close();
|
||||
}
|
||||
|
||||
@@ -201,7 +230,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
|
||||
<!-- Drawer -->
|
||||
<Transition name="drawer">
|
||||
<aside v-if="modelValue" class="renderer-drawer">
|
||||
<aside v-if="modelValue" class="app-drawer renderer-drawer">
|
||||
<!-- Header -->
|
||||
<header class="drawer-header">
|
||||
<div class="drawer-title-section">
|
||||
@@ -296,7 +325,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
getProtocolLabel(
|
||||
renderer.protocol,
|
||||
)
|
||||
}}
|
||||
}}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="renderer-details">
|
||||
@@ -383,7 +412,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
</div>
|
||||
<div class="renderer-info">
|
||||
<p class="renderer-name">
|
||||
{{ renderer.friendly_name }}
|
||||
{{ renderer.friendly_name }}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="renderer.model_name"
|
||||
@@ -440,95 +469,25 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.drawer-backdrop {
|
||||
right: 0; /* Mobile portrait: backdrop prend tout l'écran */
|
||||
background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */
|
||||
display: none; /* Mobile portrait: le drawer couvre 100vw, pas besoin de backdrop */
|
||||
}
|
||||
}
|
||||
|
||||
/* Positionnement spécifique — le reste est dans drawers.css (.app-drawer) */
|
||||
.renderer-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 50vw; /* Desktop/landscape: 50% de l'écran */
|
||||
background: rgba(255, 255, 255, 0.08); /* Plus transparent */
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.25);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@media (prefers-color-scheme: light) {
|
||||
.renderer-drawer {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-left-color: rgba(255, 255, 255, 0.1);
|
||||
border-left-color: rgba(0, 0, 0, 0.1);
|
||||
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.drawer-close-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
/* .drawer-header, .drawer-title, .drawer-close-btn, .drawer-content définis dans drawers.css */
|
||||
|
||||
.renderer-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
@@ -655,40 +614,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Protocol badge */
|
||||
.protocol-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.protocol-chromecast {
|
||||
background-color: rgba(244, 114, 182, 0.15);
|
||||
color: #f472b6;
|
||||
border: 1px solid rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
/* Protocol badge classes (.protocol-badge, .protocol-upnp, etc.) are now global in pmocontrol.css */
|
||||
|
||||
/* Transport button */
|
||||
.transport-btn {
|
||||
@@ -756,7 +682,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
min-width: 200px;
|
||||
background: var(--color-surface-elevated);
|
||||
background: rgba(20, 20, 30, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
@@ -934,14 +860,8 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
/* Mobile responsive - portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.renderer-drawer {
|
||||
width: 100vw; /* Mobile portrait: 100% de l'écran */
|
||||
background: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.06
|
||||
); /* Encore plus transparent sur mobile */
|
||||
box-shadow: none; /* Pas d'ombre sur les côtés */
|
||||
width: 100vw;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
|
||||
@@ -19,6 +19,7 @@ const { renderer, state, queue, refresh } = useRenderer(
|
||||
toRef(props, "rendererId"),
|
||||
);
|
||||
const uiStore = useUIStore();
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
|
||||
// Détection mobile portrait pour afficher le drawer au lieu de la colonne
|
||||
const isMobilePortrait = useMediaQuery(
|
||||
@@ -59,7 +60,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
error,
|
||||
);
|
||||
uiStore.notifyError(
|
||||
`Erreur: ${error instanceof Error ? error.message : "Impossible de sauter à cet item"}`,
|
||||
`« ${rendererName.value} » — impossible de lire ce morceau: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -142,6 +143,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Layout principal - 800x600 landscape (2 colonnes) */
|
||||
@@ -153,6 +155,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
/* padding-right: 0 pour coller la scrollbar au bord */
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Colonne gauche - Contrôles */
|
||||
@@ -185,11 +188,14 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-bottom: 80px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-viewer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Queue drawer - masqué sur desktop, visible uniquement sur mobile portrait */
|
||||
@@ -225,19 +231,20 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
/* Queue drawer visible sur mobile (géré par v-if maintenant) */
|
||||
.queue-drawer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
/* Ancré juste au-dessus de la BottomTabBar (64px) et de la safe-area système */
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.2);
|
||||
/* Fermé: caché sauf le toggle (56px) qui dépasse au-dessus de la BottomTabBar (64px) */
|
||||
transform: translateY(calc(100% - 56px - 64px));
|
||||
background: rgba(22, 22, 32, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4);
|
||||
/* Fermé: seul le toggle (56px) visible */
|
||||
transform: translateY(calc(100% - 56px));
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 95; /* Au-dessus de la BottomTabBar (z-index: 100) */
|
||||
max-height: 70vh;
|
||||
z-index: 95; /* En dessous de la BottomTabBar (z-index: 100) */
|
||||
max-height: calc(100vh - 64px - env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Fermé: ne bloque pas les clics en dehors du toggle */
|
||||
@@ -245,14 +252,22 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
}
|
||||
|
||||
.queue-drawer.open {
|
||||
/* Ouvert: remonte mais s'arrête à 64px du bas pour laisser la BottomTabBar accessible */
|
||||
transform: translateY(64px);
|
||||
pointer-events: auto; /* Ouvert: capture les clics */
|
||||
/* Ouvert: le drawer occupe toute la hauteur disponible au-dessus de la tab bar */
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.queue-drawer {
|
||||
background: rgba(245, 245, 250, 0.97);
|
||||
border-top-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-drawer-toggle {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -268,7 +283,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
}
|
||||
|
||||
.queue-drawer-content {
|
||||
max-height: calc(70vh - 56px);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
@@ -279,12 +294,12 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 64px; /* S'arrête au-dessus de la BottomTabBar */
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 94; /* Entre la BottomTabBar et le drawer */
|
||||
pointer-events: auto; /* Capture les clics pour fermer le drawer */
|
||||
z-index: 94;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.controls-column {
|
||||
@@ -342,8 +357,6 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
@media (min-width: 1200px) {
|
||||
.renderer-layout {
|
||||
grid-template-columns: 350px 1fr;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, reactive } from "vue";
|
||||
import { ref, computed, watch, reactive, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
X,
|
||||
@@ -18,7 +18,6 @@ import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse,
|
||||
ContainerEntry,
|
||||
} from "@/services/pmocontrol/types";
|
||||
|
||||
@@ -35,11 +34,54 @@ const {
|
||||
allServers,
|
||||
fetchServers,
|
||||
browseContainer,
|
||||
getBrowseCached,
|
||||
loadMoreBrowse,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
currentPath,
|
||||
setPath,
|
||||
clearPath,
|
||||
searchServer,
|
||||
} = useMediaServers();
|
||||
|
||||
const searchInput = ref('');
|
||||
|
||||
async function handleSearch() {
|
||||
console.log('[ServerDrawer] handleSearch called, currentServer:', currentServer.value?.id, 'searchInput:', searchInput.value);
|
||||
if (!currentServer.value || !searchInput.value.trim()) return;
|
||||
|
||||
const query = searchInput.value.trim();
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const virtualId = await searchServer(currentServer.value.id, query);
|
||||
if (!virtualId) return;
|
||||
|
||||
currentContainerId.value = virtualId;
|
||||
setPath([
|
||||
{ id: '0', title: currentServer.value.friendly_name },
|
||||
{ id: virtualId, title: `Recherche : ${query}` },
|
||||
]);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearSearch() {
|
||||
searchInput.value = '';
|
||||
if (!currentServer.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await browseContainer(currentServer.value.id, "0");
|
||||
currentContainerId.value = "0";
|
||||
setPath([{ id: "0", title: currentServer.value.friendly_name }]);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur navigation racine après clear search:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
|
||||
useRenderers();
|
||||
|
||||
@@ -47,9 +89,65 @@ const router = useRouter();
|
||||
|
||||
// État de navigation
|
||||
const currentServer = ref<MediaServerSummary | null>(null);
|
||||
const browseData = ref<BrowseResponse | null>(null);
|
||||
const currentContainerId = ref<string | null>(null);
|
||||
const browseData = computed(() =>
|
||||
currentServer.value && currentContainerId.value
|
||||
? (getBrowseCached(currentServer.value.id, currentContainerId.value) ?? null)
|
||||
: null,
|
||||
);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Infinite scroll
|
||||
const sentinelRef = ref<HTMLElement | null>(null);
|
||||
const drawerContentRef = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const canLoadMore = computed(() =>
|
||||
currentServer.value && currentContainerId.value
|
||||
? hasMore(currentServer.value.id, currentContainerId.value)
|
||||
: false,
|
||||
);
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect();
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
|
||||
if (currentServer.value && currentContainerId.value) {
|
||||
loadMoreBrowse(currentServer.value.id, currentContainerId.value);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: drawerContentRef.value, threshold: 0.1 },
|
||||
);
|
||||
if (sentinelRef.value) observer.observe(sentinelRef.value);
|
||||
}
|
||||
|
||||
onMounted(() => setupObserver());
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
|
||||
watch(sentinelRef, (el) => {
|
||||
if (el) setupObserver();
|
||||
});
|
||||
|
||||
watch(drawerContentRef, (el) => {
|
||||
if (el) setupObserver();
|
||||
});
|
||||
|
||||
// Recharger quand le cache est invalidé par un événement SSE (containers_updated / global_updated)
|
||||
watch(browseData, async (data, oldData) => {
|
||||
if (!data && oldData && currentServer.value && currentContainerId.value && !isLoading.value) {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await browseContainer(currentServer.value.id, currentContainerId.value, false);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur reload après invalidation cache:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
|
||||
const openMenuId = ref<string | null>(null);
|
||||
|
||||
@@ -85,10 +183,11 @@ watch(
|
||||
} else {
|
||||
// Reset navigation quand on ferme
|
||||
currentServer.value = null;
|
||||
browseData.value = null;
|
||||
currentContainerId.value = null;
|
||||
clearPath();
|
||||
closeMenu();
|
||||
imageStates.clear();
|
||||
searchInput.value = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -139,15 +238,17 @@ async function handleServerClick(server: MediaServerSummary) {
|
||||
|
||||
// Commencer la navigation dans ce serveur
|
||||
currentServer.value = server;
|
||||
searchInput.value = '';
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// Browse racine (containerId = "0")
|
||||
browseData.value = await browseContainer(server.id, "0");
|
||||
await browseContainer(server.id, "0");
|
||||
currentContainerId.value = "0";
|
||||
setPath([{ id: "0", title: server.friendly_name }]);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur browse racine:", error);
|
||||
browseData.value = null;
|
||||
currentContainerId.value = null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -155,8 +256,9 @@ async function handleServerClick(server: MediaServerSummary) {
|
||||
|
||||
function goBack() {
|
||||
currentServer.value = null;
|
||||
browseData.value = null;
|
||||
currentContainerId.value = null;
|
||||
clearPath();
|
||||
searchInput.value = '';
|
||||
}
|
||||
|
||||
async function handleContainerClick(item: ContainerEntry) {
|
||||
@@ -164,10 +266,8 @@ async function handleContainerClick(item: ContainerEntry) {
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
browseData.value = await browseContainer(
|
||||
currentServer.value.id,
|
||||
item.id,
|
||||
);
|
||||
await browseContainer(currentServer.value.id, item.id);
|
||||
currentContainerId.value = item.id;
|
||||
setPath([...currentPath.value, { id: item.id, title: item.title }]);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur browse container:", error);
|
||||
@@ -185,10 +285,8 @@ async function handleBreadcrumbClick(index: number) {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
browseData.value = await browseContainer(
|
||||
currentServer.value.id,
|
||||
targetCrumb.id,
|
||||
);
|
||||
await browseContainer(currentServer.value.id, targetCrumb.id);
|
||||
currentContainerId.value = targetCrumb.id;
|
||||
setPath(currentPath.value.slice(0, index + 1));
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur breadcrumb navigation:", error);
|
||||
@@ -215,8 +313,12 @@ function isPlayable(item: ContainerEntry): boolean {
|
||||
|
||||
// Détermine si un container est navigable
|
||||
function isNavigable(item: ContainerEntry): boolean {
|
||||
// Tous les containers sont navigables (on laisse le serveur décider si vide)
|
||||
return item.is_container;
|
||||
if (!item.is_container) return false;
|
||||
// Une playlist avec un seul item : on garde le caractère jouable mais on retire
|
||||
// la navigation pour éviter que l'utilisateur joue l'item directement et perde
|
||||
// le bénéfice des mises à jour de métadonnées de la playlist.
|
||||
if (isPlayable(item) && item.child_count === 1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleItemClick(item: ContainerEntry) {
|
||||
@@ -311,7 +413,7 @@ async function handleAddAfterCurrent(event: Event, item: ContainerEntry) {
|
||||
}
|
||||
|
||||
function handleSettingsClick() {
|
||||
router.push("/debug/api-dashboard");
|
||||
router.push("/debug");
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
@@ -325,7 +427,7 @@ function handleSettingsClick() {
|
||||
|
||||
<!-- Drawer -->
|
||||
<Transition name="drawer">
|
||||
<aside v-if="modelValue" class="server-drawer">
|
||||
<aside v-if="modelValue" class="app-drawer server-drawer">
|
||||
<!-- Header - Liste des serveurs -->
|
||||
<header v-if="!isNavigating" class="drawer-header">
|
||||
<div class="drawer-title-section">
|
||||
@@ -344,7 +446,7 @@ function handleSettingsClick() {
|
||||
<!-- Header - Navigation dans un serveur -->
|
||||
<header v-else class="drawer-header">
|
||||
<button
|
||||
class="back-btn"
|
||||
class="drawer-back-btn"
|
||||
@click="goBack"
|
||||
aria-label="Retour aux serveurs"
|
||||
>
|
||||
@@ -385,8 +487,30 @@ function handleSettingsClick() {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Search bar (visible en mode navigation) -->
|
||||
<div v-if="isNavigating" class="search-bar">
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher..."
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="searchInput"
|
||||
class="search-clear-btn"
|
||||
@click="handleClearSearch"
|
||||
title="Effacer"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
<button class="search-btn" @click="handleSearch" title="Rechercher">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="drawer-content">
|
||||
<div ref="drawerContentRef" class="drawer-content">
|
||||
<!-- Liste des serveurs -->
|
||||
<div v-if="!isNavigating">
|
||||
<!-- Servers online -->
|
||||
@@ -502,7 +626,6 @@ function handleSettingsClick() {
|
||||
:src="item.album_art_uri"
|
||||
:alt="item.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@load="handleImageLoad(item.id)"
|
||||
@error="handleImageError(item.id)"
|
||||
/>
|
||||
@@ -628,8 +751,16 @@ function handleSettingsClick() {
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Sentinel infinite scroll -->
|
||||
<div v-if="canLoadMore" ref="sentinelRef" class="scroll-sentinel" />
|
||||
|
||||
<!-- Spinner load more -->
|
||||
<div v-if="loadingMore" class="load-more-spinner">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else class="empty-servers">
|
||||
<div v-else-if="!browseData" class="empty-servers">
|
||||
<Folder :size="48" />
|
||||
<p>Dossier vide</p>
|
||||
</div>
|
||||
@@ -667,115 +798,81 @@ function handleSettingsClick() {
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.drawer-backdrop {
|
||||
left: 0; /* Mobile portrait: backdrop commence à gauche car drawer prend 100vw */
|
||||
background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */
|
||||
display: none; /* Mobile portrait: le drawer couvre 100vw, pas besoin de backdrop */
|
||||
}
|
||||
}
|
||||
|
||||
/* Positionnement spécifique — le reste est dans drawers.css (.app-drawer) */
|
||||
.server-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 50vw; /* Desktop/landscape: 50% de l'écran */
|
||||
background: rgba(255, 255, 255, 0.08); /* Plus transparent */
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 4px 0 32px rgba(0, 0, 0, 0.25);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 4px 0 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@media (prefers-color-scheme: light) {
|
||||
.server-drawer {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-right-color: rgba(255, 255, 255, 0.1);
|
||||
border-right-color: rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 4px 0 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.drawer-header {
|
||||
/* Breadcrumb */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
gap: 6px;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--color-text);
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.drawer-title.small {
|
||||
font-size: var(--text-base);
|
||||
.search-input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
.search-clear-btn,
|
||||
.search-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover,
|
||||
.search-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.drawer-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.drawer-close-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -816,12 +913,7 @@ function handleSettingsClick() {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
/* .drawer-content défini dans drawers.css */
|
||||
|
||||
.server-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
@@ -1195,11 +1287,7 @@ function handleSettingsClick() {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
/* @keyframes spin is now global in pmocontrol.css */
|
||||
|
||||
.loading-state p {
|
||||
margin: 0;
|
||||
@@ -1258,6 +1346,17 @@ function handleSettingsClick() {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.scroll-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.backdrop-enter-active {
|
||||
transition: opacity 0.3s ease-out;
|
||||
@@ -1321,14 +1420,8 @@ function handleSettingsClick() {
|
||||
/* Mobile responsive - portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.server-drawer {
|
||||
width: 100vw; /* Mobile portrait: 100% de l'écran */
|
||||
background: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.06
|
||||
); /* Encore plus transparent sur mobile */
|
||||
box-shadow: none; /* Pas d'ombre sur les côtés */
|
||||
width: 100vw;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
|
||||
@@ -187,8 +187,6 @@ onMounted(async () => {
|
||||
/* Large desktop */
|
||||
@media (min-width: 1200px) {
|
||||
.browser-section {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,9 +215,6 @@ watch(editingVar, (newVar) => {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.error-state {
|
||||
@@ -375,7 +372,14 @@ watch(editingVar, (newVar) => {
|
||||
|
||||
.event-badge {
|
||||
font-size: 1rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.event-badge {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
286
pmoapp/webapp/src/composables/apiCache.ts
Normal file
286
pmoapp/webapp/src/composables/apiCache.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Cache API centralisé pour les requêtes HTTP
|
||||
*
|
||||
* Fonctionnalités:
|
||||
* - Cache mémoire avec TTL configurable
|
||||
* - Dédupplication des requêtes en cours (une seule requête pour plusieurs callers)
|
||||
* - Invalidation par pattern (ex: invalidate('renderers/*'))
|
||||
* - Subscribe aux changements de données pour reactivity
|
||||
*/
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
ttl?: number;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
export interface ApiCacheOptions {
|
||||
ttl?: number;
|
||||
staleWhileRevalidate?: boolean;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
promise: Promise<unknown>;
|
||||
subscribers: Set<(data: unknown) => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classe principale du cache API
|
||||
*/
|
||||
class ApiCacheService {
|
||||
private cache = new Map<string, CacheEntry<unknown>>();
|
||||
private pendingRequests = new Map<string, PendingRequest>();
|
||||
private subscriptions = new Map<string, Set<(data: unknown) => void>>();
|
||||
|
||||
private options: Required<ApiCacheOptions> = {
|
||||
ttl: 2000,
|
||||
staleWhileRevalidate: true,
|
||||
};
|
||||
|
||||
configure(options: Partial<ApiCacheOptions>) {
|
||||
this.options = { ...this.options, ...options };
|
||||
}
|
||||
|
||||
private makeKey(endpoint: string, params?: Record<string, string | number | boolean>): string {
|
||||
if (!params) return endpoint;
|
||||
const sorted = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
|
||||
const query = sorted.map(([k, v]) => `${k}=${v}`).join('&');
|
||||
return `${endpoint}?${query}`;
|
||||
}
|
||||
|
||||
private isFresh(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
// Lire le TTL de l'entrée, sinon utiliser le TTL global par défaut
|
||||
const ttl = entry.ttl ?? this.options.ttl;
|
||||
return Date.now() - entry.timestamp < ttl;
|
||||
}
|
||||
|
||||
get<T>(endpoint: string, params?: Record<string, string | number | boolean>): T | null {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
|
||||
|
||||
if (!entry) return null;
|
||||
if (!this.isFresh(key)) {
|
||||
return this.options.staleWhileRevalidate ? entry.data : null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
set<T>(endpoint: string, data: T, params?: Record<string, string | number | boolean>, etag?: string, ttl?: number): void {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
ttl,
|
||||
etag,
|
||||
});
|
||||
|
||||
this.notifySubscribers(key, data);
|
||||
}
|
||||
|
||||
async fetch<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean> | undefined,
|
||||
fetcher: () => Promise<T>,
|
||||
options: { force?: boolean; ttl?: number } = {}
|
||||
): Promise<T> {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
const { force = false, ttl } = options;
|
||||
|
||||
if (!force && this.isFresh(key)) {
|
||||
const cached = this.get<T>(endpoint, params);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
// Utiliser une clé unique pour éviter les problèmes de race condition
|
||||
// avec les requêtes en cours qui peuvent être supprimées avant résolution
|
||||
const requestKey = `request:${key}`;
|
||||
|
||||
// Récupérer ou créer la requête
|
||||
let pendingRequest = this.pendingRequests.get(requestKey);
|
||||
|
||||
// Si une requête est en cours et sa promesse n'a pas encore été resolved/rejected
|
||||
// on retourne directement cette promesse
|
||||
if (pendingRequest) {
|
||||
try {
|
||||
// Attendre la résolution pour s'assurer que c'est toujours valide
|
||||
return await pendingRequest.promise as T;
|
||||
} catch (e) {
|
||||
// La requête a échoué, on continue pour faire une nouvelle requête
|
||||
this.pendingRequests.delete(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
let resolvePromise!: (value: T) => void;
|
||||
let rejectPromise!: (reason: unknown) => void;
|
||||
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
resolvePromise = resolve;
|
||||
rejectPromise = reject;
|
||||
});
|
||||
|
||||
this.pendingRequests.set(requestKey, {
|
||||
promise,
|
||||
subscribers: new Set(),
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await fetcher();
|
||||
|
||||
// Passer le TTL directement à set() pour éviter les problèmes de race condition
|
||||
// avec la modification globale de this.options.ttl
|
||||
this.set(endpoint, data, params, undefined, ttl);
|
||||
|
||||
resolvePromise(data);
|
||||
|
||||
const pending = this.pendingRequests.get(requestKey);
|
||||
if (pending) {
|
||||
pending.subscribers.forEach(cb => cb(data));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
rejectPromise(error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.pendingRequests.delete(requestKey);
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
subscribe<T>(endpoint: string, params: Record<string, string | number | boolean>, callback: (data: T) => void): () => void {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
|
||||
if (!this.subscriptions.has(key)) {
|
||||
this.subscriptions.set(key, new Set());
|
||||
}
|
||||
|
||||
this.subscriptions.get(key)!.add(callback as (data: unknown) => void);
|
||||
|
||||
const cached = this.get<T>(endpoint, params);
|
||||
if (cached) {
|
||||
callback(cached);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const subs = this.subscriptions.get(key);
|
||||
if (subs) {
|
||||
subs.delete(callback as (data: unknown) => void);
|
||||
if (subs.size === 0) {
|
||||
this.subscriptions.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
invalidate(pattern: string): void {
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
if (pattern.includes('*')) {
|
||||
const prefix = pattern.replace('*', '');
|
||||
this.cache.forEach((_, key) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (this.cache.has(pattern)) {
|
||||
keysToDelete.push(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
keysToDelete.forEach(key => {
|
||||
this.cache.delete(key);
|
||||
// NE PAS supprimer this.subscriptions.get(key)
|
||||
// Les abonnés seront notifiés lors du prochain set() après un refetch
|
||||
});
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
|
||||
async invalidateAndFetch<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean>,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> {
|
||||
this.invalidate(this.makeKey(endpoint, params));
|
||||
return this.fetch(endpoint, params, fetcher, { force: true });
|
||||
}
|
||||
|
||||
getStats() {
|
||||
let fresh = 0;
|
||||
let stale = 0;
|
||||
const now = Date.now();
|
||||
|
||||
this.cache.forEach((entry) => {
|
||||
if (now - entry.timestamp < this.options.ttl) {
|
||||
fresh++;
|
||||
} else {
|
||||
stale++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
total: this.cache.size,
|
||||
fresh,
|
||||
stale,
|
||||
pending: this.pendingRequests.size,
|
||||
subscriptions: this.subscriptions.size,
|
||||
};
|
||||
}
|
||||
|
||||
private notifySubscribers(key: string, data: unknown) {
|
||||
const subs = this.subscriptions.get(key);
|
||||
if (subs) {
|
||||
subs.forEach(cb => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (e) {
|
||||
console.error('[ApiCache] Error in subscriber:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiCache = new ApiCacheService();
|
||||
|
||||
export function useApiCache() {
|
||||
return {
|
||||
fetch<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean> | undefined,
|
||||
fetcher: () => Promise<T>,
|
||||
options?: { force?: boolean; ttl?: number }
|
||||
): Promise<T> {
|
||||
return apiCache.fetch(endpoint, params, fetcher, options);
|
||||
},
|
||||
|
||||
subscribe<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean>,
|
||||
callback: (data: T) => void
|
||||
): () => void {
|
||||
return apiCache.subscribe(endpoint, params, callback);
|
||||
},
|
||||
|
||||
invalidate(pattern: string): void {
|
||||
apiCache.invalidate(pattern);
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
apiCache.clear();
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return apiCache.getStats();
|
||||
},
|
||||
};
|
||||
}
|
||||
264
pmoapp/webapp/src/composables/imageCache.ts
Normal file
264
pmoapp/webapp/src/composables/imageCache.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Cache centralisé pour les images de couvertures
|
||||
*
|
||||
* Ce service fournit:
|
||||
* - Cache mémoire pour les états de chargement (évite les requêtes doubles)
|
||||
* - Intégration avec le cache serveur (/api/covers)
|
||||
* - Gestion centralisée des retries
|
||||
* - Subscription aux changements d'état (plusieurs composants partagent le même état)
|
||||
*/
|
||||
|
||||
import { ref, computed, onUnmounted, watch, type Ref } from 'vue';
|
||||
|
||||
// Types pour le cache
|
||||
export interface ImageCacheEntry {
|
||||
url: string;
|
||||
loaded: boolean;
|
||||
error: boolean;
|
||||
loading: boolean;
|
||||
retryCount: number;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface ImageCacheOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
useServerCache?: boolean;
|
||||
}
|
||||
|
||||
// Singleton - état du cache global
|
||||
class ImageCacheService {
|
||||
private cache = new Map<string, ImageCacheEntry>();
|
||||
private subscriptions = new Map<string, Set<(entry: ImageCacheEntry) => void>>();
|
||||
private options: ImageCacheOptions = {
|
||||
maxRetries: 5,
|
||||
retryDelay: 500,
|
||||
useServerCache: true,
|
||||
};
|
||||
|
||||
private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000;
|
||||
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.cleanupIntervalId = setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie le timer de cleanup. À appeler lors de la destruction de l'application.
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.cleanupIntervalId !== null) {
|
||||
clearInterval(this.cleanupIntervalId);
|
||||
this.cleanupIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
configure(options: Partial<ImageCacheOptions>) {
|
||||
this.options = { ...this.options, ...options };
|
||||
}
|
||||
|
||||
getOrCreate(url: string | null | undefined): ImageCacheEntry | null {
|
||||
if (!url) return null;
|
||||
|
||||
const normalizedUrl = url.replace(/[?&]_cb=[^&]*/, '');
|
||||
const cacheKey = normalizedUrl;
|
||||
|
||||
if (!this.cache.has(cacheKey)) {
|
||||
this.cache.set(cacheKey, {
|
||||
url: normalizedUrl,
|
||||
loaded: false,
|
||||
error: false,
|
||||
loading: false,
|
||||
retryCount: 0,
|
||||
lastError: null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.cache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
subscribe(url: string | null | undefined, callback: (entry: ImageCacheEntry) => void): () => void {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return () => {};
|
||||
|
||||
const normalizedUrl = entry.url;
|
||||
|
||||
if (!this.subscriptions.has(normalizedUrl)) {
|
||||
this.subscriptions.set(normalizedUrl, new Set());
|
||||
}
|
||||
|
||||
this.subscriptions.get(normalizedUrl)!.add(callback);
|
||||
callback(entry);
|
||||
|
||||
return () => {
|
||||
const subs = this.subscriptions.get(normalizedUrl);
|
||||
if (subs) {
|
||||
subs.delete(callback);
|
||||
if (subs.size === 0) {
|
||||
this.subscriptions.delete(normalizedUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
startLoading(url: string | null | undefined) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.loading = true;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
markLoaded(url: string | null | undefined) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.loaded = true;
|
||||
entry.error = false;
|
||||
entry.loading = false;
|
||||
entry.retryCount = 0;
|
||||
entry.lastError = null;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
markError(url: string | null | undefined, error: string) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.error = true;
|
||||
entry.loading = false;
|
||||
entry.lastError = error;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
shouldRetry(url: string | null | undefined): boolean {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return false;
|
||||
|
||||
if (entry.retryCount >= (this.options.maxRetries ?? 5)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.retryCount++;
|
||||
entry.loading = true;
|
||||
this.notifySubscribers(entry.url);
|
||||
return true;
|
||||
}
|
||||
|
||||
getRetryDelay(retryCount: number): number {
|
||||
const baseDelay = this.options.retryDelay ?? 500;
|
||||
return baseDelay * Math.pow(2, retryCount - 1);
|
||||
}
|
||||
|
||||
private notifySubscribers(url: string) {
|
||||
const entry = this.cache.get(url);
|
||||
if (!entry) return;
|
||||
|
||||
const subs = this.subscriptions.get(url);
|
||||
if (subs) {
|
||||
subs.forEach(callback => {
|
||||
try {
|
||||
callback(entry);
|
||||
} catch (e) {
|
||||
console.error('[ImageCache] Error in subscriber:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const toDelete: string[] = [];
|
||||
|
||||
this.cache.forEach((entry, url) => {
|
||||
const hasSubs = this.subscriptions.has(url);
|
||||
if (!hasSubs && entry.loaded) {
|
||||
toDelete.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
toDelete.forEach(url => this.cache.delete(url));
|
||||
}
|
||||
|
||||
getStats() {
|
||||
let loaded = 0;
|
||||
let loading = 0;
|
||||
let error = 0;
|
||||
let pending = 0;
|
||||
|
||||
this.cache.forEach(entry => {
|
||||
if (entry.loaded) loaded++;
|
||||
else if (entry.loading) loading++;
|
||||
else if (entry.error) error++;
|
||||
else pending++;
|
||||
});
|
||||
|
||||
return {
|
||||
total: this.cache.size,
|
||||
loaded,
|
||||
loading,
|
||||
error,
|
||||
pending,
|
||||
subscribers: this.subscriptions.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const imageCache = new ImageCacheService();
|
||||
|
||||
/**
|
||||
* Hook pour utiliser le cache d'images de manière reactive
|
||||
*/
|
||||
export function useImageCache(imageUrl: Ref<string | null | undefined>) {
|
||||
const entry = ref<ImageCacheEntry | null>(null);
|
||||
const cleanup = ref<(() => void) | null>(null);
|
||||
|
||||
const loading = computed(() => entry.value?.loading ?? false);
|
||||
const loaded = computed(() => entry.value?.loaded ?? false);
|
||||
const error = computed(() => entry.value?.error ?? false);
|
||||
const lastError = computed(() => entry.value?.lastError ?? null);
|
||||
const retryCount = computed(() => entry.value?.retryCount ?? 0);
|
||||
|
||||
watch(
|
||||
imageUrl,
|
||||
(newUrl) => {
|
||||
if (cleanup.value) {
|
||||
cleanup.value();
|
||||
cleanup.value = null;
|
||||
}
|
||||
|
||||
if (newUrl) {
|
||||
cleanup.value = imageCache.subscribe(newUrl, (newEntry) => {
|
||||
entry.value = newEntry;
|
||||
});
|
||||
} else {
|
||||
entry.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cleanup.value) {
|
||||
cleanup.value();
|
||||
cleanup.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
function reload() {
|
||||
const url = imageUrl.value;
|
||||
if (url) {
|
||||
imageCache.startLoading(url);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entry,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
lastError,
|
||||
retryCount,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
@@ -1,178 +1,182 @@
|
||||
import { ref, watch, onMounted, nextTick, type Ref } from "vue";
|
||||
/**
|
||||
* Composable pour gérer les images de couvertures
|
||||
*
|
||||
* Version optimisée avec:
|
||||
* - Cache centralisé pour partager l'état entre composants
|
||||
* - Intégration optionnelle avec le cache serveur
|
||||
* - Retry automatique avec backoff exponentiel
|
||||
* - Cache-busting pour éviter les problèmes de cache navigateur
|
||||
*/
|
||||
import { ref, watch, computed, type Ref } from "vue";
|
||||
import { imageCache, useImageCache } from "./imageCache";
|
||||
import { simpleHash } from "../utils/string";
|
||||
|
||||
export interface CoverImageOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
forceReload?: boolean;
|
||||
useServerCache?: boolean; // Passer par /api/covers pour le caching serveur
|
||||
}
|
||||
|
||||
export function useCoverImage(
|
||||
imageUrl: Ref<string | null | undefined>,
|
||||
options: CoverImageOptions = {},
|
||||
) {
|
||||
const { maxRetries = 3, retryDelay = 1000, forceReload = true } = options;
|
||||
const {
|
||||
maxRetries = 5,
|
||||
retryDelay = 500,
|
||||
forceReload = true,
|
||||
useServerCache = true
|
||||
} = options;
|
||||
|
||||
// Configurer le cache global
|
||||
imageCache.configure({ maxRetries, retryDelay });
|
||||
|
||||
// État local
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||
const retryCount = ref(0);
|
||||
const currentUrl = ref<string | null>(null);
|
||||
const cacheBustedUrl = ref<string | null>(null);
|
||||
const isLoadingNewImage = ref(false);
|
||||
|
||||
// Function to check if the image is already loaded (cached)
|
||||
function checkImageComplete() {
|
||||
nextTick(() => {
|
||||
if (
|
||||
coverImageRef.value?.complete &&
|
||||
coverImageRef.value?.naturalWidth > 0
|
||||
) {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
retryCount.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Simple hash function for URL
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
// Function to add cache-busting parameter
|
||||
|
||||
// Utiliser le cache centralisé pour l'état de chargement
|
||||
const cacheEntry = useImageCache(imageUrl);
|
||||
|
||||
// Computed: synchroniser avec le cache centralisé
|
||||
// Note: on garde le controle local du loaded/error pour éviter les effets de bord
|
||||
|
||||
// Génère une URL avec cache-busting
|
||||
function getCacheBustedUrl(url: string, retry: number): string {
|
||||
if (!forceReload && retry === 0) {
|
||||
return url;
|
||||
}
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
// Use URL hash for stable cache-busting, timestamp only for retries
|
||||
const cacheBuster =
|
||||
retry > 0
|
||||
|
||||
// Si on utilise le cache serveur, transformer l'URL
|
||||
if (useServerCache && url.startsWith('http')) {
|
||||
// L'URL sera transformée côté serveur via le cache
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
const cacheBuster = retry > 0
|
||||
? `${simpleHash(url)}_r${retry}_${Date.now()}`
|
||||
: simpleHash(url);
|
||||
return `${url}${separator}_cb=${cacheBuster}`;
|
||||
}
|
||||
|
||||
// Pour les URLs locales (data: ou /api/), juste ajouter un paramètre de cache-busting
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
const cacheBuster = retry > 0
|
||||
? `${simpleHash(url)}_r${retry}_${Date.now()}`
|
||||
: simpleHash(url);
|
||||
return `${url}${separator}_cb=${cacheBuster}`;
|
||||
}
|
||||
|
||||
// Retry loading the image
|
||||
function retryLoad() {
|
||||
if (!currentUrl.value) return;
|
||||
|
||||
if (retryCount.value < maxRetries) {
|
||||
retryCount.value++;
|
||||
console.log(
|
||||
`[useCoverImage] Retrying image load (${retryCount.value}/${maxRetries}): ${currentUrl.value}`,
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!currentUrl.value) return;
|
||||
|
||||
// Update cache-busted URL with new retry count
|
||||
cacheBustedUrl.value = getCacheBustedUrl(
|
||||
currentUrl.value,
|
||||
retryCount.value,
|
||||
);
|
||||
console.log(`[useCoverImage] Retry URL: ${cacheBustedUrl.value}`);
|
||||
}, retryDelay * retryCount.value); // Exponential backoff
|
||||
} else {
|
||||
console.error(
|
||||
`[useCoverImage] Max retries (${maxRetries}) reached for: ${currentUrl.value}`,
|
||||
);
|
||||
imageError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle successful image load
|
||||
// Gère le chargement réussi
|
||||
function handleImageLoad() {
|
||||
console.log(
|
||||
`[useCoverImage] Image loaded successfully: ${currentUrl.value}`,
|
||||
);
|
||||
const url = imageUrl.value;
|
||||
if (url) {
|
||||
imageCache.markLoaded(url);
|
||||
}
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
retryCount.value = 0;
|
||||
isLoadingNewImage.value = false;
|
||||
}
|
||||
|
||||
// Handle image load error
|
||||
// Gère l'erreur de chargement
|
||||
function handleImageError(event: Event) {
|
||||
const url = imageUrl.value;
|
||||
const img = event.target as HTMLImageElement;
|
||||
console.warn(
|
||||
`[useCoverImage] Image load error (attempt ${retryCount.value + 1}/${maxRetries + 1}): ${img.src}`,
|
||||
);
|
||||
|
||||
|
||||
console.warn(`[useCoverImage] Image load error: ${img.src}`);
|
||||
|
||||
imageLoaded.value = false;
|
||||
|
||||
// Retry if we haven't reached max retries
|
||||
if (retryCount.value < maxRetries) {
|
||||
retryLoad();
|
||||
if (url) {
|
||||
// Demander au cache si on doit réessayer
|
||||
if (imageCache.shouldRetry(url)) {
|
||||
const delay = imageCache.getRetryDelay(cacheEntry.retryCount.value);
|
||||
console.log(`[useCoverImage] Retrying in ${delay}ms...`);
|
||||
|
||||
setTimeout(() => {
|
||||
// Générer une nouvelle URL avec retry count
|
||||
const retry = cacheEntry.retryCount.value;
|
||||
cacheBustedUrl.value = getCacheBustedUrl(url, retry);
|
||||
|
||||
// Forcer le rechargement de l'image
|
||||
if (coverImageRef.value) {
|
||||
coverImageRef.value.src = cacheBustedUrl.value;
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
imageError.value = true;
|
||||
imageCache.markError(url, "Max retries reached");
|
||||
}
|
||||
} else {
|
||||
imageError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset image state when URL changes
|
||||
// Watch sur l'URL pour générer la cache-busted URL
|
||||
watch(
|
||||
imageUrl,
|
||||
(newUri, oldUri) => {
|
||||
console.log(
|
||||
`[useCoverImage] URL changed from "${oldUri}" to "${newUri}"`,
|
||||
);
|
||||
|
||||
// Reset de l'état d'erreur
|
||||
imageError.value = false;
|
||||
retryCount.value = 0;
|
||||
|
||||
// Si c'est un changement d'URL (pas l'initialisation)
|
||||
|
||||
// Gestion des transitions
|
||||
if (oldUri && newUri && oldUri !== newUri) {
|
||||
console.log(
|
||||
`[useCoverImage] Changing image, keeping old one visible during load`,
|
||||
);
|
||||
isLoadingNewImage.value = true;
|
||||
// On garde imageLoaded à true pour garder l'ancienne image visible
|
||||
// Garder l'image précédente visible pendant le chargement
|
||||
} else if (!newUri) {
|
||||
// Pas d'URL, on cache tout
|
||||
imageLoaded.value = false;
|
||||
isLoadingNewImage.value = false;
|
||||
cacheBustedUrl.value = null;
|
||||
} else if (!oldUri && newUri) {
|
||||
// Initialisation, on part de zéro
|
||||
imageLoaded.value = false;
|
||||
isLoadingNewImage.value = true;
|
||||
}
|
||||
|
||||
currentUrl.value = newUri || null;
|
||||
|
||||
if (newUri) {
|
||||
// Generate cache-busted URL
|
||||
// Indiquer au cache qu'on commence à charger
|
||||
imageCache.startLoading(newUri);
|
||||
|
||||
// Générer l'URL avec cache-busting
|
||||
cacheBustedUrl.value = getCacheBustedUrl(newUri, 0);
|
||||
console.log(
|
||||
`[useCoverImage] New cache-busted URL: ${cacheBustedUrl.value}`,
|
||||
);
|
||||
} else {
|
||||
cacheBustedUrl.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Check on mount
|
||||
onMounted(() => {
|
||||
currentUrl.value = imageUrl.value || null;
|
||||
if (currentUrl.value) {
|
||||
cacheBustedUrl.value = getCacheBustedUrl(currentUrl.value, 0);
|
||||
// Callback pour le ref de l'image
|
||||
function setImageRef(el: HTMLImageElement | null) {
|
||||
coverImageRef.value = el;
|
||||
|
||||
// Si on a une URL et une référence, initiate le chargement
|
||||
if (el && cacheBustedUrl.value && !imageLoaded.value) {
|
||||
// L'image va commencer à charger naturellement via le src
|
||||
// Le handler handleImageLoad sera appelé quand terminé
|
||||
}
|
||||
checkImageComplete();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
// État
|
||||
imageLoaded: computed(() => imageLoaded.value || cacheEntry.loaded.value),
|
||||
imageError: computed(() => imageError.value || cacheEntry.error.value),
|
||||
coverImageRef: ref(coverImageRef),
|
||||
cacheBustedUrl,
|
||||
isLoadingNewImage,
|
||||
|
||||
// Méthodes
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
setImageRef,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Version simplifiée de useCoverImage pour les cas où on n'a pas besoin
|
||||
* de tous les options. Utilise le cache centralisé par défaut.
|
||||
*/
|
||||
export function useCover(url: Ref<string | null | undefined>) {
|
||||
return useCoverImage(url, { forceReload: false, useServerCache: true });
|
||||
}
|
||||
@@ -4,10 +4,11 @@
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../services/pmocontrol/api'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import { useSSE } from './useSSE'
|
||||
import { apiCache } from './apiCache'
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse
|
||||
ContainerEntry,
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
@@ -15,31 +16,41 @@ export interface BreadcrumbItem {
|
||||
title: string
|
||||
}
|
||||
|
||||
// Cache global partagé
|
||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseResponse>>(new Map())
|
||||
const currentPath = ref<BreadcrumbItem[]>([])
|
||||
|
||||
// Timestamps
|
||||
const lastFetch = {
|
||||
servers: 0
|
||||
export interface BrowseState {
|
||||
container_id: string
|
||||
entries: ContainerEntry[]
|
||||
total_count: number
|
||||
hasMore?: boolean
|
||||
currentOffset?: number
|
||||
}
|
||||
|
||||
// Cache global partagé
|
||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseState>>(new Map())
|
||||
|
||||
function browseCacheKey(serverId: string, containerId: string): string {
|
||||
return `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`
|
||||
}
|
||||
const currentPath = ref<BreadcrumbItem[]>([])
|
||||
|
||||
const CACHE_DURATION_MS = 2000
|
||||
const BROWSE_WINDOW_SIZE = 200
|
||||
|
||||
// Connecter SSE une seule fois
|
||||
let sseConnected = false
|
||||
function ensureSSEConnected() {
|
||||
if (sseConnected) return
|
||||
// Initialiser SSE une seule fois via le composable centralisé
|
||||
let sseInitialized = false
|
||||
function ensureSSEInitialized() {
|
||||
if (sseInitialized) return
|
||||
|
||||
sse.onMediaServerEvent((event) => {
|
||||
const { onMediaServerEvent, connect } = useSSE()
|
||||
|
||||
// Démarrer la connexion SSE
|
||||
connect()
|
||||
|
||||
onMediaServerEvent((event) => {
|
||||
const serverId = event.server_id
|
||||
|
||||
switch (event.type) {
|
||||
case 'online':
|
||||
// Nouveau serveur découvert
|
||||
console.log(`[useMediaServers] Serveur ${serverId} (${event.friendly_name}) est maintenant en ligne`)
|
||||
|
||||
// Ajouter au cache avec les infos disponibles
|
||||
const server: MediaServerSummary = {
|
||||
id: serverId,
|
||||
@@ -55,9 +66,6 @@ function ensureSSEConnected() {
|
||||
break
|
||||
|
||||
case 'offline':
|
||||
// Serveur déconnecté
|
||||
console.log(`[useMediaServers] Serveur ${serverId} est maintenant hors ligne`)
|
||||
|
||||
// Marquer comme offline dans le cache
|
||||
const existingServer = serversCache.value.get(serverId)
|
||||
if (existingServer) {
|
||||
@@ -66,9 +74,10 @@ function ensureSSEConnected() {
|
||||
}
|
||||
|
||||
// Invalider tout le cache browse de ce serveur
|
||||
const encodedServerId = encodeURIComponent(serverId)
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerId + ':')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
@@ -77,10 +86,10 @@ function ensureSSEConnected() {
|
||||
|
||||
case 'global_updated':
|
||||
// Invalider tout le cache de ce serveur
|
||||
console.log(`[useMediaServers] GlobalUpdated pour ${serverId}`)
|
||||
const encodedServerIdGlobal = encodeURIComponent(serverId)
|
||||
const globalKeysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerIdGlobal + ':')) {
|
||||
globalKeysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
@@ -89,25 +98,25 @@ function ensureSSEConnected() {
|
||||
|
||||
case 'containers_updated':
|
||||
// Invalider les containers spécifiques
|
||||
console.log(`[useMediaServers] ContainersUpdated pour ${serverId}:`, event.container_ids)
|
||||
event.container_ids.forEach(containerId => {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
browseCache.value.delete(key)
|
||||
})
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
sseConnected = true
|
||||
sseInitialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable principal pour gérer les media servers
|
||||
*/
|
||||
export function useMediaServers() {
|
||||
ensureSSEConnected()
|
||||
ensureSSEInitialized()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters computed
|
||||
@@ -116,19 +125,20 @@ export function useMediaServers() {
|
||||
|
||||
// Fetch servers list
|
||||
async function fetchServers(force = false) {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
|
||||
return // Cache encore valide
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const data = await api.getServers()
|
||||
|
||||
// Utiliser le cache API centralisé
|
||||
const data = await apiCache.fetch(
|
||||
'/servers',
|
||||
undefined,
|
||||
() => api.getServers(),
|
||||
{ force, ttl: CACHE_DURATION_MS }
|
||||
)
|
||||
|
||||
serversCache.value.clear()
|
||||
data.forEach(s => serversCache.value.set(s.id, s))
|
||||
lastFetch.servers = now
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
|
||||
console.error('[useMediaServers] Erreur fetch:', e)
|
||||
@@ -137,11 +147,10 @@ export function useMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Browse container (avec cache automatique)
|
||||
// Charge la première page (remplace le cache)
|
||||
async function browseContainer(serverId: string, containerId: string, useCache = true) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
|
||||
// Vérifier le cache
|
||||
if (useCache && browseCache.value.has(key)) {
|
||||
return browseCache.value.get(key)!
|
||||
}
|
||||
@@ -150,12 +159,17 @@ export function useMediaServers() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const data = await api.browseContainer(serverId, containerId)
|
||||
const data = await api.browseContainer(serverId, containerId, 0, 50)
|
||||
|
||||
// Mettre en cache
|
||||
browseCache.value.set(key, data)
|
||||
browseCache.value.set(key, {
|
||||
container_id: data.container_id,
|
||||
entries: data.entries,
|
||||
total_count: data.total_count,
|
||||
hasMore: data.entries.length < data.total_count,
|
||||
currentOffset: data.entries.length,
|
||||
})
|
||||
|
||||
return data
|
||||
return browseCache.value.get(key)!
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur browse container'
|
||||
console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, e)
|
||||
@@ -165,16 +179,82 @@ export function useMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Charge la page suivante et accumule (infinite scroll)
|
||||
async function loadMoreBrowse(serverId: string, containerId: string) {
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
const state = browseCache.value.get(key)
|
||||
|
||||
if (!state) return
|
||||
if (!('hasMore' in state) || !state.hasMore) return
|
||||
if (loadingMore.value) return
|
||||
|
||||
try {
|
||||
loadingMore.value = true
|
||||
|
||||
// Le type cast est nécessaire car les anciens cached entries n'ont pas hasMore
|
||||
const state = browseCache.value.get(key) as BrowseState & { hasMore?: boolean; currentOffset?: number }
|
||||
const offset = state.currentOffset ?? state.entries.length
|
||||
const data = await api.browseContainer(serverId, containerId, offset)
|
||||
|
||||
// Accumuler les nouvelles entrées avec fenêtre glissante
|
||||
const combined = [...state.entries, ...data.entries]
|
||||
// Ne garder que les últimos BROWSE_WINDOW_SIZE items
|
||||
state.entries = combined.slice(-BROWSE_WINDOW_SIZE)
|
||||
state.total_count = data.total_count
|
||||
state.currentOffset = (state.currentOffset ?? 0) + data.entries.length
|
||||
state.hasMore = state.currentOffset < state.total_count
|
||||
// Forcer la réactivité
|
||||
browseCache.value.set(key, { ...state })
|
||||
} catch (e) {
|
||||
console.error(`[useMediaServers] Erreur load more ${serverId}/${containerId}:`, e)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Recherche dans un serveur — retourne l'ID du container virtuel de résultats
|
||||
async function searchServer(serverId: string, query: string): Promise<string | null> {
|
||||
if (!query.trim()) return null
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const data = await api.searchServer(serverId, query)
|
||||
// data.container_id est l'ID virtuel réel (ex: "qobuz:search:catalog:all:camille")
|
||||
const key = browseCacheKey(serverId, data.container_id)
|
||||
browseCache.value.set(key, {
|
||||
container_id: data.container_id,
|
||||
entries: data.entries,
|
||||
total_count: data.total_count,
|
||||
})
|
||||
return data.container_id
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur recherche'
|
||||
console.error(`[useMediaServers] Erreur search ${serverId}:`, e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Getters
|
||||
function getServerById(id: string) {
|
||||
return serversCache.value.get(id)
|
||||
}
|
||||
|
||||
function getBrowseCached(serverId: string, containerId: string) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
return browseCache.value.get(key)
|
||||
}
|
||||
|
||||
function hasMore(serverId: string, containerId: string): boolean {
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
const state = browseCache.value.get(key)
|
||||
if (!state) return false
|
||||
return state.entries.length < state.total_count
|
||||
}
|
||||
|
||||
// Breadcrumb path management
|
||||
function setPath(path: BreadcrumbItem[]) {
|
||||
currentPath.value = path
|
||||
@@ -187,14 +267,12 @@ export function useMediaServers() {
|
||||
// Invalidation du cache
|
||||
function invalidateCache(serverId: string, containerId?: string) {
|
||||
if (containerId) {
|
||||
// Invalider un container spécifique
|
||||
const key = `${serverId}/${containerId}`
|
||||
browseCache.value.delete(key)
|
||||
browseCache.value.delete(browseCacheKey(serverId, containerId))
|
||||
} else {
|
||||
// Invalider tous les containers d'un serveur
|
||||
const encodedServerId = encodeURIComponent(serverId)
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerId + ':')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
@@ -205,6 +283,7 @@ export function useMediaServers() {
|
||||
return {
|
||||
// État
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
currentPath,
|
||||
// Getters
|
||||
@@ -212,9 +291,12 @@ export function useMediaServers() {
|
||||
onlineServers,
|
||||
getServerById,
|
||||
getBrowseCached,
|
||||
hasMore,
|
||||
// Actions
|
||||
fetchServers,
|
||||
searchServer,
|
||||
browseContainer,
|
||||
loadMoreBrowse,
|
||||
setPath,
|
||||
clearPath,
|
||||
invalidateCache
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
* - Les snapshots complets proviennent de /renderers/{id}/full
|
||||
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
||||
*/
|
||||
import { ref, reactive, computed, toRaw, type Ref } from "vue";
|
||||
import { ref, reactive, computed, type Ref, onUnmounted } from "vue";
|
||||
import { api } from "../services/pmocontrol/api";
|
||||
import { sse } from "../services/pmocontrol/sse";
|
||||
import { useSSE } from "./useSSE";
|
||||
import { apiCache } from "./apiCache";
|
||||
import { parseTimeToMs } from "../utils/time";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
@@ -14,51 +17,62 @@ import type {
|
||||
AttachedPlaylistInfo,
|
||||
FullRendererSnapshot,
|
||||
} from "../services/pmocontrol/types";
|
||||
import { isTransportState } from "../services/pmocontrol/types";
|
||||
|
||||
interface RendererSnapshotState {
|
||||
snapshots: Map<string, FullRendererSnapshot>;
|
||||
lastSnapshotAt: Map<string, number>;
|
||||
lastEventAt: Map<string, number>;
|
||||
loadingIds: Set<string>;
|
||||
selectedRendererId: string | null;
|
||||
}
|
||||
// État global des snapshots avec reactive pour une réactivité native Vue sur les Maps
|
||||
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
|
||||
const lastSnapshotAt = reactive(new Map<string, number>());
|
||||
const lastEventAt = reactive(new Map<string, number>());
|
||||
const loadingIds = reactive(new Set<string>());
|
||||
const queueRefreshingIds = reactive(new Set<string>());
|
||||
const selectedRendererId = ref<string | null>(null);
|
||||
|
||||
// Compteur d'erreurs consécutives par renderer — on ne notifie l'utilisateur
|
||||
// qu'après SNAPSHOT_ERROR_THRESHOLD échecs d'affilée pour absorber les hoquets
|
||||
// transitoires des devices (Arylic, etc.).
|
||||
const consecutiveSnapshotErrors = new Map<string, number>();
|
||||
const SNAPSHOT_ERROR_THRESHOLD = 3;
|
||||
|
||||
// Cache des renderers (summary)
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||
const RENDERERS_CACHE_MS = 2000;
|
||||
const lastRenderersFetch = ref(0);
|
||||
|
||||
const snapshotState = reactive<RendererSnapshotState>({
|
||||
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
|
||||
lastSnapshotAt: reactive(new Map<string, number>()),
|
||||
lastEventAt: reactive(new Map<string, number>()),
|
||||
loadingIds: reactive(new Set<string>()),
|
||||
selectedRendererId: null,
|
||||
});
|
||||
// Supprimé : les helpers triggerXXX ne sont plus nécessaires avec reactive
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
let sseConnected = false;
|
||||
function ensureSSEConnected() {
|
||||
if (sseConnected) return;
|
||||
// Utiliser le composable SSE centralisé
|
||||
let sseInitialized = false;
|
||||
|
||||
sse.onRendererEvent((event) => {
|
||||
/**
|
||||
* Réinitialise le flag SSE pour permettre une nouvelle connexion après reconnexion
|
||||
*/
|
||||
function resetSSE() {
|
||||
sseInitialized = false;
|
||||
}
|
||||
|
||||
function ensureSSEInitialized() {
|
||||
if (sseInitialized) return;
|
||||
|
||||
const { onRendererEvent, connect } = useSSE();
|
||||
|
||||
// Démarrer la connexion SSE
|
||||
connect();
|
||||
|
||||
onRendererEvent((event) => {
|
||||
const rendererId = event.renderer_id;
|
||||
const timestamp = Date.parse(event.timestamp ?? "") || Date.now();
|
||||
|
||||
// Gérer les événements Online/Offline différemment
|
||||
if (event.type === "online") {
|
||||
// Nouveau renderer découvert
|
||||
console.log(
|
||||
`[useRenderers] Renderer ${rendererId} (${event.friendly_name}) est maintenant en ligne`,
|
||||
);
|
||||
|
||||
// Ajouter au cache avec les infos disponibles
|
||||
// Note: on n'a pas toutes les infos (capabilities, protocol) donc on fetch ensuite
|
||||
const renderer: RendererSummary = {
|
||||
id: rendererId,
|
||||
friendly_name: event.friendly_name,
|
||||
model_name: event.model_name,
|
||||
manufacturer: event.manufacturer,
|
||||
protocol: "upnp", // Valeur par défaut, sera mise à jour par le fetch
|
||||
capabilities: {
|
||||
has_avtransport: false,
|
||||
@@ -86,11 +100,6 @@ function ensureSSEConnected() {
|
||||
}
|
||||
|
||||
if (event.type === "offline") {
|
||||
// Renderer déconnecté
|
||||
console.log(
|
||||
`[useRenderers] Renderer ${rendererId} est maintenant hors ligne`,
|
||||
);
|
||||
|
||||
// Marquer comme offline dans le cache
|
||||
const renderer = renderersCache.value.get(rendererId);
|
||||
if (renderer) {
|
||||
@@ -99,19 +108,16 @@ function ensureSSEConnected() {
|
||||
}
|
||||
|
||||
// Supprimer le snapshot (il n'est plus valide)
|
||||
snapshotState.snapshots.delete(rendererId);
|
||||
snapshotState.lastSnapshotAt.delete(rendererId);
|
||||
snapshotState.lastEventAt.delete(rendererId);
|
||||
snapshots.delete(rendererId);
|
||||
lastSnapshotAt.delete(rendererId);
|
||||
lastEventAt.delete(rendererId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pour les autres événements, mettre à jour le snapshot local directement
|
||||
snapshotState.lastEventAt.set(rendererId, timestamp);
|
||||
lastEventAt.set(rendererId, timestamp);
|
||||
|
||||
const snapshot = snapshotState.snapshots.get(rendererId);
|
||||
console.log(
|
||||
`[SSE Event] type=${event.type}, rendererId=${rendererId}, snapshot exists=${!!snapshot}, transport=${snapshot?.state?.transport_state}`,
|
||||
);
|
||||
const snapshot = snapshots.get(rendererId);
|
||||
|
||||
// Si pas de snapshot, on doit fetch
|
||||
if (!snapshot) {
|
||||
@@ -122,73 +128,54 @@ function ensureSSEConnected() {
|
||||
// Sinon, mettre à jour le snapshot localement selon le type d'événement
|
||||
switch (event.type) {
|
||||
case "state_changed":
|
||||
snapshot.state.transport_state = event.state as any;
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
if (isTransportState(event.state)) {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, transport_state: event.state },
|
||||
});
|
||||
|
||||
} else {
|
||||
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "position_changed":
|
||||
// Debug: log pour tracer les oscillations
|
||||
console.log(
|
||||
`[position_changed] renderer=${rendererId}, duration=${event.track_duration}`,
|
||||
);
|
||||
|
||||
// Mettre à jour position et durée de manière atomique pour garantir la cohérence
|
||||
// Le backend envoie TOUJOURS les deux valeurs (même si null)
|
||||
|
||||
// Convertir rel_time (HH:MM:SS) en millisecondes
|
||||
if (event.rel_time) {
|
||||
const parts = event.rel_time.split(":").map(Number);
|
||||
if (parts.length === 3) {
|
||||
snapshot.state.position_ms =
|
||||
((parts[0] ?? 0) * 3600 +
|
||||
(parts[1] ?? 0) * 60 +
|
||||
(parts[2] ?? 0)) *
|
||||
1000;
|
||||
}
|
||||
} else {
|
||||
// Si rel_time est null/undefined, mettre position à 0
|
||||
snapshot.state.position_ms = 0;
|
||||
}
|
||||
const positionMs = parseTimeToMs(event.rel_time ?? null);
|
||||
|
||||
// Convertir track_duration (HH:MM:SS) en millisecondes
|
||||
if (event.track_duration) {
|
||||
const parts = event.track_duration.split(":").map(Number);
|
||||
if (parts.length === 3) {
|
||||
snapshot.state.duration_ms =
|
||||
((parts[0] ?? 0) * 3600 +
|
||||
(parts[1] ?? 0) * 60 +
|
||||
(parts[2] ?? 0)) *
|
||||
1000;
|
||||
}
|
||||
} else {
|
||||
// Si track_duration est null/undefined (flux continu sans durée),
|
||||
// mettre duration_ms à null pour afficher "--:--"
|
||||
snapshot.state.duration_ms = null;
|
||||
}
|
||||
|
||||
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
||||
// Le shallow copy ne suffit pas car snapshot.state est partagé entre renderers
|
||||
// Il faut copier state aussi pour éviter que les modifications d'un renderer
|
||||
// n'affectent les autres renderers
|
||||
// IMPORTANT: Utiliser toRaw() pour obtenir l'objet brut non-réactif avant de copier
|
||||
// sinon Vue copie les getters réactifs qui continuent à pointer vers l'objet d'origine
|
||||
const rawState = toRaw(snapshot.state);
|
||||
const newState = { ...rawState };
|
||||
const newSnapshot = {
|
||||
const durationMs = parseTimeToMs(event.track_duration ?? null);
|
||||
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: newState,
|
||||
};
|
||||
console.log(
|
||||
`[position_changed] Setting new snapshot for ${rendererId}, state ref=${Object.prototype.toString.call(newState)}, transport=${newState.transport_state}`,
|
||||
);
|
||||
snapshotState.snapshots.set(rendererId, newSnapshot);
|
||||
state: {
|
||||
...snapshot.state,
|
||||
position_ms: positionMs ?? 0,
|
||||
duration_ms: durationMs,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
|
||||
case "volume_changed":
|
||||
snapshot.state.volume = event.volume;
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, volume: event.volume },
|
||||
});
|
||||
break;
|
||||
|
||||
case "mute_changed":
|
||||
snapshot.state.mute = event.mute;
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, mute: event.mute },
|
||||
});
|
||||
break;
|
||||
|
||||
case "metadata_changed":
|
||||
@@ -205,15 +192,24 @@ function ensureSSEConnected() {
|
||||
snapshot.state.current_track.album = event.album;
|
||||
snapshot.state.current_track.album_art_uri = event.album_art_uri;
|
||||
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
||||
snapshotState.snapshots.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state },
|
||||
});
|
||||
break;
|
||||
|
||||
case "queue_refreshing":
|
||||
queueRefreshingIds.add(rendererId);
|
||||
|
||||
break;
|
||||
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
// Pour la queue complète, on doit refetch
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
|
||||
// Fetch queue items immediately when we get queue_updated
|
||||
// The debounce was causing race conditions where queue_len was updated
|
||||
// but items weren't fetched yet when user clicked play
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
|
||||
@@ -229,10 +225,17 @@ function ensureSSEConnected() {
|
||||
snapshot.binding = null;
|
||||
snapshot.state.attached_playlist = null;
|
||||
}
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state },
|
||||
});
|
||||
break;
|
||||
|
||||
case "stream_state_changed":
|
||||
snapshot.is_stream = event.is_stream;
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.set(rendererId, { ...snapshot });
|
||||
break;
|
||||
|
||||
case "timer_started":
|
||||
@@ -245,11 +248,11 @@ function ensureSSEConnected() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Trigger reactivity
|
||||
snapshotState.snapshots.set(rendererId, snapshot);
|
||||
// Note: chaque case est maintenant responsable de stocker le snapshot dans la Map
|
||||
// Plus de réassignation finale après le switch
|
||||
});
|
||||
|
||||
sseConnected = true;
|
||||
sseInitialized = true;
|
||||
}
|
||||
|
||||
const allRenderers = computed(() => Array.from(renderersCache.value.values()));
|
||||
@@ -257,7 +260,7 @@ const onlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r) => r.online),
|
||||
);
|
||||
const allSnapshots = computed(() =>
|
||||
Array.from(snapshotState.snapshots.values()),
|
||||
Array.from(snapshots.values()),
|
||||
);
|
||||
const playingRenderers = computed(() =>
|
||||
allSnapshots.value
|
||||
@@ -270,82 +273,165 @@ function getRendererById(id: string) {
|
||||
}
|
||||
|
||||
function getSnapshotById(id: string) {
|
||||
return snapshotState.snapshots.get(id) ?? null;
|
||||
return snapshots.get(id) ?? null;
|
||||
}
|
||||
|
||||
function getStateById(id: string): RendererState | null {
|
||||
return snapshotState.snapshots.get(id)?.state ?? null;
|
||||
return snapshots.get(id)?.state ?? null;
|
||||
}
|
||||
|
||||
function getQueueById(id: string): QueueSnapshot | null {
|
||||
return snapshotState.snapshots.get(id)?.queue ?? null;
|
||||
return snapshots.get(id)?.queue ?? null;
|
||||
}
|
||||
|
||||
function getBindingById(id: string): AttachedPlaylistInfo | null {
|
||||
return snapshotState.snapshots.get(id)?.binding ?? null;
|
||||
return snapshots.get(id)?.binding ?? null;
|
||||
}
|
||||
|
||||
function isSnapshotLoading(id: string) {
|
||||
return snapshotState.loadingIds.has(id);
|
||||
return loadingIds.has(id);
|
||||
}
|
||||
|
||||
function isQueueRefreshing(id: string) {
|
||||
return queueRefreshingIds.has(id);
|
||||
}
|
||||
|
||||
function selectRenderer(id: string | null) {
|
||||
snapshotState.selectedRendererId = id;
|
||||
selectedRendererId.value = id;
|
||||
}
|
||||
|
||||
async function fetchRenderers(force = false) {
|
||||
ensureSSEConnected();
|
||||
async function fetchRenderers(force = false, retries = 2) {
|
||||
ensureSSEInitialized();
|
||||
|
||||
// Créer le store UI pour les notifications (lazy import pour éviter les effets de bord)
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
|
||||
return;
|
||||
}
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const data = await api.getRenderers();
|
||||
renderersCache.value = new Map(
|
||||
data.map((renderer) => [renderer.id, renderer]),
|
||||
);
|
||||
lastRenderersFetch.value = now;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
|
||||
console.error("[useRenderers] Erreur fetch:", err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
// Utiliser le cache API centralisé
|
||||
const data = await apiCache.fetch(
|
||||
'/renderers',
|
||||
undefined,
|
||||
() => api.getRenderers(),
|
||||
{ force, ttl: RENDERERS_CACHE_MS }
|
||||
);
|
||||
|
||||
renderersCache.value = new Map(
|
||||
data.map((renderer) => [renderer.id, renderer]),
|
||||
);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error("Erreur fetch renderers");
|
||||
console.error("[useRenderers] Erreur fetch (attempt " + (attempt + 1) + "):", lastError);
|
||||
if (attempt < retries) {
|
||||
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
error.value = lastError?.message ?? "Erreur fetch renderers";
|
||||
|
||||
// Notifier l'utilisateur en cas d'erreur finale
|
||||
uiStore.notifyError("Impossible de rafraîchir la liste des lecteurs audio");
|
||||
}
|
||||
|
||||
async function fetchRendererSnapshot(
|
||||
rendererId: string,
|
||||
opts?: { force?: boolean },
|
||||
) {
|
||||
ensureSSEConnected();
|
||||
ensureSSEInitialized();
|
||||
const force = opts?.force ?? false;
|
||||
const hasSnapshot = snapshotState.snapshots.has(rendererId);
|
||||
const hasSnapshot = snapshots.has(rendererId);
|
||||
|
||||
if (!force && hasSnapshot) {
|
||||
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0;
|
||||
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0;
|
||||
const lastSnapshot = lastSnapshotAt.get(rendererId) ?? 0;
|
||||
const lastEvent = lastEventAt.get(rendererId) ?? 0;
|
||||
if (lastEvent <= lastSnapshot) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshotState.loadingIds.has(rendererId)) {
|
||||
// Éviter les requêtes multiples simultanées pour le même renderer
|
||||
if (loadingIds.has(rendererId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
snapshotState.loadingIds.add(rendererId);
|
||||
loadingIds.add(rendererId);
|
||||
|
||||
|
||||
// Lazy load UI store pour les notifications
|
||||
const uiStore = useUIStore();
|
||||
|
||||
try {
|
||||
const snapshot = await api.getRendererFullSnapshot(rendererId);
|
||||
snapshotState.snapshots.set(rendererId, snapshot);
|
||||
snapshotState.lastSnapshotAt.set(rendererId, Date.now());
|
||||
snapshots.set(rendererId, snapshot);
|
||||
lastSnapshotAt.set(rendererId, Date.now());
|
||||
consecutiveSnapshotErrors.set(rendererId, 0);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
|
||||
const count = (consecutiveSnapshotErrors.get(rendererId) ?? 0) + 1;
|
||||
consecutiveSnapshotErrors.set(rendererId, count);
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId} (${count}/${SNAPSHOT_ERROR_THRESHOLD}):`, err);
|
||||
|
||||
// Garder le dernier snapshot connu plutôt que de le supprimer — l'UI reste
|
||||
// affichable même si le device est momentanément muet.
|
||||
|
||||
// Notifier seulement après N échecs consécutifs pour absorber les hoquets passagers
|
||||
if (count >= SNAPSHOT_ERROR_THRESHOLD) {
|
||||
const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId;
|
||||
uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`);
|
||||
// Remettre à zéro pour ne pas spammer si le device reste instable
|
||||
consecutiveSnapshotErrors.set(rendererId, 0);
|
||||
}
|
||||
} finally {
|
||||
snapshotState.loadingIds.delete(rendererId);
|
||||
loadingIds.delete(rendererId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch les snapshots de plusieurs renderers en parallèle controlée.
|
||||
* - Limite le nombre de requêtes simultanées (concurrency)
|
||||
* - Ajoute un délai entre chaque batch pour ne pas saturer le réseau
|
||||
* - Continue même si certaines requêtes échouent
|
||||
*/
|
||||
async function fetchBatchSnapshots(
|
||||
rendererIds: string[],
|
||||
options: {
|
||||
concurrency?: number; // Nombre max de requêtes parallèles (défaut: 3)
|
||||
batchDelay?: number; // Délai entre les batches en ms (défaut: 100ms)
|
||||
force?: boolean; // Forcer le refetch même en cache
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const { concurrency = 3, batchDelay = 100, force = false } = options;
|
||||
|
||||
// Filtrer les rendererIds valides
|
||||
const validIds = rendererIds.filter(id => id && typeof id === 'string');
|
||||
|
||||
if (validIds.length === 0) return;
|
||||
|
||||
// Fonction pour traiter un batch
|
||||
const processBatch = async (batch: string[]): Promise<void> => {
|
||||
await Promise.allSettled(
|
||||
batch.map(id => fetchRendererSnapshot(id, { force }))
|
||||
);
|
||||
};
|
||||
|
||||
// Exécuter par batches avec controlled concurrency
|
||||
for (let i = 0; i < validIds.length; i += concurrency) {
|
||||
const batch = validIds.slice(i, i + concurrency);
|
||||
await processBatch(batch);
|
||||
|
||||
// Délai entre les batches (sauf pour le dernier)
|
||||
if (i + concurrency < validIds.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, batchDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +441,7 @@ async function play(id: string) {
|
||||
}
|
||||
|
||||
async function resumeOrPlayFromQueue(id: string) {
|
||||
const snapshot = snapshotState.snapshots.get(id);
|
||||
const snapshot = snapshots.get(id);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Renderer ${id} non trouvé`);
|
||||
}
|
||||
@@ -365,10 +451,11 @@ async function resumeOrPlayFromQueue(id: string) {
|
||||
return play(id);
|
||||
}
|
||||
|
||||
if (
|
||||
["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
|
||||
snapshot.queue.items.length > 0
|
||||
) {
|
||||
// If queue has items, always try to play from queue regardless of transport
|
||||
// state. After a sleep timer stop, the device may report an unknown/transient
|
||||
// state ("load", network error → "UNKNOWN", etc.) that doesn't match the
|
||||
// expected "STOPPED"/"NO_MEDIA" strings, yet the queue is non-empty.
|
||||
if (snapshot.queue.items.length > 0) {
|
||||
return api.resume(id);
|
||||
}
|
||||
|
||||
@@ -459,7 +546,7 @@ async function addAfterCurrent(
|
||||
}
|
||||
|
||||
export function useRenderers() {
|
||||
ensureSSEConnected();
|
||||
ensureSSEInitialized();
|
||||
|
||||
return {
|
||||
loading,
|
||||
@@ -475,11 +562,12 @@ export function useRenderers() {
|
||||
getQueueById,
|
||||
getBindingById,
|
||||
isSnapshotLoading,
|
||||
isQueueRefreshing,
|
||||
selectRenderer,
|
||||
snapshotState,
|
||||
// Fetchers
|
||||
fetchRenderers,
|
||||
fetchRendererSnapshot,
|
||||
fetchBatchSnapshots,
|
||||
// Transport controls
|
||||
play,
|
||||
resumeOrPlayFromQueue,
|
||||
@@ -499,25 +587,50 @@ export function useRenderers() {
|
||||
playContent,
|
||||
addToQueue,
|
||||
addAfterCurrent,
|
||||
// SSE
|
||||
resetSSE,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRenderer(rendererId: Ref<string>) {
|
||||
ensureSSEConnected();
|
||||
ensureSSEInitialized();
|
||||
|
||||
const renderer = computed(() => renderersCache.value.get(rendererId.value));
|
||||
const snapshot = computed(
|
||||
() => snapshotState.snapshots.get(rendererId.value) ?? null,
|
||||
);
|
||||
const state = computed(() => snapshot.value?.state ?? null);
|
||||
const queue = computed(() => snapshot.value?.queue ?? null);
|
||||
const binding = computed(() => snapshot.value?.binding ?? null);
|
||||
// Delegates to useRenderers functions - no duplication
|
||||
const renderer = computed(() => getRendererById(rendererId.value));
|
||||
const snapshot = computed(() => getSnapshotById(rendererId.value));
|
||||
const state = computed(() => getStateById(rendererId.value));
|
||||
const queue = computed(() => getQueueById(rendererId.value));
|
||||
const binding = computed(() => getBindingById(rendererId.value));
|
||||
const isStream = computed(() => snapshot.value?.is_stream ?? false);
|
||||
const queueRefreshing = computed(() => isQueueRefreshing(rendererId.value));
|
||||
|
||||
// Debounce pour éviter les refreshs multiples trop fréquents
|
||||
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const REFRESH_DEBOUNCE_MS = 500;
|
||||
|
||||
// Nettoyer le timer debounce si le composant est démonté
|
||||
onUnmounted(() => {
|
||||
if (refreshDebounceTimer !== null) {
|
||||
clearTimeout(refreshDebounceTimer);
|
||||
refreshDebounceTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function refresh(force = true) {
|
||||
const currentRendererId = rendererId.value;
|
||||
|
||||
// Debounce: ignorer si un refresh est en cours pour ce renderer
|
||||
if (refreshDebounceTimer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshDebounceTimer = setTimeout(() => {
|
||||
refreshDebounceTimer = null;
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
|
||||
await Promise.all([
|
||||
fetchRenderers(force),
|
||||
fetchRendererSnapshot(rendererId.value, { force: true }),
|
||||
fetchRendererSnapshot(currentRendererId, { force: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -528,6 +641,7 @@ export function useRenderer(rendererId: Ref<string>) {
|
||||
queue,
|
||||
binding,
|
||||
isStream,
|
||||
queueRefreshing,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
248
pmoapp/webapp/src/composables/useSSE.ts
Normal file
248
pmoapp/webapp/src/composables/useSSE.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Composable centralisé pour la gestion des Server-Sent Events
|
||||
*
|
||||
* Ce composable fournit une interface unifiée pour:
|
||||
* - Une seule connexion SSE (évite les connexions multiples)
|
||||
* - Des abonnements typés aux événements (renderers, servers)
|
||||
* - Le suivi de l'état de connexion
|
||||
*
|
||||
* Usage:
|
||||
* const { connected, onRendererEvent, onMediaServerEvent } = useSSE()
|
||||
*/
|
||||
import { ref, readonly, onUnmounted } from 'vue'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import type {
|
||||
RendererEventPayload,
|
||||
MediaServerEventPayload
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
// État global partagé
|
||||
const connected = ref(sse.isConnectedState());
|
||||
const connectionCallbacks: Set<(connected: boolean) => void> = new Set();
|
||||
|
||||
// Flag pour éviter les double-connexions SSE avec lock
|
||||
let connectionLock = false;
|
||||
|
||||
// Abonnement à l'état de connexion global
|
||||
function setupConnectionListener() {
|
||||
// Vérifier avec lock pour éviter les conditions de course
|
||||
if (connectionCallbacks.size === 0 && !connectionLock) {
|
||||
connectionLock = true;
|
||||
sse.onConnectionChange((isConnected) => {
|
||||
connected.value = isConnected
|
||||
connectionCallbacks.forEach(cb => cb(isConnected))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook principal pour utiliser SSE
|
||||
*/
|
||||
export function useSSE() {
|
||||
// S'assurer que le listener de connexion est configuré
|
||||
setupConnectionListener()
|
||||
|
||||
/**
|
||||
* Abonnement aux événements de type renderer
|
||||
* Retourne une fonction de cleanup
|
||||
*/
|
||||
function onRendererEvent(
|
||||
callback: (event: RendererEventPayload) => void
|
||||
): () => void {
|
||||
return sse.onRendererEvent(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abonnement aux événements de type media server
|
||||
* Retourne une fonction de cleanup
|
||||
*/
|
||||
function onMediaServerEvent(
|
||||
callback: (event: MediaServerEventPayload) => void
|
||||
): () => void {
|
||||
return sse.onMediaServerEvent(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abonnement aux changements de connexion
|
||||
* Retourne une fonction de cleanup
|
||||
*/
|
||||
function onConnectionChange(
|
||||
callback: (connected: boolean) => void
|
||||
): () => void {
|
||||
connectionCallbacks.add(callback)
|
||||
// Appeler immédiatement avec l'état actuel
|
||||
callback(connected.value)
|
||||
|
||||
// Retourner fonction de cleanup
|
||||
return () => {
|
||||
connectionCallbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force la connexion SSE
|
||||
*/
|
||||
function connect(): void {
|
||||
sse.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force la déconnexion SSE
|
||||
*/
|
||||
function disconnect(): void {
|
||||
sse.disconnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si actuellement connecté
|
||||
*/
|
||||
function isConnected(): boolean {
|
||||
return connected.value
|
||||
}
|
||||
|
||||
return {
|
||||
// État (readonly pour éviter les modifications directes)
|
||||
connected: readonly(connected),
|
||||
|
||||
// Abonnements
|
||||
onRendererEvent,
|
||||
onMediaServerEvent,
|
||||
onConnectionChange,
|
||||
|
||||
// Actions
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour s'abonner à un type spécifique d'événement renderer
|
||||
* avec filtrage optionnel par rendererId
|
||||
*/
|
||||
export function useRendererEvents(
|
||||
rendererId: () => string | null,
|
||||
options?: {
|
||||
onStateChanged?: (event: RendererEventPayload) => void
|
||||
onPositionChanged?: (event: RendererEventPayload) => void
|
||||
onVolumeChanged?: (event: RendererEventPayload) => void
|
||||
onMetadataChanged?: (event: RendererEventPayload) => void
|
||||
onQueueUpdated?: (event: RendererEventPayload) => void
|
||||
onBindingChanged?: (event: RendererEventPayload) => void
|
||||
onTimerEvent?: (event: RendererEventPayload) => void
|
||||
}
|
||||
) {
|
||||
const { onRendererEvent } = useSSE()
|
||||
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
function setup() {
|
||||
cleanup = onRendererEvent((event) => {
|
||||
const currentId = rendererId()
|
||||
|
||||
// Si un rendererId est spécifié, filtrer
|
||||
if (currentId && event.renderer_id !== currentId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch vers le handler approprié
|
||||
switch (event.type) {
|
||||
case 'state_changed':
|
||||
case 'online':
|
||||
case 'offline':
|
||||
options?.onStateChanged?.(event)
|
||||
break
|
||||
case 'position_changed':
|
||||
options?.onPositionChanged?.(event)
|
||||
break
|
||||
case 'volume_changed':
|
||||
case 'mute_changed':
|
||||
options?.onVolumeChanged?.(event)
|
||||
break
|
||||
case 'metadata_changed':
|
||||
options?.onMetadataChanged?.(event)
|
||||
break
|
||||
case 'queue_updated':
|
||||
case 'queue_refreshing':
|
||||
options?.onQueueUpdated?.(event)
|
||||
break
|
||||
case 'binding_changed':
|
||||
options?.onBindingChanged?.(event)
|
||||
break
|
||||
case 'timer_started':
|
||||
case 'timer_updated':
|
||||
case 'timer_tick':
|
||||
case 'timer_expired':
|
||||
case 'timer_cancelled':
|
||||
options?.onTimerEvent?.(event)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setup()
|
||||
|
||||
// Cleanup automatique au unmount du composant
|
||||
onUnmounted(() => {
|
||||
cleanup?.()
|
||||
})
|
||||
|
||||
return {
|
||||
refresh: setup, // Permet de recréer l'abonnement si besoin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour s'abonner aux événements media server
|
||||
* avec filtrage optionnel par serverId
|
||||
*/
|
||||
export function useMediaServerEvents(
|
||||
serverId: () => string | null,
|
||||
options?: {
|
||||
onOnline?: (event: MediaServerEventPayload) => void
|
||||
onOffline?: (event: MediaServerEventPayload) => void
|
||||
onGlobalUpdated?: (event: MediaServerEventPayload) => void
|
||||
onContainersUpdated?: (event: MediaServerEventPayload) => void
|
||||
}
|
||||
) {
|
||||
const { onMediaServerEvent } = useSSE()
|
||||
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
function setup() {
|
||||
cleanup = onMediaServerEvent((event) => {
|
||||
const currentId = serverId()
|
||||
|
||||
// Si un serverId est spécifié, filtrer
|
||||
if (currentId && event.server_id !== currentId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch vers le handler approprié
|
||||
switch (event.type) {
|
||||
case 'online':
|
||||
options?.onOnline?.(event)
|
||||
break
|
||||
case 'offline':
|
||||
options?.onOffline?.(event)
|
||||
break
|
||||
case 'global_updated':
|
||||
options?.onGlobalUpdated?.(event)
|
||||
break
|
||||
case 'containers_updated':
|
||||
options?.onContainersUpdated?.(event)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setup()
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup?.()
|
||||
})
|
||||
|
||||
return {
|
||||
refresh: setup,
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ const state = reactive<TabsState>({
|
||||
// Flag pour éviter les boucles de sauvegarde
|
||||
let isRestoringFromStorage = false;
|
||||
|
||||
// Debounce timer pour la sauvegarde localStorage
|
||||
let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const SAVE_DEBOUNCE_MS = 100;
|
||||
|
||||
/**
|
||||
* Retourne le titre complet sans troncature
|
||||
* Note: On laisse le CSS gérer l'overflow avec ellipsis pour un affichage stable
|
||||
@@ -53,29 +57,38 @@ function truncateTitle(title: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde l'état dans localStorage
|
||||
* Sauvegarde l'état dans localStorage (avec debounce)
|
||||
* Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés)
|
||||
*/
|
||||
function saveToLocalStorage() {
|
||||
if (isRestoringFromStorage) return;
|
||||
|
||||
try {
|
||||
const stateToSave = {
|
||||
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
||||
tabs: state.tabs
|
||||
.filter((tab) => tab.type === "server")
|
||||
.map((tab) => ({
|
||||
...tab,
|
||||
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
||||
icon: undefined,
|
||||
})),
|
||||
activeTabId: state.activeTabId,
|
||||
tabHistory: state.tabHistory,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
|
||||
// Annuler le timer précédent
|
||||
if (saveDebounceTimer !== null) {
|
||||
clearTimeout(saveDebounceTimer);
|
||||
}
|
||||
|
||||
// Débouncer pour éviter les écritures multiples
|
||||
saveDebounceTimer = setTimeout(() => {
|
||||
try {
|
||||
const stateToSave = {
|
||||
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
||||
tabs: state.tabs
|
||||
.filter((tab) => tab.type === "server")
|
||||
.map((tab) => ({
|
||||
...tab,
|
||||
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
||||
icon: undefined,
|
||||
})),
|
||||
activeTabId: state.activeTabId,
|
||||
tabHistory: state.tabHistory,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
|
||||
}
|
||||
saveDebounceTimer = null;
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,11 +96,11 @@ function saveToLocalStorage() {
|
||||
* Note: Restaure uniquement les onglets server (les renderer tabs seront auto-générés)
|
||||
*/
|
||||
function restoreFromLocalStorage() {
|
||||
isRestoringFromStorage = true;
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
|
||||
isRestoringFromStorage = true;
|
||||
const savedState = JSON.parse(saved);
|
||||
|
||||
// Reconstituer uniquement les tabs server avec les bonnes icônes
|
||||
@@ -110,10 +123,9 @@ function restoreFromLocalStorage() {
|
||||
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
|
||||
state.activeTabId = "";
|
||||
}
|
||||
|
||||
isRestoringFromStorage = false;
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur restauration localStorage:", error);
|
||||
} finally {
|
||||
isRestoringFromStorage = false;
|
||||
}
|
||||
}
|
||||
@@ -346,12 +358,18 @@ function openServer(server: MediaServerSummary | undefined) {
|
||||
*/
|
||||
export function useTabs() {
|
||||
// Watch pour sauvegarde automatique (uniquement les server tabs)
|
||||
// Watch séparés sans deep pour éviter la sérialisation complète à chaque mutation
|
||||
watch(() => state.activeTabId, () => {
|
||||
saveToLocalStorage();
|
||||
});
|
||||
watch(() => state.tabHistory.length, () => {
|
||||
saveToLocalStorage();
|
||||
});
|
||||
watch(
|
||||
() => [state.tabs, state.activeTabId, state.tabHistory],
|
||||
() => state.tabs.map(t => t.id + t.type + (t.metadata?.rendererId ?? '') + (t.metadata?.serverId ?? '')).join('|'),
|
||||
() => {
|
||||
saveToLocalStorage();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Restaurer au montage (uniquement les server tabs)
|
||||
|
||||
214
pmoapp/webapp/src/composables/useWebRenderer.ts
Normal file
214
pmoapp/webapp/src/composables/useWebRenderer.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Composable pour gérer le WebRenderer navigateur.
|
||||
*
|
||||
* Utilise PMOPlayer pour le controle remote.
|
||||
* S'enregistre via POST /api/webrenderer/register
|
||||
* Utilise polling HTTP pour les commands
|
||||
*/
|
||||
|
||||
import { ref, onMounted, onUnmounted, readonly } from "vue";
|
||||
import { PMOPlayer } from "@/services/PMOPlayer";
|
||||
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
|
||||
const RENDERER_UDN_KEY = "pmomusic_webrenderer_udn";
|
||||
|
||||
// Load persisted UDN on module load
|
||||
function loadPersistedUdn(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(RENDERER_UDN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function savePersistedUdn(udn: string) {
|
||||
try {
|
||||
sessionStorage.setItem(RENDERER_UDN_KEY, udn);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level singleton for PMOPlayer to prevent duplicate instances
|
||||
let globalPlayer: PMOPlayer | null = null;
|
||||
let globalInstanceId: string | null = null;
|
||||
let registering = false;
|
||||
|
||||
// Module-level reactive state shared across all composable invocations
|
||||
const sharedConnected = ref(false);
|
||||
const sharedStreamUrl = ref<string | null>(null);
|
||||
const sharedRendererUdn = ref<string | null>(loadPersistedUdn());
|
||||
|
||||
function getOrCreateInstanceId(): string {
|
||||
try {
|
||||
let id = sessionStorage.getItem(INSTANCE_ID_KEY);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
sessionStorage.setItem(INSTANCE_ID_KEY, id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return generateUUID();
|
||||
}
|
||||
}
|
||||
|
||||
export function useWebRenderer() {
|
||||
const connected = sharedConnected;
|
||||
const streamUrl = sharedStreamUrl;
|
||||
const rendererUdn = sharedRendererUdn;
|
||||
|
||||
let player: PMOPlayer | null = null;
|
||||
let onConnectedCallback: (() => void) | null = null;
|
||||
|
||||
async function register(): Promise<void> {
|
||||
// Allow re-registration if we have a stale player but no connection
|
||||
if (globalPlayer && !connected.value) {
|
||||
console.log('[WebRenderer] stale globalPlayer, clearing');
|
||||
globalPlayer = null;
|
||||
}
|
||||
|
||||
if (globalPlayer) {
|
||||
player = globalPlayer;
|
||||
connected.value = true;
|
||||
console.log('[WebRenderer] reusing existing player');
|
||||
return;
|
||||
}
|
||||
|
||||
if (registering) {
|
||||
console.log('[WebRenderer] already registering, waiting...');
|
||||
// Wait a bit and try once
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
if (globalPlayer) {
|
||||
player = globalPlayer;
|
||||
connected.value = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
registering = true;
|
||||
|
||||
let instanceId;
|
||||
try {
|
||||
instanceId = getOrCreateInstanceId();
|
||||
} catch (e) {
|
||||
console.error('[WebRenderer] failed to get instanceId:', e);
|
||||
registering = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WebRenderer] registering with instanceId:', instanceId);
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/webrenderer/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
instance_id: instanceId,
|
||||
user_agent: navigator.userAgent,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
console.error("[WebRenderer] register failed:", resp.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
streamUrl.value = data.stream_url;
|
||||
rendererUdn.value = data.udn;
|
||||
savePersistedUdn(data.udn);
|
||||
|
||||
player = new PMOPlayer(instanceId);
|
||||
globalPlayer = player;
|
||||
globalInstanceId = instanceId;
|
||||
player.setDebug(true);
|
||||
|
||||
player.on('play', () => console.log('[WebRenderer] playing'));
|
||||
player.on('pause', () => console.log('[WebRenderer] paused'));
|
||||
|
||||
// Si le backend est déjà en lecture (reconnexion après reload), démarrer immédiatement
|
||||
if (data.should_play && data.stream_url) {
|
||||
console.log('[WebRenderer] backend already playing, starting stream');
|
||||
player.playStream(data.stream_url);
|
||||
}
|
||||
|
||||
connected.value = true;
|
||||
onConnectedCallback?.();
|
||||
|
||||
// Force refresh renderers list after registration
|
||||
// The SSE might miss the initial event
|
||||
setTimeout(() => {
|
||||
console.log('[WebRenderer] forcing renderers refresh');
|
||||
window.dispatchEvent(new CustomEvent('webrenderer-registered'));
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
console.error("[WebRenderer] register error:", e);
|
||||
} finally {
|
||||
registering = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unregister(): Promise<void> {
|
||||
// Only unregister if this is the global player - prevent duplicate unregister calls
|
||||
if (player !== globalPlayer || !player) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceId = globalInstanceId || getOrCreateInstanceId();
|
||||
|
||||
// Clear global first to prevent other components from using it
|
||||
globalPlayer = null;
|
||||
globalInstanceId = null;
|
||||
|
||||
try {
|
||||
await fetch(`/api/webrenderer/${instanceId}`, { method: "DELETE" });
|
||||
} catch {
|
||||
// Ignored
|
||||
}
|
||||
player?.destroy();
|
||||
player = null;
|
||||
}
|
||||
|
||||
function setVolume(_v: number): void {
|
||||
// PMOPlayer doesn't control volume directly - handled by backend
|
||||
}
|
||||
|
||||
function setMute(_m: boolean): void {
|
||||
// PMOPlayer doesn't control mute directly - handled by backend
|
||||
}
|
||||
|
||||
const beforeUnloadHandler = () => void unregister();
|
||||
|
||||
onMounted(() => {
|
||||
void register();
|
||||
window.addEventListener("beforeunload", beforeUnloadHandler);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
void unregister();
|
||||
window.removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
});
|
||||
|
||||
return {
|
||||
connected: readonly(connected),
|
||||
streamUrl: readonly(streamUrl),
|
||||
rendererUdn: readonly(rendererUdn),
|
||||
onConnected(fn: () => void) {
|
||||
onConnectedCallback = fn;
|
||||
},
|
||||
setVolume,
|
||||
setMute,
|
||||
};
|
||||
}
|
||||
@@ -9,11 +9,15 @@ import { sse } from "./services/pmocontrol/sse";
|
||||
// Store UI (garde UIStore pour les notifications et état UI global)
|
||||
import { useUIStore } from "./stores/ui";
|
||||
|
||||
// Image cache (pour cleanup)
|
||||
import { imageCache } from "./composables/imageCache";
|
||||
|
||||
// Styles
|
||||
import "./style.css";
|
||||
import "./assets/styles/variables.css";
|
||||
import "./assets/styles/pmocontrol.css";
|
||||
import "./assets/styles/glass-theme.css";
|
||||
import "./assets/styles/drawers.css";
|
||||
|
||||
// Créer l'application Vue
|
||||
const app = createApp(App);
|
||||
@@ -23,23 +27,24 @@ const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
// Initialiser UIStore AVANT le montage pour éviter la race condition (P2)
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Monter l'application
|
||||
app.mount("#app");
|
||||
|
||||
// Après montage, initialiser SSE
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Les composables se connectent automatiquement à SSE
|
||||
// Ils gèrent eux-mêmes le re-fetch lors des événements
|
||||
|
||||
sse.onConnectionChange((connected) => {
|
||||
uiStore.setSSEConnected(connected);
|
||||
if (connected) {
|
||||
console.log("[App] SSE connecté");
|
||||
}
|
||||
});
|
||||
|
||||
// Démarrer la connexion SSE
|
||||
sse.connect();
|
||||
|
||||
console.log("[App] PMOControl initialisé");
|
||||
// Cleanup global lors du unload de la page
|
||||
window.addEventListener('beforeunload', () => {
|
||||
imageCache.destroy();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
|
||||
// PMOControl Unified View (nouvelle interface unifiée)
|
||||
import UnifiedControlView from "../views/UnifiedControlView.vue";
|
||||
@@ -8,17 +9,10 @@ import DashboardView from "../views/DashboardView.vue";
|
||||
import RendererView from "../views/RendererView.vue";
|
||||
import MediaServerView from "../views/MediaServerView.vue";
|
||||
|
||||
// Debug Components (anciennes routes)
|
||||
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
|
||||
import LogView from "../components/LogView.vue";
|
||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||
import AudioCacheManager from "../components/AudioCacheManager.vue";
|
||||
import PlayListManager from "../components/PlayListManager.vue";
|
||||
import UpnpExplorer from "../components/UpnpExplorer.vue";
|
||||
import APIDashboard from "../components/APIDashboard.vue";
|
||||
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
||||
// Debug Components - lazy loaded uniquement en mode développement (P8)
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
const routes = [
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// PMOControl Unified Interface (nouvelle interface unifiée avec onglets)
|
||||
{
|
||||
path: "/",
|
||||
@@ -42,50 +36,65 @@ const routes = [
|
||||
name: "MediaServer",
|
||||
component: MediaServerView,
|
||||
},
|
||||
|
||||
// Debug menu (anciennes routes déplacées sous /debug)
|
||||
{
|
||||
path: "/debug/generic-player",
|
||||
name: "GenericPlayer",
|
||||
component: GenericMusicPlayer,
|
||||
},
|
||||
{
|
||||
path: "/debug/logs",
|
||||
name: "Logs",
|
||||
component: LogView,
|
||||
},
|
||||
{
|
||||
path: "/debug/covers-cache",
|
||||
name: "CoversCache",
|
||||
component: CoverCacheManager,
|
||||
},
|
||||
{
|
||||
path: "/debug/audio-cache",
|
||||
name: "AudioCache",
|
||||
component: AudioCacheManager,
|
||||
},
|
||||
{
|
||||
path: "/debug/playlists",
|
||||
name: "PlaylistsManager",
|
||||
component: PlayListManager,
|
||||
},
|
||||
{
|
||||
path: "/debug/upnp",
|
||||
name: "UpnpExplorer",
|
||||
component: UpnpExplorer,
|
||||
},
|
||||
{
|
||||
path: "/debug/api-dashboard",
|
||||
name: "APIDashboard",
|
||||
component: APIDashboard,
|
||||
},
|
||||
{
|
||||
path: "/debug/radio-paradise",
|
||||
name: "RadioParadise",
|
||||
component: RadioParadiseExplorer,
|
||||
},
|
||||
];
|
||||
|
||||
// Ajouter les routes de debug uniquement en développement
|
||||
if (isDev) {
|
||||
routes.push(
|
||||
{
|
||||
path: "/debug",
|
||||
name: "Debug",
|
||||
component: () => import("../views/DebugView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/generic-player",
|
||||
name: "GenericPlayer",
|
||||
component: () => import("../components/GenericMusicPlayer.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/logs",
|
||||
name: "Logs",
|
||||
component: () => import("../components/LogView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/covers-cache",
|
||||
name: "CoversCache",
|
||||
component: () => import("../components/CoverCacheManager.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/audio-cache",
|
||||
name: "AudioCache",
|
||||
component: () => import("../components/AudioCacheManager.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/playlists",
|
||||
name: "PlaylistsManager",
|
||||
component: () => import("../components/PlayListManager.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/upnp",
|
||||
name: "UpnpExplorer",
|
||||
component: () => import("../components/UpnpExplorer.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/api-dashboard",
|
||||
name: "APIDashboard",
|
||||
component: () => import("../components/APIDashboard.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/radio-paradise",
|
||||
name: "RadioParadise",
|
||||
component: () => import("../components/RadioParadiseExplorer.vue"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Wildcard redirect pour les routes inconnues
|
||||
routes.push({
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: "/",
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
// history avec base /app
|
||||
history: createWebHistory("/app"),
|
||||
|
||||
481
pmoapp/webapp/src/services/PMOPlayer.ts
Normal file
481
pmoapp/webapp/src/services/PMOPlayer.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* PMOPlayer - Invisible remote-controlled audio player for browser
|
||||
*
|
||||
* Receives commands from Web Media Renderer (backend)
|
||||
* Reports position/state back to backend via HTTP
|
||||
*
|
||||
* Usage:
|
||||
* const player = new PMOPlayer('my-instance-id');
|
||||
* player.on('play', () => console.log('playing'));
|
||||
*/
|
||||
|
||||
export type PlayerState = 'playing' | 'paused' | 'stopped' | 'buffering' | 'error';
|
||||
export type ReadyState = 'have_nothing' | 'have_metadata' | 'have_current_data' | 'have_future_data' | 'can_play' | 'can_play_through';
|
||||
|
||||
export interface PlayerEvents {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
stop: () => void;
|
||||
flush: () => void;
|
||||
positionchange: (position_sec: number) => void;
|
||||
durationchange: (duration_sec: number) => void;
|
||||
statechange: (state: PlayerState) => void;
|
||||
trackchange: (track: TrackInfo) => void;
|
||||
readychange: (ready: ReadyState) => void;
|
||||
error: (error: string) => void;
|
||||
}
|
||||
|
||||
export interface TrackInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
// Types stricts pour les commandes reçues du backend (P6)
|
||||
interface StreamCommand {
|
||||
type: 'stream';
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PlayCommand {
|
||||
type: 'play';
|
||||
}
|
||||
|
||||
interface PauseCommand {
|
||||
type: 'pause';
|
||||
}
|
||||
|
||||
interface SeekCommand {
|
||||
type: 'seek';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FlushCommand {
|
||||
type: 'flush';
|
||||
}
|
||||
|
||||
interface StopCommand {
|
||||
type: 'stop';
|
||||
}
|
||||
|
||||
type CommandMessage = StreamCommand | PlayCommand | PauseCommand | SeekCommand | FlushCommand | StopCommand;
|
||||
|
||||
function isValidCommand(msg: Record<string, unknown> | unknown): msg is CommandMessage {
|
||||
if (!msg || typeof msg !== 'object') return false;
|
||||
if (!('type' in msg)) return false;
|
||||
|
||||
const type = (msg as Record<string, unknown>).type;
|
||||
if (typeof type !== 'string') return false;
|
||||
|
||||
// Valider les champs selon le type
|
||||
switch (type) {
|
||||
case 'stream':
|
||||
return 'url' in msg && typeof (msg as StreamCommand).url === 'string';
|
||||
case 'seek':
|
||||
return 'timestamp' in msg && typeof (msg as SeekCommand).timestamp === 'number';
|
||||
case 'play':
|
||||
case 'pause':
|
||||
case 'flush':
|
||||
case 'stop':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class PMOPlayer {
|
||||
private audio: HTMLAudioElement;
|
||||
private instanceId: string;
|
||||
private ac: AudioContext | null = null;
|
||||
|
||||
private state: PlayerState = 'stopped';
|
||||
private positionInterval: number | null = null;
|
||||
private commandInterval: number | null = null;
|
||||
private listeners: Partial<PlayerEvents> = {};
|
||||
private debug: boolean = false;
|
||||
private pendingPlay = false;
|
||||
private unlockListener: (() => void) | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private readonly MAX_RECONNECT_ATTEMPTS = 5;
|
||||
private reconnectTimeout: number | null = null;
|
||||
|
||||
constructor(instanceId: string) {
|
||||
this.instanceId = instanceId;
|
||||
console.log('[PMOPlayer] constructor called for:', instanceId);
|
||||
this.audio = new Audio();
|
||||
|
||||
this.audio.preload = 'auto';
|
||||
this.audio.style.display = 'none';
|
||||
this.audio.style.visibility = 'hidden';
|
||||
this.audio.style.position = 'absolute';
|
||||
this.audio.style.width = '0';
|
||||
this.audio.style.height = '0';
|
||||
this.audio.style.overflow = 'hidden';
|
||||
document.body.appendChild(this.audio);
|
||||
|
||||
this.setupAudioListeners();
|
||||
this.startCommandPolling();
|
||||
}
|
||||
|
||||
private ensureAudioContext(): AudioContext {
|
||||
if (!this.ac) {
|
||||
this.ac = new AudioContext();
|
||||
const source = this.ac.createMediaElementSource(this.audio);
|
||||
source.connect(this.ac.destination);
|
||||
}
|
||||
return this.ac;
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
|
||||
this.log('max reconnect attempts reached, giving up');
|
||||
this.setState('error');
|
||||
return;
|
||||
}
|
||||
const delayMs = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 16000);
|
||||
this.reconnectAttempts++;
|
||||
this.log(`reconnect attempt ${this.reconnectAttempts} in ${delayMs}ms`);
|
||||
this.reconnectTimeout = window.setTimeout(() => {
|
||||
const url = this.audio.getAttribute('data-stream-url');
|
||||
if (url) {
|
||||
this.stream(url);
|
||||
this.play();
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private log(...args: unknown[]) {
|
||||
if (this.debug) {
|
||||
console.log('[PMOPlayer]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCommand() {
|
||||
try {
|
||||
const resp = await fetch(`/api/webrenderer/${this.instanceId}/command`);
|
||||
if (resp.status === 204) {
|
||||
// No command pending
|
||||
return;
|
||||
}
|
||||
if (resp.ok) {
|
||||
const text = await resp.text();
|
||||
if (text) {
|
||||
const cmd = JSON.parse(text);
|
||||
if (cmd && cmd.type) {
|
||||
this.log('fetched command', cmd);
|
||||
this.handleCommand(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.log('fetch command error', err);
|
||||
}
|
||||
}
|
||||
|
||||
private startCommandPolling() {
|
||||
this.commandInterval = window.setInterval(() => {
|
||||
// ATTENTION : ne pas conditionner ce poll à l'état courant.
|
||||
// L'état initial est 'stopped', et c'est dans cet état que le backend
|
||||
// envoie la première commande 'stream' pour démarrer la lecture.
|
||||
// Filtrer sur state !== 'stopped' casse le chemin nominal de démarrage.
|
||||
this.fetchCommand();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private setupAudioListeners() {
|
||||
this.audio.addEventListener('play', () => {
|
||||
this.log('play event');
|
||||
this.setState('playing');
|
||||
this.startPositionReporting();
|
||||
});
|
||||
|
||||
this.audio.addEventListener('pause', () => {
|
||||
this.log('pause event');
|
||||
this.setState('paused');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('ended', () => {
|
||||
this.log('ended event');
|
||||
this.setState('stopped');
|
||||
this.stopPositionReporting();
|
||||
});
|
||||
|
||||
this.audio.addEventListener('error', () => {
|
||||
if (!this.audio.getAttribute('src')) return;
|
||||
const code = this.audio.error?.code;
|
||||
const isNetworkError = code === MediaError.MEDIA_ERR_NETWORK
|
||||
|| code === MediaError.MEDIA_ERR_DECODE;
|
||||
if (isNetworkError && this.state !== 'stopped') {
|
||||
this.log('network error, scheduling reconnect');
|
||||
this.scheduleReconnect();
|
||||
} else {
|
||||
this.log('error event', this.audio.error);
|
||||
this.setState('error');
|
||||
this.listeners.error?.(this.audio.error?.message || 'unknown error');
|
||||
}
|
||||
});
|
||||
|
||||
this.audio.addEventListener('waiting', () => {
|
||||
this.log('waiting event');
|
||||
this.setState('buffering');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('canplay', () => {
|
||||
this.log('canplay event');
|
||||
this.reportReadyState('can_play');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('durationchange', () => {
|
||||
const dur = this.audio.duration;
|
||||
if (isFinite(dur)) {
|
||||
this.log('durationchange', dur);
|
||||
this.reportDuration(dur);
|
||||
}
|
||||
});
|
||||
|
||||
this.audio.addEventListener('loadedmetadata', () => {
|
||||
this.log('loadedmetadata', this.audio.duration);
|
||||
this.reportReadyState('have_metadata');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('loadeddata', () => {
|
||||
this.log('loadeddata');
|
||||
this.reportReadyState('have_current_data');
|
||||
});
|
||||
}
|
||||
|
||||
private handleCommand(msg: Record<string, unknown>) {
|
||||
// Validate command structure before processing (P6)
|
||||
if (!isValidCommand(msg)) {
|
||||
console.warn('[PMOPlayer] Invalid command received:', msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = msg.type;
|
||||
|
||||
switch (type) {
|
||||
case 'stream': {
|
||||
this.playStream(msg.url);
|
||||
break;
|
||||
}
|
||||
case 'play': {
|
||||
const streamUrl = `/api/webrenderer/${this.instanceId}/stream`;
|
||||
this.playStream(streamUrl);
|
||||
break;
|
||||
}
|
||||
case 'pause':
|
||||
this.pause();
|
||||
break;
|
||||
case 'seek':
|
||||
this.seek(msg.timestamp);
|
||||
break;
|
||||
case 'flush':
|
||||
this.flush();
|
||||
break;
|
||||
case 'stop':
|
||||
this.stop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Commands from backend ─────────────────────────────────────────────
|
||||
|
||||
stream(url: string) {
|
||||
this.log('stream:', url);
|
||||
this.audio.setAttribute('data-stream-url', url);
|
||||
this.reconnectAttempts = 0;
|
||||
this.audio.src = url;
|
||||
this.audio.load();
|
||||
}
|
||||
|
||||
playStream(url: string) {
|
||||
this.stream(url);
|
||||
this.play();
|
||||
}
|
||||
|
||||
play() {
|
||||
this.log('play()');
|
||||
const ac = this.ensureAudioContext();
|
||||
if (ac.state === 'suspended') {
|
||||
ac.resume().catch(err => this.log('AudioContext resume error', err));
|
||||
}
|
||||
this.audio.play().catch(err => {
|
||||
if ((err as DOMException).name === 'NotAllowedError') {
|
||||
this.log('autoplay blocked, will retry on user interaction');
|
||||
this.pendingPlay = true;
|
||||
this.setupAutoplayUnlock();
|
||||
} else {
|
||||
this.log('play error', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupAutoplayUnlock() {
|
||||
if (this.unlockListener) return;
|
||||
this.unlockListener = () => {
|
||||
if (this.pendingPlay) {
|
||||
this.pendingPlay = false;
|
||||
this.log('retrying play after user interaction');
|
||||
this.audio.play().catch(err => this.log('play retry error', err));
|
||||
}
|
||||
document.removeEventListener('click', this.unlockListener!);
|
||||
this.unlockListener = null;
|
||||
};
|
||||
document.addEventListener('click', this.unlockListener);
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.log('pause()');
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
seek(timestamp: number) {
|
||||
this.log('seek:', timestamp);
|
||||
this.audio.currentTime = timestamp;
|
||||
}
|
||||
|
||||
flush() {
|
||||
this.log('flush()');
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
this.ac?.suspend();
|
||||
this.listeners.flush?.();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.log('stop()');
|
||||
this.flush();
|
||||
this.setState('stopped');
|
||||
}
|
||||
|
||||
// ─── Reports to backend ──────────────────────────────────────────
|
||||
|
||||
private setState(state: PlayerState) {
|
||||
if (this.state !== state) {
|
||||
this.state = state;
|
||||
this.log('state:', state);
|
||||
this.listeners.statechange?.(state);
|
||||
|
||||
this.httpReport('report', {
|
||||
state: state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private reportPosition() {
|
||||
const pos = this.audio.currentTime;
|
||||
const dur = isFinite(this.audio.duration) ? this.audio.duration : null;
|
||||
|
||||
this.listeners.positionchange?.(pos);
|
||||
|
||||
this.httpReport('report', {
|
||||
position_sec: pos,
|
||||
duration_sec: dur,
|
||||
state: this.state,
|
||||
});
|
||||
}
|
||||
|
||||
private reportDuration(dur: number) {
|
||||
this.listeners.durationchange?.(dur);
|
||||
}
|
||||
|
||||
private reportReadyState(ready: ReadyState) {
|
||||
this.log('ready_state:', ready);
|
||||
this.listeners.readychange?.(ready);
|
||||
|
||||
this.httpReport('report', {
|
||||
ready_state: ready,
|
||||
});
|
||||
}
|
||||
|
||||
private async httpReport(endpoint: string, data: Record<string, unknown>) {
|
||||
try {
|
||||
await fetch(`/api/webrenderer/${this.instanceId}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
} catch (err) {
|
||||
this.log('http report error', err);
|
||||
}
|
||||
}
|
||||
|
||||
private startPositionReporting() {
|
||||
this.stopPositionReporting();
|
||||
this.positionInterval = window.setInterval(() => {
|
||||
if (this.state === 'playing') {
|
||||
this.reportPosition();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private stopPositionReporting() {
|
||||
if (this.positionInterval !== null) {
|
||||
clearInterval(this.positionInterval);
|
||||
this.positionInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
on<K extends keyof PlayerEvents>(event: K, fn: PlayerEvents[K]) {
|
||||
this.listeners[event] = fn;
|
||||
}
|
||||
|
||||
off<K extends keyof PlayerEvents>(event: K) {
|
||||
delete this.listeners[event];
|
||||
}
|
||||
|
||||
getState(): PlayerState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getPosition(): number {
|
||||
return this.audio.currentTime;
|
||||
}
|
||||
|
||||
getDuration(): number | null {
|
||||
const dur = this.audio.duration;
|
||||
return isFinite(dur) ? dur : null;
|
||||
}
|
||||
|
||||
// Track info from backend - not implemented yet
|
||||
getTrack(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
setDebug(enabled: boolean) {
|
||||
this.debug = enabled;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.log('destroy()');
|
||||
this.stopPositionReporting();
|
||||
if (this.commandInterval !== null) {
|
||||
clearInterval(this.commandInterval);
|
||||
this.commandInterval = null;
|
||||
}
|
||||
if (this.unlockListener) {
|
||||
document.removeEventListener('click', this.unlockListener);
|
||||
this.unlockListener = null;
|
||||
}
|
||||
if (this.reconnectTimeout !== null) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
this.ac?.close();
|
||||
this.ac = null;
|
||||
// Rapport final garanti via sendBeacon (fonctionne pendant beforeunload)
|
||||
navigator.sendBeacon(
|
||||
`/api/webrenderer/${this.instanceId}/report`,
|
||||
JSON.stringify({ state: 'stopped' })
|
||||
);
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
this.audio.remove();
|
||||
}
|
||||
}
|
||||
@@ -200,34 +200,11 @@ export function getJpegUrl(pk: string, size?: number): string {
|
||||
return `/covers/jpeg/${pk}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG par défaut pour les images qui ne se chargent pas
|
||||
*/
|
||||
const DEFAULT_COVER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgGrad)"/>
|
||||
<g transform="translate(200, 200)">
|
||||
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
|
||||
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
</g>
|
||||
<text x="200" y="360" text-anchor="middle"
|
||||
font-family="system-ui, -apple-system, sans-serif"
|
||||
font-size="20" fill="white" opacity="0.6">
|
||||
No Image Available
|
||||
</text>
|
||||
</svg>`;
|
||||
import defaultCoverSvg from '../assets/default-cover.svg?raw';
|
||||
|
||||
/**
|
||||
* Retourne l'URL de l'image par défaut comme data URL
|
||||
*/
|
||||
export function getDefaultImageUrl(): string {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(DEFAULT_COVER_SVG)}`;
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(defaultCoverSvg)}`;
|
||||
}
|
||||
|
||||
@@ -17,12 +17,45 @@ import type {
|
||||
ErrorResponse,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Fetch avec timeout et AbortController
|
||||
*/
|
||||
function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeoutMs = 10_000,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return fetch(url, { ...options, signal: controller.signal }).finally(
|
||||
() => clearTimeout(id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client API REST pour le Control Point PMOMusic
|
||||
*/
|
||||
class PMOControlAPI {
|
||||
private readonly baseURL = "/api/control";
|
||||
|
||||
/**
|
||||
* Valide la structure de base d'une réponse
|
||||
* Jette une erreur si la réponse est invalide
|
||||
*/
|
||||
private validateResponse<T>(data: unknown, path: string): T {
|
||||
// Vérification basique : null ou undefined
|
||||
if (data == null) {
|
||||
throw new Error(`[PMOControlAPI] Réponse nulle pour ${path}`);
|
||||
}
|
||||
|
||||
// Vérification que c'est un objet
|
||||
if (typeof data !== 'object') {
|
||||
throw new Error(`[PMOControlAPI] Réponse invalide pour ${path}: attendu un objet`);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une requête HTTP générique
|
||||
*/
|
||||
@@ -32,7 +65,7 @@ class PMOControlAPI {
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -47,7 +80,15 @@ class PMOControlAPI {
|
||||
throw new Error(error.error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const data = await response.json();
|
||||
|
||||
// Validation de la réponse (P5)
|
||||
const validated = this.validateResponse<T>(data, path);
|
||||
|
||||
if (import.meta.env.DEV && data == null) {
|
||||
console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -418,9 +459,21 @@ class PMOControlAPI {
|
||||
async browseContainer(
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
offset = 0,
|
||||
limit = 50,
|
||||
): Promise<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`,
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}?offset=${offset}&limit=${limit}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche dans un serveur media
|
||||
* GET /api/control/servers/{serverId}/search?q={query}
|
||||
*/
|
||||
async searchServer(serverId: string, query: string): Promise<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/search?q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,13 +31,10 @@ export class PMOControlSSE {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[SSE] Connexion à /api/control/events...')
|
||||
|
||||
try {
|
||||
this.eventSource = new EventSource('/api/control/events')
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
console.log('[SSE] Connexion établie')
|
||||
this.reconnectAttempts = 0
|
||||
this.isConnected = true
|
||||
this.notifyConnectionCallbacks(true)
|
||||
@@ -75,7 +72,6 @@ export class PMOControlSSE {
|
||||
}
|
||||
|
||||
if (this.eventSource) {
|
||||
console.log('[SSE] Déconnexion')
|
||||
this.eventSource.close()
|
||||
this.eventSource = null
|
||||
this.isConnected = false
|
||||
@@ -99,8 +95,6 @@ export class PMOControlSSE {
|
||||
this.maxReconnectDelay
|
||||
)
|
||||
|
||||
console.log(`[SSE] Reconnexion dans ${delay / 1000}s (tentative ${this.reconnectAttempts})`)
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
|
||||
@@ -29,21 +29,17 @@ export interface RendererSummary {
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
model_name: string;
|
||||
manufacturer?: string;
|
||||
protocol: RendererProtocolSummary;
|
||||
capabilities: RendererCapabilitiesSummary;
|
||||
online: boolean;
|
||||
server_host?: string;
|
||||
}
|
||||
|
||||
export interface RendererState {
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
transport_state:
|
||||
| "PLAYING"
|
||||
| "PAUSED"
|
||||
| "STOPPED"
|
||||
| "TRANSITIONING"
|
||||
| "NO_MEDIA"
|
||||
| "UNKNOWN";
|
||||
transport_state: TransportState;
|
||||
position_ms: number | null;
|
||||
duration_ms: number | null;
|
||||
volume: number | null; // 0-100
|
||||
@@ -53,6 +49,15 @@ export interface RendererState {
|
||||
current_track: CurrentTrackMetadata | null;
|
||||
}
|
||||
|
||||
export type TransportState = "PLAYING" | "PAUSED" | "STOPPED" | "TRANSITIONING" | "NO_MEDIA" | "UNKNOWN";
|
||||
|
||||
/**
|
||||
* Guard de type pour valider que string est un TransportState valide
|
||||
*/
|
||||
export function isTransportState(s: string): s is TransportState {
|
||||
return ["PLAYING", "PAUSED", "STOPPED", "TRANSITIONING", "NO_MEDIA", "UNKNOWN"].includes(s);
|
||||
}
|
||||
|
||||
export interface CurrentTrackMetadata {
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
@@ -119,6 +124,9 @@ export interface ContainerEntry {
|
||||
export interface BrowseResponse {
|
||||
container_id: string;
|
||||
entries: ContainerEntry[];
|
||||
total_count: number;
|
||||
offset: number;
|
||||
hasMore?: boolean; // Client-side flag pour infinite scroll
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -194,6 +202,11 @@ export type RendererEventPayload =
|
||||
queue_length: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "queue_refreshing";
|
||||
renderer_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "binding_changed";
|
||||
renderer_id: string;
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface Notification {
|
||||
duration?: number // ms, undefined = permanent
|
||||
}
|
||||
|
||||
const MAX_NOTIFICATIONS = 5
|
||||
|
||||
export const useUIStore = defineStore('ui', () => {
|
||||
// État
|
||||
const selectedRendererId = ref<string | null>(null)
|
||||
@@ -17,6 +19,9 @@ export const useUIStore = defineStore('ui', () => {
|
||||
const sseConnected = ref(false)
|
||||
const notifications = ref<Notification[]>([])
|
||||
|
||||
// Map pour suivre les timers et permettre le cleanup
|
||||
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
// Actions
|
||||
function selectRenderer(id: string | null) {
|
||||
selectedRendererId.value = id
|
||||
@@ -39,6 +44,18 @@ export const useUIStore = defineStore('ui', () => {
|
||||
message: string,
|
||||
duration?: number
|
||||
) {
|
||||
// Limiter le nombre de notifications (P15)
|
||||
if (notifications.value.length >= MAX_NOTIFICATIONS) {
|
||||
const oldest = notifications.value.shift()
|
||||
if (oldest) {
|
||||
const timer = notificationTimers.get(oldest.id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
notificationTimers.delete(oldest.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const id = `notif-${Date.now()}-${Math.random()}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
@@ -49,12 +66,13 @@ export const useUIStore = defineStore('ui', () => {
|
||||
|
||||
notifications.value.push(notification)
|
||||
|
||||
// Auto-remove après duration (défaut: 5s)
|
||||
// Auto-remove après duration (défaut: 5s) - avec tracking pour cleanup
|
||||
const timeout = duration !== undefined ? duration : 5000
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => {
|
||||
const timer = setTimeout(() => {
|
||||
removeNotification(id)
|
||||
}, timeout)
|
||||
notificationTimers.set(id, timer)
|
||||
}
|
||||
|
||||
return id
|
||||
@@ -65,12 +83,27 @@ export const useUIStore = defineStore('ui', () => {
|
||||
if (index !== -1) {
|
||||
notifications.value.splice(index, 1)
|
||||
}
|
||||
// Nettoyer le timer associated
|
||||
const timer = notificationTimers.get(id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
notificationTimers.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
function clearNotifications() {
|
||||
// Nettoyer tous les timers
|
||||
notificationTimers.forEach(timer => clearTimeout(timer))
|
||||
notificationTimers.clear()
|
||||
notifications.value = []
|
||||
}
|
||||
|
||||
// Cleanup function pour appeler lors du unmount de l'app
|
||||
function $dispose() {
|
||||
notificationTimers.forEach(timer => clearTimeout(timer))
|
||||
notificationTimers.clear()
|
||||
}
|
||||
|
||||
// Raccourcis pour les types de notifications
|
||||
function notifySuccess(message: string, duration?: number) {
|
||||
return addNotification('success', message, duration)
|
||||
@@ -107,5 +140,6 @@ export const useUIStore = defineStore('ui', () => {
|
||||
notifyError,
|
||||
notifyWarning,
|
||||
notifyInfo,
|
||||
$dispose,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
/* Taille de base légèrement réduite — remplace le zoom: 0.85 global */
|
||||
font-size: 15px;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
@@ -68,8 +71,6 @@ button:focus-visible {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
zoom: 0.85;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
|
||||
24
pmoapp/webapp/src/types/vue-virtual-scroller.d.ts
vendored
Normal file
24
pmoapp/webapp/src/types/vue-virtual-scroller.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
declare module 'vue-virtual-scroller' {
|
||||
import { DefineComponent } from 'vue';
|
||||
|
||||
export interface RecycleScrollerProps {
|
||||
items?: any[];
|
||||
itemSize?: number;
|
||||
keyField?: string;
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
minItemSize?: number;
|
||||
sizeField?: string;
|
||||
typeField?: string;
|
||||
buffer?: number;
|
||||
pageMode?: boolean;
|
||||
prerender?: number;
|
||||
}
|
||||
|
||||
export interface RecycleScrollerRef {
|
||||
scrollToItem(index: number): void;
|
||||
scrollToOffset(offset: number): void;
|
||||
$el: HTMLElement;
|
||||
}
|
||||
|
||||
export const RecycleScroller: DefineComponent<RecycleScrollerProps>;
|
||||
}
|
||||
53
pmoapp/webapp/src/utils/string.ts
Normal file
53
pmoapp/webapp/src/utils/string.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Utilitaires pour les chaînes de caractères
|
||||
*/
|
||||
|
||||
/**
|
||||
* Génère un hash simple et rapide pour une chaîne
|
||||
* @param str - Chaîne à hasher
|
||||
* @returns Hash sous forme de chaîne hexadécimale positive
|
||||
*/
|
||||
export function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un paramètre cache-busting à une URL
|
||||
* @param url - URL originale
|
||||
* @param cacheKey - Clé de cache (hash ou timestamp)
|
||||
* @returns URL avec le paramètre _cb ajouté
|
||||
*/
|
||||
export function addCacheBust(url: string, cacheKey: string): string {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}_cb=${cacheKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie une URL en supprimant les paramètres de cache-busting
|
||||
* @param url - URL avec possibly _cb params
|
||||
* @returns URL nettoyée
|
||||
*/
|
||||
export function normalizeUrl(url: string): string {
|
||||
return url.replace(/[?&]_cb=[^&]*/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tronque une chaîne à une longueur maximale
|
||||
* @param str - Chaîne à tronquer
|
||||
* @param maxLength - Longueur maximale
|
||||
* @returns Chaîne tronquée avec suffix si nécessaire
|
||||
*/
|
||||
export function truncate(str: string, maxLength: number, suffix = '...'): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
// Guard: si suffix est plus long que maxLength, retourner juste le suffixe
|
||||
if (suffix.length >= maxLength) {
|
||||
return str.slice(0, maxLength);
|
||||
}
|
||||
return str.slice(0, maxLength - suffix.length) + suffix;
|
||||
}
|
||||
51
pmoapp/webapp/src/utils/time.ts
Normal file
51
pmoapp/webapp/src/utils/time.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Utilitaires pour les dates et durées
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convertit une durée au format HH:MM:SS en millisecondes
|
||||
* @param time - Durée au format "HH:MM:SS" ou "MM:SS"
|
||||
* @returns Durée en millisecondes, ou null si invalide
|
||||
*/
|
||||
export function parseTimeToMs(time: string | null | undefined): number | null {
|
||||
if (!time) return null;
|
||||
|
||||
const parts = time.split(':').map(Number);
|
||||
|
||||
if (parts.length === 3) {
|
||||
// HH:MM:SS
|
||||
const hours = parts[0] ?? 0;
|
||||
const minutes = parts[1] ?? 0;
|
||||
const seconds = parts[2] ?? 0;
|
||||
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return null;
|
||||
return (hours * 3600 + minutes * 60 + seconds) * 1000;
|
||||
} else if (parts.length === 2) {
|
||||
// MM:SS
|
||||
const minutes = parts[0] ?? 0;
|
||||
const seconds = parts[1] ?? 0;
|
||||
if (isNaN(minutes) || isNaN(seconds)) return null;
|
||||
return (minutes * 60 + seconds) * 1000;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit des millisecondes en format HH:MM:SS
|
||||
* @param ms - Durée en millisecondes
|
||||
* @returns Durée au format "HH:MM:SS" ou "MM:SS"
|
||||
*/
|
||||
export function formatMsToTime(ms: number | null): string {
|
||||
if (ms === null || ms === undefined || ms < 0) return '--:--';
|
||||
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const h = hours > 0 ? `${hours}:` : '';
|
||||
const m = `${minutes.toString().padStart(2, '0')}:`;
|
||||
const s = seconds.toString().padStart(2, '0');
|
||||
|
||||
return `${h}${m}${s}`;
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import RendererCard from '@/components/pmocontrol/RendererCard.vue'
|
||||
import MediaServerCard from '@/components/pmocontrol/MediaServerCard.vue'
|
||||
import { Radio, Server } from 'lucide-vue-next'
|
||||
import { Radio, Server, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const {
|
||||
allRenderers: renderers,
|
||||
onlineRenderers,
|
||||
getStateById,
|
||||
fetchRenderers,
|
||||
fetchRendererSnapshot
|
||||
fetchBatchSnapshots
|
||||
} = useRenderers()
|
||||
|
||||
const {
|
||||
@@ -20,15 +20,34 @@ const {
|
||||
fetchServers
|
||||
} = useMediaServers()
|
||||
|
||||
// État de chargement pour l'UI
|
||||
const isLoadingSnapshots = ref(false)
|
||||
|
||||
// Charger les données au montage
|
||||
onMounted(async () => {
|
||||
await fetchRenderers()
|
||||
await fetchServers()
|
||||
// Charger les listes de renderers et servers en parallèle
|
||||
await Promise.all([
|
||||
fetchRenderers(),
|
||||
fetchServers()
|
||||
])
|
||||
|
||||
for (const renderer of renderers.value) {
|
||||
fetchRendererSnapshot(renderer.id, { force: true })
|
||||
// Charger les snapshots en batch controlé
|
||||
// On récupère les IDs des renderers découverts
|
||||
const rendererIds = renderers.value.map(r => r.id)
|
||||
|
||||
if (rendererIds.length > 0) {
|
||||
isLoadingSnapshots.value = true
|
||||
|
||||
// Appel batch avec concurrency controlée
|
||||
await fetchBatchSnapshots(rendererIds, {
|
||||
concurrency: 3, // Max 3 requêtes simultanées
|
||||
batchDelay: 100, // 100ms entre chaque batch
|
||||
force: true
|
||||
})
|
||||
|
||||
isLoadingSnapshots.value = false
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -55,6 +74,10 @@ onMounted(async () => {
|
||||
<Radio :size="24" />
|
||||
<span>Renderers Audio</span>
|
||||
</h2>
|
||||
<span v-if="isLoadingSnapshots" class="loading-indicator">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Chargement...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="renderers.length" class="renderers-grid">
|
||||
@@ -175,6 +198,24 @@ onMounted(async () => {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Loading indicator */
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading-indicator .spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Grids */
|
||||
.renderers-grid,
|
||||
.servers-grid {
|
||||
|
||||
197
pmoapp/webapp/src/views/DebugView.vue
Normal file
197
pmoapp/webapp/src/views/DebugView.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
Music,
|
||||
ScrollText,
|
||||
Image,
|
||||
Database,
|
||||
ListMusic,
|
||||
Network,
|
||||
LayoutDashboard,
|
||||
Radio,
|
||||
ArrowLeft,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const tools = [
|
||||
{
|
||||
path: "/debug/covers-cache",
|
||||
name: "Cache des couvertures",
|
||||
description: "Visualiser et gérer le cache d'images de couverture",
|
||||
icon: Image,
|
||||
},
|
||||
{
|
||||
path: "/debug/audio-cache",
|
||||
name: "Cache audio",
|
||||
description: "Gérer le cache des fichiers audio",
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
path: "/debug/playlists",
|
||||
name: "Playlists",
|
||||
description: "Gérer et inspecter les playlists en cache",
|
||||
icon: ListMusic,
|
||||
},
|
||||
{
|
||||
path: "/debug/logs",
|
||||
name: "Logs",
|
||||
description: "Consulter les logs du serveur en temps réel",
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
path: "/debug/upnp",
|
||||
name: "Explorateur UPnP",
|
||||
description: "Parcourir les dispositifs et services UPnP",
|
||||
icon: Network,
|
||||
},
|
||||
{
|
||||
path: "/debug/api-dashboard",
|
||||
name: "API Dashboard",
|
||||
description: "Documentation et test des API disponibles",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
path: "/debug/generic-player",
|
||||
name: "Lecteur générique",
|
||||
description: "Lecteur de test pour les flux audio",
|
||||
icon: Music,
|
||||
},
|
||||
{
|
||||
path: "/debug/radio-paradise",
|
||||
name: "Radio Paradise",
|
||||
description: "Explorateur Radio Paradise",
|
||||
icon: Radio,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="debug-view">
|
||||
<header class="debug-header">
|
||||
<button class="back-btn" @click="router.push('/')" title="Retour">
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<h1 class="debug-title">Outils de debug</h1>
|
||||
</header>
|
||||
|
||||
<div class="debug-grid">
|
||||
<button
|
||||
v-for="tool in tools"
|
||||
:key="tool.path"
|
||||
class="tool-card"
|
||||
@click="router.push(tool.path)"
|
||||
>
|
||||
<div class="tool-icon">
|
||||
<component :is="tool.icon" :size="28" />
|
||||
</div>
|
||||
<div class="tool-info">
|
||||
<div class="tool-name">{{ tool.name }}</div>
|
||||
<div class="tool-desc">{{ tool.description }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.debug-view {
|
||||
min-height: 100vh;
|
||||
padding: var(--spacing-lg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.debug-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
transition: background var(--transition-fast) ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.debug-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.debug-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--color-text);
|
||||
transition: all var(--transition-fast) ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tool-card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { useTabs } from "@/composables/useTabs";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useWebRenderer } from "@/composables/useWebRenderer";
|
||||
import { useSwipe } from "@vueuse/core";
|
||||
|
||||
// Import des composants
|
||||
@@ -21,6 +22,11 @@ const { tabs, activeTabId, switchTab, activeTab, syncWithRenderers, isEmpty } =
|
||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||
const { allServers, fetchServers } = useMediaServers();
|
||||
|
||||
// WebRenderer : ce navigateur s'enregistre automatiquement comme renderer UPnP
|
||||
const webRenderer = useWebRenderer();
|
||||
// Rafraîchir la liste des renderers dès que la session WebRenderer est établie
|
||||
webRenderer.onConnected(() => void fetchRenderers(true));
|
||||
|
||||
// État des drawers
|
||||
const drawerOpen = ref(false);
|
||||
const rendererDrawerOpen = ref(false);
|
||||
@@ -28,17 +34,19 @@ const rendererDrawerOpen = ref(false);
|
||||
// Ref pour le swipe edge detection
|
||||
const viewRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// Position initiale du swipe pour détecter un swipe depuis le bord gauche
|
||||
const swipeStartX = ref(0);
|
||||
|
||||
// Swipe depuis le bord gauche pour ouvrir le drawer
|
||||
useSwipe(viewRef, {
|
||||
threshold: 50,
|
||||
onSwipeStart(e: TouchEvent) {
|
||||
swipeStartX.value = e.touches[0]?.clientX ?? 0;
|
||||
},
|
||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||
// Swipe right depuis le bord gauche → ouvrir drawer
|
||||
if (swipeDirection === "right" && !drawerOpen.value) {
|
||||
const touch = _e.changedTouches[0];
|
||||
// Vérifier que le swipe commence depuis le bord gauche (< 50px)
|
||||
if (touch && touch.clientX < 50) {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
if (swipeDirection === "right" && !drawerOpen.value && swipeStartX.value < 50) {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -71,9 +79,9 @@ const onlineServersCount = computed(
|
||||
() => allServers.value.filter((s) => s.online).length,
|
||||
);
|
||||
|
||||
// Nombre de renderers online pour afficher dans le badge
|
||||
// Nombre de renderers online (filtrés) pour afficher dans le badge
|
||||
const onlineRenderersCount = computed(
|
||||
() => allRenderers.value.filter((r) => r.online).length,
|
||||
() => filterRenderers(allRenderers.value).filter((r) => r.online).length,
|
||||
);
|
||||
|
||||
// Gestion de l'ouverture du drawer depuis le bouton
|
||||
@@ -97,13 +105,29 @@ function handleRendererSelect(rendererId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to show only our own WebRenderer based on UDN match
|
||||
function filterRenderers(renderers: typeof allRenderers.value) {
|
||||
const myUdn = webRenderer.rendererUdn.value;
|
||||
const normalizedUdn = myUdn?.replace(/^uuid:/, '') ?? null;
|
||||
return renderers.filter((r) => {
|
||||
// WebRenderers have "Web Audio – " as friendly_name prefix
|
||||
const isWebRenderer = r.friendly_name?.startsWith("Web Audio – ") ?? false;
|
||||
if (isWebRenderer) {
|
||||
if (normalizedUdn === null) return false;
|
||||
const normalizedId = r.id.replace(/^uuid:/, '');
|
||||
return normalizedId === normalizedUdn;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Sync route query params avec l'état des tabs
|
||||
onMounted(async () => {
|
||||
// Fetch renderers et servers au montage
|
||||
await Promise.all([fetchRenderers(), fetchServers()]);
|
||||
|
||||
// Sync initial des tabs avec les renderers
|
||||
syncWithRenderers(allRenderers.value);
|
||||
// Sync initial des tabs avec les renderers (en excluant les WebRenderers étrangers)
|
||||
syncWithRenderers(filterRenderers(allRenderers.value));
|
||||
|
||||
// Restaurer l'onglet actif depuis l'URL
|
||||
const urlTabId = route.query.tab as string;
|
||||
@@ -113,12 +137,26 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
// Watch renderers pour sync automatique des tabs
|
||||
// Note: on watch la taille du tableau + les IDs pour éviter un deep watch coûteux
|
||||
watch(
|
||||
() => allRenderers.value,
|
||||
(newRenderers) => {
|
||||
syncWithRenderers(newRenderers);
|
||||
() => ({
|
||||
length: allRenderers.value.length,
|
||||
ids: allRenderers.value.map(r => r.id).join(','),
|
||||
}),
|
||||
(newVal, oldVal) => {
|
||||
// Re-sync uniquement si le nombre ou les IDs ont changé
|
||||
if (newVal.length !== oldVal?.length || newVal.ids !== oldVal?.ids) {
|
||||
syncWithRenderers(filterRenderers(allRenderers.value));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Watch l'UDN du WebRenderer local : quand il s'établit, resync pour faire apparaître notre onglet
|
||||
watch(
|
||||
() => webRenderer.rendererUdn.value,
|
||||
() => {
|
||||
syncWithRenderers(filterRenderers(allRenderers.value));
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Watch les changements d'URL pour changer d'onglet
|
||||
@@ -232,6 +270,7 @@ const currentTabProps = computed(() => {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
@@ -240,6 +279,7 @@ const currentTabProps = computed(() => {
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Placeholder temporaire */
|
||||
@@ -343,4 +383,5 @@ const currentTabProps = computed(() => {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
1
pmoapp/webapp/tsconfig.tsbuildinfo
Normal file
1
pmoapp/webapp/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"fileNames":[],"fileInfos":[],"root":[],"version":"5.8.3"}
|
||||
@@ -1,10 +1,58 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
base: '/app/',
|
||||
manifest: {
|
||||
name: 'PMOMusic',
|
||||
short_name: 'PMOMusic',
|
||||
description: 'Contrôleur UPnP/DLNA pour votre musique',
|
||||
start_url: '/app/',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
theme_color: '#111827',
|
||||
background_color: '#111827',
|
||||
icons: [
|
||||
{
|
||||
src: '/app/icons/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/app/icons/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/app/index.html',
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^\/audio\//,
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
base: '/app/', // Base path pour le déploiement
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -13,6 +61,11 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/webrenderer/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "pmoaudio-ext"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# Core audio types
|
||||
@@ -30,10 +30,13 @@ rand = "0.8"
|
||||
bytes = { version = "1.0", optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
reqwest = { workspace = true, features = ["stream"], optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
ureq = { version = "2", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata", "dep:serde_json"]
|
||||
playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"]
|
||||
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde"]
|
||||
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde", "dep:reqwest", "dep:futures", "dep:ureq"]
|
||||
all = ["cache-sink", "playlist", "http-stream"]
|
||||
|
||||
@@ -28,7 +28,7 @@ pub mod sinks;
|
||||
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
|
||||
pub mod nodes;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
#[cfg(any(feature = "playlist", feature = "http-stream"))]
|
||||
pub mod sources;
|
||||
|
||||
// Re-exports pour faciliter l'utilisation
|
||||
@@ -39,4 +39,10 @@ pub use sinks::*;
|
||||
pub use nodes::*;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
pub use sources::*;
|
||||
pub use sources::PlaylistSource;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use sources::UriSource;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use sources::{PlayerCommand, PlayerEvent, PlayerHandle, PlayerSource};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! - Drops frames that are late (audio_ts < elapsed)
|
||||
//! - Paces broadcast to match audio playback rate
|
||||
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::trace;
|
||||
|
||||
/// Error returned when a frame should be skipped (too late)
|
||||
@@ -13,7 +13,6 @@ use tracing::trace;
|
||||
pub struct SkipFrame;
|
||||
|
||||
/// Manages broadcast pacing with TopZeroSync detection
|
||||
#[allow(dead_code)]
|
||||
pub struct BroadcastPacer {
|
||||
/// Start time (reset on TopZeroSync)
|
||||
start_time: Instant,
|
||||
@@ -21,8 +20,6 @@ pub struct BroadcastPacer {
|
||||
max_lead_time: f64,
|
||||
/// Label for logging (e.g., "FLAC" or "OGG")
|
||||
label: String,
|
||||
/// Pending reset flag - will reset timer on next chunk
|
||||
pending_reset: bool,
|
||||
}
|
||||
|
||||
impl BroadcastPacer {
|
||||
@@ -37,24 +34,38 @@ impl BroadcastPacer {
|
||||
start_time: Instant::now(),
|
||||
max_lead_time: max_lead_time.max(0.0),
|
||||
label: label.into(),
|
||||
pending_reset: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check timing and apply pacing - NO-OP VERSION
|
||||
/// Reset the pacer clock (call when audio timestamp resets to 0).
|
||||
#[allow(dead_code)]
|
||||
pub fn reset(&mut self) {
|
||||
self.start_time = Instant::now();
|
||||
trace!("{} broadcaster: pacer reset", self.label);
|
||||
}
|
||||
|
||||
/// Check timing and apply pacing.
|
||||
///
|
||||
/// Pacing is now handled entirely by the expiration-based system in
|
||||
/// TimedBroadcast. This method is kept for backward compatibility
|
||||
/// but always returns Ok(()).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - Always returns `Ok(())`
|
||||
/// If the audio is ahead of real time by more than `max_lead_time`, sleeps
|
||||
/// until the lead is within bounds. Returns `Err(SkipFrame)` if the chunk
|
||||
/// is already late (audio_ts < elapsed - 1s grace).
|
||||
pub async fn check_and_pace(&mut self, audio_timestamp: f64) -> Result<(), SkipFrame> {
|
||||
trace!(
|
||||
"{} broadcaster: check_and_pace called with audio_ts={:.3}s (no-op - pacing handled by TimedBroadcast)",
|
||||
self.label, audio_timestamp
|
||||
);
|
||||
if self.max_lead_time <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
let lead = audio_timestamp - elapsed;
|
||||
|
||||
if lead > self.max_lead_time {
|
||||
let sleep_secs = lead - self.max_lead_time;
|
||||
trace!(
|
||||
"{} broadcaster: audio ahead by {:.3}s, sleeping {:.3}s",
|
||||
self.label, lead, sleep_secs
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs_f64(sleep_secs)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user