Compare commits
463 Commits
push-puzom
...
push-lxpmr
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f5fa69e1a | |||
| 6d9c50d454 | |||
| 6b5282903e | |||
| d6f8d94ca5 | |||
| 86711c1e48 | |||
| 18d12fa088 | |||
| 7c901ace41 | |||
| 3e980af484 | |||
| ea6ddf90c0 | |||
| d4917e159a | |||
| d35e17ae95 | |||
| 4124fe2083 | |||
| 0348397173 | |||
| bfb8f588b8 | |||
| 9574ccbe0b | |||
| 090ab9060b | |||
| eab891aec7 | |||
| 7084707a4a | |||
| c3588de340 | |||
| a753fbf910 | |||
| 4713b7b121 | |||
| 1a7e5dd790 | |||
| 5f7ffe1bbe | |||
| 3f9798be9b | |||
| 0264e0c2e1 | |||
| 7de0008cc8 | |||
| 1bf34fe949 | |||
| ca36a102e5 | |||
| d6920703a1 | |||
| 68e6f528e5 | |||
| d2d8111668 | |||
| 90399b408a | |||
| 4bc80dfd08 | |||
| d2f6abf4bb | |||
| 50693aaf7a | |||
| cd19d68703 | |||
| 7389c55970 | |||
| 76132086b6 | |||
| 31ee25c7f2 | |||
| 565e3f81e5 | |||
| 380e105873 | |||
| daef8d6150 | |||
| 4d79c6d883 | |||
| 27aa70d81f | |||
| 2ffd3b5fb5 | |||
| 9f62191830 | |||
| b38d2593b3 | |||
| 75878be564 | |||
| 1432f2507c | |||
| 23705c9c21 | |||
| 72bf73ef10 | |||
| 825254a2bb | |||
| f99ab2a41e | |||
| aeb9332387 | |||
| ea1774f216 | |||
| f434c521e8 | |||
| 7b1d35cebc | |||
| 14f6b0b379 | |||
| 0e70a91ff9 | |||
| f096a8cd8f | |||
| 2108e7ce39 | |||
| cf2636a992 | |||
| bed0eca7f6 | |||
| 3ec692ea6f | |||
| 6cbf4791cd | |||
| 0a03f72467 | |||
| cf3f0afde4 | |||
| 8f043a4d80 | |||
| 6ae06b38e1 | |||
| a559375176 | |||
| 503b8bc4ff | |||
| 7562801989 | |||
| 128fa823a9 | |||
| 4e979a2d79 | |||
| b04a90818f | |||
| 216fdfa744 | |||
| bd0544820a | |||
| 6589279bb3 | |||
| c972182b3b | |||
| f2dcd55549 | |||
| 996a2096d0 | |||
| 9e8491710c | |||
| 3d1673157b | |||
| 0b31d75021 | |||
| ccd2112305 | |||
| e21fa5948e | |||
| d55c22a267 | |||
| d1ca476c4f | |||
| aeddcc9c64 | |||
| 3957e17f64 | |||
| b7995a14a1 | |||
| 17edc082b1 | |||
| 792ac5cb95 | |||
| a51d7c551e | |||
| e00d0ce4d8 | |||
| f7f2364da6 | |||
| f7c89d8000 | |||
| 75e4ad28b7 | |||
| 7fd8d50b8b | |||
| 12389dd7c1 | |||
| ac93d09212 | |||
| a0f723e2d2 | |||
| 1ef9a8a932 | |||
| 77e5eb047e | |||
| cd47266fc8 | |||
| 9be6835ddc | |||
| 9bc0c2544d | |||
| cbe197da9c | |||
| 765070c4b0 | |||
| 66416dafa8 | |||
| c81a4651d6 | |||
| 97a383c079 | |||
| 58c4383023 | |||
| d9ad056933 | |||
| 4e27255305 | |||
| 1c2d30cbe9 | |||
|
|
de84cbafbb | ||
|
|
fd5cead8d4 | ||
|
|
9b36dfd5ab | ||
|
|
9a7e4d9f00 | ||
|
|
6eef3ab91c | ||
|
|
46dee1de7b | ||
|
|
ba3ef23e67 | ||
|
|
4a3cefa2fd | ||
|
|
0129fd49cc | ||
|
|
4cdc65dac1 | ||
|
|
3219a8beda | ||
|
|
ae5c65db4a | ||
|
|
1e8eedc253 | ||
|
|
4d03ce9f74 | ||
|
|
4e42ccbb03 | ||
|
|
290a55a25f | ||
|
|
f783244477 | ||
|
|
2ee08d4650 | ||
|
|
216d2b9205 | ||
|
|
8a8843bbf1 | ||
|
|
69cb3eb80a | ||
|
|
8eafff0f0c | ||
|
|
dbb809261a | ||
|
|
e44bef2021 | ||
|
|
ac2d5c9501 | ||
|
|
215b097f4b | ||
|
|
b3f22d1b61 | ||
|
|
dc50e5d12b | ||
|
|
8ba6c1365a | ||
|
|
4bae8b00dd | ||
|
|
81b990a828 | ||
|
|
a8d31353c3 | ||
|
|
fd421cfc80 | ||
|
|
7fb953c5a3 | ||
|
|
49630f4e54 | ||
|
|
684187b6ce | ||
|
|
3ad6f1ec61 | ||
|
|
d4508e603f | ||
|
|
acf504aaec | ||
|
|
8518740f3e | ||
|
|
79254ea4a3 | ||
|
|
d9bc1cfc03 | ||
|
|
1f5884627c | ||
|
|
5c29f55942 | ||
|
|
aceb9ab2a1 | ||
|
|
2b47f851b6 | ||
|
|
fe428301d0 | ||
|
|
98a07cf737 | ||
|
|
d595476aba | ||
|
|
55c66f0462 | ||
|
|
9043e54076 | ||
|
|
941fbbed71 | ||
|
|
4884fddf0e | ||
|
|
b25a4f9fb3 | ||
|
|
419bc35a46 | ||
|
|
07dcc5bef1 | ||
|
|
ea62d11f64 | ||
|
|
cdf24b0143 | ||
|
|
befdd90149 | ||
|
|
c9a71df250 | ||
|
|
96ee568840 | ||
|
|
56abb68c0d | ||
|
|
b6723e529d | ||
|
|
074f943a6a | ||
|
|
f1d74d1ca6 | ||
|
|
dfd71e4d37 | ||
|
|
25eb705f59 | ||
|
|
f1224516e0 | ||
|
|
413047cce5 | ||
|
|
483ec26dfe | ||
|
|
03bc9c9ae7 | ||
|
|
019c0124e0 | ||
|
|
9e1ab7198a | ||
|
|
175a280b78 | ||
|
|
018d689189 | ||
|
|
dba9f668f6 | ||
|
|
4ac81fecac | ||
|
|
7e81a8e777 | ||
|
|
58e6753a81 | ||
|
|
d8594e72ad | ||
|
|
0df18fc637 | ||
|
|
a8f9e19f4a | ||
|
|
ed0bbfbf69 | ||
|
|
d9b1f8cf59 | ||
|
|
ff859372cf | ||
|
|
a5a2ea1181 | ||
|
|
b0c08c3c8c | ||
|
|
78004b0327 | ||
|
|
a0dd3278ad | ||
|
|
64586721b9 | ||
|
|
3bd2a33497 | ||
|
|
c92ad696de | ||
|
|
7fbb2c418b | ||
|
|
f23e43b5ea | ||
|
|
f3d56f4150 | ||
|
|
427c527810 | ||
|
|
06f514e6c6 | ||
|
|
fdd07842df | ||
|
|
3b229eab28 | ||
|
|
46d99bd96c | ||
|
|
818d7ce31a | ||
|
|
ac99176ee6 | ||
|
|
05920b52f6 | ||
|
|
e4e3e91ecb | ||
|
|
ad3c92eadc | ||
|
|
15eb4da669 | ||
|
|
590cd655d6 | ||
|
|
b629d57c34 | ||
|
|
4803c6ab73 | ||
|
|
4e5b8a9148 | ||
|
|
e06a8d95df | ||
|
|
a6cb7ac5e1 | ||
|
|
9d1aa3ea3d | ||
|
|
123bac1fdf | ||
|
|
4e775e2119 | ||
|
|
a0b6463273 | ||
|
|
95b7a9015c | ||
|
|
df06bc74fd | ||
|
|
50fed84a1b | ||
|
|
5337615c6a | ||
|
|
c047e625d8 | ||
|
|
9871cfcb70 | ||
|
|
e60535ed3c | ||
|
|
282b4cfad6 | ||
|
|
06a2797479 | ||
|
|
30d30739bd | ||
|
|
8076ed5a48 | ||
|
|
bf1de53952 | ||
|
|
6b3851de19 | ||
|
|
ff4ebcdfa5 | ||
|
|
971f0ba9d6 | ||
|
|
83e9cca756 | ||
|
|
6a7ba01102 | ||
|
|
aa916fa1ac | ||
|
|
33fefa7d33 | ||
|
|
4110b3875a | ||
|
|
98bf45cd27 | ||
|
|
bac4a94cad | ||
|
|
0a54db5963 | ||
|
|
cc3e31dbd0 | ||
|
|
27f7b0a304 | ||
|
|
23af037f36 | ||
|
|
6dd3ef6c61 | ||
|
|
7cefc51020 | ||
|
|
8ff74da191 | ||
|
|
78cbded701 | ||
|
|
010d233920 | ||
|
|
69831a17df | ||
|
|
9b45be87d6 | ||
|
|
74d8788463 | ||
|
|
f80ebd9f3d | ||
|
|
23e07fb6cf | ||
|
|
f8e09939ba | ||
|
|
1817d1becc | ||
|
|
77469d5ee8 | ||
|
|
ce63cbffb3 | ||
|
|
28e33dc26f | ||
|
|
094c4af082 | ||
|
|
dbfa392429 | ||
|
|
fc5b0288b7 | ||
|
|
17dec3351e | ||
|
|
ec61af7b71 | ||
|
|
c2b78fa040 | ||
|
|
1d8bdfa30c | ||
|
|
e084e75faa | ||
|
|
8b0317f88d | ||
|
|
92c53ed3a5 | ||
|
|
5d88d3a25e | ||
|
|
701decfc55 | ||
|
|
3b1b81a893 | ||
| 4332aa9b89 | |||
| 134d769374 | |||
| 88349e7797 | |||
| 8389d1a78e | |||
| 83d7520840 | |||
| 0603da2998 | |||
| 157baadcfd | |||
| 7d907e7429 | |||
| 5f29496d79 | |||
| e743c8affe | |||
| 0cd2b6a64a | |||
| a14210345c | |||
| 56b3ec8285 | |||
| 0351d17fee | |||
| a6ed30e0c7 | |||
| b4a8925281 | |||
| b004c8a8bf | |||
| a1170a8689 | |||
| f77376e07c | |||
| 61b15f290d | |||
| 5b60fdbe1d | |||
| e82979561a | |||
| 7c1485ae72 | |||
| 107e657666 | |||
| bc1d0ef275 | |||
| 162265c661 | |||
| da2d45c12d | |||
| 36949a1da8 | |||
| dd5ed5e890 | |||
| fe984edb7b | |||
| 93db9c25e7 | |||
| 09cdd3f516 | |||
| 24502ff53b | |||
| dea7062038 | |||
| aae941b7cb | |||
| c5569cdcf2 | |||
| 4d510431d3 | |||
| 1d0a1c22f4 | |||
| d49a1e54d6 | |||
| 058db96ade | |||
| 55be74bd94 | |||
| d2b9cbf93b | |||
| e5fbf372c4 | |||
| de40b5b90c | |||
| fc093743c1 | |||
| e3c17bf131 | |||
| df138565af | |||
| 4b29d12859 | |||
| 882b6e2886 | |||
| a64a5f6a6a | |||
| 2ab526464d | |||
| 6c40b93089 | |||
| 37c3d9daf6 | |||
| ffaa56fac9 | |||
| 1567beee2e | |||
| f510e59b1a | |||
| f5f6bcf3ab | |||
| b6e439dcf6 | |||
| 2632233dbd | |||
| ec89a227f2 | |||
| f3e6c59143 | |||
| 1371f5d1d8 | |||
| 31dba7d84a | |||
| 776c535862 | |||
| 90a7b8b6fa | |||
| da9684812a | |||
| 749f96c168 | |||
| 3ab0c58e4a | |||
| 8be1e50681 | |||
| 4d15fb6ea2 | |||
| 4c9233e249 | |||
| e9109a8a0c | |||
| 7a8562fe8e | |||
| 032b55a6f1 | |||
| f00260ac4d | |||
| 3ae20d0e3a | |||
| 5c36116092 | |||
| 203907dfaf | |||
| cf12567ae5 | |||
| 4cd6b125a4 | |||
| ebc15b0518 | |||
| c1e0151a11 | |||
| d768ab7fd5 | |||
| 2e4b69e758 | |||
| fdd6cb6401 | |||
| 2f9abff6f1 | |||
| f94a600ff8 | |||
| 8b3cb719a9 | |||
| e67bca60e1 | |||
| c496dd86ed | |||
| 0ddf7d9637 | |||
| 4e5f86730d | |||
| f9dc2ecb12 | |||
| d657ea2315 | |||
| bd61fdba81 | |||
| fef3b6f645 | |||
| 5c06a0f0e9 | |||
| e0fbb11475 | |||
| 68b0c89130 | |||
| 5eef699a60 | |||
| 8ccb3a26ec | |||
| 447af737a6 | |||
| ffecc219b5 | |||
| 11187db71b | |||
| fb94886c53 | |||
| a17b77a840 | |||
| 3fa53c1ba8 | |||
| d29b6cc1fb | |||
| bdb4d72f50 | |||
| 708cefd5ab | |||
| 4a8deae567 | |||
| 2c8a1325b4 | |||
| dca6fcb565 | |||
| 83fa3287f4 | |||
| 4431a3b50b | |||
| 982e5317fb | |||
| 5ace866ee0 | |||
| fd1a44b6fa | |||
| e5c83d01c0 | |||
| 2ec567ebf3 | |||
| 115658bf6a | |||
| c409c6eb1b | |||
| ff2d6b23bd | |||
| adb6555aa6 | |||
| adeb37ce83 | |||
| 428d54641e | |||
| adb316a6ac | |||
| abcf4ebaf9 | |||
| 02c2788ecc | |||
| 87859397df | |||
| a648931960 | |||
| 4b65795972 | |||
| 73342ea3ec | |||
| 2a46e4574f | |||
| bcb95c6be1 | |||
| 2776aac2a6 | |||
| 7e193fb230 | |||
| 24da9eb418 | |||
| f6503e4d22 | |||
| fffb26cd56 | |||
| ae3fed8217 | |||
| 1a6b52fd35 | |||
| c06f7d8028 | |||
| 7e5353abf8 | |||
| c316e7f1fe | |||
| 921a29fa9f | |||
| a4001ae691 | |||
| d10c61913a | |||
| 98bafd4e93 | |||
| 79cfc6d3c9 | |||
| f5eba06476 | |||
| e8b42aa284 | |||
| 26a57b1b50 | |||
| 3385c72d1a | |||
| 5f65b8c1ed | |||
| 96d2a95798 | |||
| 590df8f2a2 | |||
| 29f0add92b | |||
| e4b56b75cb | |||
| b8370a552a | |||
| 85e053aad0 | |||
| a5e99ccfd3 | |||
| cf24262084 | |||
| 6f1207e15f | |||
| 9deccabb98 | |||
| aefdee92b8 | |||
| 5266d68d2f | |||
| 437aeb84e5 | |||
| ac21d5fd9e | |||
| cbe5a4e1f4 | |||
| f7f14d9f62 | |||
| 40d791103d | |||
| 6024f2db60 | |||
| 96a48ffa54 | |||
| d728a47224 | |||
| 022b178b3f | |||
| 21caf3cac2 |
70
.dockerignore
Normal file
70
.dockerignore
Normal file
@@ -0,0 +1,70 @@
|
||||
# Build artifacts
|
||||
target/
|
||||
**/target/
|
||||
|
||||
# Webapp build artifacts and dependencies
|
||||
pmoapp/webapp/node_modules/
|
||||
pmoapp/webapp/dist/
|
||||
**/node_modules/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.jj/
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Cache directories
|
||||
cache/
|
||||
.pmomusic/
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# Documentation
|
||||
doc/
|
||||
*.md
|
||||
!Readme.md
|
||||
|
||||
# Test files
|
||||
test_upnp/
|
||||
examples/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.pcap
|
||||
|
||||
# Database files
|
||||
db/
|
||||
|
||||
# Old code and backups
|
||||
old_code/
|
||||
*.txt
|
||||
!Cargo.lock
|
||||
|
||||
# Development tools
|
||||
tools/
|
||||
gupnp-tools/
|
||||
|
||||
# Build scripts (we have them in the Dockerfile)
|
||||
Makefile
|
||||
setup-deps.sh
|
||||
setup-env.sh
|
||||
|
||||
# SVG and other assets not needed for runtime
|
||||
*.svg
|
||||
|
||||
# Audio test files
|
||||
*.ogg
|
||||
*.flac
|
||||
*.wav
|
||||
*.mp3
|
||||
29
.gitea/workflows/build-push.yaml
Normal file
29
.gitea/workflows/build-push.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main # Changez cela si votre branche principale a un autre nom
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Setup cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: dont-cache-${{ github.run_id }}
|
||||
- name: Build and push image
|
||||
uses: https://gargoton.petite-maison-orange.fr/pmo-actions/build-push-image@main
|
||||
with:
|
||||
image_name: public/pmomusic
|
||||
image_tags: latest
|
||||
no_cache: true
|
||||
version_file: version.txt
|
||||
check_uuid: 82a30d23-b3bd-4199-9237-776965831d20
|
||||
|
||||
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -36,4 +36,10 @@ all.txt
|
||||
pmo_src.txt
|
||||
upmpdcli/
|
||||
/*.xml
|
||||
test_upnp
|
||||
test_upnp*.cargo/
|
||||
.cargo/
|
||||
setup-env.sh
|
||||
cache
|
||||
gupnp-tools
|
||||
pmo*_[0_9]*.txt
|
||||
webapp_[0_9]*.txt
|
||||
@@ -17,8 +17,8 @@ host:
|
||||
udn: uuid:28963b75-4c5f-4da7-b10e-ffafd
|
||||
accounts:
|
||||
qobuz:
|
||||
username: eric@coissac.eu
|
||||
password: '*Misfcr73110$'
|
||||
username: your-email@example.com
|
||||
password: 'YOUR_PASSWORD_HERE'
|
||||
devices:
|
||||
mediarenderer:
|
||||
pmo_mediarenderer:
|
||||
121
BROWSEMETADATA_FIX.md
Normal file
121
BROWSEMETADATA_FIX.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# BrowseMetadata Fix for Radio Paradise - PMO Music
|
||||
|
||||
**Date:** 2025-11-27
|
||||
**Issue:** gupnp-av-cp failed to get metadata for live streams and history containers
|
||||
|
||||
## Problem
|
||||
|
||||
UPnP clients (like gupnp-av-cp) were unable to get metadata for:
|
||||
- Live stream items (e.g., `radio-paradise:channel:mellow:live`)
|
||||
- History containers (e.g., `radio-paradise:channel:mellow:history`)
|
||||
|
||||
Error:
|
||||
```
|
||||
Failed to get metadata for 'radio-paradise:channel:mellow:live'
|
||||
Failed to get metadata for 'radio-paradise:channel:mellow:history'
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
The UPnP ContentDirectory service has two browse modes:
|
||||
- **BrowseMetadata**: Get metadata for a specific object (item or container)
|
||||
- **BrowseDirectChildren**: Get the children of a container
|
||||
|
||||
The `ContentHandler::browse_metadata()` was calling `source.browse()` for all objects, but:
|
||||
1. The `MusicSource::browse()` trait method is designed to return children, not object metadata
|
||||
2. For leaf items (LiveStream, HistoryTrack), `RadioParadiseSource::browse()` was rejecting them as "cannot be browsed"
|
||||
3. The trait doesn't provide a way to distinguish between BrowseMetadata and BrowseDirectChildren requests
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Modified ContentHandler ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
|
||||
|
||||
- `browse_metadata()` now tries `get_item()` first for leaf items before falling back to `browse()`
|
||||
- This allows proper metadata retrieval for items (LiveStream, HistoryTrack)
|
||||
|
||||
### 2. Modified RadioParadiseSource ([pmoparadise/src/source.rs](pmoparadise/src/source.rs))
|
||||
|
||||
**For LiveStream items:**
|
||||
- `browse()` now returns `BrowseResult::Items([live_item])` with the item's metadata
|
||||
- This supports both BrowseMetadata (via ContentHandler) and direct browse calls
|
||||
|
||||
**For HistoryTrack items:**
|
||||
- `browse()` now returns `BrowseResult::Items([track])` using `get_item()` internally
|
||||
- Properly retrieves track metadata from the history playlist
|
||||
|
||||
**For History containers:**
|
||||
- `browse()` now returns `BrowseResult::Mixed { containers: [history_container], items: [tracks] }`
|
||||
- Provides both container metadata and its children in one result
|
||||
|
||||
### 3. Added Container Filtering ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
|
||||
|
||||
- `browse_result_to_didl()` now filters out containers that match the browsed `object_id`
|
||||
- Prevents containers from appearing as children of themselves
|
||||
- For History: BrowseDirectChildren returns only tracks, not the container
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. ✅ [pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs)
|
||||
- Lines 120-135: Try get_item() first in browse_metadata()
|
||||
- Lines 290-310: Added object_id parameter and container filtering in browse_result_to_didl()
|
||||
- Lines 212, 286: Updated callers to pass object_id
|
||||
|
||||
2. ✅ [pmoparadise/src/source.rs](pmoparadise/src/source.rs)
|
||||
- Lines 345-369: Modified History browse to return Mixed (container + items)
|
||||
- Lines 371-377: Modified LiveStream browse to return item metadata
|
||||
- Lines 379-383: Modified HistoryTrack browse to return track metadata
|
||||
|
||||
## Validation
|
||||
|
||||
### Live Stream Metadata ✅
|
||||
```bash
|
||||
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:live
|
||||
```
|
||||
Returns:
|
||||
```xml
|
||||
<item id="radio-paradise:channel:mellow:live" parentID="radio-paradise:channel:mellow">
|
||||
<dc:title>Unknown Title</dc:title>
|
||||
<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>
|
||||
<res protocolInfo="http-get:*:audio/flac:*">http://.../radioparadise/stream/mellow/flac</res>
|
||||
</item>
|
||||
```
|
||||
|
||||
### History Container Metadata ✅
|
||||
```bash
|
||||
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:history
|
||||
```
|
||||
Returns:
|
||||
```xml
|
||||
<container id="radio-paradise:channel:mellow:history" parentID="radio-paradise:channel:mellow">
|
||||
<dc:title>Mellow Mix - History</dc:title>
|
||||
<upnp:class>object.container.playlistContainer</upnp:class>
|
||||
</container>
|
||||
```
|
||||
|
||||
### History Children ✅
|
||||
```bash
|
||||
curl -X POST -H "SOAPAction: ..." BrowseDirectChildren radio-paradise:channel:mellow:history
|
||||
```
|
||||
Returns only track items (not the container itself)
|
||||
|
||||
## Design Notes
|
||||
|
||||
This solution works around a fundamental limitation in the `MusicSource` trait:
|
||||
- The `browse()` method doesn't receive the `browse_flag` parameter
|
||||
- It can't distinguish between BrowseMetadata and BrowseDirectChildren
|
||||
- We use `get_item()` for items and `browse()` for containers
|
||||
- Container filtering ensures correct BrowseDirectChildren behavior
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] BrowseMetadata works for LiveStream items
|
||||
- [x] BrowseMetadata works for History containers
|
||||
- [x] BrowseDirectChildren works for History (returns only tracks)
|
||||
- [x] Container filtering prevents self-reference
|
||||
- [ ] Test with BubbleUPnP (user to verify)
|
||||
- [ ] Test with gupnp-av-cp (user to verify)
|
||||
|
||||
## References
|
||||
|
||||
- Original issue report: [UPNP_FIX_SUMMARY.md](UPNP_FIX_SUMMARY.md)
|
||||
- UPnP AV Architecture: https://openconnectivity.org/developer/specifications/upnp-resources/upnp/
|
||||
2008
Cargo.lock
generated
2008
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -19,5 +19,5 @@ members = [
|
||||
"pmosource",
|
||||
"pmoplaylist",
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
"pmometadata", "pmocontrol",
|
||||
]
|
||||
|
||||
72
DEPENDENCIES.md
Normal file
72
DEPENDENCIES.md
Normal file
@@ -0,0 +1,72 @@
|
||||
## Diagramme des dépendances PMOMusic (crates internes)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
|
||||
PMOMusic --> pmoapp
|
||||
PMOMusic --> pmoaudio_ext
|
||||
PMOMusic --> pmoaudiocache
|
||||
PMOMusic --> pmocovers
|
||||
PMOMusic --> pmoconfig
|
||||
PMOMusic --> pmoserver
|
||||
PMOMusic --> pmosource
|
||||
PMOMusic --> pmoupnp
|
||||
PMOMusic --> pmomediaserver
|
||||
PMOMusic --> pmomediarenderer
|
||||
PMOMusic --> pmoqobuz
|
||||
PMOMusic --> pmoparadise
|
||||
|
||||
pmoaudio_ext --> pmoaudio
|
||||
pmoaudio_ext --> pmocovers
|
||||
pmoaudio_ext --> pmocache
|
||||
pmoaudio_ext --> pmoaudiocache
|
||||
pmoaudio_ext --> pmometadata
|
||||
pmoaudio_ext --> pmoplaylist
|
||||
|
||||
pmoaudiocache --> pmocache
|
||||
pmoaudiocache --> pmometadata
|
||||
|
||||
pmocovers --> pmocache
|
||||
|
||||
pmoplaylist --> pmocache
|
||||
pmoplaylist --> pmoaudiocache
|
||||
pmoplaylist --> pmometadata
|
||||
pmoplaylist --> pmodidl
|
||||
|
||||
pmosource --> pmoaudiocache
|
||||
pmosource --> pmocovers
|
||||
pmosource --> pmocache
|
||||
pmosource --> pmoplaylist
|
||||
pmosource --> pmodidl
|
||||
pmosource --> pmoconfig
|
||||
pmosource --> pmoserver
|
||||
pmosource --> pmoupnp
|
||||
|
||||
pmoparadise --> pmosource
|
||||
pmoparadise --> pmoaudiocache
|
||||
pmoparadise --> pmoplaylist
|
||||
pmoparadise --> pmoserver
|
||||
pmoparadise --> pmoconfig
|
||||
|
||||
pmoqobuz --> pmosource
|
||||
pmoqobuz --> pmoaudiocache
|
||||
pmoqobuz --> pmocovers
|
||||
pmoqobuz --> pmoserver
|
||||
pmoqobuz --> pmoconfig
|
||||
|
||||
pmomediaserver --> pmoserver
|
||||
pmomediaserver --> pmosource
|
||||
pmomediaserver --> pmoconfig
|
||||
pmomediaserver --> pmocovers
|
||||
pmomediaserver --> pmoaudiocache
|
||||
pmomediaserver --> pmoplaylist
|
||||
|
||||
pmoupnp --> pmoserver
|
||||
pmoupnp --> pmocovers
|
||||
pmoupnp --> pmoaudiocache
|
||||
pmoupnp --> pmoplaylist
|
||||
pmoupnp --> pmocache
|
||||
pmoupnp --> pmoconfig
|
||||
```
|
||||
|
||||
> Flèches = “dépend de”. Dépendances externes non représentées. Cette vue correspond aux features activées par défaut dans la workspace.
|
||||
302
DOCKER.md
Normal file
302
DOCKER.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# Docker Deployment Guide for PMOMusic
|
||||
|
||||
Ce guide explique comment construire et déployer PMOMusic avec Docker.
|
||||
|
||||
## Architecture
|
||||
|
||||
Le Dockerfile utilise une approche multi-stage pour créer une image minimale :
|
||||
|
||||
1. **Stage 1 (webapp-builder)** : Compile l'application Vue.js avec Node.js
|
||||
2. **Stage 2 (rust-builder)** : Compile le binaire Rust avec toutes ses dépendances
|
||||
3. **Stage 3 (runtime)** : Image finale minimale Debian Slim avec uniquement le binaire et les bibliothèques runtime
|
||||
|
||||
### Avantages
|
||||
|
||||
- **Binaire auto-contenu** : L'application web est embarquée dans le binaire Rust
|
||||
- **Image minimale** : ~200-300MB (vs plusieurs GB pour les images de build)
|
||||
- **Sécurité** : Exécution en tant qu'utilisateur non-root
|
||||
- **Reproductibilité** : Build complet et déterministe
|
||||
|
||||
## Build de l'image
|
||||
|
||||
### Option 1 : Build manuel avec Docker
|
||||
|
||||
```bash
|
||||
# Build l'image
|
||||
docker build -t pmomusic:latest .
|
||||
|
||||
# Le build prend environ 10-15 minutes selon votre machine
|
||||
```
|
||||
|
||||
### Option 2 : Build avec docker-compose
|
||||
|
||||
```bash
|
||||
# Build et démarre le conteneur
|
||||
docker-compose up --build
|
||||
|
||||
# Ou juste build
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
### Build optimisé avec cache
|
||||
|
||||
Pour accélérer les builds successifs, Docker réutilise les couches en cache :
|
||||
|
||||
```bash
|
||||
# Build avec cache
|
||||
docker build -t pmomusic:latest .
|
||||
|
||||
# Build sans cache (force rebuild complet)
|
||||
docker build --no-cache -t pmomusic:latest .
|
||||
```
|
||||
|
||||
## Exécution du conteneur
|
||||
|
||||
### Option 1 : Avec docker-compose (recommandé)
|
||||
|
||||
```bash
|
||||
# Démarrer en arrière-plan
|
||||
docker-compose up -d
|
||||
|
||||
# Voir les logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Arrêter
|
||||
docker-compose down
|
||||
|
||||
# Redémarrer
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### Option 2 : Avec docker run
|
||||
|
||||
```bash
|
||||
# Run en mode interactif
|
||||
docker run -it --rm \
|
||||
--name pmomusic \
|
||||
--network host \
|
||||
-v $(pwd)/config:/home/pmomusic/.pmomusic \
|
||||
-v $(pwd)/cache:/home/pmomusic/cache \
|
||||
pmomusic:latest
|
||||
|
||||
# Run en mode détaché
|
||||
docker run -d \
|
||||
--name pmomusic \
|
||||
--network host \
|
||||
--restart unless-stopped \
|
||||
-v $(pwd)/config:/home/pmomusic/.pmomusic \
|
||||
-v $(pwd)/cache:/home/pmomusic/cache \
|
||||
pmomusic:latest
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Ports
|
||||
|
||||
Par défaut, PMOMusic écoute sur le port **8080**. Vous pouvez modifier cela :
|
||||
|
||||
- Dans `docker-compose.yml` : modifier la section `ports`
|
||||
- Avec `docker run` : utiliser `-p 8080:8080`
|
||||
|
||||
### Volumes
|
||||
|
||||
Deux volumes sont recommandés pour la persistance :
|
||||
|
||||
- **Configuration** : `/home/pmomusic/.pmomusic` - Fichiers de configuration
|
||||
- **Cache** : `/home/pmomusic/cache` - Cache audio et métadonnées
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
Configurable via `docker-compose.yml` ou `-e` avec `docker run` :
|
||||
|
||||
```bash
|
||||
# Niveau de logs Rust
|
||||
RUST_LOG=debug
|
||||
|
||||
# Autres variables (selon votre configuration)
|
||||
# ...
|
||||
```
|
||||
|
||||
### Réseau
|
||||
|
||||
Pour UPnP/DLNA, utilisez **network_mode: host** pour permettre :
|
||||
- La découverte multicast
|
||||
- La communication avec les devices UPnP sur le réseau local
|
||||
|
||||
**Note** : Le mode `host` ne fonctionne que sur Linux. Sur macOS/Windows avec Docker Desktop, utilisez le mapping de ports standard.
|
||||
|
||||
## Gestion de l'image
|
||||
|
||||
### Taille de l'image
|
||||
|
||||
```bash
|
||||
# Voir la taille de l'image
|
||||
docker images pmomusic:latest
|
||||
|
||||
# Résultat attendu : ~200-300MB
|
||||
```
|
||||
|
||||
### Nettoyage
|
||||
|
||||
```bash
|
||||
# Supprimer l'image
|
||||
docker rmi pmomusic:latest
|
||||
|
||||
# Nettoyer les images de build intermédiaires
|
||||
docker builder prune
|
||||
|
||||
# Nettoyer tous les caches Docker (libère beaucoup d'espace)
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
## Build multi-plateforme
|
||||
|
||||
Pour builder pour différentes architectures (ARM64, AMD64) :
|
||||
|
||||
```bash
|
||||
# Créer un builder multi-plateforme
|
||||
docker buildx create --name multiarch --use
|
||||
|
||||
# Build pour AMD64 et ARM64
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t pmomusic:latest \
|
||||
--push \
|
||||
.
|
||||
|
||||
# Note : nécessite un registry Docker (Docker Hub, GHCR, etc.)
|
||||
```
|
||||
|
||||
## Déploiement en production
|
||||
|
||||
### 1. Avec docker-compose (simple)
|
||||
|
||||
```bash
|
||||
# Sur le serveur de production
|
||||
git clone <votre-repo>
|
||||
cd pmomusic
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 2. Avec un registry Docker (recommandé)
|
||||
|
||||
```bash
|
||||
# Sur votre machine de dev
|
||||
docker build -t yourregistry.com/pmomusic:v1.0.0 .
|
||||
docker push yourregistry.com/pmomusic:v1.0.0
|
||||
|
||||
# Sur le serveur de production
|
||||
docker pull yourregistry.com/pmomusic:v1.0.0
|
||||
docker run -d ... yourregistry.com/pmomusic:v1.0.0
|
||||
```
|
||||
|
||||
### 3. Avec un orchestrateur (Kubernetes, Docker Swarm)
|
||||
|
||||
Créer un fichier de déploiement approprié selon votre orchestrateur.
|
||||
|
||||
## Debugging
|
||||
|
||||
### Logs du conteneur
|
||||
|
||||
```bash
|
||||
# Logs en temps réel
|
||||
docker logs -f pmomusic
|
||||
|
||||
# Logs avec docker-compose
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### Entrer dans le conteneur
|
||||
|
||||
```bash
|
||||
# Shell interactif (bash n'est pas disponible, utiliser sh)
|
||||
docker exec -it pmomusic sh
|
||||
|
||||
# Vérifier les processus
|
||||
docker exec -it pmomusic ps aux
|
||||
|
||||
# Vérifier les fichiers
|
||||
docker exec -it pmomusic ls -la /home/pmomusic
|
||||
```
|
||||
|
||||
### Health check
|
||||
|
||||
Le conteneur inclut un health check. Vérifier l'état :
|
||||
|
||||
```bash
|
||||
# Voir l'état de santé
|
||||
docker inspect --format='{{.State.Health.Status}}' pmomusic
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Le build échoue
|
||||
|
||||
1. **Erreur de dépendances npm** :
|
||||
- Vérifier que `pmoapp/webapp/package.json` est correct
|
||||
- Essayer `docker build --no-cache`
|
||||
|
||||
2. **Erreur de compilation Rust** :
|
||||
- Vérifier que tous les fichiers Cargo.toml sont présents
|
||||
- Vérifier les dépendances système (libsoxr, libasound2)
|
||||
|
||||
3. **Out of memory** :
|
||||
- Augmenter la mémoire allouée à Docker Desktop (settings)
|
||||
- Utiliser `--memory` pour limiter la mémoire du build
|
||||
|
||||
### Le conteneur ne démarre pas
|
||||
|
||||
1. **Port déjà utilisé** :
|
||||
```bash
|
||||
# Vérifier quel processus utilise le port 8080
|
||||
sudo lsof -i :8080
|
||||
```
|
||||
|
||||
2. **Permissions** :
|
||||
- Vérifier les permissions des volumes montés
|
||||
- Le conteneur s'exécute en tant qu'utilisateur `pmomusic` (UID 1000)
|
||||
|
||||
3. **Configuration manquante** :
|
||||
- Créer les répertoires de configuration avant de démarrer :
|
||||
```bash
|
||||
mkdir -p config cache
|
||||
```
|
||||
|
||||
### UPnP ne fonctionne pas
|
||||
|
||||
1. **Network mode** :
|
||||
- Sur Linux : utiliser `network_mode: host`
|
||||
- Sur macOS/Windows : UPnP peut ne pas fonctionner correctement avec Docker Desktop
|
||||
|
||||
2. **Firewall** :
|
||||
- Vérifier que les ports UPnP ne sont pas bloqués
|
||||
- Autoriser le multicast sur le réseau
|
||||
|
||||
## Performance
|
||||
|
||||
### Optimisations du build
|
||||
|
||||
1. **Build cache** : Docker réutilise les couches en cache
|
||||
2. **Multi-stage build** : Réduit la taille de l'image finale
|
||||
3. **Strip des symboles** : Le binaire est strippé pour réduire sa taille
|
||||
|
||||
### Optimisations runtime
|
||||
|
||||
1. **Resource limits** : Définir des limites CPU/mémoire dans docker-compose.yml
|
||||
2. **Volumes** : Utiliser des volumes pour les données persistantes
|
||||
3. **Logs** : Configurer la rotation des logs Docker
|
||||
|
||||
## Sécurité
|
||||
|
||||
- ✅ Exécution en tant qu'utilisateur non-root
|
||||
- ✅ Image minimale (surface d'attaque réduite)
|
||||
- ✅ Pas de secrets dans l'image
|
||||
- ✅ Health checks activés
|
||||
- ✅ Certificats CA inclus pour HTTPS
|
||||
|
||||
## Références
|
||||
|
||||
- [Dockerfile](./Dockerfile)
|
||||
- [docker-compose.yml](./docker-compose.yml)
|
||||
- [.dockerignore](./.dockerignore)
|
||||
- [Documentation Rust](./Readme.md)
|
||||
117
Dockerfile
Normal file
117
Dockerfile
Normal file
@@ -0,0 +1,117 @@
|
||||
# ===================================
|
||||
# Stage 1: Build Vue.js webapp
|
||||
# ===================================
|
||||
FROM node:22-alpine AS webapp-builder
|
||||
|
||||
WORKDIR /webapp
|
||||
|
||||
# Copy webapp package files
|
||||
COPY pmoapp/webapp/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --production=false
|
||||
|
||||
# Copy webapp source
|
||||
COPY pmoapp/webapp/ ./
|
||||
|
||||
# Build the webapp
|
||||
RUN npm run build
|
||||
|
||||
# ===================================
|
||||
# Stage 2: Build Rust binary
|
||||
# ===================================
|
||||
FROM rustlang/rust:nightly-bookworm AS rust-builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install system dependencies for building
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsoxr-dev \
|
||||
libasound2-dev \
|
||||
pkg-config \
|
||||
cmake \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy Cargo workspace files
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# Copy all crates
|
||||
COPY PMOMusic/ ./PMOMusic/
|
||||
COPY pmoupnp/ ./pmoupnp/
|
||||
COPY pmomediarenderer/ ./pmomediarenderer/
|
||||
COPY pmomediaserver/ ./pmomediaserver/
|
||||
COPY pmoconfig/ ./pmoconfig/
|
||||
COPY pmoutils/ ./pmoutils/
|
||||
COPY pmodidl/ ./pmodidl/
|
||||
COPY pmoserver/ ./pmoserver/
|
||||
COPY pmocache/ ./pmocache/
|
||||
COPY pmocovers/ ./pmocovers/
|
||||
COPY pmoaudiocache/ ./pmoaudiocache/
|
||||
COPY pmoaudio/ ./pmoaudio/
|
||||
COPY pmoqobuz/ ./pmoqobuz/
|
||||
COPY pmoparadise/ ./pmoparadise/
|
||||
COPY pmosource/ ./pmosource/
|
||||
COPY pmoplaylist/ ./pmoplaylist/
|
||||
COPY pmoflac/ ./pmoflac/
|
||||
COPY pmometadata/ ./pmometadata/
|
||||
COPY pmocontrol/ ./pmocontrol/
|
||||
COPY pmoaudio-ext/ ./pmoaudio-ext/
|
||||
COPY pmoapp/ ./pmoapp/
|
||||
|
||||
# Copy the webapp dist from previous stage
|
||||
COPY --from=webapp-builder /webapp/dist ./pmoapp/webapp/dist/
|
||||
|
||||
# Build the Rust binary in release mode
|
||||
RUN cargo build --release --bin PMOMusic
|
||||
|
||||
# Strip debug symbols to reduce binary size
|
||||
RUN strip /build/target/release/PMOMusic
|
||||
|
||||
# ===================================
|
||||
# Stage 3: Minimal runtime image
|
||||
# ===================================
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG BUILD_DATE
|
||||
LABEL org.opencontainers.image.created=$BUILD_DATE
|
||||
LABEL org.opencontainers.image.title="PMOMusic UPNP Music Server"
|
||||
LABEL org.opencontainers.image.version="0.1"
|
||||
LABEL org.opencontainers.image.authors="eric@coissac.eu"
|
||||
LABEL org.opencontainers.image.licenses="CeCILL-2.0"
|
||||
LABEL org.opencontainers.image.source="https://gargoton.petite-maison-orange.fr/eric/pmomusic"
|
||||
LABEL org.opencontainers.image.description="PMOMusic est une application Rust proposant en un binaire unique une Serveur de Musique, et une point de controle UPNP controlable via une application web."
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsoxr0 \
|
||||
libasound2 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user
|
||||
RUN useradd -m -u 1000 pmomusic
|
||||
|
||||
# Copy the binary from builder
|
||||
COPY --from=rust-builder /build/target/release/PMOMusic /usr/local/bin/PMOMusic
|
||||
|
||||
# Set ownership
|
||||
RUN chown pmomusic:pmomusic /usr/local/bin/PMOMusic
|
||||
|
||||
# Switch to non-root user
|
||||
USER pmomusic
|
||||
|
||||
# Create directories for configuration and cache
|
||||
RUN mkdir -p /home/pmomusic/.pmomusic
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /home/pmomusic
|
||||
|
||||
# Expose default port (adjust if needed)
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check (adjust the URL if needed)
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD ["/usr/local/bin/PMOMusic", "--help"] || exit 1
|
||||
|
||||
# Run the binary
|
||||
ENTRYPOINT ["/usr/local/bin/PMOMusic"]
|
||||
296
INSTALL_LIBSOXR.md
Normal file
296
INSTALL_LIBSOXR.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# Installation des dépendances système sans droits sudo
|
||||
|
||||
Ce document explique comment installer les dépendances système de `pmoaudio` localement sans privilèges administrateur.
|
||||
|
||||
## Dépendances requises
|
||||
|
||||
1. **libsoxr** - Nécessaire pour `ResamplingNode` (resampling audio haute qualité)
|
||||
2. **libasound2** (ALSA) - Nécessaire pour `AudioSink` via cpal (lecture audio sur Linux)
|
||||
|
||||
## Contexte
|
||||
|
||||
Les crates `soxr` et `cpal` nécessitent des bibliothèques système. Dans un environnement sans droits sudo (comme Claude Code), voici comment les installer localement.
|
||||
|
||||
## Méthode : Installation locale via apt-get download
|
||||
|
||||
### 1. Télécharger les packages .deb
|
||||
|
||||
```bash
|
||||
cd ~/.local
|
||||
|
||||
# Pour libsoxr (ResamplingNode)
|
||||
apt-get download libsoxr-dev libsoxr0
|
||||
|
||||
# Pour ALSA (AudioSink)
|
||||
# Note: libasound2t64 contient la bibliothèque partagée, libasound2-dev les headers
|
||||
apt-get download libasound2-dev libasound2t64
|
||||
```
|
||||
|
||||
Cela télécharge les fichiers `.deb` sans les installer système-wide.
|
||||
|
||||
### 2. Extraire les packages
|
||||
|
||||
```bash
|
||||
# Extraire libsoxr
|
||||
dpkg -x libsoxr-dev_*.deb .
|
||||
dpkg -x libsoxr0_*.deb .
|
||||
|
||||
# Extraire ALSA
|
||||
dpkg -x libasound2-dev_*.deb .
|
||||
dpkg -x libasound2t64_*.deb .
|
||||
```
|
||||
|
||||
Les fichiers sont extraits dans `~/.local/usr/lib/x86_64-linux-gnu/` et `~/.local/usr/include/`.
|
||||
|
||||
### 3. Configurer les variables d'environnement
|
||||
|
||||
Ajouter à votre `~/.bashrc` ou exporter dans votre session :
|
||||
|
||||
```bash
|
||||
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||
```
|
||||
|
||||
**IMPORTANT:** Ces variables doivent être définies dans chaque session où vous compilez le projet.
|
||||
|
||||
### 4. Vérifier l'installation
|
||||
|
||||
```bash
|
||||
# Vérifier libsoxr
|
||||
pkg-config --libs --cflags soxr
|
||||
|
||||
# Vérifier ALSA
|
||||
pkg-config --libs --cflags alsa
|
||||
```
|
||||
|
||||
Devrait retourner quelque chose comme :
|
||||
```
|
||||
# soxr
|
||||
-I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr
|
||||
|
||||
# alsa
|
||||
-I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound
|
||||
```
|
||||
|
||||
## Utilisation avec Cargo
|
||||
|
||||
### Pour les builds réguliers
|
||||
|
||||
Les variables d'environnement suffisent pour `cargo build` et `cargo run`.
|
||||
|
||||
### Pour les tests
|
||||
|
||||
Les tests nécessitent également la configuration du linker. Deux options :
|
||||
|
||||
#### Option A : Configuration locale du projet (NON RECOMMANDÉ pour le versioning)
|
||||
|
||||
Créer `.cargo/config.toml` dans chaque crate :
|
||||
|
||||
```toml
|
||||
[build]
|
||||
rustflags = ["-L", "/root/.local/usr/lib/x86_64-linux-gnu"]
|
||||
```
|
||||
|
||||
**⚠️ NE PAS committer ces fichiers** - ils contiennent des chemins spécifiques à votre installation.
|
||||
|
||||
#### Option B : Variables d'environnement pour cargo test
|
||||
|
||||
```bash
|
||||
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Pour d'autres distributions
|
||||
|
||||
### macOS (avec Homebrew)
|
||||
|
||||
```bash
|
||||
brew install libsoxr
|
||||
# Note: ALSA n'est pas nécessaire sur macOS (rodio utilise CoreAudio)
|
||||
```
|
||||
|
||||
### Debian/Ubuntu (avec sudo)
|
||||
|
||||
```bash
|
||||
sudo apt-get install libsoxr-dev libasound2-dev
|
||||
```
|
||||
|
||||
### Fedora/RHEL
|
||||
|
||||
```bash
|
||||
sudo dnf install soxr-devel
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Erreur : "Package 'soxr' was not found" ou "Package 'alsa' was not found"
|
||||
|
||||
- Vérifier que `PKG_CONFIG_PATH` contient le bon chemin
|
||||
- Vérifier que les fichiers `soxr.pc` et `alsa.pc` existent dans ce répertoire
|
||||
|
||||
### Erreur de link : "unable to find library -lsoxr" ou "-lasound"
|
||||
|
||||
- Pour `cargo build` : vérifier `LD_LIBRARY_PATH`
|
||||
- Pour `cargo test` : utiliser la configuration rustflags (Option A ci-dessus)
|
||||
|
||||
### Le test compile mais échoue au runtime
|
||||
|
||||
```
|
||||
error while loading shared libraries: libsoxr.so.0: cannot open shared object file
|
||||
```
|
||||
|
||||
Solution : Ajouter `LD_LIBRARY_PATH` également pour l'exécution :
|
||||
|
||||
```bash
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Guide complet pour environnement Claude Code
|
||||
|
||||
### Configuration initiale (à faire une seule fois)
|
||||
|
||||
Dans une session Claude Code (https://claude.ai/code), vous n'avez pas de droits sudo.
|
||||
|
||||
**🚀 Méthode rapide (recommandée) :**
|
||||
|
||||
```bash
|
||||
# 1. Installation automatique des dépendances (une seule fois)
|
||||
./setup-deps.sh
|
||||
|
||||
# 2. Configuration des variables d'environnement (à chaque session)
|
||||
source setup-env.sh
|
||||
|
||||
# 3. Compilation
|
||||
cargo build
|
||||
```
|
||||
|
||||
**📋 Méthode manuelle (si les scripts ne fonctionnent pas) :**
|
||||
|
||||
#### 1. Installation des dépendances
|
||||
|
||||
```bash
|
||||
# Créer le répertoire local
|
||||
mkdir -p ~/.local
|
||||
cd ~/.local
|
||||
|
||||
# Télécharger tous les packages nécessaires
|
||||
apt-get download libsoxr-dev libsoxr0 libasound2-dev libasound2t64
|
||||
|
||||
# Extraire tous les packages
|
||||
dpkg -x libsoxr-dev_*.deb .
|
||||
dpkg -x libsoxr0_*.deb .
|
||||
dpkg -x libasound2-dev_*.deb .
|
||||
dpkg -x libasound2t64_*.deb .
|
||||
|
||||
# Retourner au projet
|
||||
cd /home/user/pmomusic
|
||||
```
|
||||
|
||||
#### 2. Configuration des variables d'environnement
|
||||
|
||||
**IMPORTANT:** Ces variables doivent être exportées dans CHAQUE session Claude Code avant de compiler :
|
||||
|
||||
```bash
|
||||
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||
```
|
||||
|
||||
**Astuce :** Créez un fichier `setup-env.sh` pour ne pas avoir à retaper ces commandes à chaque session :
|
||||
|
||||
```bash
|
||||
cat > setup-env.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Script de configuration des variables d'environnement pour PMOMusic
|
||||
# Usage: source setup-env.sh
|
||||
|
||||
# Configuration des chemins pour libsoxr et libasound2
|
||||
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||
|
||||
echo "Variables d'environnement configurées pour PMOMusic"
|
||||
echo " PKG_CONFIG_PATH=$PKG_CONFIG_PATH"
|
||||
echo " LD_LIBRARY_PATH=$LD_LIBRARY_PATH"
|
||||
echo " RUSTFLAGS=$RUSTFLAGS"
|
||||
echo ""
|
||||
echo "Vous pouvez maintenant compiler avec: cargo build"
|
||||
EOF
|
||||
|
||||
chmod +x setup-env.sh
|
||||
```
|
||||
|
||||
Puis dans chaque session :
|
||||
|
||||
```bash
|
||||
source setup-env.sh
|
||||
```
|
||||
|
||||
⚠️ **Note :** Le fichier `setup-env.sh` est dans `.gitignore` (configuration locale), vous devez le créer vous-même avec le contenu ci-dessus.
|
||||
|
||||
#### 3. Vérifier l'installation
|
||||
|
||||
```bash
|
||||
# Vérifier que pkg-config trouve les bibliothèques
|
||||
pkg-config --libs --cflags soxr
|
||||
pkg-config --libs --cflags alsa
|
||||
|
||||
# Devrait afficher quelque chose comme :
|
||||
# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr
|
||||
# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound
|
||||
```
|
||||
|
||||
#### 4. Compiler et tester
|
||||
|
||||
```bash
|
||||
# Compiler le workspace complet
|
||||
cargo build
|
||||
|
||||
# Tester l'exemple play_and_cache de pmoparadise
|
||||
cargo run --package pmoparadise --example play_and_cache --features full -- 0
|
||||
```
|
||||
|
||||
### Workflow pour chaque nouvelle session
|
||||
|
||||
À chaque fois que vous démarrez une nouvelle session Claude Code :
|
||||
|
||||
```bash
|
||||
# 1. Configuration de l'environnement
|
||||
source setup-env.sh
|
||||
|
||||
# 2. Compilation
|
||||
cargo build
|
||||
|
||||
# 3. Exécution des exemples
|
||||
cargo run --package pmoparadise --example play_and_cache --features full -- 0
|
||||
```
|
||||
|
||||
**IMPORTANT :** Si vous oubliez d'exporter les variables, vous obtiendrez des erreurs comme :
|
||||
```
|
||||
error: failed to run custom build command for `soxr-sys`
|
||||
Package 'soxr' was not found in the pkg-config search path
|
||||
```
|
||||
|
||||
ou
|
||||
|
||||
```
|
||||
rust-lld: error: unable to find library -lasound
|
||||
```
|
||||
|
||||
**Solution :** Exécutez `source setup-env.sh` et recompilez.
|
||||
|
||||
### Notes importantes
|
||||
|
||||
- ✅ Les dépendances installées dans `~/.local` persistent entre les sessions
|
||||
- ✅ Les variables d'environnement doivent être réexportées à chaque nouvelle session
|
||||
- ❌ NE JAMAIS créer de fichiers `.cargo/config.toml` dans le projet (chemins spécifiques)
|
||||
- ❌ NE JAMAIS committer `setup-env.sh` (configuration locale)
|
||||
- 💡 Sur macOS (via Homebrew) : seul `libsoxr` est nécessaire (pas d'ALSA)
|
||||
|
||||
## Références
|
||||
|
||||
- libsoxr GitHub: https://github.com/chirlu/soxr
|
||||
- Documentation pkg-config: https://www.freedesktop.org/wiki/Software/pkg-config/
|
||||
89
INSTALL_NOTES.md
Normal file
89
INSTALL_NOTES.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Notes d'installation pour PMOMusic
|
||||
|
||||
## Prérequis système
|
||||
|
||||
### libsoxr (obligatoire pour pmoaudio - resampling)
|
||||
|
||||
La bibliothèque `libsoxr` est requise pour le resampling audio dans `pmoaudio`.
|
||||
|
||||
### libasound2/ALSA (obligatoire pour pmoaudio - lecture audio sur Linux)
|
||||
|
||||
La bibliothèque ALSA est requise pour `AudioSink` via `cpal` sur Linux. Sur macOS et Windows, aucune dépendance externe n'est nécessaire (CoreAudio et WASAPI sont utilisés).
|
||||
|
||||
**Installation** :
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt-get install libsoxr-dev libasound2-dev
|
||||
|
||||
# Fedora/RHEL
|
||||
sudo dnf install libsoxr-devel alsa-lib-devel
|
||||
|
||||
# Arch Linux
|
||||
sudo pacman -S libsoxr alsa-lib
|
||||
|
||||
# macOS (Homebrew) - ALSA non nécessaire sur macOS
|
||||
brew install libsoxr
|
||||
|
||||
# Alpine Linux
|
||||
apk add soxr-dev alsa-lib-dev
|
||||
```
|
||||
|
||||
**Sans privilèges root (Claude Code, environnements sans sudo)** :
|
||||
|
||||
🚀 **Installation automatique** :
|
||||
|
||||
```bash
|
||||
# 1. Installation des dépendances (une seule fois)
|
||||
./setup-deps.sh
|
||||
|
||||
# 2. Configuration de l'environnement (à chaque session)
|
||||
source setup-env.sh
|
||||
|
||||
# 3. Compilation
|
||||
cargo build
|
||||
```
|
||||
|
||||
Pour plus de détails, consultez `INSTALL_LIBSOXR.md`.
|
||||
|
||||
---
|
||||
|
||||
## Nouveaux composants
|
||||
|
||||
### PlaylistSource (pmoaudio-ext)
|
||||
|
||||
Source audio qui lit une playlist `pmoplaylist` et diffuse les pistes en continu.
|
||||
|
||||
**Feature** : `playlist`
|
||||
|
||||
```bash
|
||||
# Compiler avec la feature playlist
|
||||
cargo build --package pmoaudio-ext --features playlist
|
||||
```
|
||||
|
||||
**⚠️ Important** : Cette source émet du PCM avec sample_rate et bit_depth **variables**. Pour un flux homogène, ajoutez dans le pipeline :
|
||||
- `ResamplingNode` (normalise le sample_rate)
|
||||
- `ToI24Node` / `ToI16Node` (normalise la profondeur de bits)
|
||||
|
||||
### ResamplingNode (pmoaudio)
|
||||
|
||||
Nœud générique qui normalise le sample_rate vers une valeur cible fixe.
|
||||
|
||||
**Usage** :
|
||||
```rust
|
||||
let mut resampler = ResamplingNode::new(48000); // Force 48kHz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compilation
|
||||
|
||||
```bash
|
||||
# Compiler tout le workspace (nécessite libsoxr)
|
||||
cargo build
|
||||
|
||||
# Compiler sans pmoaudio (si libsoxr manque)
|
||||
cargo build --package pmoplaylist
|
||||
cargo build --package pmoaudiocache
|
||||
# etc.
|
||||
```
|
||||
485
OPTIMIZATION_PREBUFFER_TO_PLAYLIST.md
Normal file
485
OPTIMIZATION_PREBUFFER_TO_PLAYLIST.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# Optimisation: Réduction du délai prebuffer → playlist (19s → 1s)
|
||||
|
||||
## Contexte
|
||||
|
||||
Le système de progressive caching fonctionne correctement, mais il y a un délai non optimal entre le moment où le prebuffer est atteint et le moment où la track est ajoutée à la playlist.
|
||||
|
||||
### État actuel (branche `claude/fix-play-and-cache-streaming-011CUsMBxH4fsgoadgkiPdoK`)
|
||||
|
||||
**Timing mesuré:**
|
||||
```
|
||||
t=0.6s : Prebuffer complete (512KB téléchargés) ✅
|
||||
t=19.2s : tokio::join!() complete (pump_future finit)
|
||||
t=19.2s : Track added to playlist
|
||||
t=19.7s : Playback starts
|
||||
```
|
||||
|
||||
**Délai total: ~19 secondes**
|
||||
|
||||
### Code actuel problématique
|
||||
|
||||
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs:167-178`
|
||||
|
||||
```rust
|
||||
// Exécuter pump et add_from_reader en parallèle
|
||||
let pump_future = pump_track_segments(
|
||||
first_segment,
|
||||
&mut rx, // ← emprunte muablement rx
|
||||
pcm_tx,
|
||||
bits_per_sample,
|
||||
sample_rate,
|
||||
&stop_token,
|
||||
);
|
||||
|
||||
// Attendre les deux tâches en parallèle
|
||||
let (cache_result, pump_result) = tokio::join!(cache_future, pump_future);
|
||||
|
||||
let pk = cache_result.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to add to cache: {}", e))
|
||||
})?;
|
||||
|
||||
let (_chunks, _samples, _duration_sec, stop_reason) = pump_result?;
|
||||
```
|
||||
|
||||
**Le problème:** `tokio::join!()` attend que **LES DEUX** futures se terminent:
|
||||
- `cache_future` retourne après prebuffer (~0.6s) ✅
|
||||
- `pump_future` lit **toute** la première track du RadioParadiseStreamSource (~19s) ⏱️
|
||||
|
||||
Donc même si le prebuffer est atteint en 0.6s, on attend 19s avant de push à la playlist!
|
||||
|
||||
## Objectif
|
||||
|
||||
Réduire le délai à **~1 seconde** en pushant à la playlist **immédiatement après le prebuffer**, sans attendre que `pump_future` se termine.
|
||||
|
||||
**Timing visé:**
|
||||
```
|
||||
t=0.6s : Prebuffer complete ✅
|
||||
t=0.7s : Track added to playlist ← IMMÉDIAT!
|
||||
t=1.2s : Playback starts ← ~1 seconde!
|
||||
t=19.2s : pump_future finit en arrière-plan
|
||||
```
|
||||
|
||||
## Contraintes techniques
|
||||
|
||||
### 1. Problème du borrow checker
|
||||
|
||||
`pump_future` emprunte muablement `rx`:
|
||||
```rust
|
||||
async fn pump_track_segments(
|
||||
first_segment: Arc<AudioSegment>,
|
||||
rx: &mut mpsc::Receiver<Arc<AudioSegment>>, // ← &mut borrow
|
||||
// ...
|
||||
)
|
||||
```
|
||||
|
||||
On ne peut pas faire:
|
||||
```rust
|
||||
tokio::pin!(cache_future);
|
||||
tokio::pin!(pump_future); // ← pump_future contient un &mut rx
|
||||
|
||||
let pk = cache_future.await; // cache_future termine
|
||||
|
||||
// ❌ ERREUR: on a toujours un borrow mutable de rx dans pump_future
|
||||
// On ne peut pas continuer à utiliser rx (ou l'objet qui le contient)
|
||||
playlist_handle.push(pk.clone()).await;
|
||||
|
||||
let result = pump_future.await; // pump_future continue
|
||||
```
|
||||
|
||||
Le borrow checker nous empêche d'attendre `cache_future` seul, puis de faire d'autres opérations, puis d'attendre `pump_future`, car `pump_future` garde un borrow mutable de `rx` pendant toute sa durée de vie.
|
||||
|
||||
### 2. Contraintes de l'API
|
||||
|
||||
- `pump_track_segments()` doit lire `rx` pour recevoir les segments du RadioParadiseStreamSource
|
||||
- Le FlacCacheSinkLogic doit garder ownership de `rx` pour traiter les tracks suivantes
|
||||
- `pump_future` ne peut pas être spawné dans un tokio::spawn car il retourne un `StopReason` nécessaire pour la logique métier
|
||||
|
||||
## Solutions possibles
|
||||
|
||||
### Solution A: Refactoriser pump_track_segments pour prendre ownership de rx
|
||||
|
||||
**Approche:**
|
||||
1. Créer `pump_track_segments_owned` qui prend ownership de `rx`
|
||||
2. Cette fonction retourne `(result, rx)` - elle rend ownership de `rx`
|
||||
3. Spawner cette future dans tokio::spawn
|
||||
4. Attendre cache_future seul, push immédiatement
|
||||
5. Attendre la task spawnée plus tard
|
||||
|
||||
**Signature:**
|
||||
```rust
|
||||
async fn pump_track_segments_owned(
|
||||
first_segment: Arc<AudioSegment>,
|
||||
rx: mpsc::Receiver<Arc<AudioSegment>>, // ownership!
|
||||
pcm_tx: mpsc::Sender<Vec<u8>>,
|
||||
bits_per_sample: u8,
|
||||
expected_rate: u32,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(u64, u64, f64, StopReason, mpsc::Receiver<Arc<AudioSegment>>), AudioError>
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ rend rx
|
||||
```
|
||||
|
||||
**Utilisation:**
|
||||
```rust
|
||||
let pump_handle = tokio::spawn(pump_track_segments_owned(
|
||||
first_segment,
|
||||
rx, // move ownership
|
||||
pcm_tx,
|
||||
bits_per_sample,
|
||||
sample_rate,
|
||||
stop_token.clone(),
|
||||
));
|
||||
|
||||
// Attendre SEULEMENT le prebuffer
|
||||
let pk = cache_future.await?;
|
||||
|
||||
// Push IMMÉDIATEMENT à la playlist
|
||||
#[cfg(feature = "playlist")]
|
||||
if let Some(ref playlist_handle) = self.playlist_handle {
|
||||
playlist_handle.push(pk.clone()).await?;
|
||||
}
|
||||
|
||||
// MAINTENANT attendre que pump finisse
|
||||
let (result, rx_returned) = pump_handle.await.unwrap()?;
|
||||
rx = rx_returned; // récupérer rx pour la prochaine track
|
||||
```
|
||||
|
||||
**Avantages:**
|
||||
- ✅ Pas de problème de borrow checker
|
||||
- ✅ Push immédiat après prebuffer
|
||||
- ✅ Délai réduit à ~1s
|
||||
|
||||
**Inconvénients:**
|
||||
- ⚠️ Nécessite de modifier la signature de `pump_track_segments`
|
||||
- ⚠️ Plus complexe (ownership passé puis rendu)
|
||||
|
||||
### Solution B: Utiliser un channel pour signaler le prebuffer
|
||||
|
||||
**Approche:**
|
||||
1. Créer un oneshot channel `(prebuffer_tx, prebuffer_rx)`
|
||||
2. `cache_future` envoie le pk via `prebuffer_tx` dès le prebuffer atteint
|
||||
3. Le code principal attend `prebuffer_rx`, push immédiatement
|
||||
4. Puis attend `tokio::join!()` normalement
|
||||
|
||||
**Code:**
|
||||
```rust
|
||||
let (prebuffer_tx, prebuffer_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
let cache_future = async {
|
||||
let pk = self.cache.add_from_reader(...).await?;
|
||||
let _ = prebuffer_tx.send(pk.clone()); // Signal prebuffer!
|
||||
Ok(pk)
|
||||
};
|
||||
|
||||
let pump_future = pump_track_segments(...);
|
||||
|
||||
// Spawner les deux en parallèle
|
||||
let cache_handle = tokio::spawn(cache_future);
|
||||
let pump_handle = tokio::spawn(pump_future);
|
||||
|
||||
// Attendre SEULEMENT le signal de prebuffer
|
||||
let pk = prebuffer_rx.await.unwrap();
|
||||
|
||||
// Push IMMÉDIATEMENT à la playlist
|
||||
playlist_handle.push(pk.clone()).await?;
|
||||
|
||||
// Puis attendre que tout finisse
|
||||
let (cache_result, pump_result) = tokio::join!(cache_handle, pump_handle);
|
||||
```
|
||||
|
||||
**Avantages:**
|
||||
- ✅ Pas besoin de changer les signatures
|
||||
- ✅ Push immédiat après prebuffer
|
||||
|
||||
**Inconvénients:**
|
||||
- ⚠️ Nécessite de wrapper cache_future pour envoyer le signal
|
||||
- ⚠️ Ajoute un oneshot channel
|
||||
|
||||
### Solution C: Modifier l'API du cache pour avoir un callback
|
||||
|
||||
**Approche:**
|
||||
1. Ajouter un paramètre callback à `add_from_reader()`
|
||||
2. Le cache appelle ce callback dès le prebuffer atteint
|
||||
3. Le callback push à la playlist
|
||||
|
||||
**Signature:**
|
||||
```rust
|
||||
pub async fn add_from_reader_with_callback<R, F>(
|
||||
&self,
|
||||
source_uri: Option<&str>,
|
||||
reader: R,
|
||||
length: Option<u64>,
|
||||
collection: Option<&str>,
|
||||
on_prebuffer: F, // ← nouveau callback
|
||||
) -> Result<String>
|
||||
where
|
||||
R: AsyncRead + Send + Unpin + 'static,
|
||||
F: FnOnce(String) + Send + 'static, // F reçoit le pk
|
||||
```
|
||||
|
||||
**Avantages:**
|
||||
- ✅ API propre et réutilisable
|
||||
- ✅ Pas de problème de borrow checker
|
||||
|
||||
**Inconvénients:**
|
||||
- ⚠️ Nécessite de modifier l'API du cache (impact sur autres parties du code)
|
||||
- ⚠️ Ajoute de la complexité à l'API
|
||||
|
||||
## Recommandation
|
||||
|
||||
**Je recommande la Solution A** (refactoriser `pump_track_segments_owned`):
|
||||
- Plus explicite et claire
|
||||
- Pas d'impact sur l'API du cache
|
||||
- Ownership bien défini (passage puis retour de rx)
|
||||
- Testable indépendamment
|
||||
|
||||
## Plan d'implémentation
|
||||
|
||||
### Étape 1: Créer pump_track_segments_owned
|
||||
|
||||
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs`
|
||||
|
||||
```rust
|
||||
/// Pompe les segments pour une seule track (s'arrête au TrackBoundary).
|
||||
///
|
||||
/// Version qui prend ownership de rx pour permettre un await séparé du cache.
|
||||
/// Retourne rx à la fin pour permettre le traitement des tracks suivantes.
|
||||
async fn pump_track_segments_owned(
|
||||
first_segment: Arc<AudioSegment>,
|
||||
mut rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||
pcm_tx: mpsc::Sender<Vec<u8>>,
|
||||
bits_per_sample: u8,
|
||||
expected_rate: u32,
|
||||
stop_token: CancellationToken,
|
||||
) -> Result<(u64, u64, f64, StopReason, mpsc::Receiver<Arc<AudioSegment>>), AudioError> {
|
||||
let mut chunks = 0u64;
|
||||
let mut samples = 0u64;
|
||||
let mut duration_sec = 0.0f64;
|
||||
|
||||
// Traiter le premier segment
|
||||
if let Some(chunk) = first_segment.as_chunk() {
|
||||
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
|
||||
if !pcm_bytes.is_empty() {
|
||||
if pcm_tx.send(pcm_bytes).await.is_err() {
|
||||
drop(pcm_tx);
|
||||
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||
}
|
||||
chunks += 1;
|
||||
samples += chunk.len() as u64;
|
||||
duration_sec += chunk.len() as f64 / expected_rate as f64;
|
||||
}
|
||||
}
|
||||
|
||||
// Loop pour le reste des segments...
|
||||
loop {
|
||||
let segment = tokio::select! {
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Some(seg) => seg,
|
||||
None => {
|
||||
drop(pcm_tx);
|
||||
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = stop_token.cancelled() => {
|
||||
drop(pcm_tx);
|
||||
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||
}
|
||||
};
|
||||
|
||||
match &segment.segment {
|
||||
_AudioSegment::Chunk(chunk) => {
|
||||
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
|
||||
if !pcm_bytes.is_empty() {
|
||||
if pcm_tx.send(pcm_bytes).await.is_err() {
|
||||
drop(pcm_tx);
|
||||
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||
}
|
||||
chunks += 1;
|
||||
samples += chunk.len() as u64;
|
||||
duration_sec += chunk.len() as f64 / expected_rate as f64;
|
||||
}
|
||||
}
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||
drop(pcm_tx);
|
||||
return Ok((chunks, samples, duration_sec, StopReason::TrackBoundary(metadata.clone()), rx));
|
||||
}
|
||||
SyncMarker::EndOfStream => {
|
||||
drop(pcm_tx);
|
||||
return Ok((chunks, samples, duration_sec, StopReason::EndOfStream, rx));
|
||||
}
|
||||
_ => continue,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 2: Modifier FlacCacheSinkLogic::process
|
||||
|
||||
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs:~167`
|
||||
|
||||
```rust
|
||||
let collection_ref = self.collection.as_deref();
|
||||
let cache_future = self.cache.add_from_reader(
|
||||
None,
|
||||
flac_stream,
|
||||
None,
|
||||
collection_ref,
|
||||
);
|
||||
|
||||
// Spawner pump_future avec ownership de rx
|
||||
let pump_handle = tokio::spawn(pump_track_segments_owned(
|
||||
first_segment,
|
||||
rx, // move ownership!
|
||||
pcm_tx,
|
||||
bits_per_sample,
|
||||
sample_rate,
|
||||
stop_token.clone(),
|
||||
));
|
||||
|
||||
// Attendre SEULEMENT le prebuffer (cache retourne après 512KB)
|
||||
tracing::debug!("FlacCacheSink: Waiting for cache prebuffer to complete");
|
||||
let pk = cache_future.await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to add to cache: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::debug!("FlacCacheSink: Prebuffer complete with pk {}, pushing to playlist NOW", pk);
|
||||
|
||||
// Copier les métadonnées AVANT push
|
||||
if let Some(src_metadata) = track_metadata.clone() {
|
||||
let dest_metadata = self.cache.track_metadata(&pk);
|
||||
pmometadata::copy_metadata_into(&src_metadata, &dest_metadata)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to copy metadata to cache: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
// Push IMMÉDIATEMENT à la playlist (après prebuffer, avant pump complet!)
|
||||
#[cfg(feature = "playlist")]
|
||||
if let Some(ref playlist_handle) = self.playlist_handle {
|
||||
tracing::debug!("FlacCacheSink: Pushing pk {} to playlist", pk);
|
||||
playlist_handle.push(pk.clone()).await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Failed to add to playlist: {}", e))
|
||||
})?;
|
||||
tracing::debug!("FlacCacheSink: Successfully pushed to playlist");
|
||||
}
|
||||
|
||||
// MAINTENANT attendre que pump finisse (il continue en arrière-plan)
|
||||
tracing::debug!("FlacCacheSink: Waiting for pump to complete");
|
||||
let pump_result = pump_handle.await.map_err(|e| {
|
||||
AudioError::ProcessingError(format!("Pump task panicked: {}", e))
|
||||
})?;
|
||||
|
||||
let (_chunks, _samples, _duration_sec, stop_reason, rx_returned) = pump_result?;
|
||||
rx = rx_returned; // récupérer rx pour la prochaine track
|
||||
tracing::debug!("FlacCacheSink: Pump completed");
|
||||
|
||||
// Continuer avec download des covers en arrière-plan...
|
||||
```
|
||||
|
||||
### Étape 3: Tester
|
||||
|
||||
```bash
|
||||
# Nettoyer et rebuild
|
||||
rm -rf /tmp/pmomusic_test
|
||||
source setup-env.sh
|
||||
cargo build --example play_and_cache --features full
|
||||
|
||||
# Tester avec logs de timing
|
||||
RUST_LOG=debug target/debug/examples/play_and_cache 0 --null-audio 2>&1 | \
|
||||
grep -E "Prebuffer complete|Pushing pk.*to playlist|popped track" | \
|
||||
head -20
|
||||
```
|
||||
|
||||
**Résultats attendus:**
|
||||
```
|
||||
[TIME_A] FlacCacheSink: Prebuffer complete with pk XXX, pushing to playlist NOW
|
||||
[TIME_B] FlacCacheSink: Successfully pushed to playlist
|
||||
[TIME_C] PlaylistSourceLogic: popped track from playlist
|
||||
|
||||
Délai (TIME_C - TIME_A) devrait être < 1 seconde!
|
||||
```
|
||||
|
||||
### Étape 4: Valider le comportement
|
||||
|
||||
Vérifier que:
|
||||
1. ✅ Le prebuffer est atteint rapidement (~0.6s)
|
||||
2. ✅ Le push à la playlist est immédiat (~0.1s après prebuffer)
|
||||
3. ✅ La lecture démarre rapidement (~1s total)
|
||||
4. ✅ Toutes les tracks se suivent correctement
|
||||
5. ✅ Les completion markers sont créés
|
||||
6. ✅ Les tracks suivantes fonctionnent (rx est bien récupéré)
|
||||
7. ✅ Pas de panic ou deadlock
|
||||
|
||||
## Debugging
|
||||
|
||||
### Si le borrow checker proteste
|
||||
|
||||
Vérifier que:
|
||||
- `pump_track_segments_owned` prend bien ownership de `rx` (pas `&mut`)
|
||||
- `rx` est bien retourné dans le tuple de retour
|
||||
- `rx = rx_returned;` récupère bien ownership après await
|
||||
|
||||
### Si les tracks suivantes ne fonctionnent pas
|
||||
|
||||
Vérifier que:
|
||||
- `rx` est bien réassigné après le pump: `rx = rx_returned;`
|
||||
- La loop dans `process()` continue correctement avec le nouveau `rx`
|
||||
|
||||
### Si le timing n'est pas amélioré
|
||||
|
||||
Ajouter des logs avec timestamps:
|
||||
```rust
|
||||
let start = std::time::Instant::now();
|
||||
let pk = cache_future.await?;
|
||||
tracing::info!("Prebuffer took {:?}", start.elapsed());
|
||||
|
||||
let start2 = std::time::Instant::now();
|
||||
playlist_handle.push(pk.clone()).await?;
|
||||
tracing::info!("Playlist push took {:?}", start2.elapsed());
|
||||
```
|
||||
|
||||
## Fichiers à modifier
|
||||
|
||||
1. **pmoaudio-ext/src/sinks/flac_cache_sink.rs**
|
||||
- Ajouter `pump_track_segments_owned()` (~ligne 432)
|
||||
- Modifier `FlacCacheSinkLogic::process()` (~ligne 167)
|
||||
|
||||
## Tests de régression
|
||||
|
||||
Après l'implémentation, tester:
|
||||
|
||||
```bash
|
||||
# Test 1: Premier download (cache vide)
|
||||
rm -rf /tmp/pmomusic_test
|
||||
target/debug/examples/play_and_cache 0 --null-audio
|
||||
|
||||
# Test 2: Deuxième download (fichier déjà en cache)
|
||||
# Ne pas supprimer /tmp/pmomusic_test
|
||||
target/debug/examples/play_and_cache 0 --null-audio
|
||||
|
||||
# Test 3: Download interrompu (Ctrl+C)
|
||||
target/debug/examples/play_and_cache 0 --null-audio
|
||||
# Appuyer Ctrl+C après 2 secondes
|
||||
|
||||
# Test 4: Plusieurs tracks consécutives
|
||||
# Laisser tourner 1 minute pour voir plusieurs tracks
|
||||
timeout 60 target/debug/examples/play_and_cache 0 --null-audio
|
||||
```
|
||||
|
||||
## Métriques de succès
|
||||
|
||||
- ✅ Délai prebuffer → playlist: **< 1 seconde** (actuellement ~19s)
|
||||
- ✅ Délai prebuffer → lecture: **< 2 secondes** (actuellement ~19.5s)
|
||||
- ✅ Pas de régression fonctionnelle
|
||||
- ✅ Toutes les tracks se suivent correctement
|
||||
- ✅ Les completion markers sont créés
|
||||
|
||||
## Références
|
||||
|
||||
- Branche actuelle: `claude/fix-play-and-cache-streaming-011CUsMBxH4fsgoadgkiPdoK`
|
||||
- Code de référence: commit `ed0bbfb` (Add FlacCacheSink debug logs - system now works!)
|
||||
- Issue originale: "play_and_cache n'a pas le comportement souhaité"
|
||||
285
PLAYER_PMOSOURCE_README.md
Normal file
285
PLAYER_PMOSOURCE_README.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Player Générique PMO Music
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce document décrit l'implémentation d'un nouveau player web générique qui utilise **uniquement** l'API du trait `pmosource` sans dépendre d'aucune implémentation spécifique (comme `pmoparadise`).
|
||||
|
||||
## Objectifs
|
||||
|
||||
L'objectif principal est de **tester l'API `pmosource` dans un cas d'application concret** afin d'identifier ce qui manque ou pourrait être amélioré dans l'API générique.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Service API TypeScript (`pmoapp/webapp/src/services/pmosource.ts`)
|
||||
|
||||
Service qui encapsule toutes les interactions avec l'API REST de pmosource :
|
||||
|
||||
```typescript
|
||||
// Endpoints utilisés
|
||||
GET /api/sources // Liste les sources
|
||||
GET /api/sources/{id} // Info sur une source
|
||||
GET /api/sources/{id}/root // Container racine
|
||||
GET /api/sources/{id}/browse // Parcourt un container
|
||||
GET /api/sources/{id}/resolve // Résout l'URI d'un item
|
||||
GET /api/sources/{id}/image // Image de la source
|
||||
GET /api/sources/{id}/capabilities // Capacités de la source
|
||||
```
|
||||
|
||||
**Fonctions implémentées :**
|
||||
- `listSources()` - Liste toutes les sources enregistrées
|
||||
- `getSource(id)` - Récupère une source spécifique
|
||||
- `getSourceRoot(id)` - Récupère le container racine
|
||||
- `browseSource(id, objectId?, pagination?)` - Navigation dans les containers
|
||||
- `resolveUri(sourceId, objectId)` - Résout l'URI de streaming
|
||||
- `getSourceImageUrl(id)` - URL de l'image de la source
|
||||
|
||||
### 2. Composant Player (`pmoapp/webapp/src/components/GenericMusicPlayer.vue`)
|
||||
|
||||
Composant Vue.js qui implémente :
|
||||
|
||||
#### Fonctionnalités implémentées
|
||||
|
||||
1. **Sélection de sources**
|
||||
- Affichage de toutes les sources disponibles
|
||||
- Affichage du logo de chaque source
|
||||
- Affichage des capacités (FIFO, Search, Favorites)
|
||||
|
||||
2. **Navigation dans les containers**
|
||||
- Breadcrumb pour remonter dans la hiérarchie
|
||||
- Affichage des sous-containers (dossiers)
|
||||
- Navigation par clic dans les containers
|
||||
|
||||
3. **Liste des morceaux**
|
||||
- Affichage de tous les items audio d'un container
|
||||
- Métadonnées : titre, artiste, album, cover art
|
||||
- Numérotation des morceaux
|
||||
|
||||
4. **Lecteur audio**
|
||||
- Lecture d'un morceau via résolution d'URI
|
||||
- Contrôles audio natifs HTML5
|
||||
- Section "Now Playing" avec métadonnées
|
||||
- Gestion des erreurs de lecture
|
||||
|
||||
5. **Interface utilisateur**
|
||||
- Design moderne avec dégradés et animations
|
||||
- Responsive design
|
||||
- Indicateurs visuels (morceau actif, en cours de lecture)
|
||||
- Messages d'erreur clairs
|
||||
|
||||
### 3. Intégration
|
||||
|
||||
Le player a été configuré comme **page d'accueil par défaut** de l'application web PMO :
|
||||
|
||||
```typescript
|
||||
// router/index.ts
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: GenericMusicPlayer },
|
||||
// ... autres routes
|
||||
]
|
||||
```
|
||||
|
||||
## Ce qui fonctionne
|
||||
|
||||
✅ **Complètement fonctionnel avec l'API actuelle de pmosource :**
|
||||
|
||||
1. Découverte des sources disponibles
|
||||
2. Navigation complète dans la hiérarchie des containers
|
||||
3. Affichage des métadonnées des morceaux
|
||||
4. Résolution des URIs et lecture audio
|
||||
5. Affichage des images de sources
|
||||
6. **Métadonnées temps réel via Server-Sent Events (SSE)** 🆕
|
||||
- Mise à jour automatique toutes les 3 secondes
|
||||
- Pas de polling, push serveur
|
||||
- Reconnexion automatique
|
||||
|
||||
## Limitations identifiées et améliorations possibles
|
||||
|
||||
### 1. Métadonnées de couverture d'album
|
||||
|
||||
**Problème :** Le trait `MusicSource` n'expose pas directement de méthode pour résoudre les URIs de couvertures d'album.
|
||||
|
||||
**État actuel :**
|
||||
- Le champ `album_art` dans `Item` contient parfois une URI
|
||||
- Le champ `album_art_pk` contient une clé primaire mais pas d'URL exploitable directement
|
||||
- Certaines implémentations (pmoparadise) utilisent `/cache/cover/{pk}` mais ce n'est pas standardisé
|
||||
|
||||
**Proposition :**
|
||||
```rust
|
||||
/// Résout l'URI de la couverture d'album pour un item
|
||||
async fn resolve_cover_uri(&self, object_id: &str) -> Result<Option<String>>;
|
||||
```
|
||||
|
||||
### 2. Recherche globale
|
||||
|
||||
**Problème :** La méthode `search()` existe mais retourne `SearchNotSupported` par défaut.
|
||||
|
||||
**État actuel :**
|
||||
- Pas d'interface standardisée pour la recherche dans l'UI
|
||||
- Pas de retour clair sur les capacités de recherche
|
||||
|
||||
**Proposition :**
|
||||
- Utiliser `capabilities().supports_search` pour afficher/masquer l'UI de recherche
|
||||
- Documenter clairement le format attendu des requêtes de recherche
|
||||
|
||||
### 3. Pagination
|
||||
|
||||
**Problème :** L'API supporte la pagination mais les métadonnées ne permettent pas de connaître le nombre total d'items.
|
||||
|
||||
**État actuel :**
|
||||
- `BrowseResponse.total` retourne le nombre d'items retournés, pas le total disponible
|
||||
- Pas de méthode `get_total_count(object_id)` dans le trait
|
||||
|
||||
**Proposition :**
|
||||
```rust
|
||||
/// Retourne le nombre total d'items dans un container
|
||||
async fn get_total_count(&self, object_id: &str) -> Result<usize>;
|
||||
```
|
||||
|
||||
Ou ajouter `total_available` dans `BrowseResponse` :
|
||||
```rust
|
||||
pub struct SourceBrowseResponse {
|
||||
// ... champs existants
|
||||
pub total_available: Option<usize>, // Total disponible (pas juste retourné)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Métadonnées de stream en temps réel ✅ **IMPLÉMENTÉ**
|
||||
|
||||
**Solution implémentée :**
|
||||
- ✅ Méthode `get_item(object_id)` dans le trait `MusicSource`
|
||||
- ✅ Endpoint REST `GET /api/sources/{id}/item?object_id={id}` pour récupérer les métadonnées d'un item
|
||||
- ✅ Endpoint SSE `GET /api/sources/{id}/item/stream?object_id={id}` pour recevoir les mises à jour en temps réel
|
||||
- ✅ Le player web utilise Server-Sent Events (SSE) pour les métadonnées temps réel
|
||||
|
||||
**Comment ça fonctionne :**
|
||||
1. Le serveur envoie automatiquement les métadonnées à jour toutes les 3 secondes via SSE
|
||||
2. Le client se connecte avec `EventSource` (API browser native)
|
||||
3. Les métadonnées sont automatiquement mises à jour dans l'interface sans polling
|
||||
|
||||
**Pour RadioParadise :**
|
||||
- La méthode `get_item()` pour les live streams récupère les métadonnées depuis `/radioparadise/metadata/{slug}`
|
||||
- Le SSE permet d'avoir les métadonnées à jour en moins de 3 secondes (au lieu de 10 secondes avec le polling)
|
||||
|
||||
### 5. Playlists utilisateur
|
||||
|
||||
**Problème :** Les méthodes existent (`get_user_playlists()`, `add_to_playlist()`) mais retournent `NotSupported` par défaut.
|
||||
|
||||
**État actuel :**
|
||||
- Pas encore testé dans le player
|
||||
- Nécessiterait une UI dédiée
|
||||
|
||||
**Proposition :**
|
||||
- Créer une section "Playlists" dans le player
|
||||
- Tester l'API avec une implémentation qui supporte les playlists (ex: Qobuz)
|
||||
|
||||
### 6. Favoris
|
||||
|
||||
**Problème :** Similaire aux playlists, l'API existe mais n'est pas testée.
|
||||
|
||||
**Proposition :**
|
||||
- Ajouter un bouton "⭐ Favoris" sur chaque morceau
|
||||
- Afficher visuellement les morceaux favoris
|
||||
- Créer une section "Mes Favoris"
|
||||
|
||||
### 7. Auto-play / Queue
|
||||
|
||||
**Problème :** Il n'y a pas de méthode pour gérer une file d'attente de lecture.
|
||||
|
||||
**Proposition :**
|
||||
```rust
|
||||
/// Interface pour gérer une queue de lecture
|
||||
pub trait Playable: MusicSource {
|
||||
async fn get_next_track(&self) -> Result<Option<Item>>;
|
||||
async fn get_previous_track(&self) -> Result<Option<Item>>;
|
||||
async fn add_to_queue(&self, item: Item) -> Result<()>;
|
||||
async fn clear_queue(&self) -> Result<()>;
|
||||
async fn get_queue(&self) -> Result<Vec<Item>>;
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Durée totale d'un container
|
||||
|
||||
**Problème :** Pour afficher "Album: 45:32 min, 12 morceaux", il faut parcourir tous les items.
|
||||
|
||||
**Proposition :**
|
||||
```rust
|
||||
/// Statistiques d'un container spécifique
|
||||
async fn get_container_stats(&self, object_id: &str) -> Result<ContainerStats>;
|
||||
|
||||
pub struct ContainerStats {
|
||||
pub item_count: usize,
|
||||
pub total_duration_ms: Option<u64>,
|
||||
pub total_size_bytes: Option<u64>,
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Formats audio disponibles
|
||||
|
||||
**Problème :** La méthode `get_available_formats()` existe mais n'est pas exploitée dans l'UI.
|
||||
|
||||
**Proposition :**
|
||||
- Ajouter un sélecteur de qualité dans le player
|
||||
- Afficher les formats disponibles (FLAC 24/96, MP3 320, etc.)
|
||||
|
||||
### 10. État du cache
|
||||
|
||||
**Problème :** Les méthodes existent (`get_cache_status()`, `cache_item()`) mais ne sont pas intégrées.
|
||||
|
||||
**Proposition :**
|
||||
- Afficher un indicateur de cache sur chaque morceau
|
||||
- Bouton "📥 Télécharger" pour mettre en cache
|
||||
- Barre de progression pour le téléchargement
|
||||
|
||||
## Prochaines étapes
|
||||
|
||||
### Court terme
|
||||
1. ✅ Tester le player avec `pmoparadise` (déjà implémenté)
|
||||
2. 🔄 Identifier les bugs et limitations pratiques
|
||||
3. 🔄 Tester avec une deuxième source (ex: `pmoqobuz`) pour valider la généricité
|
||||
|
||||
### Moyen terme
|
||||
1. Implémenter les fonctionnalités manquantes identifiées ci-dessus
|
||||
2. Ajouter la gestion de queue et auto-play
|
||||
3. Ajouter la recherche si supportée
|
||||
4. Intégrer la gestion du cache
|
||||
|
||||
### Long terme
|
||||
1. Support des playlists utilisateur
|
||||
2. Support des favoris
|
||||
3. Égaliseur et effets audio
|
||||
4. Visualisations audio
|
||||
5. Mode hors-ligne avec cache
|
||||
|
||||
## Conclusion
|
||||
|
||||
Le player générique démontre que **l'API `pmosource` est déjà très utilisable** pour créer une application musicale fonctionnelle. Les principales limitations concernent :
|
||||
|
||||
1. **Les métadonnées de couvertures** (pas d'URL standardisée)
|
||||
2. **La pagination avancée** (pas de compte total)
|
||||
3. **Les métadonnées temps réel** (pour les streams live)
|
||||
4. **La gestion de queue** (pas d'API dédiée)
|
||||
|
||||
Ces limitations ne sont pas bloquantes mais leur résolution améliorerait significativement l'expérience utilisateur et la complétude de l'API.
|
||||
|
||||
## Utilisation
|
||||
|
||||
Pour tester le player :
|
||||
|
||||
1. Lancer le serveur backend avec au moins une source enregistrée :
|
||||
```bash
|
||||
cargo run --example single_channel_server --features full
|
||||
```
|
||||
|
||||
2. Accéder à l'application web :
|
||||
```
|
||||
http://localhost:8080/app/
|
||||
```
|
||||
|
||||
3. Le player devrait afficher automatiquement les sources disponibles et permettre la navigation et la lecture.
|
||||
|
||||
## Remarques importantes
|
||||
|
||||
- ✅ Le player **n'utilise QUE l'API pmosource générique**
|
||||
- ✅ Aucune dépendance sur `pmoparadise` ou toute autre implémentation spécifique
|
||||
- ✅ Tout est basé sur les endpoints REST de `pmosource::api`
|
||||
- ✅ Le code est totalement réutilisable pour toute nouvelle source (Qobuz, Spotify, etc.)
|
||||
Binary file not shown.
BIN
PMOMusic/.pmomusic/cache_covers/cache.db
Normal file
BIN
PMOMusic/.pmomusic/cache_covers/cache.db
Normal file
Binary file not shown.
21
PMOMusic/.pmomusic/config.yaml
Normal file
21
PMOMusic/.pmomusic/config.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
host:
|
||||
http_port: '8080'
|
||||
cover_cache:
|
||||
directory: cache_covers
|
||||
size: 2000
|
||||
audio_cache:
|
||||
directory: cache_audio
|
||||
size: 500
|
||||
logger:
|
||||
buffer_capacity: 200
|
||||
enable_console: true
|
||||
min_level: INFO
|
||||
playlists:
|
||||
directory: playlists
|
||||
devices:
|
||||
mediarenderer:
|
||||
pmo_mediarenderer:
|
||||
udn: f77de90b-3a4a-408c-8462-3308ad500744
|
||||
mediaserver:
|
||||
pmo_mediaserver:
|
||||
udn: 88b84e76-4de0-4ee6-b794-99cc4a278cc9
|
||||
Binary file not shown.
@@ -14,6 +14,7 @@ pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
|
||||
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
|
||||
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
|
||||
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
||||
tracing = "0.1.41"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use pmoapp::{WebAppExt, Webapp};
|
||||
use pmocontrol::ControlPointExt;
|
||||
use pmomediarenderer::MEDIA_RENDERER;
|
||||
use pmomediaserver::{MEDIA_SERVER, sources::SourcesExt};
|
||||
use pmomediaserver::{
|
||||
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
|
||||
};
|
||||
use pmoserver::Server;
|
||||
use pmosource::MusicSourceExt;
|
||||
use pmoupnp::UpnpServerExt;
|
||||
@@ -35,14 +38,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Enregistrer les sources musicales
|
||||
info!("🎵 Registering music sources...");
|
||||
|
||||
// // Enregistrer Qobuz
|
||||
// if let Err(e) = server.register_qobuz().await {
|
||||
// tracing::warn!("⚠️ Failed to register Qobuz: {}", e);
|
||||
// }
|
||||
// Enregistrer Qobuz pour activer les lazy providers (QOBUZ:PK)
|
||||
if let Err(e) = server.write().await.register_qobuz().await {
|
||||
tracing::warn!("⚠️ Failed to register Qobuz source: {}", e);
|
||||
}
|
||||
|
||||
// Enregistrer Radio Paradise (inclut l'initialisation de l'API)
|
||||
if let Err(e) = server.write().await.register_paradise().await {
|
||||
tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e);
|
||||
// Initialiser les canaux de streaming Radio Paradise (pipelines + routes HTTP)
|
||||
info!("📻 Initializing Radio Paradise streaming channels...");
|
||||
if let Err(e) = server.write().await.init_paradise_streaming().await {
|
||||
tracing::warn!("⚠️ Failed to initialize Paradise streaming: {}", e);
|
||||
} else {
|
||||
// Enregistrer la source Radio Paradise UPnP (inclut l'initialisation de l'API)
|
||||
if let Err(e) = server.write().await.register_paradise().await {
|
||||
tracing::warn!("⚠️ Failed to register Radio Paradise source: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Lister toutes les sources enregistrées
|
||||
@@ -75,12 +84,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await
|
||||
.expect("Failed to register MediaServer");
|
||||
|
||||
// Enregistrer l'instance ContentDirectory pour les notifications GENA
|
||||
if let Some(cd_service) = server_instance.get_service("ContentDirectory") {
|
||||
pmomediaserver::contentdirectory::state::register_instance(&cd_service);
|
||||
}
|
||||
|
||||
// Initialiser les ProtocolInfo du MediaServer
|
||||
server_instance.init_protocol_info();
|
||||
|
||||
info!(
|
||||
"✅ MediaServer ready at {}{}",
|
||||
server_instance.base_url(),
|
||||
server_instance.description_route()
|
||||
);
|
||||
|
||||
// Enregistrer le Control Point (découverte renderers/serveurs + API REST + SSE)
|
||||
info!("🎛️ Registering Control Point...");
|
||||
let _control_point = server
|
||||
.write()
|
||||
.await
|
||||
.register_control_point(5)
|
||||
.await
|
||||
.expect("Failed to register Control Point");
|
||||
|
||||
// Ajouter la webapp via le trait WebAppExt
|
||||
info!("📡 Registering Web application...");
|
||||
server
|
||||
@@ -96,7 +122,16 @@ 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;
|
||||
|
||||
Ok(())
|
||||
// Le serveur HTTP est arrêté, mais des threads (ControlPoint, etc.) peuvent encore tourner
|
||||
// Attendre 2 secondes pour laisser le temps aux threads de se terminer
|
||||
info!("Waiting for background threads to finish...");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
// Forcer l'arrêt du processus (les threads du ControlPoint tournent en boucle infinie)
|
||||
info!("✅ PMOMusic stopped");
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
447
Plan_d_implementation_webui_control_point.md
Normal file
447
Plan_d_implementation_webui_control_point.md
Normal file
@@ -0,0 +1,447 @@
|
||||
# PMOControl WebUI - Design Recommendations & Implementation Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Based on my analysis of the existing codebase, I'm providing comprehensive recommendations for implementing a Vue.js WebUI for PMOControl. The system already has:
|
||||
- A complete REST API with OpenAPI documentation (`/api/control/*`)
|
||||
- SSE endpoints for real-time updates (`/api/control/events/*`)
|
||||
- Vue 3 + TypeScript + Vite setup
|
||||
- Existing components (GenericMusicPlayer, UpnpExplorer, Cache Managers, LogView)
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions & Recommendations
|
||||
|
||||
### 1. State Management: **Use Pinia**
|
||||
|
||||
**Recommendation: Pinia (Vue 3's official state management)**
|
||||
|
||||
**Rationale:**
|
||||
- **Centralized real-time state**: Essential for managing SSE updates from multiple sources (renderers, media servers)
|
||||
- **Multi-client synchronization**: Single source of truth for renderer states, volumes, playback positions
|
||||
- **TypeScript native**: Better type inference than Vuex
|
||||
- **DevTools integration**: Built-in debugging for SSE event flows
|
||||
- **Composition API friendly**: Matches existing Vue 3 patterns in codebase
|
||||
- **Performance**: Lightweight (~1KB), modular stores
|
||||
- **Official Vue 3 recommendation**: Future-proof choice
|
||||
|
||||
**Store Architecture:**
|
||||
```typescript
|
||||
// stores/renderers.ts - Renderer state (SSE updates)
|
||||
// stores/mediaServers.ts - Media server state (SSE updates)
|
||||
// stores/playback.ts - Current playback session
|
||||
// stores/ui.ts - UI state (selected renderer, view preferences)
|
||||
```
|
||||
|
||||
**Benefits for your use case:**
|
||||
- Handle 20+ concurrent clients with shared state
|
||||
- Real-time SSE event synchronization across all views
|
||||
- Easy to scale with multi-renderer, multi-server, multi-session architecture
|
||||
|
||||
---
|
||||
|
||||
### 2. UI Component Library: **Headless UI + Custom Components**
|
||||
|
||||
**Recommendation: Hybrid approach - Headless UI components + custom styling**
|
||||
|
||||
**Component Library: Shadcn-vue (Headless UI primitives)**
|
||||
|
||||
**Rationale:**
|
||||
- **Lightweight & performant**: Only import what you need
|
||||
- **Full style control**: Match "carte uniforme, responsive, colorée selon statut" spec exactly
|
||||
- **TypeScript-first**: Perfect type safety
|
||||
- **Accessibility built-in**: ARIA compliance out of the box
|
||||
- **No theme lock-in**: Complete CSS freedom
|
||||
- **Composable primitives**: Card, Dialog, Dropdown, Slider components
|
||||
|
||||
**Why NOT a full framework (Vuetify, Element Plus)?**
|
||||
- Heavy bundle size (100-500KB vs ~10KB for headless)
|
||||
- Theme customization overhead
|
||||
- Your spec requires custom status-based coloring
|
||||
- Performance critical with 20 concurrent clients
|
||||
|
||||
**Alternative if you prefer pre-styled:** PrimeVue
|
||||
- Good performance
|
||||
- Customizable themes
|
||||
- Strong TypeScript support
|
||||
- But: 150KB+ bundle size
|
||||
|
||||
**Custom Components to Build:**
|
||||
- `RendererCard` - Status-colored cards for each renderer
|
||||
- `TransportControls` - Play/Pause/Stop/Next buttons
|
||||
- `VolumeControl` - Slider with mute toggle
|
||||
- `QueueViewer` - Playlist display with drag-drop
|
||||
- `MediaServerBrowser` - Container navigation
|
||||
|
||||
---
|
||||
|
||||
### 3. Existing Components: **Reorganize into Debug Section**
|
||||
|
||||
**Recommendation: Keep existing components, create new PMOControl home**
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
/app (root) → PMOControl Dashboard (NEW)
|
||||
/app/debug → Dropdown menu
|
||||
├─ /logs → LogView
|
||||
├─ /upnp → UpnpExplorer
|
||||
├─ /covers-cache → CoverCacheManager
|
||||
├─ /audio-cache → AudioCacheManager
|
||||
├─ /api-dashboard → APIDashboard
|
||||
└─ /radio-paradise → RadioParadiseExplorer
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Existing components are valuable for development/debugging
|
||||
- Don't break existing functionality
|
||||
- PMOControl becomes primary interface as specified
|
||||
- Debug tools remain accessible but not prominent
|
||||
- Matches current App.vue dropdown pattern
|
||||
|
||||
**Home Screen (/) - PMOControl Dashboard:**
|
||||
- Grid of renderer cards (status-colored)
|
||||
- Active playback session viewer
|
||||
- Quick controls (play/pause/volume)
|
||||
- Media server browser panel
|
||||
|
||||
---
|
||||
|
||||
### 4. Responsive Design: **Mobile-first with 3 breakpoints**
|
||||
|
||||
**Recommendation: Follow existing 768px pattern + add tablet/desktop**
|
||||
|
||||
**Breakpoints:**
|
||||
```css
|
||||
/* Mobile: < 768px (existing pattern) */
|
||||
- Single column layout
|
||||
- Stacked renderer cards
|
||||
- Bottom-fixed playback controls
|
||||
- Collapsible media browser
|
||||
|
||||
/* Tablet: 768px - 1024px */
|
||||
- Two column layout
|
||||
- Grid of renderer cards (2 columns)
|
||||
- Side panel for media browser
|
||||
- Floating playback controls
|
||||
|
||||
/* Desktop: > 1024px */
|
||||
- Three column layout
|
||||
- Renderer cards grid (3-4 columns)
|
||||
- Persistent media browser sidebar
|
||||
- Always-visible playback controls
|
||||
```
|
||||
|
||||
**Target Devices:**
|
||||
- **Primary**: Desktop browsers (control station)
|
||||
- **Secondary**: Tablets (remote control)
|
||||
- **Tertiary**: Mobile phones (quick controls)
|
||||
|
||||
**Performance considerations:**
|
||||
- Virtualized lists for 20+ renderers (use vue-virtual-scroller)
|
||||
- Lazy load album art
|
||||
- Throttle SSE position updates (max 1/sec per renderer)
|
||||
|
||||
---
|
||||
|
||||
### 5. Icons: **Lucide Icons (SVG library)**
|
||||
|
||||
**Recommendation: Lucide Icons (NOT emoji)**
|
||||
|
||||
**Rationale:**
|
||||
- **Professional appearance**: Emojis inconsistent across platforms
|
||||
- **Customizable**: Size, color, stroke width
|
||||
- **Lightweight**: Tree-shakeable SVG imports (~1KB per icon)
|
||||
- **Status coloring**: Icons can match card status colors
|
||||
- **Accessibility**: Proper ARIA labels
|
||||
- **Vue components**: `lucide-vue-next` package
|
||||
|
||||
**Icon mapping:**
|
||||
```typescript
|
||||
Play → PlayCircle
|
||||
Pause → PauseCircle
|
||||
Stop → StopCircle
|
||||
Next → SkipForward
|
||||
Volume → Volume2 / VolumeX (muted)
|
||||
Renderer → Speaker / MonitorSpeaker
|
||||
Server → Server / Database
|
||||
Queue → ListMusic
|
||||
```
|
||||
|
||||
**Alternative if you prefer minimal bundle:** Heroicons
|
||||
- Smaller set (fewer icons)
|
||||
- Tailwind CSS integration
|
||||
- But: less comprehensive for music player needs
|
||||
|
||||
**Why NOT emoji:**
|
||||
- Platform inconsistencies (iOS ≠ Android ≠ Windows)
|
||||
- No color control
|
||||
- Accessibility issues
|
||||
- Unprofessional for production UI
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Real-time SSE Integration
|
||||
|
||||
**SSE Event Handling:**
|
||||
```typescript
|
||||
// services/controlPointSSE.ts
|
||||
class ControlPointSSE {
|
||||
private eventSource: EventSource
|
||||
private renderersStore: ReturnType<typeof useRenderersStore>
|
||||
|
||||
connect() {
|
||||
this.eventSource = new EventSource('/api/control/events')
|
||||
|
||||
this.eventSource.addEventListener('control', (e) => {
|
||||
const event = JSON.parse(e.data)
|
||||
|
||||
if (event.category === 'renderer') {
|
||||
this.handleRendererEvent(event)
|
||||
} else if (event.category === 'media_server') {
|
||||
this.handleServerEvent(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
handleRendererEvent(event: RendererEventPayload) {
|
||||
switch (event.type) {
|
||||
case 'state_changed':
|
||||
this.renderersStore.updateState(event.renderer_id, event.state)
|
||||
break
|
||||
case 'volume_changed':
|
||||
this.renderersStore.updateVolume(event.renderer_id, event.volume)
|
||||
break
|
||||
// ... handle all event types
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Store Integration:**
|
||||
```typescript
|
||||
// stores/renderers.ts
|
||||
export const useRenderersStore = defineStore('renderers', () => {
|
||||
const renderers = ref<Map<string, RendererState>>(new Map())
|
||||
|
||||
// SSE updates
|
||||
function updateState(id: string, state: string) {
|
||||
const renderer = renderers.value.get(id)
|
||||
if (renderer) {
|
||||
renderer.transport_state = state
|
||||
}
|
||||
}
|
||||
|
||||
// REST API calls
|
||||
async function play(id: string) {
|
||||
await fetch(`/api/control/renderers/${id}/play`, { method: 'POST' })
|
||||
// SSE will update state automatically
|
||||
}
|
||||
|
||||
return { renderers, updateState, play }
|
||||
})
|
||||
```
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
**For 20+ concurrent clients:**
|
||||
|
||||
1. **Throttle position updates**:
|
||||
```typescript
|
||||
const throttledPositionUpdate = throttle((id, pos) => {
|
||||
store.updatePosition(id, pos)
|
||||
}, 1000) // Max 1 update/second
|
||||
```
|
||||
|
||||
2. **Virtual scrolling** for renderer lists:
|
||||
```bash
|
||||
npm install vue-virtual-scroller
|
||||
```
|
||||
|
||||
3. **Lazy load album art**:
|
||||
```vue
|
||||
<img :src="albumArt" loading="lazy" />
|
||||
```
|
||||
|
||||
4. **Debounce volume sliders**:
|
||||
```typescript
|
||||
const debouncedVolumeChange = debounce((id, vol) => {
|
||||
api.setVolume(id, vol)
|
||||
}, 300)
|
||||
```
|
||||
|
||||
5. **Memoize computed properties**:
|
||||
```typescript
|
||||
const activeRenderers = computed(() =>
|
||||
renderers.value.filter(r => r.online)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Core Infrastructure (Week 1)
|
||||
1. Install Pinia + configure stores
|
||||
2. Install Lucide Icons
|
||||
3. Create SSE service layer
|
||||
4. Setup store structure (renderers, servers, playback, ui)
|
||||
5. Connect SSE events to stores
|
||||
|
||||
### Phase 2: UI Components (Week 2)
|
||||
5. Build RendererCard component (status-colored)
|
||||
6. Build TransportControls component
|
||||
7. Build VolumeControl component
|
||||
8. Build QueueViewer component
|
||||
9. Create responsive grid layouts
|
||||
|
||||
### Phase 3: Dashboard Assembly (Week 3)
|
||||
10. Create PMOControl home view
|
||||
11. Integrate all components
|
||||
12. Add media server browser panel
|
||||
13. Implement responsive breakpoints
|
||||
14. Add loading states & error handling
|
||||
|
||||
### Phase 4: Polish & Testing (Week 4)
|
||||
15. Test with 20+ concurrent clients
|
||||
16. Performance profiling & optimization
|
||||
17. Accessibility audit (ARIA, keyboard nav)
|
||||
18. Cross-browser testing
|
||||
19. Mobile/tablet testing
|
||||
20. Documentation
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Install
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"pinia": "^2.2.8",
|
||||
"lucide-vue-next": "^0.470.0",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
// Already installed: vue, vue-router, typescript, vite
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Total bundle size estimate:** +15KB gzipped (Pinia + Lucide + Virtual Scroller)
|
||||
|
||||
---
|
||||
|
||||
## Status-based Coloring Scheme
|
||||
|
||||
Based on "carte uniforme, responsive, colorée selon statut" spec:
|
||||
|
||||
```css
|
||||
/* Renderer Card Status Colors */
|
||||
.renderer-card.playing {
|
||||
border-color: #22c55e; /* green */
|
||||
background: linear-gradient(135deg, #22c55e10, transparent);
|
||||
}
|
||||
|
||||
.renderer-card.paused {
|
||||
border-color: #f59e0b; /* amber */
|
||||
background: linear-gradient(135deg, #f59e0b10, transparent);
|
||||
}
|
||||
|
||||
.renderer-card.stopped {
|
||||
border-color: #6b7280; /* gray */
|
||||
background: linear-gradient(135deg, #6b728010, transparent);
|
||||
}
|
||||
|
||||
.renderer-card.offline {
|
||||
border-color: #ef4444; /* red */
|
||||
background: linear-gradient(135deg, #ef444410, transparent);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.renderer-card.transitioning {
|
||||
border-color: #3b82f6; /* blue */
|
||||
background: linear-gradient(135deg, #3b82f610, transparent);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Answers to Your Specific Questions
|
||||
|
||||
### 1. State Management?
|
||||
**Answer: Pinia** - Vue 3 official, perfect for SSE real-time updates, TypeScript native, lightweight
|
||||
|
||||
### 2. UI Component Library?
|
||||
**Answer: Headless UI (Shadcn-vue) + Custom Components** - Full control over status-based styling, lightweight, no theme lock-in
|
||||
|
||||
### 3. Keep existing components?
|
||||
**Answer: Yes, reorganize into Debug section** - Keep valuable dev tools, make PMOControl the new home screen
|
||||
|
||||
### 4. Responsive breakpoints?
|
||||
**Answer: Mobile-first with 3 breakpoints** - <768px (mobile), 768-1024px (tablet), >1024px (desktop)
|
||||
|
||||
### 5. Icons?
|
||||
**Answer: Lucide Icons (SVG library)** - Professional, customizable, status-colored, NOT emoji
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
**Potential challenges:**
|
||||
|
||||
1. **SSE connection management across tabs**
|
||||
- Solution: Use BroadcastChannel API for cross-tab sync
|
||||
- Fallback: LocalStorage events
|
||||
|
||||
2. **20+ renderers performance**
|
||||
- Solution: Virtual scrolling + throttled updates
|
||||
- Monitor: Chrome DevTools Performance profiler
|
||||
|
||||
3. **Network reliability (SSE reconnection)**
|
||||
- Solution: Exponential backoff reconnection
|
||||
- UI indicator for connection status
|
||||
|
||||
4. **Album art loading (CORS, 404s)**
|
||||
- Solution: Proxy through backend
|
||||
- Fallback: Default placeholder image
|
||||
|
||||
5. **Browser compatibility (SSE support)**
|
||||
- Chrome/Edge: Native support ✅
|
||||
- Firefox: Native support ✅
|
||||
- Safari: Native support ✅
|
||||
- IE11: Use EventSource polyfill
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
**Performance targets:**
|
||||
- Initial load: <2s (FCP)
|
||||
- SSE event latency: <100ms
|
||||
- UI interaction: <16ms (60fps)
|
||||
- Memory usage: <50MB with 20 renderers
|
||||
- Bundle size: <250KB gzipped
|
||||
|
||||
**Functionality checklist:**
|
||||
- [ ] Display all discovered renderers in real-time
|
||||
- [ ] Show accurate playback state (play/pause/stop)
|
||||
- [ ] Volume control works across all renderer types
|
||||
- [ ] Queue display syncs with server
|
||||
- [ ] Media server browsing functional
|
||||
- [ ] Playlist attachment working
|
||||
- [ ] Responsive on mobile/tablet/desktop
|
||||
- [ ] Accessible (WCAG AA compliance)
|
||||
- [ ] 20+ concurrent clients supported
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review & approve** this plan with stakeholders
|
||||
2. **Clarify any ambiguities** in requirements
|
||||
3. **Set up development environment** (install dependencies)
|
||||
4. **Begin Phase 1** (Core Infrastructure)
|
||||
|
||||
Would you like me to proceed with implementation, or do you have questions about any of these recommendations?
|
||||
38
Readme.md
38
Readme.md
@@ -1,5 +1,43 @@
|
||||
# Développement de l'application PMOMusic en RUST
|
||||
|
||||
## 🚀 Démarrage rapide
|
||||
|
||||
### Installation des dépendances (environnement sans sudo)
|
||||
|
||||
Pour compiler PMOMusic dans un environnement sans privilèges sudo (comme Claude Code) :
|
||||
|
||||
```bash
|
||||
# 1. Installation automatique de libsoxr et libasound2 (une seule fois)
|
||||
./setup-deps.sh
|
||||
|
||||
# 2. Créer le fichier setup-env.sh (une seule fois, voir INSTALL_LIBSOXR.md pour le contenu)
|
||||
cat > setup-env.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||
echo "Variables d'environnement configurées pour PMOMusic"
|
||||
EOF
|
||||
|
||||
# 3. Configuration de l'environnement (à chaque nouvelle session)
|
||||
source setup-env.sh
|
||||
|
||||
# 4. Compilation
|
||||
cargo build
|
||||
|
||||
# 5. Test de l'exemple Radio Paradise
|
||||
cargo run --package pmoparadise --example play_and_cache --features full -- 0
|
||||
```
|
||||
|
||||
⚠️ **Note :** Le fichier `setup-env.sh` est dans `.gitignore` car il contient une configuration locale.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **[INSTALL_NOTES.md](INSTALL_NOTES.md)** - Guide d'installation général
|
||||
- **[INSTALL_LIBSOXR.md](INSTALL_LIBSOXR.md)** - Installation détaillée de libsoxr et ALSA
|
||||
|
||||
---
|
||||
|
||||
## Création de la structure
|
||||
|
||||
```bash
|
||||
|
||||
27
SECURITY_CONFIG.md
Normal file
27
SECURITY_CONFIG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Configuration Sécurisée
|
||||
|
||||
## Configuration de PMOMusic
|
||||
|
||||
Le fichier `.pmomusic.yml` contient des informations sensibles (mots de passe, identifiants).
|
||||
|
||||
### Installation
|
||||
|
||||
1. Copiez le fichier exemple :
|
||||
```bash
|
||||
cp .pmomusic.yml.example .pmomusic.yml
|
||||
```
|
||||
|
||||
2. Éditez `.pmomusic.yml` et remplacez les valeurs par vos véritables identifiants :
|
||||
- `accounts.qobuz.username` : votre email Qobuz
|
||||
- `accounts.qobuz.password` : votre mot de passe Qobuz
|
||||
|
||||
3. **Important** : Ne commitez JAMAIS le fichier `.pmomusic.yml` dans git !
|
||||
- Il est déjà dans `.gitignore`
|
||||
- Utilisez des variables d'environnement pour la production
|
||||
|
||||
## Variables d'environnement (recommandé pour production)
|
||||
|
||||
```bash
|
||||
export QOBUZ_USERNAME="votre-email@example.com"
|
||||
export QOBUZ_PASSWORD="votre-mot-de-passe"
|
||||
```
|
||||
243
UPNP_ANALYSIS_REPORT.md
Normal file
243
UPNP_ANALYSIS_REPORT.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Rapport d'Analyse UPnP - PMO Music vs Serveurs Fonctionnels
|
||||
|
||||
**Date:** 2025-11-26
|
||||
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
|
||||
|
||||
## Résumé Exécutif
|
||||
|
||||
Le serveur PMO Music MediaServer est correctement découvert via SSDP et répond aux requêtes SOAP, mais présente plusieurs différences avec les serveurs qui fonctionnent (comme Upmpdcli). Les problèmes identifiés sont principalement liés aux en-têtes HTTP et aux métadonnées du device.
|
||||
|
||||
## Découverte Réseau
|
||||
|
||||
### Devices UPnP Détectés
|
||||
|
||||
| Device | IP | USN | Status |
|
||||
|--------|------|-----|---------|
|
||||
| PMO Music MediaServer | 192.168.0.138:8080 | uuid:8b8e9b19-9c65-4d59-b127-b34717658085 | ✅ Découvert |
|
||||
| Upmpdcli (pizzicato) | 192.168.0.200:49152 | uuid:c110358f-d885-b44a-d6d3-dca6329ead0d | ✅ Découvert |
|
||||
| Freebox | 192.168.0.254:52424 | uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | ✅ Découvert |
|
||||
| Jellyfin | 192.168.0.34:8096 | uuid:526dedec-fde2-4224-bac6-06f7b11711cf | ✅ Découvert |
|
||||
|
||||
**Conclusion SSDP:** ✅ PMO Music est correctement annoncé et découvert via SSDP
|
||||
|
||||
## Comparaison des Descripteurs XML
|
||||
|
||||
### PMO Music MediaServer
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor> <!-- ⚠️ Version 1.0 -->
|
||||
</specVersion>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<friendlyName>PMOMusic Media Server</friendlyName>
|
||||
<manufacturer>PMOMusic</manufacturer>
|
||||
<modelName>PMOMusic Media Server</modelName>
|
||||
<UDN>uuid:8b8e9b19-9c65-4d59-b127-b34717658085</UDN> <!-- ✅ Format correct -->
|
||||
<!-- ❌ Pas d'iconList -->
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
|
||||
<SCPDURL>/device/.../service/ContentDirectory/desc.xml</SCPDURL>
|
||||
<controlURL>/device/.../service/ContentDirectory/control</controlURL>
|
||||
<eventSubURL>/device/.../service/ContentDirectory/event</eventSubURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
|
||||
...
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
```
|
||||
|
||||
### Upmpdcli (Fonctionnel)
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>1</minor> <!-- ✅ Version 1.1 -->
|
||||
</specVersion>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||
<manufacturer>lesbonscomptes.com/upmpdcli</manufacturer>
|
||||
<modelName>Upmpdcli Media Server</modelName>
|
||||
<friendlyName>pizzicato-Music-mediaserver</friendlyName>
|
||||
<iconList> <!-- ✅ Présence d'icônes -->
|
||||
<icon>
|
||||
<mimetype>image/png</mimetype>
|
||||
<width>64</width>
|
||||
<height>64</height>
|
||||
<depth>32</depth>
|
||||
<url>/uuid-.../icon.png</url>
|
||||
</icon>
|
||||
</iconList>
|
||||
<UDN>uuid:c110358f-d885-b44a-d6d3-dca6329ead0d</UDN>
|
||||
<serviceList>
|
||||
<!-- Mêmes services -->
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
```
|
||||
|
||||
### Différences Clés dans le Descripteur
|
||||
|
||||
| Élément | PMO Music | Upmpdcli | Impact |
|
||||
|---------|-----------|----------|---------|
|
||||
| **specVersion minor** | 0 | 1 | ⚠️ Moyen - Certains clients peuvent filtrer par version |
|
||||
| **Ordre des éléments** | deviceType, friendlyName, manufacturer, modelName, UDN | deviceType, manufacturer, modelName, friendlyName, iconList, UDN | ⚠️ Faible - Ordre différent mais valide XML |
|
||||
| **iconList** | ❌ Absent | ✅ Présent | ⚠️ Moyen - Requis pour certains clients |
|
||||
| **UDN prefix** | ✅ uuid: | ✅ uuid: | ✅ Correct |
|
||||
|
||||
## Comparaison des Réponses SOAP
|
||||
|
||||
### Test 1: ConnectionManager::GetProtocolInfo
|
||||
|
||||
#### PMO Music
|
||||
```http
|
||||
Status: 200 OK
|
||||
Content-Type: (absent) ⚠️ PROBLÈME CRITIQUE
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
|
||||
<Source></Source> ⚠️ Vide
|
||||
<Sink></Sink> ⚠️ Vide
|
||||
</u:GetProtocolInfoResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
```
|
||||
|
||||
#### Upmpdcli
|
||||
```http
|
||||
Status: 200 OK
|
||||
Content-Type: text/xml; charset="utf-8" ✅ Présent
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
|
||||
<Source></Source>
|
||||
<Sink>http-get:*:audio/flac:*,http-get:*:audio/mp3:*,...</Sink> ✅ Formats listés
|
||||
</u:GetProtocolInfoResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
```
|
||||
|
||||
### Test 2: ContentDirectory::Browse
|
||||
|
||||
Les deux serveurs répondent correctement, mais PMO Music manque toujours le header `Content-Type`.
|
||||
|
||||
## Problèmes Identifiés par Ordre de Criticité
|
||||
|
||||
### 🔴 CRITIQUE
|
||||
|
||||
1. **Absence du header Content-Type dans les réponses SOAP**
|
||||
- **Impact:** Les clients UPnP stricts (comme BubbleUPnP) peuvent rejeter les réponses sans Content-Type
|
||||
- **Spec UPnP:** La spécification UPnP Device Architecture 1.0 exige `Content-Type: text/xml; charset="utf-8"`
|
||||
- **Localisation probable:** Dans le code de réponse SOAP du serveur UPnP
|
||||
- **Fichiers à vérifier:**
|
||||
- `pmoupnp/src/services/service_instance.rs` (handler SOAP)
|
||||
- `pmoupnp/src/soap/builder.rs`
|
||||
|
||||
2. **ProtocolInfo vide pour Source et Sink**
|
||||
- **Impact:** Les clients ne savent pas quels formats audio sont supportés
|
||||
- **Spec UPnP:** ConnectionManager doit annoncer les formats supportés
|
||||
- **Action:** Implémenter la liste des formats dans ConnectionManager
|
||||
|
||||
### 🟡 MOYEN
|
||||
|
||||
3. **specVersion 1.0 au lieu de 1.1**
|
||||
- **Impact:** Certains clients modernes peuvent filtrer les devices UPnP 1.0
|
||||
- **Solution:** Passer à specVersion 1.1
|
||||
|
||||
4. **Absence d'iconList**
|
||||
- **Impact:** Pas d'icône visible dans les clients UPnP
|
||||
- **Solution:** Ajouter au moins une icône PNG 64x64
|
||||
|
||||
### 🟢 FAIBLE
|
||||
|
||||
5. **Ordre des éléments XML différent**
|
||||
- **Impact:** Minimal - XML valide dans tous les cas
|
||||
- **Action:** Optionnel - standardiser l'ordre
|
||||
|
||||
## Recommandations d'Implémentation
|
||||
|
||||
### Priorité 1: Corriger le Content-Type
|
||||
|
||||
Localiser le code qui génère les réponses SOAP et ajouter le header:
|
||||
|
||||
```rust
|
||||
// Dans pmoupnp/src/services/service_instance.rs ou similaire
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")], // ← AJOUTER
|
||||
xml
|
||||
)
|
||||
```
|
||||
|
||||
### Priorité 2: Implémenter GetProtocolInfo correctement
|
||||
|
||||
Dans ConnectionManager, retourner la liste des formats supportés:
|
||||
|
||||
```rust
|
||||
// Exemple de formats à supporter
|
||||
let sink_protocols = vec![
|
||||
"http-get:*:audio/flac:*",
|
||||
"http-get:*:audio/mpeg:*",
|
||||
"http-get:*:audio/mp4:*",
|
||||
"http-get:*:audio/ogg:*",
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
### Priorité 3: Passer à UPnP 1.1
|
||||
|
||||
Changer la specVersion de 1.0 à 1.1 dans le device descriptor.
|
||||
|
||||
### Priorité 4: Ajouter une icône
|
||||
|
||||
Créer une icône PNG 64x64 et l'ajouter au descripteur:
|
||||
|
||||
```xml
|
||||
<iconList>
|
||||
<icon>
|
||||
<mimetype>image/png</mimetype>
|
||||
<width>64</width>
|
||||
<height>64</height>
|
||||
<depth>32</depth>
|
||||
<url>/icon.png</url>
|
||||
</icon>
|
||||
</iconList>
|
||||
```
|
||||
|
||||
## Fichiers à Modifier
|
||||
|
||||
1. **pmoupnp/src/services/service_instance.rs** - Ajouter Content-Type aux réponses SOAP
|
||||
2. **pmoupnp/src/devices/device_methods.rs** - Ajouter iconList au descripteur
|
||||
3. **pmoupnp/src/devices/device.rs** - Passer specVersion à 1.1
|
||||
4. **pmomediaserver/src/connectionmanager/actions/getprotocolinfo.rs** - Implémenter la liste des formats
|
||||
|
||||
## Tests de Validation
|
||||
|
||||
Après les corrections, vérifier:
|
||||
|
||||
1. ✅ `curl` sur le descripteur montre specVersion 1.1 et iconList
|
||||
2. ✅ Requête SOAP GetProtocolInfo retourne `Content-Type: text/xml`
|
||||
3. ✅ GetProtocolInfo retourne les formats supportés dans Sink
|
||||
4. ✅ BubbleUPnP détecte et affiche le serveur PMO Music
|
||||
|
||||
## Conclusion
|
||||
|
||||
Le serveur PMO Music est **fonctionnellement correct** au niveau de SSDP et des services SOAP, mais présente des problèmes de conformité aux standards UPnP qui peuvent causer des rejets par certains clients stricts comme BubbleUPnP.
|
||||
|
||||
Les corrections sont simples et localisées. La priorité absolue est d'ajouter le header `Content-Type` aux réponses SOAP.
|
||||
128
UPNP_FIX_SUMMARY.md
Normal file
128
UPNP_FIX_SUMMARY.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Résolution du Problème UPnP - PMO Music MediaServer
|
||||
|
||||
**Date:** 2025-11-26
|
||||
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
|
||||
|
||||
## Diagnostic
|
||||
|
||||
Après une analyse approfondie avec des outils de découverte UPnP et de tests SOAP, le problème identifié était :
|
||||
|
||||
**🔴 PROBLÈME CRITIQUE : `SourceProtocolInfo` vide**
|
||||
|
||||
Le service `ConnectionManager` du MediaServer retournait des valeurs vides pour `SourceProtocolInfo`, ce qui empêchait les clients UPnP (comme BubbleUPnP) de savoir quels formats audio le serveur pouvait fournir.
|
||||
|
||||
### Réponse AVANT la correction :
|
||||
|
||||
```xml
|
||||
<u:GetProtocolInfoResponse>
|
||||
<Source></Source> <!-- ❌ VIDE -->
|
||||
<Sink></Sink>
|
||||
</u:GetProtocolInfoResponse>
|
||||
```
|
||||
|
||||
## Solution Implémentée
|
||||
|
||||
### 1. Nouveau Module : `device_ext.rs`
|
||||
|
||||
Création d'un trait d'extension `MediaServerDeviceExt` pour `Arc<DeviceInstance>` qui initialise automatiquement les `ProtocolInfo`.
|
||||
|
||||
**Fichier:** [`pmomediaserver/src/device_ext.rs`](pmomediaserver/src/device_ext.rs)
|
||||
|
||||
```rust
|
||||
pub trait MediaServerDeviceExt {
|
||||
/// Initialise les ProtocolInfo du ConnectionManager pour PMO Music.
|
||||
///
|
||||
/// PMO Music convertit tous les flux audio en FLAC (et OGG-FLAC).
|
||||
fn init_protocol_info(&self);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Formats Supportés
|
||||
|
||||
PMO Music convertit tout au vol en FLAC, donc `SourceProtocolInfo` annonce :
|
||||
|
||||
- `http-get:*:audio/flac:*` - FLAC standard
|
||||
- `http-get:*:audio/x-flac:*` - FLAC (format alternatif)
|
||||
- `http-get:*:application/flac:*` - FLAC (MIME type alternatif)
|
||||
- `http-get:*:application/x-flac:*` - FLAC (MIME type alternatif)
|
||||
- `http-get:*:application/ogg:*` - OGG-FLAC
|
||||
- `http-get:*:audio/ogg:*` - OGG-FLAC
|
||||
- `http-get:*:audio/x-ogg:*` - OGG-FLAC (format alternatif)
|
||||
|
||||
### 3. Intégration dans `main.rs`
|
||||
|
||||
**Fichier:** [`PMOMusic/src/main.rs`](PMOMusic/src/main.rs)
|
||||
|
||||
```rust
|
||||
use pmomediaserver::MediaServerDeviceExt;
|
||||
|
||||
let server_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_SERVER.clone())
|
||||
.await
|
||||
.expect("Failed to register MediaServer");
|
||||
|
||||
// ✅ Initialiser les ProtocolInfo du MediaServer
|
||||
server_instance.init_protocol_info();
|
||||
```
|
||||
|
||||
### 4. Export dans `lib.rs`
|
||||
|
||||
**Fichier:** [`pmomediaserver/src/lib.rs`](pmomediaserver/src/lib.rs)
|
||||
|
||||
```rust
|
||||
pub mod device_ext;
|
||||
pub use device_ext::MediaServerDeviceExt;
|
||||
```
|
||||
|
||||
## Réponse APRÈS la correction
|
||||
|
||||
```xml
|
||||
<u:GetProtocolInfoResponse>
|
||||
<Source>http-get:*:audio/flac:*,http-get:*:audio/x-flac:*,http-get:*:application/flac:*,http-get:*:application/x-flac:*,http-get:*:application/ogg:*,http-get:*:audio/ogg:*,http-get:*:audio/x-ogg:*</Source> <!-- ✅ INITIALISÉ -->
|
||||
<Sink></Sink> <!-- ✅ Vide pour un MediaServer (normal) -->
|
||||
</u:GetProtocolInfoResponse>
|
||||
```
|
||||
|
||||
## Fichiers Modifiés
|
||||
|
||||
1. ✅ **Nouveau:** `pmomediaserver/src/device_ext.rs` - Trait d'extension pour initialiser ProtocolInfo
|
||||
2. ✅ **Modifié:** `pmomediaserver/src/lib.rs` - Export du trait
|
||||
3. ✅ **Modifié:** `PMOMusic/src/main.rs` - Appel à `init_protocol_info()`
|
||||
|
||||
## Test de Validation
|
||||
|
||||
Après redémarrage du serveur PMO Music, vérifier avec :
|
||||
|
||||
```bash
|
||||
python3 tools/test_soap.py
|
||||
```
|
||||
|
||||
Ou directement :
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: text/xml" \
|
||||
-H "SOAPAction: \"urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo\"" \
|
||||
-d '<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:GetProtocolInfo xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>' \
|
||||
http://localhost:8080/device/.../service/ConnectionManager/control
|
||||
```
|
||||
|
||||
## Prochaines Étapes
|
||||
|
||||
1. ✅ Redémarrer le serveur PMO Music
|
||||
2. ⏳ Tester avec BubbleUPnP pour confirmer que le serveur est maintenant reconnu
|
||||
3. ⏳ (Optionnel) Ajouter une icône pour le MediaServer (amélioration UX)
|
||||
4. ⏳ (Optionnel) Passer à specVersion 1.1 (amélioration de compatibilité)
|
||||
|
||||
## Références
|
||||
|
||||
- Rapport d'analyse complet : [`UPNP_ANALYSIS_REPORT.md`](UPNP_ANALYSIS_REPORT.md)
|
||||
- UPnP AV Architecture Specification :
|
||||
https://openconnectivity.org/developer/specifications/upnp-resources/upnp/
|
||||
31
analyze_flac.sh
Executable file
31
analyze_flac.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# Affiche toutes les stats d'un fichier FLAC
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <fichier.flac>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILE="$1"
|
||||
|
||||
if [ ! -f "$FILE" ]; then
|
||||
echo "Error: File not found: $FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Analyzing: $(basename "$FILE") ==="
|
||||
echo ""
|
||||
echo "--- SoX Statistics ---"
|
||||
sox "$FILE" -n stat 2>&1
|
||||
|
||||
echo ""
|
||||
echo "--- File Info ---"
|
||||
file "$FILE"
|
||||
|
||||
echo ""
|
||||
echo "--- FLAC Metadata ---"
|
||||
metaflac --list "$FILE" 2>/dev/null || echo "metaflac not installed"
|
||||
|
||||
echo ""
|
||||
echo "--- Audio Integrity Check ---"
|
||||
flac -t "$FILE" 2>&1 || echo "flac not installed"
|
||||
BIN
bubble_upmpdcli.pcap
Normal file
BIN
bubble_upmpdcli.pcap
Normal file
Binary file not shown.
90
check_audio_quality.sh
Executable file
90
check_audio_quality.sh
Executable file
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# Script pour vérifier la qualité audio (détection de clics via le ratio delta)
|
||||
|
||||
CACHE_DIR="${1:-/tmp/pmomusic_test/audio_cache}"
|
||||
THRESHOLD=10.0 # Ratio Maximum delta / Mean delta acceptable
|
||||
|
||||
if [ ! -d "$CACHE_DIR" ]; then
|
||||
echo "Error: Cache directory not found: $CACHE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Audio Quality Check ==="
|
||||
echo "Scanning: $CACHE_DIR"
|
||||
echo "Threshold: Maximum/Mean delta ratio < $THRESHOLD"
|
||||
echo ""
|
||||
|
||||
# Vérifier que sox est installé
|
||||
if ! command -v sox &> /dev/null; then
|
||||
echo "Error: sox is not installed. Install it with: sudo apt install sox"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
count=0
|
||||
suspicious=0
|
||||
good=0
|
||||
|
||||
for file in "$CACHE_DIR"/*.orig.flac; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "No FLAC files found in $CACHE_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
filename=$(basename "$file")
|
||||
|
||||
# Obtenir les stats delta
|
||||
stats=$(sox "$file" -n stat 2>&1)
|
||||
|
||||
max_delta=$(echo "$stats" | grep "Maximum delta" | awk '{print $3}')
|
||||
mean_delta=$(echo "$stats" | grep "Mean delta" | awk '{print $3}')
|
||||
|
||||
if [ -z "$max_delta" ] || [ -z "$mean_delta" ]; then
|
||||
echo "❌ $filename - Cannot parse stats"
|
||||
suspicious=$((suspicious + 1))
|
||||
count=$((count + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Éviter division par zéro
|
||||
if (( $(echo "$mean_delta == 0" | bc -l) )); then
|
||||
echo "❌ $filename - Invalid mean delta (0)"
|
||||
suspicious=$((suspicious + 1))
|
||||
count=$((count + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Calculer le ratio
|
||||
ratio=$(echo "scale=2; $max_delta / $mean_delta" | bc -l)
|
||||
|
||||
# Comparer au seuil
|
||||
is_bad=$(echo "$ratio > $THRESHOLD" | bc -l)
|
||||
|
||||
if [ "$is_bad" = "1" ]; then
|
||||
echo "⚠️ CLICKS DETECTED: $filename"
|
||||
echo " Max delta: $max_delta, Mean delta: $mean_delta, Ratio: ${ratio}x (threshold: ${THRESHOLD}x)"
|
||||
suspicious=$((suspicious + 1))
|
||||
else
|
||||
echo "✓ OK: $filename (ratio: ${ratio}x)"
|
||||
good=$((good + 1))
|
||||
fi
|
||||
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo "Total files scanned: $count"
|
||||
echo "✓ Good quality: $good"
|
||||
echo "⚠️ Clicks detected: $suspicious"
|
||||
|
||||
if [ $suspicious -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ Warning: $suspicious file(s) have clicks."
|
||||
echo "These files were likely encoded with the old buffer size (8)."
|
||||
echo "Delete the cache and re-download to fix: rm -rf $CACHE_DIR/*.flac"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✓ All files are good quality!"
|
||||
exit 0
|
||||
76
detect_clicks.sh
Executable file
76
detect_clicks.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# Script pour détecter les clics dans les fichiers FLAC du cache
|
||||
|
||||
CACHE_DIR="${1:-/tmp/pmomusic_test/audio_cache}"
|
||||
|
||||
if [ ! -d "$CACHE_DIR" ]; then
|
||||
echo "Error: Cache directory not found: $CACHE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== FLAC Click Detection Tool ==="
|
||||
echo "Scanning: $CACHE_DIR"
|
||||
echo ""
|
||||
|
||||
# Vérifier que sox est installé
|
||||
if ! command -v sox &> /dev/null; then
|
||||
echo "Error: sox is not installed. Install it with: sudo apt install sox"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
count=0
|
||||
suspicious=0
|
||||
|
||||
for file in "$CACHE_DIR"/*.orig.flac; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "No FLAC files found in $CACHE_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
filename=$(basename "$file")
|
||||
echo "Analyzing: $filename"
|
||||
|
||||
# Obtenir toutes les stats
|
||||
stats=$(sox "$file" -n stat 2>&1)
|
||||
|
||||
# Extraire les valeurs importantes
|
||||
pk_lev=$(echo "$stats" | grep "Pk lev dB" | awk '{print $4}')
|
||||
rms_lev=$(echo "$stats" | grep "RMS lev dB" | awk '{print $4}')
|
||||
crest=$(echo "$stats" | grep "Crest factor" | awk '{print $3}')
|
||||
|
||||
echo " Peak level: ${pk_lev:-N/A} dB"
|
||||
echo " RMS level: ${rms_lev:-N/A} dB"
|
||||
echo " Crest factor: ${crest:-N/A} dB"
|
||||
|
||||
# Analyser la variance d'amplitude (détection de clics)
|
||||
# On compte le nombre de pics au-dessus d'un seuil
|
||||
peaks=$(sox "$file" -n stats 2>&1 | grep "Maximum amplitude" | awk '{print $3}')
|
||||
|
||||
if [ -n "$peaks" ]; then
|
||||
# Si le peak est proche de 1.0 (clipping), c'est suspect
|
||||
is_clipping=$(echo "$peaks > 0.95" | bc -l 2>/dev/null)
|
||||
if [ "$is_clipping" = "1" ]; then
|
||||
echo " ⚠️ WARNING: Possible clipping detected!"
|
||||
suspicious=$((suspicious + 1))
|
||||
else
|
||||
echo " ✓ OK"
|
||||
fi
|
||||
else
|
||||
echo " ✓ OK"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
echo "=== Summary ==="
|
||||
echo "Files scanned: $count"
|
||||
echo "Suspicious files: $suspicious"
|
||||
|
||||
if [ $suspicious -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ Some files may have issues. Listen to them carefully."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
135
docker-build.sh
Executable file
135
docker-build.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# Script de build Docker pour PMOMusic
|
||||
# Usage: ./docker-build.sh [OPTIONS]
|
||||
|
||||
set -e
|
||||
|
||||
# Couleurs pour l'affichage
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration par défaut
|
||||
IMAGE_NAME="pmomusic"
|
||||
TAG="latest"
|
||||
NO_CACHE=false
|
||||
PUSH=true
|
||||
REGISTRY="niepce.petite-maison-orange.fr/public"
|
||||
|
||||
# Fonction d'aide
|
||||
show_help() {
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -t, --tag TAG Tag de l'image (défaut: latest)"
|
||||
echo " -r, --registry URL Registry Docker (ex: ghcr.io/user)"
|
||||
echo " -n, --no-cache Build sans cache"
|
||||
echo " -p, --push Push l'image vers le registry"
|
||||
echo " -h, --help Affiche cette aide"
|
||||
echo ""
|
||||
echo "Exemples:"
|
||||
echo " $0 # Build local avec tag 'latest'"
|
||||
echo " $0 -t v1.0.0 # Build avec tag 'v1.0.0'"
|
||||
echo " $0 -t v1.0.0 -r ghcr.io/user -p # Build et push vers GHCR"
|
||||
echo " $0 -n # Build sans cache"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Parsing des arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-t|--tag)
|
||||
TAG="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--registry)
|
||||
REGISTRY="$2"
|
||||
shift 2
|
||||
;;
|
||||
-n|--no-cache)
|
||||
NO_CACHE=true
|
||||
shift
|
||||
;;
|
||||
-p|--push)
|
||||
PUSH=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Erreur: Option inconnue '$1'${NC}"
|
||||
show_help
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Construire le nom complet de l'image
|
||||
if [ -n "$REGISTRY" ]; then
|
||||
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG"
|
||||
else
|
||||
FULL_IMAGE_NAME="$IMAGE_NAME:$TAG"
|
||||
fi
|
||||
|
||||
# Afficher la configuration
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}Build Docker PMOMusic${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo "Image: $FULL_IMAGE_NAME"
|
||||
echo "No cache: $NO_CACHE"
|
||||
echo "Push: $PUSH"
|
||||
echo ""
|
||||
|
||||
# Construire la commande Docker
|
||||
DOCKER_CMD="docker build"
|
||||
|
||||
if [ "$NO_CACHE" = true ]; then
|
||||
DOCKER_CMD="$DOCKER_CMD --no-cache"
|
||||
fi
|
||||
|
||||
DOCKER_CMD="$DOCKER_CMD --build-arg BUILD_DATE=\"$(date -u +'%Y-%m-%dT%H:%M:%SZ')\" -t $FULL_IMAGE_NAME ."
|
||||
|
||||
# Exécuter le build
|
||||
echo -e "${YELLOW}→ Démarrage du build...${NC}"
|
||||
echo "Commande: $DOCKER_CMD"
|
||||
echo ""
|
||||
|
||||
if eval "$DOCKER_CMD"; then
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Build réussi !${NC}"
|
||||
|
||||
# Afficher la taille de l'image
|
||||
IMAGE_SIZE=$(docker images "$FULL_IMAGE_NAME" --format "{{.Size}}")
|
||||
echo "Taille de l'image: $IMAGE_SIZE"
|
||||
|
||||
# Push si demandé
|
||||
if [ "$PUSH" = true ]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}→ Push de l'image vers le registry...${NC}"
|
||||
|
||||
if docker push "$FULL_IMAGE_NAME"; then
|
||||
echo -e "${GREEN}✓ Image pushée avec succès !${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Erreur lors du push${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}Build terminé avec succès !${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo "Pour lancer le conteneur:"
|
||||
echo " docker run -it --rm --network host $FULL_IMAGE_NAME"
|
||||
echo ""
|
||||
echo "Ou avec docker-compose:"
|
||||
echo " docker-compose up -d"
|
||||
|
||||
else
|
||||
echo ""
|
||||
echo -e "${RED}✗ Erreur lors du build${NC}"
|
||||
exit 1
|
||||
fi
|
||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pmomusic:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: pmomusic:latest
|
||||
container_name: pmomusic
|
||||
|
||||
# Port mapping (adjust to your needs)
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
# Volume for persistent configuration
|
||||
volumes:
|
||||
- ./config:/home/pmomusic/.pmomusic
|
||||
- ./cache:/home/pmomusic/cache
|
||||
|
||||
# Environment variables (adjust as needed)
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
# Add other environment variables here
|
||||
|
||||
# Network mode for UPnP/DLNA discovery (host mode for multicast)
|
||||
network_mode: host
|
||||
|
||||
# Restart policy
|
||||
restart: unless-stopped
|
||||
|
||||
# Resource limits (optional)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
431
media_
Normal file
431
media_
Normal file
@@ -0,0 +1,431 @@
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoserver/src/config_ext.rs:35:5
|
||||
|
|
||||
35 | async fn init_config_api(&mut self) -> Result<()>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
35 - async fn init_config_api(&mut self) -> Result<()>;
|
||||
35 + fn init_config_api(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
|
||||
|
||||
warning: `pmoserver` (lib) generated 1 warning
|
||||
warning: variable does not need to be mutable
|
||||
--> pmocache/src/cache.rs:604:9
|
||||
|
|
||||
604 | mut reader: R,
|
||||
| ----^^^^^^
|
||||
| |
|
||||
| help: remove this `mut`
|
||||
|
|
||||
= note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:97:5
|
||||
|
|
||||
97 | async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
97 - async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
|
||||
97 + fn add_from_url(&self, url: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:111:5
|
||||
|
|
||||
111 | async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
111 - async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
|
||||
111 + fn add_from_file(&self, path: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:118:5
|
||||
|
|
||||
118 | async fn get(&self, pk: &str) -> Result<PathBuf>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
118 - async fn get(&self, pk: &str) -> Result<PathBuf>;
|
||||
118 + fn get(&self, pk: &str) -> impl std::future::Future<Output = Result<PathBuf>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:125:5
|
||||
|
|
||||
125 | async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
125 - async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
|
||||
125 + fn get_collection(&self, collection: &str) -> impl std::future::Future<Output = Result<Vec<PathBuf>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:128:5
|
||||
|
|
||||
128 | async fn purge(&self) -> Result<()>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
128 - async fn purge(&self) -> Result<()>;
|
||||
128 + fn purge(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:131:5
|
||||
|
|
||||
131 | async fn consolidate(&self) -> Result<()>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
131 - async fn consolidate(&self) -> Result<()>;
|
||||
131 + fn consolidate(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/cache_trait.rs:147:5
|
||||
|
|
||||
147 | async fn is_valid_pk(&self, pk: &str) -> bool {
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
147 ~ fn is_valid_pk(&self, pk: &str) -> impl std::future::Future<Output = bool> + Send {async {
|
||||
148 | if self.get_database().get(pk, false).is_err() {
|
||||
...
|
||||
218 | false
|
||||
219 ~ } }
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmocache/src/pmoserver_ext.rs:391:5
|
||||
|
|
||||
391 | async fn init_generic_cache<C: CacheConfig + 'static>(
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
391 ~ fn init_generic_cache<C: CacheConfig + 'static>(
|
||||
392 | &mut self,
|
||||
...
|
||||
395 | content_type: &'static str,
|
||||
396 ~ ) -> impl std::future::Future<Output = anyhow::Result<Arc<Cache<C>>>> + Send;
|
||||
|
|
||||
|
||||
warning: `pmocache` (lib) generated 9 warnings (run `cargo fix --lib -p pmocache` to apply 1 suggestion)
|
||||
warning: unused import: `serde_json::Value`
|
||||
--> pmoaudiocache/src/cache.rs:11:5
|
||||
|
|
||||
11 | use serde_json::Value;
|
||||
| ^^^^^^^^^^^^^^^^^
|
||||
|
|
||||
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: unused import: `pmometadata::TrackMetadata`
|
||||
--> pmoaudiocache/src/api.rs:11:5
|
||||
|
|
||||
11 | use pmometadata::TrackMetadata;
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/lib.rs:188:5
|
||||
|
|
||||
188 | async fn init_audio_cache(
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
188 ~ fn init_audio_cache(
|
||||
189 | &mut self,
|
||||
190 | cache_dir: &str,
|
||||
191 | limit: usize,
|
||||
192 ~ ) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/lib.rs:197:5
|
||||
|
|
||||
197 | async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
197 - async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
|
||||
197 + fn init_audio_cache_configured(&mut self) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/metadata_ext.rs:36:5
|
||||
|
|
||||
36 | async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
36 - async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
36 + fn get_title(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/metadata_ext.rs:37:5
|
||||
|
|
||||
37 | async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
37 - async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
37 + fn get_artist(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/metadata_ext.rs:38:5
|
||||
|
|
||||
38 | async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
38 - async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||
38 + fn get_album(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoaudiocache/src/metadata_ext.rs:39:5
|
||||
|
|
||||
39 | async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
39 - async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
|
||||
39 + fn get_duration_secs(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<i64>>> + Send;
|
||||
|
|
||||
|
||||
warning: `pmoaudiocache` (lib) generated 8 warnings (run `cargo fix --lib -p pmoaudiocache` to apply 1 suggestion)
|
||||
warning: unused import: `tokio_stream::StreamExt`
|
||||
--> pmoplaylist/src/sse.rs:16:5
|
||||
|
|
||||
16 | use tokio_stream::StreamExt;
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
|
||||
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: methods `list_playlist_ids` and `remove_by_cache_pk` are never used
|
||||
--> pmoplaylist/src/persistence/mod.rs:219:18
|
||||
|
|
||||
17 | impl PersistenceManager {
|
||||
| ----------------------- methods in this implementation
|
||||
...
|
||||
219 | pub async fn list_playlist_ids(&self) -> Result<Vec<String>> {
|
||||
| ^^^^^^^^^^^^^^^^^
|
||||
...
|
||||
240 | pub async fn remove_by_cache_pk(&self, cache_pk: &str) -> Result<()> {
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
|
|
||||
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: function `PlaylistManager` should have a snake case name
|
||||
--> pmoplaylist/src/manager.rs:620:8
|
||||
|
|
||||
620 | pub fn PlaylistManager() -> &'static PlaylistManager {
|
||||
| ^^^^^^^^^^^^^^^ help: convert the identifier to snake case: `playlist_manager`
|
||||
|
|
||||
= note: `#[warn(non_snake_case)]` (part of `#[warn(nonstandard_style)]`) on by default
|
||||
|
||||
warning: `pmoplaylist` (lib) generated 3 warnings
|
||||
warning: unused variable: `base_url`
|
||||
--> pmoupnp/src/upnp_server.rs:306:13
|
||||
|
|
||||
306 | let base_url = self.info().base_url.clone();
|
||||
| ^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_base_url`
|
||||
|
|
||||
= note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_api.rs:229:5
|
||||
|
|
||||
229 | async fn register_upnp_api(&mut self);
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
229 - async fn register_upnp_api(&mut self);
|
||||
229 + fn register_upnp_api(&mut self) -> impl std::future::Future<Output = ()> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_server.rs:96:5
|
||||
|
|
||||
96 | async fn register_device(
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
96 ~ fn register_device(
|
||||
97 | &mut self,
|
||||
98 | device: Arc<Device>,
|
||||
99 ~ ) -> impl std::future::Future<Output = Result<Arc<DeviceInstance>, DeviceError>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_server.rs:125:5
|
||||
|
|
||||
125 | async fn init_cover_cache(
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
125 ~ fn init_cover_cache(
|
||||
126 | &mut self,
|
||||
127 | cache_dir: &str,
|
||||
128 | limit: usize,
|
||||
129 ~ ) -> impl std::future::Future<Output = Result<Arc<CoverCache>, anyhow::Error>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_server.rs:144:5
|
||||
|
|
||||
144 | async fn init_audio_cache(
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
144 ~ fn init_audio_cache(
|
||||
145 | &mut self,
|
||||
146 | cache_dir: &str,
|
||||
147 | limit: usize,
|
||||
148 ~ ) -> impl std::future::Future<Output = Result<Arc<AudioCache>, anyhow::Error>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_server.rs:158:5
|
||||
|
|
||||
158 | async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
158 - async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
|
||||
158 + fn init_caches(&mut self) -> impl std::future::Future<Output = Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>> + Send;
|
||||
|
|
||||
|
||||
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||
--> pmoupnp/src/upnp_server.rs:225:5
|
||||
|
|
||||
225 | async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
|
||||
| ^^^^^
|
||||
|
|
||||
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||
|
|
||||
225 - async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
|
||||
225 + fn create_upnp_server() -> impl std::future::Future<Output = Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>> + Send;
|
||||
|
|
||||
|
||||
warning: `pmoupnp` (lib) generated 7 warnings
|
||||
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s
|
||||
Running `target/debug/examples/media_server_events_demo`
|
||||
ControlPoint started; waiting 5s for discovery...
|
||||
Discovered media servers:
|
||||
- BubbleUPnP Media Server (SM-A536B) | model=BubbleUPnP Media Server | udn=uuid:d38a2dc7-13c1-4a39-ab36-513490cf6766 | location=http://192.168.0.98:58645/dev/d38a2dc7-13c1-4a39-ab36-513490cf6766/desc.xml
|
||||
- fenice | model=Jellyfin Server | udn=uuid:526dedec-fde2-4224-bac6-06f7b11711cf | location=http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml
|
||||
- PMOMusic Media Server | model=PMOMusic Media Server | udn=uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 | location=http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml
|
||||
- Freebox Server | model=Freebox Media Server | udn=uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | location=http://192.168.0.254:52424/device.xml
|
||||
Listening for ContentDirectory events for 90 seconds...
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=27)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:history
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=28)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=30)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=31)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=32)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=33)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=37)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=40)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=41)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=42)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=44)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=45)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=46)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=49)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=50)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=51)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=52)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=53)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=54)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=55)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=56)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=57)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=59)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=63)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=64)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=65)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=66)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=67)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=68)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=69)
|
||||
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||
Monitoring finished.
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "pmomusic",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
0
pmoapp/src/lib.rs
Normal file → Executable file
0
pmoapp/src/lib.rs
Normal file → Executable file
@@ -36,18 +36,44 @@ impl WebAppExt for Server {
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let path = path.to_string();
|
||||
|
||||
self.add_spa::<W>(&path).await;
|
||||
let mount_path = normalize_mount_path(path);
|
||||
mount_spa_with_trailing_slash_redirect::<W>(self, &mount_path).await;
|
||||
}
|
||||
|
||||
async fn add_webapp_with_redirect<W>(&mut self, path: &str)
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let path = path.to_string();
|
||||
let mount_path = normalize_mount_path(path);
|
||||
|
||||
self.add_spa::<W>(&path).await;
|
||||
self.add_redirect("/", &path).await;
|
||||
mount_spa_with_trailing_slash_redirect::<W>(self, &mount_path).await;
|
||||
self.add_redirect("/", &mount_path).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// S'assure que les chemins SPA sont cohérents : `"/app"` devient `"/app"`,
|
||||
/// tandis que `"/"` reste tel quel. Les espaces ou slashs multiples sont
|
||||
/// nettoyés pour éviter des routes dupliquées.
|
||||
fn normalize_mount_path(path: &str) -> String {
|
||||
let trimmed = path.trim();
|
||||
|
||||
if trimmed.is_empty() || trimmed == "/" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", trimmed.trim_matches('/'))
|
||||
}
|
||||
}
|
||||
|
||||
/// Monte la SPA et ajoute automatiquement une redirection `"/app/" -> "/app"`
|
||||
/// afin que les URLs avec slash final servent également l'application.
|
||||
async fn mount_spa_with_trailing_slash_redirect<W>(server: &mut Server, path: &str)
|
||||
where
|
||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
server.add_spa::<W>(path).await;
|
||||
|
||||
if path != "/" {
|
||||
let trailing = format!("{}/", path.trim_end_matches('/'));
|
||||
server.add_redirect(&trailing, path).await;
|
||||
}
|
||||
}
|
||||
|
||||
255
pmoapp/webapp/package-lock.json
generated
255
pmoapp/webapp/package-lock.json
generated
@@ -8,13 +8,18 @@
|
||||
"name": "webapp",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"dompurify": "^3.2.7",
|
||||
"lucide-vue-next": "^0.555.0",
|
||||
"marked": "^16.3.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.8.3",
|
||||
@@ -848,6 +853,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -855,6 +871,12 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
|
||||
@@ -957,6 +979,36 @@
|
||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/devtools-kit": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.9",
|
||||
"birpc": "^2.3.0",
|
||||
"hookable": "^5.5.3",
|
||||
"mitt": "^3.0.1",
|
||||
"perfect-debounce": "^1.0.0",
|
||||
"speakingurl": "^14.0.1",
|
||||
"superjson": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-kit/node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/devtools-shared": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/language-core": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.0.tgz",
|
||||
@@ -1050,6 +1102,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.1.0.tgz",
|
||||
"integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.1.0",
|
||||
"@vueuse/shared": "14.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.1.0.tgz",
|
||||
"integrity": "sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.1.0.tgz",
|
||||
"integrity": "sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.0.0.tgz",
|
||||
@@ -1057,6 +1147,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/copy-anything": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-what": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@@ -1165,6 +1279,33 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-what": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-vue-next": {
|
||||
"version": "0.555.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.555.0.tgz",
|
||||
"integrity": "sha512-7hczPsiMD/y+VNLpal5Q5Wv09kQxlHS0l/cM1xagrd+MA3i5umMm+PUXqllvsbgwAl3PHv27fo59h4PN02GM5A==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.19",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||
@@ -1186,6 +1327,12 @@
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz",
|
||||
"integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
@@ -1218,6 +1365,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1230,6 +1383,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1237,6 +1391,36 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.5.0",
|
||||
"vue": "^3.5.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-kit": "^7.7.9"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -1265,6 +1449,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
|
||||
@@ -1316,6 +1506,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/speakingurl": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/superjson": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"copy-anything": "^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -1339,6 +1550,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -1347,12 +1559,20 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
|
||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -1434,6 +1654,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
@@ -1450,6 +1671,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-observe-visibility": {
|
||||
"version": "2.0.0-alpha.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-observe-visibility/-/vue-observe-visibility-2.0.0-alpha.1.tgz",
|
||||
"integrity": "sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-resize": {
|
||||
"version": "2.0.0-alpha.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz",
|
||||
"integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||
@@ -1481,6 +1720,20 @@
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-virtual-scroller": {
|
||||
"version": "2.0.0-beta.8",
|
||||
"resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-2.0.0-beta.8.tgz",
|
||||
"integrity": "sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mitt": "^2.1.0",
|
||||
"vue-observe-visibility": "^2.0.0-alpha.1",
|
||||
"vue-resize": "^2.0.0-alpha.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,18 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"dompurify": "^3.2.7",
|
||||
"lucide-vue-next": "^0.555.0",
|
||||
"marked": "^16.3.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.8.3",
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<nav class="main-nav">
|
||||
<router-link to="/">🏠 Accueil</router-link>
|
||||
|
||||
<!-- Menu déroulant Debug -->
|
||||
<div class="dropdown" @mouseenter="showDebugMenu = true" @mouseleave="showDebugMenu = false">
|
||||
<button class="dropdown-toggle" :class="{ active: isDebugRoute }">
|
||||
🔧 Debug
|
||||
<span class="arrow">{{ showDebugMenu ? '▼' : '▶' }}</span>
|
||||
</button>
|
||||
<div v-show="showDebugMenu" class="dropdown-menu">
|
||||
<router-link to="/logs" @click="showDebugMenu = false">📋 Logs</router-link>
|
||||
<router-link to="/upnp" @click="showDebugMenu = false">🎵 UPnP Explorer</router-link>
|
||||
<router-link to="/covers-cache" @click="showDebugMenu = false">🎨 Cover Cache</router-link>
|
||||
<router-link to="/audio-cache" @click="showDebugMenu = false">🎵 Audio Cache</router-link>
|
||||
<router-link to="/api-dashboard" @click="showDebugMenu = false">🚀 API Dashboard</router-link>
|
||||
|
||||
<div class="submenu-divider">Sources</div>
|
||||
<router-link to="/radio-paradise" @click="showDebugMenu = false">📻 Radio Paradise</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Notifications Toast -->
|
||||
<NotificationToast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const showDebugMenu = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const isDebugRoute = computed(() => {
|
||||
return ['/logs', '/upnp', '/covers-cache', '/audio-cache', '/api-dashboard', '/radio-paradise'].includes(route.path)
|
||||
})
|
||||
import NotificationToast from '@/components/NotificationToast.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -48,156 +22,10 @@ const isDebugRoute = computed(() => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
background: #333;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
color: #eee;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.main-nav a:hover {
|
||||
background: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.main-nav a.router-link-active {
|
||||
background: #569cd6;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
color: #eee;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown-toggle:hover {
|
||||
background: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dropdown-toggle.active {
|
||||
background: #569cd6;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dropdown-toggle .arrow {
|
||||
font-size: 0.7em;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
min-width: 200px;
|
||||
margin-top: 0;
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.dropdown-menu a {
|
||||
padding: 0.75rem 1rem;
|
||||
color: #eee;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-menu a:hover {
|
||||
background: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dropdown-menu a.router-link-active {
|
||||
background: #569cd6;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.submenu-divider {
|
||||
padding: 0.5rem 1rem;
|
||||
margin-top: 0.5rem;
|
||||
border-top: 1px solid #555;
|
||||
color: #999;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Responsive pour petits écrans */
|
||||
@media (max-width: 768px) {
|
||||
.main-nav {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.dropdown-menu a {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
430
pmoapp/webapp/src/assets/styles/glass-theme.css
Normal file
430
pmoapp/webapp/src/assets/styles/glass-theme.css
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Liquid Glass Theme - Glassmorphism Effects
|
||||
* Style inspiré de macOS Big Sur et suivants
|
||||
* Utilise backdrop-filter pour des effets de flou et transparence
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
CLASSES DE BASE GLASS
|
||||
======================================== */
|
||||
|
||||
/* Panel glass standard (fond clair) */
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(31, 38, 135, 0.15),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Panel glass pour fond sombre */
|
||||
.glass-panel-dark {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(0, 0, 0, 0.37),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Variantes d'intensité */
|
||||
.glass-subtle {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-medium {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(15px) saturate(170%);
|
||||
-webkit-backdrop-filter: blur(15px) saturate(170%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(30px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
BOUTONS GLASS
|
||||
======================================== */
|
||||
|
||||
.glass-button {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.glass-button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.glass-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.glass-button:disabled {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Bouton glass primary (avec couleur d'accent) */
|
||||
.glass-button-primary {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
backdrop-filter: blur(10px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(150%);
|
||||
border: 1px solid rgba(102, 126, 234, 0.5);
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.glass-button-primary:hover {
|
||||
background: rgba(102, 126, 234, 0.4);
|
||||
border-color: rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
INPUTS GLASS
|
||||
======================================== */
|
||||
|
||||
.glass-input {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(102, 126, 234, 0.5);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.glass-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
CARTES GLASS
|
||||
======================================== */
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(15px) saturate(170%);
|
||||
-webkit-backdrop-filter: blur(15px) saturate(170%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
SLIDER GLASS (pour volume control)
|
||||
======================================== */
|
||||
|
||||
.glass-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 8px;
|
||||
height: 8px;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-slider:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Thumb Webkit (Chrome, Safari) */
|
||||
.glass-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.glass-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow:
|
||||
0 6px 16px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.glass-slider::-webkit-slider-thumb:active {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Thumb Mozilla */
|
||||
.glass-slider::-moz-range-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.glass-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
OVERLAY GLASS (pour CurrentTrack)
|
||||
======================================== */
|
||||
|
||||
.glass-overlay {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.glass-overlay::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(0, 0, 0, 0.3) 100%);
|
||||
backdrop-filter: blur(0px);
|
||||
-webkit-backdrop-filter: blur(0px);
|
||||
transition: backdrop-filter 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glass-overlay:hover::before {
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
UTILITAIRES
|
||||
======================================== */
|
||||
|
||||
/* Backdrop blur seul */
|
||||
.glass-blur {
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.glass-blur-sm {
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.glass-blur-lg {
|
||||
backdrop-filter: blur(30px);
|
||||
-webkit-backdrop-filter: blur(30px);
|
||||
}
|
||||
|
||||
/* Saturation */
|
||||
.glass-saturate {
|
||||
backdrop-filter: saturate(180%);
|
||||
-webkit-backdrop-filter: saturate(180%);
|
||||
}
|
||||
|
||||
/* Bordures glass */
|
||||
.glass-border {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.glass-border-subtle {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-border-strong {
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Shadow glass */
|
||||
.glass-shadow {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.glass-shadow-lg {
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Inset shadow (lumière interne) */
|
||||
.glass-inset-light {
|
||||
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
THÈME SOMBRE (prefers-color-scheme)
|
||||
======================================== */
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.glass-panel {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.glass-subtle {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.glass-medium {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.glass-button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.glass-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
FALLBACK (pas de backdrop-filter)
|
||||
======================================== */
|
||||
|
||||
@supports not (backdrop-filter: blur(20px)) {
|
||||
.glass-panel,
|
||||
.glass-panel-dark,
|
||||
.glass-subtle,
|
||||
.glass-medium,
|
||||
.glass-strong {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.glass-panel,
|
||||
.glass-panel-dark,
|
||||
.glass-subtle,
|
||||
.glass-medium,
|
||||
.glass-strong {
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.glass-button,
|
||||
.glass-button-primary {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
}
|
||||
|
||||
.glass-slider {
|
||||
background: rgba(200, 200, 200, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
ANIMATIONS
|
||||
======================================== */
|
||||
|
||||
@keyframes glassShimmer {
|
||||
0% {
|
||||
background-position: -200% center;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: glassShimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
PERFORMANCE
|
||||
======================================== */
|
||||
|
||||
/* GPU acceleration pour les éléments glass animés */
|
||||
.glass-panel,
|
||||
.glass-button,
|
||||
.glass-card,
|
||||
.glass-slider {
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Réduire l'animation sur préférence système */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.glass-button,
|
||||
.glass-card,
|
||||
.glass-slider::-webkit-slider-thumb,
|
||||
.glass-slider::-moz-range-thumb {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.glass-shimmer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
436
pmoapp/webapp/src/assets/styles/pmocontrol.css
Normal file
436
pmoapp/webapp/src/assets/styles/pmocontrol.css
Normal file
@@ -0,0 +1,436 @@
|
||||
/* Styles PMOControl spécifiques */
|
||||
@import './variables.css';
|
||||
|
||||
/* ========================================
|
||||
Reset & Base Styles
|
||||
======================================== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Touch-friendly tap targets on mobile */
|
||||
@media (max-width: 768px) {
|
||||
button, a, [role="button"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus visible for accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Status Badge Styles
|
||||
======================================== */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-badge.playing {
|
||||
background-color: var(--status-playing-bg);
|
||||
color: var(--status-playing);
|
||||
border: 1px solid var(--status-playing);
|
||||
}
|
||||
|
||||
.status-badge.paused {
|
||||
background-color: var(--status-paused-bg);
|
||||
color: var(--status-paused);
|
||||
border: 1px solid var(--status-paused);
|
||||
}
|
||||
|
||||
.status-badge.stopped {
|
||||
background-color: var(--status-stopped-bg);
|
||||
color: var(--status-stopped);
|
||||
border: 1px solid var(--status-stopped);
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
background-color: var(--status-offline-bg);
|
||||
color: var(--status-offline);
|
||||
border: 1px solid var(--status-offline);
|
||||
}
|
||||
|
||||
.status-badge.transitioning {
|
||||
background-color: var(--status-transitioning-bg);
|
||||
color: var(--status-transitioning);
|
||||
border: 1px solid var(--status-transitioning);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Card Styles
|
||||
======================================== */
|
||||
.pmo-card {
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow var(--transition-base), transform var(--transition-base);
|
||||
}
|
||||
|
||||
.pmo-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.pmo-card.active {
|
||||
border-color: var(--status-playing);
|
||||
box-shadow: 0 0 0 2px var(--status-playing-bg);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Renderer Card Status Borders
|
||||
======================================== */
|
||||
.renderer-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.renderer-card.playing {
|
||||
border-left: 4px solid var(--status-playing);
|
||||
}
|
||||
|
||||
.renderer-card.paused {
|
||||
border-left: 4px solid var(--status-paused);
|
||||
}
|
||||
|
||||
.renderer-card.stopped {
|
||||
border-left: 4px solid var(--status-stopped);
|
||||
}
|
||||
|
||||
.renderer-card.offline {
|
||||
border-left: 4px solid var(--status-offline);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Queue Item Styles
|
||||
======================================== */
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.queue-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.queue-item.current {
|
||||
background-color: var(--status-playing-bg);
|
||||
border: 1px solid var(--status-playing);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.queue-item.current::before {
|
||||
content: '▶';
|
||||
color: var(--status-playing);
|
||||
font-size: var(--text-lg);
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Button Styles
|
||||
======================================== */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--status-playing);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #16a34a;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: var(--spacing-sm);
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Grid Layouts (Responsive)
|
||||
======================================== */
|
||||
.grid-cards {
|
||||
display: grid;
|
||||
gap: var(--spacing-lg);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Tablet: 2 columns */
|
||||
@media (min-width: 768px) {
|
||||
.grid-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: 3 columns */
|
||||
@media (min-width: 1024px) {
|
||||
.grid-cards {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Animations
|
||||
======================================== */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn var(--transition-base);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Performance Optimizations
|
||||
======================================== */
|
||||
/* Use will-change for frequently animated elements */
|
||||
.progress-bar-fill,
|
||||
input[type="range"]::-webkit-slider-thumb,
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.btn:hover,
|
||||
.pmo-card:hover {
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
/* GPU acceleration for smoother animations */
|
||||
.fade-in,
|
||||
.spin,
|
||||
.status-badge.transitioning {
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Volume Slider
|
||||
======================================== */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--status-playing);
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--status-playing);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Progress Bar (Seekbar)
|
||||
======================================== */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background-color: var(--status-playing);
|
||||
transition: width 100ms linear;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Notification Toast
|
||||
======================================== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: var(--spacing-lg);
|
||||
right: var(--spacing-lg);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: fadeIn var(--transition-base);
|
||||
}
|
||||
|
||||
.toast-item.success {
|
||||
background-color: var(--status-playing);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-item.error {
|
||||
background-color: var(--status-offline);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-item.warning {
|
||||
background-color: var(--status-paused);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-item.info {
|
||||
background-color: var(--status-transitioning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Utilities
|
||||
======================================== */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
113
pmoapp/webapp/src/assets/styles/variables.css
Normal file
113
pmoapp/webapp/src/assets/styles/variables.css
Normal file
@@ -0,0 +1,113 @@
|
||||
/* Variables CSS globales pour PMOControl */
|
||||
|
||||
:root {
|
||||
/* ========================================
|
||||
Status Colors
|
||||
======================================== */
|
||||
--status-playing: #22c55e; /* Vert pour en lecture */
|
||||
--status-playing-bg: #22c55e15; /* Background playing */
|
||||
--status-paused: #f59e0b; /* Orange pour en pause */
|
||||
--status-paused-bg: #f59e0b15; /* Background paused */
|
||||
--status-stopped: #6b7280; /* Gris pour arrêté */
|
||||
--status-stopped-bg: #6b728015; /* Background stopped */
|
||||
--status-offline: #ef4444; /* Rouge pour offline */
|
||||
--status-offline-bg: #ef444415; /* Background offline */
|
||||
--status-transitioning: #3b82f6; /* Bleu pour transition */
|
||||
--status-transitioning-bg: #3b82f615; /* Background transitioning */
|
||||
|
||||
/* ========================================
|
||||
Breakpoints (pour media queries)
|
||||
======================================== */
|
||||
--breakpoint-mobile: 768px;
|
||||
--breakpoint-tablet: 1024px;
|
||||
|
||||
/* ========================================
|
||||
Spacing Scale
|
||||
======================================== */
|
||||
--spacing-xs: 0.25rem; /* 4px */
|
||||
--spacing-sm: 0.5rem; /* 8px */
|
||||
--spacing-md: 1rem; /* 16px */
|
||||
--spacing-lg: 1.5rem; /* 24px */
|
||||
--spacing-xl: 2rem; /* 32px */
|
||||
--spacing-2xl: 3rem; /* 48px */
|
||||
|
||||
/* ========================================
|
||||
Border Radius
|
||||
======================================== */
|
||||
--radius-sm: 0.25rem; /* 4px */
|
||||
--radius-md: 0.5rem; /* 8px */
|
||||
--radius-lg: 1rem; /* 16px */
|
||||
--radius-full: 9999px; /* Pill shape */
|
||||
|
||||
/* ========================================
|
||||
Shadows
|
||||
======================================== */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* ========================================
|
||||
Typography
|
||||
======================================== */
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
|
||||
/* ========================================
|
||||
Colors (neutral palette)
|
||||
======================================== */
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-secondary: #f3f4f6;
|
||||
--color-bg-tertiary: #e5e7eb;
|
||||
|
||||
--color-text: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
|
||||
--color-border: #d1d5db;
|
||||
--color-border-light: #e5e7eb;
|
||||
|
||||
/* Primary color (brand) */
|
||||
--color-primary: #667eea;
|
||||
--color-primary-hover: #5568d3;
|
||||
|
||||
/* ========================================
|
||||
Transitions
|
||||
======================================== */
|
||||
--transition-fast: 150ms ease-in-out;
|
||||
--transition-normal: 250ms ease-in-out;
|
||||
--transition-base: 300ms ease-in-out;
|
||||
--transition-slow: 500ms ease-in-out;
|
||||
|
||||
/* ========================================
|
||||
Z-index layers
|
||||
======================================== */
|
||||
--z-dropdown: 100;
|
||||
--z-modal: 200;
|
||||
--z-toast: 300;
|
||||
--z-tooltip: 400;
|
||||
}
|
||||
|
||||
/* Dark mode support (optionnel pour l'avenir) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #111827;
|
||||
--color-bg-secondary: #1f2937;
|
||||
--color-bg-tertiary: #374151;
|
||||
|
||||
--color-text: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
|
||||
--color-border: #4b5563;
|
||||
--color-border-light: #374151;
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,7 @@
|
||||
</div>
|
||||
|
||||
<div class="api-body">
|
||||
<p v-if="api.description" class="api-description">
|
||||
{{ api.description }}
|
||||
</p>
|
||||
<div v-if="api.description" class="api-description" v-html="renderMarkdown(api.description)"></div>
|
||||
<p v-else class="api-description empty">Aucune description disponible</p>
|
||||
|
||||
<div class="api-stats">
|
||||
@@ -86,6 +84,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Configurer marked pour un rendu simple
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
interface ApiRegistryEntry {
|
||||
name: string;
|
||||
@@ -142,6 +148,14 @@ function getApiIcon(name: string): string {
|
||||
return icons[name.toLowerCase()] || '🔧';
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string): string {
|
||||
const rawHtml = marked.parse(markdown, { async: false }) as string;
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'class']
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRegistry();
|
||||
});
|
||||
@@ -332,6 +346,66 @@ onMounted(() => {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Styles pour le contenu markdown rendu */
|
||||
.api-description :deep(p) {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.api-description :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.api-description :deep(strong) {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.api-description :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.api-description :deep(code) {
|
||||
background: #f7fafc;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875em;
|
||||
color: #d63384;
|
||||
}
|
||||
|
||||
.api-description :deep(pre) {
|
||||
background: #f7fafc;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.api-description :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.api-description :deep(ul),
|
||||
.api-description :deep(ol) {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.api-description :deep(li) {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.api-description :deep(a) {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.api-description :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.api-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<h2>🎵 Audio Cache Manager</h2>
|
||||
<div class="stats">
|
||||
<span>{{ tracks.length }} tracks</span>
|
||||
<span v-if="lazyTracksCount > 0">{{ lazyTracksCount }} lazy pending</span>
|
||||
<span v-if="totalHits > 0">{{ totalHits }} hits</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,14 +13,39 @@
|
||||
<div class="add-form">
|
||||
<h3>➕ Add New Track</h3>
|
||||
<form @submit.prevent="handleAddTrack">
|
||||
<div class="source-toggle">
|
||||
<label>
|
||||
<input type="radio" value="url" v-model="newTrackSourceType" /> Remote URL
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" value="path" v-model="newTrackSourceType" /> Local FLAC reference
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<template v-if="newTrackSourceType === 'url'">
|
||||
<input
|
||||
v-model="newTrackUrl"
|
||||
type="url"
|
||||
placeholder="https://example.com/track.flac"
|
||||
:required="newTrackSourceType === 'url'"
|
||||
:disabled="isAdding"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input
|
||||
v-model="newTrackPath"
|
||||
type="text"
|
||||
placeholder="/mnt/music/MyTrack.flac"
|
||||
:required="newTrackSourceType === 'path'"
|
||||
:disabled="isAdding"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<p class="local-tip" v-if="newTrackSourceType === 'path'">
|
||||
Local FLAC files are referenced without duplication. Removing the cache entry never deletes
|
||||
the original file.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<input
|
||||
v-model="newTrackUrl"
|
||||
type="url"
|
||||
placeholder="https://example.com/track.flac"
|
||||
required
|
||||
:disabled="isAdding"
|
||||
/>
|
||||
<input
|
||||
v-model="newTrackCollection"
|
||||
type="text"
|
||||
@@ -27,8 +53,8 @@
|
||||
:disabled="isAdding"
|
||||
class="collection-input"
|
||||
/>
|
||||
<button type="submit" :disabled="isAdding || !newTrackUrl">
|
||||
{{ isAdding ? "Adding..." : "Add Track" }}
|
||||
<button type="submit" :disabled="addButtonDisabled">
|
||||
{{ addButtonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="addError" class="error">{{ addError }}</p>
|
||||
@@ -72,13 +98,28 @@
|
||||
<div
|
||||
v-for="track in sortedTracks"
|
||||
:key="track.pk"
|
||||
class="track-card"
|
||||
:class="['track-card', { 'local-reference': isLocalFile(track) }]"
|
||||
@click="selectedTrack = track"
|
||||
>
|
||||
<div class="track-icon">
|
||||
<div class="music-icon">🎵</div>
|
||||
<img
|
||||
v-if="getTrackCoverUrl(track, 400)"
|
||||
:src="getTrackCoverUrl(track, 400)"
|
||||
:alt="`Cover for ${track.metadata?.title || 'Unknown'}`"
|
||||
class="cover-image"
|
||||
@error="handleCoverError(track.pk)"
|
||||
/>
|
||||
<div v-else class="music-icon">🎵</div>
|
||||
<div class="track-overlay">
|
||||
<span class="hits">{{ track.hits }} plays</span>
|
||||
<span class="hits" v-if="!isLazyTrack(track)">{{ track.hits }} plays</span>
|
||||
<span
|
||||
v-else
|
||||
class="hits lazy"
|
||||
:class="lazyProviderClass(track)"
|
||||
>
|
||||
{{ lazyBadgeLabel(track) }}
|
||||
</span>
|
||||
<span v-if="isLocalFile(track)" class="local-pill">Local file</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-info">
|
||||
@@ -91,7 +132,19 @@
|
||||
<div class="track-album" v-if="track.metadata?.album">
|
||||
{{ track.metadata.album }}
|
||||
</div>
|
||||
<div class="pk">{{ track.pk }}</div>
|
||||
<div class="pk">
|
||||
{{ track.pk }}
|
||||
<span
|
||||
v-if="isLazyTrack(track)"
|
||||
class="lazy-tag"
|
||||
:class="lazyProviderClass(track)"
|
||||
>
|
||||
<span class="lazy-label">Lazy</span>
|
||||
<span class="lazy-provider-name" v-if="lazyProviderName(track)">
|
||||
{{ lazyProviderName(track) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span v-if="durationMs(track) !== undefined">
|
||||
{{ formatDuration(durationMs(track)!) }}
|
||||
@@ -106,18 +159,29 @@
|
||||
{{ conversionLabel(track) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="local-path" v-if="isLocalFile(track)">
|
||||
<span class="local-badge">Local</span>
|
||||
<span class="local-path-text">
|
||||
{{ localSourcePath(track) || "Original file" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="collection" v-if="track.collection">
|
||||
{{ track.collection }}
|
||||
</div>
|
||||
<div class="last-used" v-if="track.last_used">
|
||||
Last used: {{ formatDate(track.last_used) }}
|
||||
</div>
|
||||
<div class="lazy-warning" v-if="isLazyTrack(track)">
|
||||
Audio not downloaded yet
|
||||
<span v-if="lazyProviderName(track)">({{ lazyProviderName(track) }} provider)</span>.
|
||||
First playback (or forcing download) will fetch it automatically.
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button
|
||||
@click.stop="playTrack(track.pk)"
|
||||
class="btn-play"
|
||||
title="Play"
|
||||
:title="isLazyTrack(track) ? 'Trigger download & play once available' : 'Play'"
|
||||
>
|
||||
▶️
|
||||
</button>
|
||||
@@ -129,6 +193,30 @@
|
||||
>
|
||||
{{ deletingTracks.has(track.pk) ? "..." : "🗑️" }}
|
||||
</button>
|
||||
<button
|
||||
@click.stop="downloadTrack(track.pk)"
|
||||
class="btn-secondary"
|
||||
:disabled="isLazyTrack(track)"
|
||||
:title="
|
||||
isLazyTrack(track)
|
||||
? 'Download available once audio finished downloading'
|
||||
: 'Download original file'
|
||||
"
|
||||
>
|
||||
⬇️
|
||||
</button>
|
||||
<button
|
||||
@click.stop="copyTrackUrl(track.pk)"
|
||||
class="btn-secondary"
|
||||
:disabled="isLazyTrack(track)"
|
||||
:title="
|
||||
isLazyTrack(track)
|
||||
? 'URL available after download completes'
|
||||
: 'Copy stream URL'
|
||||
"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +226,14 @@
|
||||
<div class="modal-content" @click.stop>
|
||||
<button class="modal-close" @click="selectedTrack = null">✕</button>
|
||||
<div class="modal-header">
|
||||
<div class="modal-icon">🎵</div>
|
||||
<img
|
||||
v-if="getTrackCoverUrl(selectedTrack, 200)"
|
||||
:src="getTrackCoverUrl(selectedTrack, 200)"
|
||||
:alt="`Cover for ${selectedTrack.metadata?.title || 'Unknown'}`"
|
||||
class="modal-cover-image"
|
||||
@error="handleCoverError(selectedTrack.pk)"
|
||||
/>
|
||||
<div v-else class="modal-icon">🎵</div>
|
||||
<h3>Track Details</h3>
|
||||
</div>
|
||||
<div class="modal-info">
|
||||
@@ -161,6 +256,13 @@
|
||||
<div class="cache-section">
|
||||
<h4>Cache Info</h4>
|
||||
<p><strong>PK:</strong> {{ selectedTrack.pk }}</p>
|
||||
<p><strong>Status:</strong> {{ trackStatusLabel(selectedTrack) }}</p>
|
||||
<p v-if="isLocalFile(selectedTrack)">
|
||||
<strong>Local file:</strong>
|
||||
<span class="local-path-text">
|
||||
{{ localSourcePath(selectedTrack) || "Original file retained" }}
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="resolveTrackOrigin(selectedTrack)">
|
||||
<strong>Source URL:</strong>
|
||||
<a :href="resolveTrackOrigin(selectedTrack)" target="_blank">{{ resolveTrackOrigin(selectedTrack) }}</a>
|
||||
@@ -174,10 +276,18 @@
|
||||
<button @click="playTrack(selectedTrack.pk)" class="btn-play">
|
||||
▶️ Play
|
||||
</button>
|
||||
<button @click="downloadTrack(selectedTrack.pk)" class="btn-secondary">
|
||||
<button
|
||||
@click="downloadTrack(selectedTrack.pk)"
|
||||
class="btn-secondary"
|
||||
:disabled="isLazyTrack(selectedTrack)"
|
||||
>
|
||||
⬇️ Download
|
||||
</button>
|
||||
<button @click="copyTrackUrl(selectedTrack.pk)" class="btn-secondary">
|
||||
<button
|
||||
@click="copyTrackUrl(selectedTrack.pk)"
|
||||
class="btn-secondary"
|
||||
:disabled="isLazyTrack(selectedTrack)"
|
||||
>
|
||||
📋 Copy URL
|
||||
</button>
|
||||
<button @click="handleDeleteTrack(selectedTrack.pk); selectedTrack = null" class="btn-danger">
|
||||
@@ -221,18 +331,39 @@ import {
|
||||
formatDuration,
|
||||
formatBitrate,
|
||||
formatSampleRate,
|
||||
getCoverUrl,
|
||||
} from "../services/audioCache";
|
||||
|
||||
interface LazyDisplayInfo {
|
||||
prefix: string;
|
||||
display: string;
|
||||
className: string;
|
||||
isLegacy: boolean;
|
||||
}
|
||||
|
||||
const LAZY_PROVIDER_LABELS: Record<string, string> = {
|
||||
QOBUZ: "Qobuz",
|
||||
};
|
||||
const LEGACY_LAZY_INFO: LazyDisplayInfo = {
|
||||
prefix: "legacy",
|
||||
display: "Legacy",
|
||||
className: "lazy-provider-legacy",
|
||||
isLegacy: true,
|
||||
};
|
||||
|
||||
// --- États ---
|
||||
const tracks = ref<AudioCacheEntry[]>([]);
|
||||
const selectedTrack = ref<AudioCacheEntry | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const sortBy = ref<"hits" | "last_used" | "recent">("hits");
|
||||
const audioPlayer = ref<HTMLAudioElement | null>(null);
|
||||
const LEGACY_LAZY_PREFIX = "L:";
|
||||
|
||||
// Formulaire d'ajout
|
||||
const newTrackUrl = ref("");
|
||||
const newTrackPath = ref("");
|
||||
const newTrackCollection = ref("");
|
||||
const newTrackSourceType = ref<"url" | "path">("url");
|
||||
const isAdding = ref(false);
|
||||
const addError = ref("");
|
||||
const addSuccess = ref("");
|
||||
@@ -246,8 +377,14 @@ const deletingTracks = ref(new Set<string>());
|
||||
const isPlaying = ref(false);
|
||||
const audioError = ref("");
|
||||
|
||||
// Gestion des erreurs de chargement des covers
|
||||
const failedCovers = ref(new Set<string>());
|
||||
|
||||
// --- Computed ---
|
||||
const totalHits = computed(() => tracks.value.reduce((sum, t) => sum + t.hits, 0));
|
||||
const lazyTracksCount = computed(() =>
|
||||
tracks.value.reduce((acc, track) => (isLazyTrack(track) ? acc + 1 : acc), 0)
|
||||
);
|
||||
|
||||
const sortedTracks = computed(() => {
|
||||
const arr = [...tracks.value];
|
||||
@@ -267,6 +404,22 @@ const sortedTracks = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const addButtonDisabled = computed(() => {
|
||||
if (isAdding.value) return true;
|
||||
const value =
|
||||
newTrackSourceType.value === "url"
|
||||
? newTrackUrl.value?.trim()
|
||||
: newTrackPath.value?.trim();
|
||||
return !value;
|
||||
});
|
||||
|
||||
const addButtonLabel = computed(() => {
|
||||
if (newTrackSourceType.value === "url") {
|
||||
return isAdding.value ? "Adding..." : "Add Track";
|
||||
}
|
||||
return isAdding.value ? "Linking..." : "Add Local File";
|
||||
});
|
||||
|
||||
// --- Fonctions ---
|
||||
async function refreshTracks() {
|
||||
isLoading.value = true;
|
||||
@@ -278,17 +431,27 @@ async function refreshTracks() {
|
||||
}
|
||||
|
||||
async function handleAddTrack() {
|
||||
if (!newTrackUrl.value) return;
|
||||
const useUrl = newTrackSourceType.value === "url";
|
||||
const rawValue = useUrl ? newTrackUrl.value.trim() : newTrackPath.value.trim();
|
||||
if (!rawValue) {
|
||||
addError.value = useUrl ? "URL is required" : "Local path is required";
|
||||
return;
|
||||
}
|
||||
isAdding.value = true;
|
||||
addError.value = "";
|
||||
addSuccess.value = "";
|
||||
try {
|
||||
const result = await addTrack(
|
||||
newTrackUrl.value,
|
||||
newTrackCollection.value || undefined
|
||||
);
|
||||
addSuccess.value = `Track added! PK: ${result.pk}`;
|
||||
const result = await addTrack({
|
||||
url: useUrl ? rawValue : undefined,
|
||||
path: useUrl ? undefined : rawValue,
|
||||
collection: newTrackCollection.value || undefined,
|
||||
});
|
||||
addSuccess.value =
|
||||
newTrackSourceType.value === "path"
|
||||
? `Local file linked! PK: ${result.pk}`
|
||||
: `Track added! PK: ${result.pk}`;
|
||||
newTrackUrl.value = "";
|
||||
newTrackPath.value = "";
|
||||
newTrackCollection.value = "";
|
||||
await refreshTracks();
|
||||
} catch (e: any) {
|
||||
@@ -300,7 +463,16 @@ async function handleAddTrack() {
|
||||
}
|
||||
|
||||
async function handleDeleteTrack(pk: string) {
|
||||
if (!confirm(`Delete track ${pk}?`)) return;
|
||||
const track = getTrackByPk(pk);
|
||||
let confirmMessage = `Delete track ${pk}?`;
|
||||
if (track && isLocalFile(track)) {
|
||||
const path = localSourcePath(track);
|
||||
confirmMessage =
|
||||
`Remove cached reference for local file?\nPK: ${pk}` +
|
||||
(path ? `\nSource: ${path}` : "") +
|
||||
"\nOriginal file will remain untouched.";
|
||||
}
|
||||
if (!confirm(confirmMessage)) return;
|
||||
deletingTracks.value.add(pk);
|
||||
try {
|
||||
await deleteTrack(pk);
|
||||
@@ -392,15 +564,25 @@ function handleAudioError() {
|
||||
}
|
||||
|
||||
function downloadTrack(pk: string) {
|
||||
const track = getTrackByPk(pk);
|
||||
if (track && isLazyTrack(track)) {
|
||||
alert("This track is still in lazy cache. Play it once to download the audio before exporting.");
|
||||
return;
|
||||
}
|
||||
window.open(getOriginalTrackUrl(pk), "_blank");
|
||||
}
|
||||
|
||||
function copyTrackUrl(pk: string) {
|
||||
const track = getTrackByPk(pk);
|
||||
if (track && isLazyTrack(track)) {
|
||||
alert("URL available after the lazy audio has been downloaded.");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(window.location.origin + getTrackUrl(pk));
|
||||
alert("✅ URL copied!");
|
||||
}
|
||||
|
||||
function resolveTrackOrigin(track: AudioCacheEntry | null): string | undefined {
|
||||
function resolveTrackOrigin(track: AudioCacheEntry | null | undefined): string | undefined {
|
||||
return track ? getOriginUrl(track) : undefined;
|
||||
}
|
||||
|
||||
@@ -449,6 +631,110 @@ function conversionLabel(track: AudioCacheEntry | null): string | undefined {
|
||||
return formatConversion(track?.metadata?.conversion ?? undefined);
|
||||
}
|
||||
|
||||
function getTrackCoverUrl(track: AudioCacheEntry | null, size?: number): string | undefined {
|
||||
if (!track || failedCovers.value.has(track.pk)) return undefined;
|
||||
return getCoverUrl(track.metadata, size);
|
||||
}
|
||||
|
||||
function handleCoverError(pk: string) {
|
||||
failedCovers.value.add(pk);
|
||||
}
|
||||
|
||||
function prettifyLazyProvider(prefix: string): string {
|
||||
if (LAZY_PROVIDER_LABELS[prefix]) {
|
||||
return LAZY_PROVIDER_LABELS[prefix];
|
||||
}
|
||||
const normalized = prefix.replace(/[^A-Za-z0-9]+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return prefix.trim().toUpperCase() || "Lazy";
|
||||
}
|
||||
return normalized
|
||||
.split(/\s+/)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function buildLazyClass(prefix: string): string {
|
||||
return `lazy-provider-${prefix.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
}
|
||||
|
||||
function extractLazyInfoFromPk(pk: string | undefined | null): LazyDisplayInfo | undefined {
|
||||
if (!pk) return undefined;
|
||||
if (pk.startsWith(LEGACY_LAZY_PREFIX)) {
|
||||
return LEGACY_LAZY_INFO;
|
||||
}
|
||||
const separatorIndex = pk.indexOf(":");
|
||||
if (separatorIndex <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const prefix = pk.slice(0, separatorIndex);
|
||||
if (!prefix) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
prefix,
|
||||
display: prettifyLazyProvider(prefix),
|
||||
className: buildLazyClass(prefix),
|
||||
isLegacy: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getLazyInfo(track: AudioCacheEntry | null | undefined): LazyDisplayInfo | undefined {
|
||||
if (!track?.pk) return undefined;
|
||||
return extractLazyInfoFromPk(track.pk);
|
||||
}
|
||||
|
||||
function isLazyTrack(track: AudioCacheEntry | null | undefined): boolean {
|
||||
return !!getLazyInfo(track);
|
||||
}
|
||||
|
||||
function lazyProviderName(track: AudioCacheEntry | null | undefined): string | undefined {
|
||||
return getLazyInfo(track)?.display;
|
||||
}
|
||||
|
||||
function lazyProviderClass(track: AudioCacheEntry | null | undefined): string {
|
||||
return getLazyInfo(track)?.className ?? "lazy-provider-generic";
|
||||
}
|
||||
|
||||
function lazyBadgeLabel(track: AudioCacheEntry | null | undefined): string {
|
||||
const info = getLazyInfo(track);
|
||||
if (!info) return "";
|
||||
return info.isLegacy ? "Lazy" : `Lazy - ${info.display}`;
|
||||
}
|
||||
|
||||
function trackStatusLabel(track: AudioCacheEntry | null): string {
|
||||
if (isLocalFile(track)) {
|
||||
return "Local FLAC reference (original preserved)";
|
||||
}
|
||||
const info = getLazyInfo(track);
|
||||
if (info) {
|
||||
const provider = info.isLegacy ? "" : ` - ${info.display}`;
|
||||
return `Lazy${provider} (audio pending download)`;
|
||||
}
|
||||
return "Cached";
|
||||
}
|
||||
|
||||
function getTrackByPk(pk: string): AudioCacheEntry | undefined {
|
||||
return tracks.value.find((t) => t.pk === pk);
|
||||
}
|
||||
|
||||
function isLocalFile(track: AudioCacheEntry | null | undefined): boolean {
|
||||
return track?.metadata?.local_passthrough === true;
|
||||
}
|
||||
|
||||
function localSourcePath(track: AudioCacheEntry | null | undefined): string | undefined {
|
||||
if (!track) return undefined;
|
||||
const metaValue = track.metadata?.local_source_path;
|
||||
if (typeof metaValue === "string" && metaValue.trim().length > 0) {
|
||||
return metaValue;
|
||||
}
|
||||
const origin = resolveTrackOrigin(track);
|
||||
if (origin?.startsWith("file://")) {
|
||||
return origin.replace("file://", "");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshTracks();
|
||||
});
|
||||
@@ -511,6 +797,25 @@ onMounted(() => {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
.source-toggle {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
color: #ccc;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.source-toggle input {
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.local-tip {
|
||||
margin: 0.25rem 0 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -679,6 +984,11 @@ button:disabled {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.track-card.local-reference {
|
||||
border: 1px solid rgba(97, 218, 251, 0.6);
|
||||
box-shadow: 0 0 0 1px rgba(97, 218, 251, 0.1);
|
||||
}
|
||||
|
||||
.track-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
@@ -692,6 +1002,15 @@ button:disabled {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.music-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -715,6 +1034,28 @@ button:disabled {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.track-overlay .hits.lazy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: rgba(156, 39, 176, 0.85);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.local-pill {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(97, 218, 251, 0.9);
|
||||
color: #0c1924;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
@@ -754,6 +1095,33 @@ button:disabled {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.lazy-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(156, 39, 176, 0.25);
|
||||
color: #f5f5f5;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(156, 39, 176, 0.4);
|
||||
}
|
||||
|
||||
.lazy-tag .lazy-label {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.lazy-tag .lazy-provider-name {
|
||||
font-size: 0.6rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.4px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
@@ -762,6 +1130,33 @@ button:disabled {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.local-path {
|
||||
margin: 0.4rem 0;
|
||||
font-size: 0.8rem;
|
||||
color: #a0f0ff;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.local-badge {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
background: rgba(97, 218, 251, 0.2);
|
||||
border: 1px solid rgba(97, 218, 251, 0.4);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
.local-path-text {
|
||||
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
|
||||
color: #e3f7ff;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.collection {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
@@ -775,6 +1170,37 @@ button:disabled {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.lazy-warning {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #ffb347;
|
||||
background: rgba(255, 152, 0, 0.15);
|
||||
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.track-overlay .hits.lazy.lazy-provider-legacy,
|
||||
.lazy-tag.lazy-provider-legacy {
|
||||
background: rgba(255, 152, 0, 0.85);
|
||||
color: #000;
|
||||
border-color: rgba(255, 193, 7, 0.8);
|
||||
}
|
||||
|
||||
.track-overlay .hits.lazy.lazy-provider-qobuz,
|
||||
.lazy-tag.lazy-provider-qobuz {
|
||||
background: rgba(76, 175, 80, 0.9);
|
||||
color: #fff;
|
||||
border-color: rgba(165, 214, 167, 0.9);
|
||||
}
|
||||
|
||||
.track-overlay .hits.lazy.lazy-provider-generic,
|
||||
.lazy-tag.lazy-provider-generic {
|
||||
background: rgba(103, 58, 183, 0.85);
|
||||
color: #fff;
|
||||
border-color: rgba(179, 157, 219, 0.8);
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
padding: 0 1rem 1rem;
|
||||
display: flex;
|
||||
@@ -838,6 +1264,14 @@ button:disabled {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-cover-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
@@ -70,10 +70,10 @@
|
||||
>
|
||||
<div class="image-wrapper">
|
||||
<img
|
||||
:src="getImageUrl(image.pk, 256)"
|
||||
:src="getImageUrlWithFallback(image.pk, 256)"
|
||||
:alt="resolveOrigin(image) || image.pk"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
@error="(e) => handleImageError(image.pk, e)"
|
||||
/>
|
||||
<div class="image-overlay">
|
||||
<span class="hits">👁️ {{ image.hits }}</span>
|
||||
@@ -107,9 +107,10 @@
|
||||
<div class="modal-content" @click.stop>
|
||||
<button class="modal-close" @click="selectedImage = null">✕</button>
|
||||
<img
|
||||
:src="getImageUrl(selectedImage.pk)"
|
||||
:src="getImageUrlWithFallback(selectedImage.pk)"
|
||||
:alt="resolveOrigin(selectedImage) || selectedImage.pk"
|
||||
class="modal-image"
|
||||
@error="(e) => selectedImage && handleImageError(selectedImage.pk, e)"
|
||||
/>
|
||||
<div class="modal-info">
|
||||
<h3>Image Details</h3>
|
||||
@@ -121,6 +122,10 @@
|
||||
<p v-else><strong>Source URL:</strong> Unknown</p>
|
||||
<p><strong>Hits:</strong> {{ selectedImage.hits }}</p>
|
||||
<p v-if="selectedImage.last_used"><strong>Last Used:</strong> {{ formatDate(selectedImage.last_used) }}</p>
|
||||
<div class="links">
|
||||
<p><strong>WebP:</strong> <a :href="getImageUrl(selectedImage.pk)" target="_blank">{{ getImageUrl(selectedImage.pk) }}</a></p>
|
||||
<p><strong>JPEG:</strong> <a :href="getJpegUrl(selectedImage.pk)" target="_blank">{{ getJpegUrl(selectedImage.pk) }}</a></p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button @click="copyImageUrl(selectedImage.pk)" class="btn-secondary">
|
||||
📋 Copy URL
|
||||
@@ -145,8 +150,10 @@ import {
|
||||
purgeCache,
|
||||
consolidateCache,
|
||||
getImageUrl,
|
||||
getJpegUrl,
|
||||
getOriginUrl,
|
||||
waitForDownload,
|
||||
getDefaultImageUrl,
|
||||
} from "../services/coverCache";
|
||||
|
||||
// --- États ---
|
||||
@@ -166,6 +173,9 @@ const isConsolidating = ref(false);
|
||||
const isPurging = ref(false);
|
||||
const deletingImages = ref(new Set<string>());
|
||||
|
||||
// Gestion des erreurs de chargement d'images
|
||||
const failedImages = ref(new Set<string>());
|
||||
|
||||
// --- Computed ---
|
||||
const totalHits = computed(() => images.value.reduce((sum, i) => sum + i.hits, 0));
|
||||
|
||||
@@ -230,8 +240,10 @@ async function handleConsolidate(){
|
||||
}
|
||||
|
||||
function copyImageUrl(pk:string){
|
||||
navigator.clipboard.writeText(window.location.origin + getImageUrl(pk));
|
||||
alert("✅ URL copied!");
|
||||
const webpUrl = window.location.origin + getImageUrl(pk);
|
||||
const jpegUrl = window.location.origin + getJpegUrl(pk);
|
||||
navigator.clipboard.writeText(`${webpUrl}\n${jpegUrl}`);
|
||||
alert("✅ URLs copied (WebP + JPEG)!");
|
||||
}
|
||||
|
||||
function resolveOrigin(entry: CacheEntry | null): string | undefined {
|
||||
@@ -246,7 +258,18 @@ function formatDate(dateString:string){
|
||||
const d=new Date(dateString), diff=Date.now()-d.getTime(), days=Math.floor(diff/(1000*60*60*24));
|
||||
if(days===0)return"Today"; if(days===1)return"Yesterday"; if(days<7)return`${days} days ago`; return d.toLocaleDateString();
|
||||
}
|
||||
function handleImageError(e:Event){(e.target as HTMLImageElement).src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='256' height='256'%3E%3Crect fill='%23333' width='256' height='256'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-size='20'%3EError%3C/text%3E%3C/svg%3E";}
|
||||
|
||||
function getImageUrlWithFallback(pk: string, size?: number): string {
|
||||
if (failedImages.value.has(pk)) {
|
||||
return getDefaultImageUrl();
|
||||
}
|
||||
return getImageUrl(pk, size);
|
||||
}
|
||||
|
||||
function handleImageError(pk: string, event: Event) {
|
||||
failedImages.value.add(pk);
|
||||
(event.target as HTMLImageElement).src = getDefaultImageUrl();
|
||||
}
|
||||
|
||||
onMounted(()=>refreshImages());
|
||||
</script>
|
||||
|
||||
1627
pmoapp/webapp/src/components/GenericMusicPlayer.vue
Normal file
1627
pmoapp/webapp/src/components/GenericMusicPlayer.vue
Normal file
File diff suppressed because it is too large
Load Diff
172
pmoapp/webapp/src/components/NotificationToast.vue
Normal file
172
pmoapp/webapp/src/components/NotificationToast.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { AlertCircle, CheckCircle, Info, X, AlertTriangle } from 'lucide-vue-next'
|
||||
import type { Notification } from '@/stores/ui'
|
||||
|
||||
const uiStore = useUIStore()
|
||||
|
||||
function removeNotification(id: string) {
|
||||
uiStore.removeNotification(id)
|
||||
}
|
||||
|
||||
function getIcon(type: Notification['type']) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return CheckCircle
|
||||
case 'error':
|
||||
return AlertCircle
|
||||
case 'warning':
|
||||
return AlertTriangle
|
||||
case 'info':
|
||||
return Info
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeClass(type: Notification['type']) {
|
||||
return `notification-${type}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notification-container">
|
||||
<TransitionGroup name="notification-list">
|
||||
<div
|
||||
v-for="notification in uiStore.notifications"
|
||||
:key="notification.id"
|
||||
:class="['notification', getTypeClass(notification.type)]"
|
||||
>
|
||||
<component :is="getIcon(notification.type)" :size="20" class="notification-icon" />
|
||||
<p class="notification-message">{{ notification.message }}</p>
|
||||
<button
|
||||
class="notification-close"
|
||||
@click="removeNotification(notification.id)"
|
||||
title="Fermer"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-container {
|
||||
position: fixed;
|
||||
top: var(--spacing-lg);
|
||||
right: var(--spacing-lg);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
pointer-events: auto;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.notification-close:hover {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Type-specific styles */
|
||||
.notification-success {
|
||||
border-left: 4px solid var(--status-playing);
|
||||
}
|
||||
|
||||
.notification-success .notification-icon {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.notification-error {
|
||||
border-left: 4px solid var(--status-offline);
|
||||
}
|
||||
|
||||
.notification-error .notification-icon {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.notification-warning {
|
||||
border-left: 4px solid var(--status-paused);
|
||||
}
|
||||
|
||||
.notification-warning .notification-icon {
|
||||
color: var(--status-paused);
|
||||
}
|
||||
|
||||
.notification-info {
|
||||
border-left: 4px solid var(--status-transitioning);
|
||||
}
|
||||
|
||||
.notification-info .notification-icon {
|
||||
color: var(--status-transitioning);
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.notification-list-enter-active,
|
||||
.notification-list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.notification-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.notification-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 767px) {
|
||||
.notification-container {
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
left: var(--spacing-md);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.notification {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1597
pmoapp/webapp/src/components/PlayListManager.vue
Normal file
1597
pmoapp/webapp/src/components/PlayListManager.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
251
pmoapp/webapp/src/components/pmocontrol/ActionMenu.vue
Normal file
251
pmoapp/webapp/src/components/pmocontrol/ActionMenu.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { Play, ListPlus, ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{
|
||||
type: 'container' | 'item'
|
||||
entryId: string
|
||||
serverId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
playNow: [rendererId: string]
|
||||
addToQueue: [rendererId: string]
|
||||
}>()
|
||||
|
||||
const { onlineRenderers } = useRenderers()
|
||||
const showMenu = ref(false)
|
||||
const showRendererSubmenu = ref<'play' | 'queue' | null>(null)
|
||||
|
||||
function handleAction(action: 'play' | 'queue', rendererId: string) {
|
||||
showMenu.value = false
|
||||
showRendererSubmenu.value = null
|
||||
|
||||
switch (action) {
|
||||
case 'play':
|
||||
emit('playNow', rendererId)
|
||||
break
|
||||
case 'queue':
|
||||
emit('addToQueue', rendererId)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
showMenu.value = !showMenu.value
|
||||
if (!showMenu.value) {
|
||||
showRendererSubmenu.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="action-menu-container" @click.stop>
|
||||
<button class="action-menu-trigger" @click="toggleMenu" :title="'Actions'">
|
||||
⋮
|
||||
</button>
|
||||
|
||||
<div v-if="showMenu" class="action-menu" @click.stop>
|
||||
<!-- Play Now -->
|
||||
<div
|
||||
class="menu-item"
|
||||
@mouseenter="showRendererSubmenu = 'play'"
|
||||
@mouseleave="showRendererSubmenu = null"
|
||||
>
|
||||
<Play :size="16" />
|
||||
<span>Lire maintenant</span>
|
||||
<ChevronRight :size="16" class="submenu-arrow" />
|
||||
|
||||
<!-- Submenu renderers -->
|
||||
<div v-if="showRendererSubmenu === 'play'" class="renderer-submenu">
|
||||
<div
|
||||
v-for="renderer in onlineRenderers"
|
||||
:key="renderer.id"
|
||||
class="submenu-item"
|
||||
@click="handleAction('play', renderer.id)"
|
||||
>
|
||||
{{ renderer.friendly_name }}
|
||||
</div>
|
||||
<div v-if="onlineRenderers.length === 0" class="submenu-empty">
|
||||
Aucun renderer disponible
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add to Queue -->
|
||||
<div
|
||||
class="menu-item"
|
||||
@mouseenter="showRendererSubmenu = 'queue'"
|
||||
@mouseleave="showRendererSubmenu = null"
|
||||
>
|
||||
<ListPlus :size="16" />
|
||||
<span>Ajouter à la queue</span>
|
||||
<ChevronRight :size="16" class="submenu-arrow" />
|
||||
|
||||
<div v-if="showRendererSubmenu === 'queue'" class="renderer-submenu">
|
||||
<div
|
||||
v-for="renderer in onlineRenderers"
|
||||
:key="renderer.id"
|
||||
class="submenu-item"
|
||||
@click="handleAction('queue', renderer.id)"
|
||||
>
|
||||
{{ renderer.friendly_name }}
|
||||
</div>
|
||||
<div v-if="onlineRenderers.length === 0" class="submenu-empty">
|
||||
Aucun renderer disponible
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop to close menu -->
|
||||
<div v-if="showMenu" class="menu-backdrop" @click="showMenu = false"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-menu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-menu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.action-menu-trigger:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.action-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
margin-top: var(--spacing-xs);
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 200px;
|
||||
z-index: var(--z-dropdown);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.menu-item:first-child {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
|
||||
.menu-item:only-child {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.menu-item svg:first-child {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-item span {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.submenu-arrow {
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.renderer-submenu {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 0;
|
||||
margin-left: var(--spacing-xs);
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 180px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: calc(var(--z-dropdown) + 1);
|
||||
}
|
||||
|
||||
.submenu-item {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.submenu-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.submenu-empty {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: calc(var(--z-dropdown) - 1);
|
||||
}
|
||||
|
||||
/* Scrollbar for long renderer lists */
|
||||
.renderer-submenu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.renderer-submenu::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.renderer-submenu::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.renderer-submenu::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
156
pmoapp/webapp/src/components/pmocontrol/Breadcrumb.vue
Normal file
156
pmoapp/webapp/src/components/pmocontrol/Breadcrumb.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRight, Home } from 'lucide-vue-next'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
items: BreadcrumbItem[]
|
||||
serverId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [containerId: string]
|
||||
}>()
|
||||
|
||||
function handleNavigate(containerId: string) {
|
||||
emit('navigate', containerId)
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
emit('navigate', '0')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="breadcrumb" aria-label="Fil d'Ariane">
|
||||
<ol class="breadcrumb-list">
|
||||
<!-- Home -->
|
||||
<li class="breadcrumb-item">
|
||||
<button class="breadcrumb-link" @click="goHome" title="Accueil">
|
||||
<Home :size="16" />
|
||||
<span>Accueil</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Path items -->
|
||||
<template v-for="(item, index) in items" :key="item.id">
|
||||
<li class="breadcrumb-separator" aria-hidden="true">
|
||||
<ChevronRight :size="16" />
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<button
|
||||
v-if="index < items.length - 1"
|
||||
class="breadcrumb-link"
|
||||
@click="handleNavigate(item.id)"
|
||||
:title="item.title"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
<span v-else class="breadcrumb-current" :title="item.title">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.breadcrumb-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-link:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.breadcrumb::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.breadcrumb::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.breadcrumb::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.breadcrumb-link,
|
||||
.breadcrumb-current {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
173
pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue
Normal file
173
pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||
import { Folder, Music } from 'lucide-vue-next'
|
||||
import ActionMenu from './ActionMenu.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: ContainerEntry
|
||||
serverId: string
|
||||
showActions?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
browse: [containerId: string]
|
||||
playNow: [containerId: string, rendererId: string]
|
||||
addToQueue: [containerId: string, rendererId: string]
|
||||
}>()
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase()
|
||||
if (cls.includes('playlist')) return Music
|
||||
if (cls.includes('album')) return Music
|
||||
return Folder
|
||||
})
|
||||
|
||||
const containerType = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase()
|
||||
if (cls.includes('playlist')) return 'Playlist'
|
||||
if (cls.includes('album')) return 'Album'
|
||||
if (cls.includes('artist')) return 'Artiste'
|
||||
if (cls.includes('genre')) return 'Genre'
|
||||
return 'Dossier'
|
||||
})
|
||||
|
||||
function handleBrowse() {
|
||||
emit('browse', props.entry.id)
|
||||
}
|
||||
|
||||
function handlePlayNow(rendererId: string) {
|
||||
emit('playNow', props.entry.id, rendererId)
|
||||
}
|
||||
|
||||
function handleAddToQueue(rendererId: string) {
|
||||
emit('addToQueue', props.entry.id, rendererId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container-item">
|
||||
<!-- Main content (clickable) -->
|
||||
<button class="container-content" @click="handleBrowse">
|
||||
<div class="container-icon">
|
||||
<component :is="iconComponent" :size="24" />
|
||||
</div>
|
||||
<div class="container-metadata">
|
||||
<div class="container-title">{{ entry.title }}</div>
|
||||
<div class="container-details">
|
||||
<span class="container-type">{{ containerType }}</span>
|
||||
<span v-if="entry.child_count !== null" class="container-count">
|
||||
{{ entry.child_count }} élément{{ entry.child_count > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Actions menu -->
|
||||
<div class="container-actions">
|
||||
<ActionMenu
|
||||
type="container"
|
||||
:entry-id="entry.id"
|
||||
:server-id="serverId"
|
||||
@play-now="handlePlayNow"
|
||||
@add-to-queue="handleAddToQueue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.container-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.container-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.container-icon {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.container-metadata {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.container-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.container-details {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.container-type {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.container-count::before {
|
||||
content: '•';
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.container-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
532
pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue
Normal file
532
pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue
Normal file
@@ -0,0 +1,532 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef, ref } from 'vue'
|
||||
import { useRenderer } from '@/composables/useRenderers'
|
||||
import { Music, X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const metadata = computed(() => state.value?.current_track)
|
||||
|
||||
// État pour l'overlay de la cover
|
||||
const showCoverOverlay = ref(false)
|
||||
const showMetadata = ref(false)
|
||||
|
||||
function openCoverOverlay() {
|
||||
if (hasCover.value) {
|
||||
showCoverOverlay.value = true
|
||||
showMetadata.value = false // Reset metadata visibility when opening
|
||||
}
|
||||
}
|
||||
|
||||
function closeCoverOverlay() {
|
||||
showCoverOverlay.value = false
|
||||
showMetadata.value = false
|
||||
}
|
||||
|
||||
// Détecter le clic sur la partie basse de l'image
|
||||
function handleOverlayContentClick(event: MouseEvent) {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
const clickY = event.clientY - rect.top
|
||||
const height = rect.height
|
||||
|
||||
// Si le clic est dans le tiers inférieur, toggle les métadonnées
|
||||
if (clickY > height * 0.66) {
|
||||
showMetadata.value = !showMetadata.value
|
||||
}
|
||||
}
|
||||
|
||||
// Calcul du pourcentage de progression
|
||||
const progressPercent = computed(() => {
|
||||
const position = state.value?.position_ms
|
||||
const duration = state.value?.duration_ms
|
||||
if (position && duration && duration > 0) {
|
||||
return (position / duration) * 100
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
// Formater la durée en MM:SS
|
||||
function formatTime(ms: number | null | undefined): string {
|
||||
if (!ms) return '--:--'
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const currentTime = computed(() => formatTime(state.value?.position_ms))
|
||||
const totalTime = computed(() => formatTime(state.value?.duration_ms))
|
||||
|
||||
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="current-track">
|
||||
<!-- Cover Art -->
|
||||
<div class="cover-container" :class="{ clickable: hasCover }" @click="openCoverOverlay">
|
||||
<img
|
||||
v-if="hasCover"
|
||||
:src="metadata?.album_art_uri!"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">
|
||||
<Music :size="64" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="metadata">
|
||||
<h2 class="title">{{ metadata?.title || 'Aucun titre' }}</h2>
|
||||
<p class="artist">{{ metadata?.artist || 'Artiste inconnu' }}</p>
|
||||
<p class="album" v-if="metadata?.album">{{ metadata.album }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-section">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-bar-fill"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="time-display">
|
||||
<span>{{ currentTime }}</span>
|
||||
<span>{{ totalTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay cover en grand avec effet glassmorphism -->
|
||||
<Teleport to="body">
|
||||
<Transition name="cover-overlay">
|
||||
<div v-if="showCoverOverlay" class="cover-overlay" @click="closeCoverOverlay">
|
||||
<div class="cover-overlay-content" @click.stop="handleOverlayContentClick">
|
||||
<button class="cover-overlay-close" @click="closeCoverOverlay" title="Fermer">
|
||||
<X :size="24" />
|
||||
</button>
|
||||
<img
|
||||
v-if="hasCover"
|
||||
:src="metadata?.album_art_uri!"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-overlay-image"
|
||||
/>
|
||||
<Transition name="metadata-fade">
|
||||
<div v-if="metadata && showMetadata" class="cover-overlay-metadata">
|
||||
<h2>{{ metadata.title }}</h2>
|
||||
<p class="artist">{{ metadata.artist }}</p>
|
||||
<p v-if="metadata.album" class="album">{{ metadata.album }}</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.current-track {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.cover-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-secondary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.cover-container.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cover-container.clickable:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.metadata {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.artist {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
}
|
||||
|
||||
.album {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (min-width: 768px) {
|
||||
.cover-container {
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.cover-container {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode kiosque - compactage pour petites hauteurs (800x600) */
|
||||
@media (max-height: 700px) and (orientation: landscape) {
|
||||
.current-track {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.cover-container {
|
||||
max-width: 160px !important;
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--text-lg);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.artist {
|
||||
font-size: var(--text-sm);
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.album {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Overlay cover en grand - Liquid metal style */
|
||||
.cover-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(20px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cover-overlay-content {
|
||||
position: relative;
|
||||
width: calc(100vw - 32px);
|
||||
height: calc(100vh - 32px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 24px;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cover-overlay-content {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.cover-overlay-metadata {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.cover-overlay-close {
|
||||
position: absolute;
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.cover-overlay-close:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1) rotate(90deg);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.cover-overlay-close:active {
|
||||
transform: scale(1.0) rotate(90deg);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cover-overlay-close {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.cover-overlay-close:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.cover-overlay-image {
|
||||
width: calc(100vw - 64px);
|
||||
height: calc(100vh - 64px);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 30px 80px rgba(0, 0, 0, 0.6),
|
||||
0 0 2px rgba(255, 255, 255, 0.2);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.cover-overlay-metadata {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.cover-overlay-metadata h2 {
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.8);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cover-overlay-metadata .artist {
|
||||
font-size: clamp(1rem, 2.5vw, 1.5rem);
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 8px 0 0 0;
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6);
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.cover-overlay-metadata .album {
|
||||
font-size: clamp(0.875rem, 2vw, 1.125rem);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin: 6px 0 0 0;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Animations de transition */
|
||||
.cover-overlay-enter-active,
|
||||
.cover-overlay-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cover-overlay-enter-active .cover-overlay-content,
|
||||
.cover-overlay-leave-active .cover-overlay-content {
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.cover-overlay-enter-from,
|
||||
.cover-overlay-leave-to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0px);
|
||||
-webkit-backdrop-filter: blur(0px);
|
||||
}
|
||||
|
||||
.cover-overlay-enter-from .cover-overlay-content,
|
||||
.cover-overlay-leave-to .cover-overlay-content {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.cover-overlay-enter-to,
|
||||
.cover-overlay-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cover-overlay-enter-to .cover-overlay-content,
|
||||
.cover-overlay-leave-from .cover-overlay-content {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Responsive overlay */
|
||||
@media (max-width: 768px) {
|
||||
.cover-overlay {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.cover-overlay-content {
|
||||
width: calc(100vw - 24px);
|
||||
height: calc(100vh - 24px);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.cover-overlay-image {
|
||||
width: calc(100vw - 48px);
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
.cover-overlay-metadata {
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.cover-overlay-close {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode kiosque - overlay adapté */
|
||||
@media (max-height: 700px) and (orientation: landscape) {
|
||||
.cover-overlay {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.cover-overlay-content {
|
||||
width: calc(100vw - 24px);
|
||||
height: calc(100vh - 24px);
|
||||
}
|
||||
|
||||
.cover-overlay-image {
|
||||
width: calc(100vw - 48px);
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
.cover-overlay-metadata {
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.cover-overlay-metadata .artist {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.cover-overlay-metadata .album {
|
||||
display: none; /* Masquer l'album en mode kiosque pour plus d'espace */
|
||||
}
|
||||
}
|
||||
|
||||
/* Transition pour les métadonnées */
|
||||
.metadata-fade-enter-active,
|
||||
.metadata-fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.metadata-fade-enter-from,
|
||||
.metadata-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.metadata-fade-enter-to,
|
||||
.metadata-fade-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
331
pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
Normal file
331
pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import Breadcrumb from './Breadcrumb.vue'
|
||||
import ContainerItem from './ContainerItem.vue'
|
||||
import MediaItem from './MediaItem.vue'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string
|
||||
containerId: string
|
||||
}>()
|
||||
|
||||
const {
|
||||
getBrowseCached,
|
||||
browseContainer,
|
||||
currentPath: breadcrumbPath,
|
||||
loading,
|
||||
error
|
||||
} = useMediaServers()
|
||||
|
||||
const {
|
||||
playContent,
|
||||
addToQueue,
|
||||
attachAndPlayPlaylist,
|
||||
attachPlaylist,
|
||||
} = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
// Flags pour gérer le rechargement automatique avec debounce et cooldown
|
||||
const isRefreshing = ref(false)
|
||||
const refreshTimeoutId = ref<number | null>(null)
|
||||
const lastRefreshTime = ref<number>(0)
|
||||
const REFRESH_COOLDOWN_MS = 5000 // Ne pas recharger plus d'une fois toutes les 5 secondes
|
||||
|
||||
const browseData = computed(() =>
|
||||
getBrowseCached(props.serverId, props.containerId)
|
||||
)
|
||||
|
||||
const containers = computed(() =>
|
||||
browseData.value?.entries.filter((e) => e.is_container) || []
|
||||
)
|
||||
|
||||
const items = computed(() =>
|
||||
browseData.value?.entries.filter((e) => !e.is_container) || []
|
||||
)
|
||||
|
||||
// Charger le container au montage et quand containerId change
|
||||
watch(
|
||||
() => props.containerId,
|
||||
async (newContainerId) => {
|
||||
if (newContainerId) {
|
||||
await browseContainer(props.serverId, newContainerId)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
|
||||
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
|
||||
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
|
||||
// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations
|
||||
// et un cooldown de 5 secondes pour éviter les rechargements successifs
|
||||
watch(
|
||||
() => browseData.value,
|
||||
(data) => {
|
||||
// Si browseData devient undefined alors que containerId est présent,
|
||||
// et qu'on n'est pas déjà en train de charger, planifier un rechargement
|
||||
if (!data && props.containerId && !loading.value) {
|
||||
// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes
|
||||
const timeSinceLastRefresh = Date.now() - lastRefreshTime.value
|
||||
if (timeSinceLastRefresh < REFRESH_COOLDOWN_MS) {
|
||||
console.log(
|
||||
`[MediaBrowser] Cache invalidé mais cooldown actif (${Math.round((REFRESH_COOLDOWN_MS - timeSinceLastRefresh) / 1000)}s restantes), rechargement ignoré`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Annuler tout timeout en cours
|
||||
if (refreshTimeoutId.value !== null) {
|
||||
clearTimeout(refreshTimeoutId.value)
|
||||
}
|
||||
|
||||
// Planifier le rechargement après 3 secondes
|
||||
// Cela permet de regrouper plusieurs événements SSE successifs
|
||||
refreshTimeoutId.value = window.setTimeout(async () => {
|
||||
if (!isRefreshing.value) {
|
||||
console.log(
|
||||
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`
|
||||
)
|
||||
isRefreshing.value = true
|
||||
await browseContainer(props.serverId, props.containerId, false)
|
||||
lastRefreshTime.value = Date.now() // Enregistrer le moment du rechargement
|
||||
isRefreshing.value = false
|
||||
refreshTimeoutId.value = null
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [containerId: string]
|
||||
}>()
|
||||
|
||||
function handleNavigate(containerId: string) {
|
||||
emit('navigate', containerId)
|
||||
}
|
||||
|
||||
function handleBrowseContainer(containerId: string) {
|
||||
emit('navigate', containerId)
|
||||
}
|
||||
|
||||
// Actions handlers pour les containers (playlists/albums)
|
||||
async function handlePlayContainer(containerId: string, rendererId: string) {
|
||||
try {
|
||||
await attachAndPlayPlaylist(rendererId, props.serverId, containerId)
|
||||
uiStore.notifySuccess('Lecture de la playlist démarrée !')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de la lecture de la playlist: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQueueContainer(containerId: string, rendererId: string) {
|
||||
try {
|
||||
await attachPlaylist(rendererId, props.serverId, containerId)
|
||||
uiStore.notifySuccess('Playlist attachée à la queue !')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de l'ajout de la playlist: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Actions handlers pour les items (tracks)
|
||||
async function handlePlayItem(itemId: string, rendererId: string) {
|
||||
try {
|
||||
await playContent(rendererId, props.serverId, itemId)
|
||||
uiStore.notifySuccess('Lecture démarrée !')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de la lecture: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
try {
|
||||
await addToQueue(rendererId, props.serverId, itemId)
|
||||
uiStore.notifySuccess('Ajouté à la queue !')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de l'ajout à la queue: ${message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-browser">
|
||||
<!-- Breadcrumb -->
|
||||
<Breadcrumb
|
||||
:items="breadcrumbPath"
|
||||
:serverId="serverId"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="browser-loading">
|
||||
<Loader2 :size="32" class="spinner" />
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="browser-error">
|
||||
<p class="error-message">{{ error }}</p>
|
||||
<button class="btn btn-secondary" @click="browseContainer(serverId, containerId, false)">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else class="browser-content">
|
||||
<!-- Containers section -->
|
||||
<div v-if="containers.length" class="browser-section">
|
||||
<h3 class="section-title">Dossiers et playlists</h3>
|
||||
<div class="entries-list">
|
||||
<ContainerItem
|
||||
v-for="container in containers"
|
||||
:key="container.id"
|
||||
:entry="container"
|
||||
:server-id="serverId"
|
||||
@browse="handleBrowseContainer"
|
||||
@play-now="handlePlayContainer"
|
||||
@add-to-queue="handleQueueContainer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items section -->
|
||||
<div v-if="items.length" class="browser-section">
|
||||
<h3 class="section-title">Pistes</h3>
|
||||
<div class="entries-list">
|
||||
<MediaItem
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:entry="item"
|
||||
:server-id="serverId"
|
||||
@play-now="handlePlayItem"
|
||||
@add-to-queue="handleQueueItem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!containers.length && !items.length" class="browser-empty">
|
||||
<p>Ce dossier est vide</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.browser-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.browser-error {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: var(--text-base);
|
||||
color: var(--status-offline);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.browser-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
padding-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.browser-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.entries-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.browser-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-base);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.browser-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.browser-content::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.browser-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.browser-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
185
pmoapp/webapp/src/components/pmocontrol/MediaItem.vue
Normal file
185
pmoapp/webapp/src/components/pmocontrol/MediaItem.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||
import { Music } from 'lucide-vue-next'
|
||||
import ActionMenu from './ActionMenu.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: ContainerEntry
|
||||
serverId: string
|
||||
showActions?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
playNow: [itemId: string, rendererId: string]
|
||||
addToQueue: [itemId: string, rendererId: string]
|
||||
}>()
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
const placeholder = img.nextElementSibling
|
||||
if (placeholder && placeholder instanceof HTMLElement) {
|
||||
placeholder.style.display = 'flex'
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlayNow(rendererId: string) {
|
||||
emit('playNow', props.entry.id, rendererId)
|
||||
}
|
||||
|
||||
function handleAddToQueue(rendererId: string) {
|
||||
emit('addToQueue', props.entry.id, rendererId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-item">
|
||||
<!-- Cover miniature -->
|
||||
<div class="media-cover">
|
||||
<img
|
||||
v-if="entry.album_art_uri"
|
||||
:src="entry.album_art_uri"
|
||||
:alt="entry.album || entry.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="cover-placeholder" :style="{ display: entry.album_art_uri ? 'none' : 'flex' }">
|
||||
<Music :size="20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="media-metadata">
|
||||
<div class="media-title">{{ entry.title }}</div>
|
||||
<div class="media-details">
|
||||
<span v-if="entry.artist" class="media-artist">{{ entry.artist }}</span>
|
||||
<span v-if="entry.album" class="media-album">{{ entry.album }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions menu -->
|
||||
<div class="media-actions">
|
||||
<ActionMenu
|
||||
type="item"
|
||||
:entry-id="entry.id"
|
||||
:server-id="serverId"
|
||||
@play-now="handlePlayNow"
|
||||
@add-to-queue="handleAddToQueue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.media-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.media-cover {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Metadata */
|
||||
.media-metadata {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.media-details {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-artist {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.media-album {
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.media-album::before {
|
||||
content: '•';
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.media-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
162
pmoapp/webapp/src/components/pmocontrol/MediaServerCard.vue
Normal file
162
pmoapp/webapp/src/components/pmocontrol/MediaServerCard.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { MediaServerSummary } from '@/services/pmocontrol/types'
|
||||
import { Server, Circle } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
server: MediaServerSummary
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const statusClass = computed(() => props.server.online ? 'online' : 'offline')
|
||||
const statusLabel = computed(() => props.server.online ? 'En ligne' : 'Hors ligne')
|
||||
|
||||
function goToServer() {
|
||||
if (props.server.online) {
|
||||
router.push(`/server/${props.server.id}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['media-server-card', { offline: !server.online }]">
|
||||
<!-- Header -->
|
||||
<div class="card-header">
|
||||
<div class="server-icon">
|
||||
<Server :size="40" />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h3 class="server-name">{{ server.friendly_name }}</h3>
|
||||
<p class="server-model">{{ server.model_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="card-status">
|
||||
<Circle :size="12" :class="['status-indicator', statusClass]" fill="currentColor" />
|
||||
<span :class="['status-label', statusClass]">{{ statusLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Browse Button -->
|
||||
<button
|
||||
class="btn btn-primary card-browse-btn"
|
||||
@click="goToServer"
|
||||
:disabled="!server.online"
|
||||
>
|
||||
Parcourir
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-server-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.media-server-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.media-server-card.offline {
|
||||
opacity: 0.6;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.card-header {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.card-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-indicator.online {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.status-indicator.offline {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-label.online {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.status-label.offline {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
/* Browse Button */
|
||||
.card-browse-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-browse-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
182
pmoapp/webapp/src/components/pmocontrol/PlaylistBindingPanel.vue
Normal file
182
pmoapp/webapp/src/components/pmocontrol/PlaylistBindingPanel.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { Link, Unlink } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const { binding } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { detachPlaylist } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
const isAttached = computed(() => !!binding.value)
|
||||
|
||||
async function handleDetach() {
|
||||
try {
|
||||
await detachPlaylist(props.rendererId)
|
||||
uiStore.notifySuccess('Playlist détachée avec succès')
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de détacher la playlist: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="playlist-binding-panel">
|
||||
<h4 class="panel-title">Synchronisation Playlist</h4>
|
||||
|
||||
<!-- État attaché -->
|
||||
<div v-if="isAttached" class="binding-status attached">
|
||||
<div class="status-info">
|
||||
<Link :size="20" />
|
||||
<div class="status-text">
|
||||
<p class="status-label">Attachée à une playlist</p>
|
||||
<p class="status-details">
|
||||
<span class="detail-label">Serveur:</span>
|
||||
{{ binding?.server_id }}
|
||||
</p>
|
||||
<p class="status-details">
|
||||
<span class="detail-label">Container:</span>
|
||||
{{ binding?.container_id }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn" @click="handleDetach">
|
||||
<Unlink :size="16" />
|
||||
Détacher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- État détaché -->
|
||||
<div v-else class="binding-status detached">
|
||||
<div class="status-info">
|
||||
<Unlink :size="20" />
|
||||
<div class="status-text">
|
||||
<p class="status-label">Non attachée</p>
|
||||
<p class="status-description">
|
||||
La file d'attente n'est pas synchronisée avec une playlist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<p class="help-text">
|
||||
<strong>📌 Playlist Dynamique</strong><br>
|
||||
La queue se synchronisera automatiquement avec la playlist/album du serveur.
|
||||
Les pistes ajoutées ou retirées sur le serveur apparaîtront ici en temps réel.
|
||||
</p>
|
||||
<p class="help-text">
|
||||
<strong>💡 Comment activer ?</strong><br>
|
||||
Naviguez vers un serveur de médias, trouvez une playlist/album,
|
||||
et cliquez sur ⋮ puis "Lire maintenant". Le binding se fera automatiquement !
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.playlist-binding-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.binding-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.status-info > svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.attached .status-info > svg {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.detached .status-info > svg {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
}
|
||||
|
||||
.status-description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-details {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: var(--spacing-xs) 0 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.help-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
padding: var(--spacing-sm);
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 3px solid var(--status-transitioning);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.help-text strong {
|
||||
color: var(--color-text);
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.help-text-warning {
|
||||
border-left-color: var(--status-paused);
|
||||
background-color: var(--status-paused-bg);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
139
pmoapp/webapp/src/components/pmocontrol/QueueItem.vue
Normal file
139
pmoapp/webapp/src/components/pmocontrol/QueueItem.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { Music, Play } from 'lucide-vue-next'
|
||||
import type { QueueItem } from '@/services/pmocontrol/types'
|
||||
|
||||
defineProps<{
|
||||
item: QueueItem
|
||||
isCurrent: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [item: QueueItem]
|
||||
}>()
|
||||
|
||||
function handleClick(item: QueueItem) {
|
||||
console.log('[QueueItem] Click detected on item:', item.index, item.title)
|
||||
emit('click', item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['queue-item', { current: isCurrent }]" @click="handleClick(item)">
|
||||
<!-- Indicateur piste en cours -->
|
||||
<div class="current-indicator" v-if="isCurrent">
|
||||
<Play :size="16" fill="currentColor" />
|
||||
</div>
|
||||
|
||||
<!-- Index (1-based pour l'affichage) -->
|
||||
<span class="item-index">{{ item.index + 1 }}</span>
|
||||
|
||||
<!-- Cover miniature -->
|
||||
<div class="item-cover">
|
||||
<img
|
||||
v-if="item.album_art_uri"
|
||||
:src="item.album_art_uri"
|
||||
:alt="item.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
<Music v-else :size="20" />
|
||||
</div>
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="item-metadata">
|
||||
<div class="item-title">{{ item.title || 'Sans titre' }}</div>
|
||||
<div class="item-artist">
|
||||
{{ item.artist || 'Artiste inconnu' }}
|
||||
<span v-if="item.album"> • {{ item.album }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.queue-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.queue-item.current {
|
||||
background-color: var(--status-playing-bg);
|
||||
border: 1px solid var(--status-playing);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.current-indicator {
|
||||
position: absolute;
|
||||
left: var(--spacing-xs);
|
||||
color: var(--status-playing);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item-index {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.queue-item.current .item-index {
|
||||
margin-left: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.item-cover {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-metadata {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.queue-item.current .item-title {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.item-artist {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
159
pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
Normal file
159
pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick, toRef } from 'vue'
|
||||
import { useRenderer } from '@/composables/useRenderers'
|
||||
import QueueItem from './QueueItem.vue'
|
||||
import { Link } from 'lucide-vue-next'
|
||||
import type { QueueItem as QueueItemType } from '@/services/pmocontrol/types'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
clickItem: [item: QueueItemType]
|
||||
}>()
|
||||
|
||||
const { queue, binding } = useRenderer(toRef(props, 'rendererId'))
|
||||
|
||||
const isAttached = computed(() => !!binding.value)
|
||||
|
||||
const queueContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
function handleItemClick(item: QueueItemType) {
|
||||
emit('clickItem', item)
|
||||
}
|
||||
|
||||
// Auto-scroll vers la piste courante lors de l'ouverture
|
||||
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
|
||||
await nextTick()
|
||||
const currentItem = queueContainer.value.querySelector('.queue-item.current')
|
||||
if (currentItem) {
|
||||
currentItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="queue-viewer">
|
||||
<!-- Header avec indication de binding -->
|
||||
<div class="queue-header">
|
||||
<h3 class="queue-title">
|
||||
File d'attente
|
||||
<span class="queue-count" v-if="queue?.items.length">
|
||||
({{ queue.items.length }})
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<!-- Indicateur playlist attachée -->
|
||||
<div v-if="isAttached" class="binding-indicator">
|
||||
<Link :size="16" />
|
||||
<span class="binding-text">
|
||||
Attachée à une playlist
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des items -->
|
||||
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
||||
<QueueItem
|
||||
v-for="item in queue.items"
|
||||
:key="item.index"
|
||||
:item="item"
|
||||
:is-current="item.index === queue.current_index"
|
||||
@click="handleItemClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- État vide -->
|
||||
<div v-else class="queue-empty">
|
||||
<p>Aucun élément dans la file d'attente</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.queue-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.queue-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.queue-count {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.binding-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--status-playing-bg);
|
||||
color: var(--status-playing);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--status-playing);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.binding-text {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.queue-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.queue-list::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.queue-list::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.queue-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.queue-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-base);
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
</style>
|
||||
332
pmoapp/webapp/src/components/pmocontrol/RendererCard.vue
Normal file
332
pmoapp/webapp/src/components/pmocontrol/RendererCard.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type {
|
||||
RendererCapabilitiesSummary,
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
} from '@/services/pmocontrol/types'
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
import { Music, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
renderer: RendererSummary
|
||||
state: RendererState | null
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Métadonnées proviennent directement de l'état du renderer (API + SSE)
|
||||
const metadata = computed(() => props.state?.current_track)
|
||||
|
||||
const protocolLabel = computed(() => {
|
||||
switch (props.renderer.protocol) {
|
||||
case 'upnp':
|
||||
return 'UPnP AV'
|
||||
case 'openhome':
|
||||
return 'OpenHome'
|
||||
case 'hybrid':
|
||||
return 'Hybrid (UPnP + OpenHome)'
|
||||
default:
|
||||
return 'Inconnu'
|
||||
}
|
||||
})
|
||||
|
||||
const protocolClass = computed(() => {
|
||||
switch (props.renderer.protocol) {
|
||||
case 'upnp':
|
||||
return 'protocol-upnp'
|
||||
case 'openhome':
|
||||
return 'protocol-openhome'
|
||||
case 'hybrid':
|
||||
return 'protocol-hybrid'
|
||||
default:
|
||||
return 'protocol-unknown'
|
||||
}
|
||||
})
|
||||
|
||||
const capabilityBadges = computed(() => {
|
||||
const caps = props.renderer.capabilities
|
||||
if (!caps) return []
|
||||
const mapping: Array<{ key: keyof RendererCapabilitiesSummary; label: string }> = [
|
||||
{ key: 'has_avtransport', label: 'AVTransport' },
|
||||
{ key: 'has_oh_playlist', label: 'OpenHome' },
|
||||
{ key: 'has_linkplay_http', label: 'Hybrid' },
|
||||
{ key: 'has_oh_volume', label: 'Vol' },
|
||||
{ key: 'has_oh_time', label: 'Time' },
|
||||
{ key: 'has_oh_info', label: 'Info' },
|
||||
]
|
||||
return mapping.filter(({ key }) => caps[key]).map(({ key, label }) => ({ key, label }))
|
||||
})
|
||||
|
||||
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||
|
||||
function goToRenderer() {
|
||||
router.push(`/renderer/${props.renderer.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['renderer-card', { offline: !renderer.online }]">
|
||||
<!-- Header -->
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<h3 class="renderer-name">{{ renderer.friendly_name }}</h3>
|
||||
<p class="renderer-model">{{ renderer.model_name }}</p>
|
||||
</div>
|
||||
<div class="badges">
|
||||
<span :class="['protocol-badge', protocolClass]">
|
||||
{{ protocolLabel }}
|
||||
</span>
|
||||
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cover Art -->
|
||||
<div v-if="capabilityBadges.length" class="capabilities">
|
||||
<span v-for="badge in capabilityBadges" :key="badge.key" class="capability-badge">
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-cover">
|
||||
<img
|
||||
v-if="hasCover"
|
||||
:src="metadata?.album_art_uri!"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">
|
||||
<Music :size="48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata (current track) -->
|
||||
<div v-if="metadata" class="card-metadata">
|
||||
<p class="track-title">{{ metadata.title || 'Sans titre' }}</p>
|
||||
<p class="track-artist">{{ metadata.artist || 'Artiste inconnu' }}</p>
|
||||
</div>
|
||||
<div v-else class="card-metadata empty">
|
||||
<p class="track-title">Aucun média</p>
|
||||
</div>
|
||||
|
||||
<!-- Volume -->
|
||||
<div v-if="state && state.volume !== null" class="card-volume">
|
||||
<VolumeX v-if="state.mute" :size="16" class="volume-icon muted" />
|
||||
<Volume2 v-else :size="16" class="volume-icon" />
|
||||
<div class="volume-bar">
|
||||
<div
|
||||
class="volume-bar-fill"
|
||||
:style="{ width: `${state.mute ? 0 : state.volume}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="volume-value">{{ state.volume }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Control Button -->
|
||||
<button class="btn btn-primary card-control-btn" @click="goToRenderer">
|
||||
Contrôler
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.renderer-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.renderer-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.renderer-card.offline {
|
||||
opacity: 0.6;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.capabilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.capability-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.protocol-badge {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
border: 1px solid #8b5cf6;
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
border: 1px solid #10b981;
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.card-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Metadata */
|
||||
.card-metadata {
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-metadata.empty .track-title {
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Volume */
|
||||
.card-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.volume-icon {
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.volume-icon.muted {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.volume-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.volume-bar-fill {
|
||||
height: 100%;
|
||||
background-color: var(--color-primary);
|
||||
transition: width var(--transition-fast);
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Control Button */
|
||||
.card-control-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Responsive grid handled by parent (DashboardView) */
|
||||
</style>
|
||||
52
pmoapp/webapp/src/components/pmocontrol/StatusBadge.vue
Normal file
52
pmoapp/webapp/src/components/pmocontrol/StatusBadge.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
status: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN' | 'OFFLINE'
|
||||
}>()
|
||||
|
||||
function getStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'PLAYING':
|
||||
return 'playing'
|
||||
case 'PAUSED':
|
||||
return 'paused'
|
||||
case 'STOPPED':
|
||||
case 'NO_MEDIA':
|
||||
return 'stopped'
|
||||
case 'TRANSITIONING':
|
||||
return 'transitioning'
|
||||
case 'OFFLINE':
|
||||
return 'offline'
|
||||
default:
|
||||
return 'stopped'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'PLAYING':
|
||||
return 'En lecture'
|
||||
case 'PAUSED':
|
||||
return 'En pause'
|
||||
case 'STOPPED':
|
||||
return 'Arrêté'
|
||||
case 'NO_MEDIA':
|
||||
return 'Aucun média'
|
||||
case 'TRANSITIONING':
|
||||
return 'Transition...'
|
||||
case 'OFFLINE':
|
||||
return 'Hors ligne'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['status-badge', getStatusClass(status)]">
|
||||
{{ getStatusLabel(status) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Les styles sont définis globalement dans pmocontrol.css */
|
||||
</style>
|
||||
209
pmoapp/webapp/src/components/pmocontrol/TransportControls.vue
Normal file
209
pmoapp/webapp/src/components/pmocontrol/TransportControls.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
const isPlaying = computed(() => state.value?.transport_state === 'PLAYING')
|
||||
const isPaused = computed(() => state.value?.transport_state === 'PAUSED')
|
||||
const isStopped = computed(() => state.value?.transport_state === 'STOPPED' || state.value?.transport_state === 'NO_MEDIA')
|
||||
|
||||
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'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePause() {
|
||||
try {
|
||||
await pause(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
try {
|
||||
await stop(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNext() {
|
||||
try {
|
||||
await next(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="transport-controls">
|
||||
<button
|
||||
class="btn btn-icon btn-primary"
|
||||
:disabled="isPlaying"
|
||||
@click="handlePlay"
|
||||
title="Lecture"
|
||||
>
|
||||
<Play :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isPaused || isStopped"
|
||||
@click="handlePause"
|
||||
title="Pause"
|
||||
>
|
||||
<Pause :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="isStopped"
|
||||
@click="handleStop"
|
||||
title="Stop"
|
||||
>
|
||||
<Square :size="20" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
:disabled="!state?.queue_len"
|
||||
@click="handleNext"
|
||||
title="Suivant"
|
||||
>
|
||||
<SkipForward :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(31, 38, 135, 0.15),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transport-controls {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
/* Boutons avec effet glass */
|
||||
.transport-controls .btn-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
min-width: 56px;
|
||||
min-height: 56px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:disabled {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Bouton primary (play) avec accent vert */
|
||||
.transport-controls .btn-primary {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.transport-controls .btn-primary:hover:not(:disabled) {
|
||||
background: rgba(34, 197, 94, 0.4);
|
||||
border-color: rgba(34, 197, 94, 0.6);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback pour navigateurs sans backdrop-filter */
|
||||
@supports not (backdrop-filter: blur(20px)) {
|
||||
.transport-controls {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.transport-controls {
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode kiosque - compactage pour petites hauteurs (800x600) */
|
||||
@media (max-height: 700px) and (orientation: landscape) {
|
||||
.transport-controls {
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.transport-controls .btn-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
286
pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue
Normal file
286
pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, toRef } from 'vue'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { Volume2, VolumeX } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { setVolume, toggleMute } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
const localVolume = ref(state.value?.volume ?? 50)
|
||||
|
||||
// Synchroniser localVolume avec le state
|
||||
watch(() => state.value?.volume, (newVolume) => {
|
||||
if (newVolume !== undefined && newVolume !== null) {
|
||||
localVolume.value = newVolume
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Debounce pour le slider
|
||||
let debounceTimer: number | null = null
|
||||
function handleVolumeChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
localVolume.value = parseInt(target.value, 10)
|
||||
|
||||
// Debounce: attendre 300ms avant d'envoyer à l'API
|
||||
if (debounceTimer !== null) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
|
||||
debounceTimer = window.setTimeout(async () => {
|
||||
try {
|
||||
await setVolume(props.rendererId, localVolume.value)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de régler le volume: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
debounceTimer = null
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function handleToggleMute() {
|
||||
try {
|
||||
await toggleMute(props.rendererId)
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="volume-control">
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
@click="handleToggleMute"
|
||||
:title="state?.mute ? 'Réactiver le son' : 'Couper le son'"
|
||||
>
|
||||
<VolumeX v-if="state?.mute" :size="20" />
|
||||
<Volume2 v-else :size="20" />
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="localVolume"
|
||||
@input="handleVolumeChange"
|
||||
class="volume-slider"
|
||||
:disabled="state?.mute ?? false"
|
||||
/>
|
||||
|
||||
<span class="volume-value">{{ localVolume }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.volume-control {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bouton mute avec effet glass */
|
||||
.volume-control .btn-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.volume-control .btn-icon:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.volume-control .btn-icon:active {
|
||||
transform: scale(1.0);
|
||||
}
|
||||
|
||||
/* Slider avec effet glass */
|
||||
.volume-slider {
|
||||
flex: 1;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 8px;
|
||||
height: 8px;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.volume-slider:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Thumb Webkit (Chrome, Safari) */
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow:
|
||||
0 6px 16px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb:active {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Thumb Mozilla */
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.volume-slider:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.volume-slider:disabled::-webkit-slider-thumb {
|
||||
cursor: not-allowed;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.volume-slider:disabled::-moz-range-thumb {
|
||||
cursor: not-allowed;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.volume-control .btn-icon {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.volume-control .btn-icon:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback pour navigateurs sans backdrop-filter */
|
||||
@supports not (backdrop-filter: blur(20px)) {
|
||||
.volume-control {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
background: rgba(200, 200, 200, 0.8);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.volume-control {
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
background: rgba(100, 100, 100, 0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode kiosque - compactage pour petites hauteurs (800x600) */
|
||||
@media (max-height: 700px) and (orientation: landscape) {
|
||||
.volume-control {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.volume-control .btn-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
font-size: 11px;
|
||||
min-width: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
452
pmoapp/webapp/src/components/unified/BottomTabBar.vue
Normal file
452
pmoapp/webapp/src/components/unified/BottomTabBar.vue
Normal file
@@ -0,0 +1,452 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { X, ChevronLeft, ChevronRight, Server } from 'lucide-vue-next'
|
||||
import { useTabs } from '@/composables/useTabs'
|
||||
import { useSwipe } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
onlineServersCount?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-drawer': []
|
||||
}>()
|
||||
|
||||
const { tabs, activeTabId, switchTab, closeTab, nextTab, previousTab, compactMode } = useTabs()
|
||||
|
||||
const tabBarRef = ref<HTMLElement | null>(null)
|
||||
const scrollContainerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Gestion du swipe pour changer d'onglet
|
||||
useSwipe(tabBarRef, {
|
||||
threshold: 50,
|
||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||
if (swipeDirection === 'left') {
|
||||
nextTab()
|
||||
} else if (swipeDirection === 'right') {
|
||||
previousTab()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Scroll vers l'onglet actif
|
||||
function scrollToActiveTab() {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
const activeTabElement = scrollContainerRef.value.querySelector('.tab-item.active')
|
||||
if (activeTabElement) {
|
||||
activeTabElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll vers l'onglet actif quand il change
|
||||
watch(() => activeTabId.value, () => {
|
||||
scrollToActiveTab()
|
||||
})
|
||||
|
||||
// Gestion des boutons de scroll
|
||||
const showLeftScroll = ref(false)
|
||||
const showRightScroll = ref(false)
|
||||
|
||||
function updateScrollButtons() {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.value
|
||||
showLeftScroll.value = scrollLeft > 10
|
||||
showRightScroll.value = scrollLeft < scrollWidth - clientWidth - 10
|
||||
}
|
||||
|
||||
function scrollLeft() {
|
||||
if (!scrollContainerRef.value) return
|
||||
scrollContainerRef.value.scrollBy({ left: -200, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function scrollRight() {
|
||||
if (!scrollContainerRef.value) return
|
||||
scrollContainerRef.value.scrollBy({ left: 200, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (scrollContainerRef.value) {
|
||||
scrollContainerRef.value.addEventListener('scroll', updateScrollButtons)
|
||||
updateScrollButtons()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scrollContainerRef.value) {
|
||||
scrollContainerRef.value.removeEventListener('scroll', updateScrollButtons)
|
||||
}
|
||||
})
|
||||
|
||||
// Gestion du clic sur un onglet
|
||||
function handleTabClick(tabId: string) {
|
||||
switchTab(tabId)
|
||||
}
|
||||
|
||||
// Gestion du clic sur le bouton fermer
|
||||
function handleCloseClick(event: Event, tabId: string) {
|
||||
event.stopPropagation()
|
||||
closeTab(tabId)
|
||||
}
|
||||
|
||||
// Gestion du clic sur le bouton server drawer
|
||||
function handleServerDrawerClick() {
|
||||
emit('open-drawer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="tabBarRef" class="bottom-tab-bar">
|
||||
<!-- Bouton pour ouvrir le drawer des servers -->
|
||||
<button
|
||||
class="server-drawer-button"
|
||||
@click="handleServerDrawerClick"
|
||||
:aria-label="`Open media servers (${onlineServersCount || 0} online)`"
|
||||
:title="`Media Servers (${onlineServersCount || 0} online)`"
|
||||
>
|
||||
<Server :size="24" />
|
||||
<span v-if="onlineServersCount && onlineServersCount > 0" class="server-badge">{{ onlineServersCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Bouton scroll gauche -->
|
||||
<button
|
||||
v-if="showLeftScroll"
|
||||
class="scroll-button scroll-left"
|
||||
@click="scrollLeft"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft :size="20" />
|
||||
</button>
|
||||
|
||||
<!-- Container avec scroll horizontal -->
|
||||
<div ref="scrollContainerRef" class="tabs-scroll-container">
|
||||
<div class="tabs-container">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="tab-item"
|
||||
:class="{
|
||||
active: tab.id === activeTabId,
|
||||
compact: compactMode
|
||||
}"
|
||||
@click="handleTabClick(tab.id)"
|
||||
:title="tab.fullTitle"
|
||||
:aria-label="`Switch to ${tab.fullTitle} tab`"
|
||||
:aria-current="tab.id === activeTabId ? 'page' : undefined"
|
||||
>
|
||||
<!-- Icône (réduite pour plus d'espace pour le texte) -->
|
||||
<component :is="tab.icon" class="tab-icon" :size="compactMode ? 24 : 20" />
|
||||
|
||||
<!-- Titre (masqué en mode compact) -->
|
||||
<span v-if="!compactMode" class="tab-title">{{ tab.title }}</span>
|
||||
|
||||
<!-- Bouton fermer (seulement pour les onglets fermables, masqué en mode compact) -->
|
||||
<button
|
||||
v-if="tab.closeable && !compactMode"
|
||||
class="tab-close"
|
||||
@click="(e) => handleCloseClick(e, tab.id)"
|
||||
:aria-label="`Close ${tab.fullTitle} tab`"
|
||||
>
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton scroll droite -->
|
||||
<button
|
||||
v-if="showRightScroll"
|
||||
class="scroll-button scroll-right"
|
||||
@click="scrollRight"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-tab-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
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);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Support pour thème sombre */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bottom-tab-bar {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-scroll-container {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
.tabs-scroll-container::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 140px; /* Largeur fixe pour éviter l'espace blanc */
|
||||
height: 64px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 4px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px; /* Réduit de var(--text-sm) pour plus d'espace */
|
||||
font-family: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Mode compact: icônes seulement */
|
||||
.tab-item.compact {
|
||||
width: 60px;
|
||||
padding: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom-color: var(--color-primary);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tab-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
flex-shrink: 0;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
/* Permettre au texte de se replier sur 2 lignes */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.2;
|
||||
max-height: 2.4em; /* 2 lignes × 1.2 line-height */
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: var(--color-text);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.tab-close:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Boutons de scroll */
|
||||
.scroll-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.scroll-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.scroll-button:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.scroll-left {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.scroll-right {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
/* Responsive mobile */
|
||||
@media (max-width: 768px) {
|
||||
.tab-item {
|
||||
width: 110px; /* Largeur fixe plus petite sur mobile */
|
||||
padding: 6px 10px;
|
||||
gap: 4px;
|
||||
font-size: 12px; /* Encore un peu plus petit sur mobile */
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
max-height: 2.6em; /* 2 lignes × 1.3 line-height */
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation d'entrée */
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-tab-bar {
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Effet de swipe visuel */
|
||||
.tabs-scroll-container {
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
/* Bouton server drawer */
|
||||
.server-drawer-button {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.server-drawer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.server-drawer-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.server-drawer-button {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.server-drawer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge pour le nombre de servers online */
|
||||
.server-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: rgba(102, 126, 234, 0.9);
|
||||
border: 2px solid var(--color-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
206
pmoapp/webapp/src/components/unified/EmptyState.vue
Normal file
206
pmoapp/webapp/src/components/unified/EmptyState.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioTower, RefreshCw } from 'lucide-vue-next'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { fetchRenderers } = useRenderers()
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
async function handleRefresh() {
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
await fetchRenderers(true) // Force refresh
|
||||
} finally {
|
||||
// Petit délai pour montrer l'animation
|
||||
setTimeout(() => {
|
||||
isRefreshing.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-content">
|
||||
<!-- Icône -->
|
||||
<div class="empty-icon">
|
||||
<RadioTower :size="80" />
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<h2 class="empty-title">Aucun renderer détecté</h2>
|
||||
<p class="empty-description">
|
||||
Vérifiez que vos renderers (Chromecast, UPnP, OpenHome) sont allumés et connectés au réseau.
|
||||
</p>
|
||||
|
||||
<!-- Bouton refresh -->
|
||||
<button
|
||||
class="btn-refresh"
|
||||
:class="{ refreshing: isRefreshing }"
|
||||
@click="handleRefresh"
|
||||
:disabled="isRefreshing"
|
||||
>
|
||||
<RefreshCw :size="20" :class="{ spin: isRefreshing }" />
|
||||
<span>{{ isRefreshing ? 'Actualisation...' : 'Actualiser' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--spacing-xl);
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%);
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
max-width: 500px;
|
||||
padding: var(--spacing-2xl);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(31, 38, 135, 0.15),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.empty-content {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: 12px 24px;
|
||||
margin-top: var(--spacing-md);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
color: white;
|
||||
background: rgba(102, 126, 234, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(102, 126, 234, 0.4);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-refresh:hover:not(:disabled) {
|
||||
background: rgba(102, 126, 234, 1);
|
||||
border-color: rgba(102, 126, 234, 0.6);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-refresh:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-refresh:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-refresh .spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive mobile */
|
||||
@media (max-width: 768px) {
|
||||
.empty-state {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.empty-icon svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback pour navigateurs sans backdrop-filter */
|
||||
@supports not (backdrop-filter: blur(20px)) {
|
||||
.empty-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.empty-content {
|
||||
background: rgba(20, 20, 30, 0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
471
pmoapp/webapp/src/components/unified/RendererTabContent.vue
Normal file
471
pmoapp/webapp/src/components/unified/RendererTabContent.vue
Normal file
@@ -0,0 +1,471 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, toRef, onMounted } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useRenderer } from '@/composables/useRenderers'
|
||||
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
|
||||
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
|
||||
import VolumeControl from '@/components/pmocontrol/VolumeControl.vue'
|
||||
import QueueViewer from '@/components/pmocontrol/QueueViewer.vue'
|
||||
import StatusBadge from '@/components/pmocontrol/StatusBadge.vue'
|
||||
import { ChevronUp, ChevronDown, Link } from 'lucide-vue-next'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { api } from '@/services/pmocontrol/api'
|
||||
import type { QueueItem } from '@/services/pmocontrol/types'
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
|
||||
const { renderer, state, queue, binding, refresh } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { detachPlaylist } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
// Détection mobile portrait pour afficher le drawer au lieu de la colonne
|
||||
const isMobilePortrait = useMediaQuery('(max-width: 768px) and (orientation: portrait)')
|
||||
|
||||
// État du drawer queue sur mobile
|
||||
const queueDrawerOpen = ref(false)
|
||||
|
||||
function toggleQueueDrawer() {
|
||||
queueDrawerOpen.value = !queueDrawerOpen.value
|
||||
}
|
||||
|
||||
// Charger les données au montage
|
||||
onMounted(async () => {
|
||||
await refresh()
|
||||
})
|
||||
|
||||
// État du renderer pour affichage
|
||||
const isOnline = computed(() => renderer.value?.online ?? false)
|
||||
const transportState = computed(() => state.value?.transport_state ?? 'STOPPED')
|
||||
const hasPlaylistBinding = computed(() => !!binding.value)
|
||||
|
||||
// Détacher la playlist
|
||||
async function handleDetachPlaylist() {
|
||||
try {
|
||||
await detachPlaylist(props.rendererId)
|
||||
uiStore.notifySuccess('Playlist détachée')
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Gérer le clic sur un item de la queue
|
||||
async function handleQueueItemClick(item: QueueItem) {
|
||||
try {
|
||||
await api.seekQueueIndex(props.rendererId, item.index)
|
||||
console.log('[RendererTabContent] Jumped to queue index:', item.index, item.title)
|
||||
} catch (error) {
|
||||
console.error('[RendererTabContent] Error seeking to queue index:', error)
|
||||
uiStore.notifyError(`Erreur: ${error instanceof Error ? error.message : 'Impossible de sauter à cet item'}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="renderer-tab-content">
|
||||
<!-- Header avec nom du renderer et état -->
|
||||
<header class="renderer-header">
|
||||
<div class="header-info">
|
||||
<h1 class="renderer-name">{{ renderer?.friendly_name || 'Renderer' }}</h1>
|
||||
<p v-if="renderer?.model_name" class="renderer-model">{{ renderer.model_name }}</p>
|
||||
</div>
|
||||
<div class="header-badges">
|
||||
<StatusBadge v-if="state" :status="transportState" />
|
||||
<span v-if="renderer?.protocol" class="protocol-badge">
|
||||
{{ renderer.protocol.toUpperCase() }}
|
||||
</span>
|
||||
|
||||
<!-- Badge playlist binding avec tooltip -->
|
||||
<div v-if="hasPlaylistBinding" class="playlist-badge" :title="`Playlist liée\nServeur: ${binding?.server_id}\nContainer: ${binding?.container_id}`">
|
||||
<button class="playlist-badge-btn" @click="handleDetachPlaylist" title="Cliquer pour détacher">
|
||||
<Link :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span v-if="!isOnline" class="offline-badge">OFFLINE</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Layout principal -->
|
||||
<div class="renderer-layout" :class="{ 'queue-open': queueDrawerOpen }">
|
||||
<!-- Colonne gauche: Contrôles -->
|
||||
<div class="controls-column">
|
||||
<!-- Pochette + infos track -->
|
||||
<CurrentTrack v-if="state" :renderer-id="rendererId" class="current-track-section" />
|
||||
|
||||
<!-- Contrôles de transport -->
|
||||
<div v-if="state" class="controls-section">
|
||||
<TransportControls :renderer-id="rendererId" />
|
||||
</div>
|
||||
|
||||
<!-- Contrôle de volume -->
|
||||
<div v-if="state" class="volume-section">
|
||||
<VolumeControl :renderer-id="rendererId" />
|
||||
</div>
|
||||
|
||||
<!-- Message si offline -->
|
||||
<div v-if="!isOnline" class="offline-message">
|
||||
<p>Ce renderer est actuellement hors ligne</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite: Queue (desktop et landscape uniquement) -->
|
||||
<div v-if="!isMobilePortrait" class="queue-column">
|
||||
<QueueViewer :renderer-id="rendererId" class="queue-viewer" @click-item="handleQueueItemClick" />
|
||||
</div>
|
||||
|
||||
<!-- Drawer queue (mobile portrait uniquement) -->
|
||||
<div v-if="isMobilePortrait" class="queue-drawer" :class="{ open: queueDrawerOpen }">
|
||||
<!-- Toggle button -->
|
||||
<button class="queue-drawer-toggle" @click="toggleQueueDrawer">
|
||||
<ChevronUp v-if="queueDrawerOpen" :size="24" />
|
||||
<ChevronDown v-else :size="24" />
|
||||
<span>File d'attente ({{ queue?.items.length || 0 }})</span>
|
||||
</button>
|
||||
|
||||
<!-- Contenu du drawer -->
|
||||
<div class="queue-drawer-content">
|
||||
<QueueViewer :renderer-id="rendererId" @click-item="handleQueueItemClick" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop pour fermer le drawer -->
|
||||
<div
|
||||
v-if="queueDrawerOpen"
|
||||
class="queue-drawer-backdrop"
|
||||
@click="queueDrawerOpen = false"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.renderer-tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.renderer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.protocol-badge,
|
||||
.offline-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.protocol-badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.offline-badge {
|
||||
background: var(--status-offline);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Badge playlist binding compact */
|
||||
.playlist-badge {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playlist-badge-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(102, 126, 234, 0.4);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: rgba(102, 126, 234, 1);
|
||||
}
|
||||
|
||||
.playlist-badge-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
border-color: rgba(102, 126, 234, 0.6);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.playlist-badge-btn:active {
|
||||
transform: scale(1.0);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.playlist-badge-btn {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.playlist-badge-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.25);
|
||||
border-color: rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Layout principal - 800x600 landscape (2 colonnes) */
|
||||
.renderer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Colonne gauche - Contrôles */
|
||||
.controls-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
padding-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.current-track-section,
|
||||
.controls-section,
|
||||
.volume-section,
|
||||
.playlist-section {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
padding: var(--spacing-lg);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
/* Colonne droite - Queue */
|
||||
.queue-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queue-viewer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Queue drawer - masqué sur desktop, visible uniquement sur mobile portrait */
|
||||
.queue-drawer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.queue-drawer-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive - Mobile portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.renderer-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.renderer-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-xl); /* Espace pour éviter que la tab bar cache les contrôles */
|
||||
}
|
||||
|
||||
/* Queue drawer visible sur mobile (géré par v-if maintenant) */
|
||||
.queue-drawer {
|
||||
position: fixed;
|
||||
bottom: 64px; /* Hauteur de la tab bar */
|
||||
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);
|
||||
transform: translateY(calc(100% - 56px));
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 90;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.queue-drawer.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.queue-drawer-toggle {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.queue-drawer-content {
|
||||
max-height: calc(70vh - 56px);
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.queue-drawer-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 64px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 89;
|
||||
}
|
||||
|
||||
.controls-column {
|
||||
padding-right: 0;
|
||||
padding-bottom: 100px; /* Espace supplémentaire pour éviter que la tab bar (64px) cache le volume */
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - 800x600 landscape et petites hauteurs (mode kiosque) */
|
||||
@media (min-width: 600px) and (max-width: 1024px) and (orientation: landscape) {
|
||||
.renderer-layout {
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-header {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode kiosque - compactage pour hauteurs ≤ 700px (ex: 800x600) */
|
||||
@media (max-height: 700px) and (orientation: landscape) {
|
||||
.renderer-header {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.renderer-layout {
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.controls-column {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Masquer le scroll, tout doit tenir */
|
||||
.controls-column {
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large desktop */
|
||||
@media (min-width: 1200px) {
|
||||
.renderer-layout {
|
||||
grid-template-columns: 350px 1fr;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.controls-column::-webkit-scrollbar,
|
||||
.queue-viewer::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.controls-column::-webkit-scrollbar-track,
|
||||
.queue-viewer::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.controls-column::-webkit-scrollbar-thumb,
|
||||
.queue-viewer::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.controls-column::-webkit-scrollbar-thumb:hover,
|
||||
.queue-viewer::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
893
pmoapp/webapp/src/components/unified/ServerDrawer.vue
Normal file
893
pmoapp/webapp/src/components/unified/ServerDrawer.vue
Normal file
@@ -0,0 +1,893 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { X, Server as ServerIcon, Circle, ChevronLeft, Folder, Music, ChevronRight, Play, Plus, Settings } from 'lucide-vue-next'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import type { MediaServerSummary, BrowseResponse, ContainerEntry } from '@/services/pmocontrol/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean // v-model pour contrôler l'ouverture
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'play-item': [item: ContainerEntry, serverId: string]
|
||||
'queue-item': [item: ContainerEntry, serverId: string]
|
||||
}>()
|
||||
|
||||
const { allServers, fetchServers, browseContainer, currentPath, setPath, clearPath } = useMediaServers()
|
||||
const router = useRouter()
|
||||
|
||||
// État de navigation
|
||||
const currentServer = ref<MediaServerSummary | null>(null)
|
||||
const browseData = ref<BrowseResponse | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Rafraîchir la liste quand le drawer s'ouvre
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) {
|
||||
fetchServers()
|
||||
} else {
|
||||
// Reset navigation quand on ferme
|
||||
currentServer.value = null
|
||||
browseData.value = null
|
||||
clearPath()
|
||||
}
|
||||
})
|
||||
|
||||
const onlineServers = computed(() => allServers.value.filter((s: MediaServerSummary) => s.online))
|
||||
const offlineServers = computed(() => allServers.value.filter((s: MediaServerSummary) => !s.online))
|
||||
|
||||
// Mode du drawer
|
||||
const isNavigating = computed(() => currentServer.value !== null)
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function handleServerClick(server: MediaServerSummary) {
|
||||
if (!server.online) return
|
||||
|
||||
// Commencer la navigation dans ce serveur
|
||||
currentServer.value = server
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Browse racine (containerId = "0")
|
||||
browseData.value = await browseContainer(server.id, '0')
|
||||
setPath([{ id: '0', title: server.friendly_name }])
|
||||
} catch (error) {
|
||||
console.error('[ServerDrawer] Erreur browse racine:', error)
|
||||
browseData.value = null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
currentServer.value = null
|
||||
browseData.value = null
|
||||
clearPath()
|
||||
}
|
||||
|
||||
async function handleContainerClick(item: ContainerEntry) {
|
||||
if (!currentServer.value) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
browseData.value = await browseContainer(currentServer.value.id, item.id)
|
||||
setPath([...currentPath.value, { id: item.id, title: item.title }])
|
||||
} catch (error) {
|
||||
console.error('[ServerDrawer] Erreur browse container:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBreadcrumbClick(index: number) {
|
||||
if (!currentServer.value || index >= currentPath.value.length) return
|
||||
|
||||
const targetCrumb = currentPath.value[index]
|
||||
if (!targetCrumb) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
browseData.value = await browseContainer(currentServer.value.id, targetCrumb.id)
|
||||
setPath(currentPath.value.slice(0, index + 1))
|
||||
} catch (error) {
|
||||
console.error('[ServerDrawer] Erreur breadcrumb navigation:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Détermine si un item est jouable (track ou container jouable comme album/playlist)
|
||||
function isPlayable(item: ContainerEntry): boolean {
|
||||
if (!item.is_container) return true // Les tracks sont toujours jouables
|
||||
|
||||
// Containers jouables basés sur la classe UPnP
|
||||
const playableClasses = [
|
||||
'object.container.album',
|
||||
'object.container.playlist',
|
||||
'object.container.person.musicartist'
|
||||
]
|
||||
|
||||
return playableClasses.some(cls => item.class.toLowerCase().includes(cls))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
function handleItemClick(item: ContainerEntry) {
|
||||
if (!currentServer.value) return
|
||||
|
||||
// Les containers sont toujours navigables
|
||||
if (item.is_container) {
|
||||
handleContainerClick(item)
|
||||
}
|
||||
// Les tracks individuels : on ne fait rien (actions via boutons)
|
||||
}
|
||||
|
||||
function handlePlayItem(event: Event, item: ContainerEntry) {
|
||||
event.stopPropagation()
|
||||
if (!currentServer.value) return
|
||||
emit('play-item', item, currentServer.value.id)
|
||||
}
|
||||
|
||||
function handleQueueItem(event: Event, item: ContainerEntry) {
|
||||
event.stopPropagation()
|
||||
if (!currentServer.value) return
|
||||
emit('queue-item', item, currentServer.value.id)
|
||||
}
|
||||
|
||||
function handleSettingsClick() {
|
||||
router.push('/debug/api-dashboard')
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Backdrop -->
|
||||
<Transition name="backdrop">
|
||||
<div v-if="modelValue" class="drawer-backdrop" @click="close"></div>
|
||||
</Transition>
|
||||
|
||||
<!-- Drawer -->
|
||||
<Transition name="drawer">
|
||||
<aside v-if="modelValue" class="server-drawer">
|
||||
<!-- Header - Liste des serveurs -->
|
||||
<header v-if="!isNavigating" class="drawer-header">
|
||||
<div class="drawer-title-section">
|
||||
<ServerIcon :size="24" />
|
||||
<h2 class="drawer-title">Media Servers</h2>
|
||||
</div>
|
||||
<button class="drawer-close-btn" @click="close" aria-label="Fermer">
|
||||
<X :size="24" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Header - Navigation dans un serveur -->
|
||||
<header v-else class="drawer-header">
|
||||
<button class="back-btn" @click="goBack" aria-label="Retour aux serveurs">
|
||||
<ChevronLeft :size="20" />
|
||||
</button>
|
||||
<div class="drawer-title-section">
|
||||
<ServerIcon :size="20" />
|
||||
<h2 class="drawer-title small">{{ currentServer?.friendly_name }}</h2>
|
||||
</div>
|
||||
<button class="drawer-close-btn" @click="close" aria-label="Fermer">
|
||||
<X :size="24" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav v-if="isNavigating && currentPath.length > 1" class="breadcrumb">
|
||||
<button
|
||||
v-for="(crumb, index) in currentPath"
|
||||
:key="crumb.id"
|
||||
class="breadcrumb-item"
|
||||
:class="{ active: index === currentPath.length - 1 }"
|
||||
@click="handleBreadcrumbClick(index)"
|
||||
>
|
||||
{{ crumb.title }}
|
||||
<ChevronRight v-if="index < currentPath.length - 1" :size="14" />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="drawer-content">
|
||||
<!-- Liste des serveurs -->
|
||||
<div v-if="!isNavigating">
|
||||
<!-- Servers online -->
|
||||
<section v-if="onlineServers.length > 0" class="server-section">
|
||||
<h3 class="section-title">Disponibles ({{ onlineServers.length }})</h3>
|
||||
<ul class="server-list">
|
||||
<li
|
||||
v-for="server in onlineServers"
|
||||
:key="server.id"
|
||||
class="server-item online"
|
||||
@click="handleServerClick(server)"
|
||||
>
|
||||
<div class="server-icon">
|
||||
<ServerIcon :size="20" />
|
||||
</div>
|
||||
<div class="server-info">
|
||||
<p class="server-name">{{ server.friendly_name }}</p>
|
||||
<p v-if="server.model_name" class="server-model">{{ server.model_name }}</p>
|
||||
</div>
|
||||
<div class="server-status">
|
||||
<Circle :size="8" fill="currentColor" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Servers offline -->
|
||||
<section v-if="offlineServers.length > 0" class="server-section">
|
||||
<h3 class="section-title">Hors ligne ({{ offlineServers.length }})</h3>
|
||||
<ul class="server-list">
|
||||
<li
|
||||
v-for="server in offlineServers"
|
||||
:key="server.id"
|
||||
class="server-item offline"
|
||||
>
|
||||
<div class="server-icon">
|
||||
<ServerIcon :size="20" />
|
||||
</div>
|
||||
<div class="server-info">
|
||||
<p class="server-name">{{ server.friendly_name }}</p>
|
||||
<p v-if="server.model_name" class="server-model">{{ server.model_name }}</p>
|
||||
</div>
|
||||
<div class="server-status">
|
||||
<Circle :size="8" fill="currentColor" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Aucun server -->
|
||||
<div v-if="allServers.length === 0" class="empty-servers">
|
||||
<ServerIcon :size="48" />
|
||||
<p>Aucun serveur multimédia détecté</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation dans un serveur -->
|
||||
<div v-else>
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Contenu du serveur -->
|
||||
<ul v-else-if="browseData" class="content-list">
|
||||
<li
|
||||
v-for="item in browseData.entries"
|
||||
:key="item.id"
|
||||
class="content-item"
|
||||
:class="{ navigable: item.is_container && isNavigable(item) }"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<div class="content-icon">
|
||||
<Folder v-if="item.is_container" :size="20" />
|
||||
<Music v-else :size="20" />
|
||||
</div>
|
||||
<div class="content-info">
|
||||
<p class="content-title">{{ item.title }}</p>
|
||||
<p v-if="item.artist" class="content-subtitle">{{ item.artist }}</p>
|
||||
<p v-else-if="item.album" class="content-subtitle">{{ item.album }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions pour items jouables -->
|
||||
<div v-if="isPlayable(item)" class="content-actions">
|
||||
<button
|
||||
class="action-btn play-btn"
|
||||
:title="item.is_container ? 'Jouer tout' : 'Jouer'"
|
||||
@click="handlePlayItem($event, item)"
|
||||
>
|
||||
<Play :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn queue-btn"
|
||||
:title="item.is_container ? 'Ajouter tout à la queue' : 'Ajouter à la queue'"
|
||||
@click="handleQueueItem($event, item)"
|
||||
>
|
||||
<Plus :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chevron pour containers navigables (même si jouables) -->
|
||||
<ChevronRight
|
||||
v-if="item.is_container && isNavigable(item)"
|
||||
:size="16"
|
||||
class="content-chevron"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else class="empty-servers">
|
||||
<Folder :size="48" />
|
||||
<p>Dossier vide</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer avec bouton settings -->
|
||||
<footer class="drawer-footer">
|
||||
<button class="settings-btn" @click="handleSettingsClick" title="Ouvrir le menu Debug">
|
||||
<Settings :size="20" />
|
||||
<span>Debug & Config</span>
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50vw; /* Commence après le drawer (desktop: 50vw) */
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.35); /* Moins sombre */
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.drawer-backdrop {
|
||||
left: 80vw; /* Mobile portrait: 80vw */
|
||||
}
|
||||
}
|
||||
|
||||
.server-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 50vw; /* Desktop/landscape: 50% de l'écran */
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.server-drawer {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-right-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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-title.small {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.back-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%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 4px;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.breadcrumb-item.active {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.breadcrumb-item.active:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.server-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.server-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.server-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 0.2s ease;
|
||||
}
|
||||
|
||||
.server-item.online {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.server-item.online:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.server-item.online:active {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.server-item.offline {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.server-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);
|
||||
}
|
||||
|
||||
.server-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 2px 0 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-status {
|
||||
flex-shrink: 0;
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.server-item.offline .server-status {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
/* Content list */
|
||||
.content-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.content-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content-item.navigable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content-item.navigable:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.content-item.navigable:active {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
|
||||
.content-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.content-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content-subtitle {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 2px 0 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content-chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Actions pour items jouables */
|
||||
.content-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
opacity: 1; /* Toujours visible pour le tactile */
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1; /* Au-dessus pour capturer les clicks */
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text);
|
||||
pointer-events: auto; /* S'assurer que les boutons capturent les clicks */
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.queue-btn {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.queue-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-2xl);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.empty-servers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-2xl);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-servers p {
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.drawer-footer {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-md);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: var(--color-text);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.settings-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.backdrop-enter-active {
|
||||
transition: opacity 0.2s ease;
|
||||
transition-delay: 0.15s; /* Attend que le drawer soit à moitié visible */
|
||||
}
|
||||
|
||||
.backdrop-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
/* Pas de delay au leave - disparaît en même temps que le drawer */
|
||||
}
|
||||
|
||||
.backdrop-enter-from,
|
||||
.backdrop-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.drawer-content::-webkit-scrollbar,
|
||||
.breadcrumb::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-track,
|
||||
.breadcrumb::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-thumb,
|
||||
.breadcrumb::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-thumb:hover,
|
||||
.breadcrumb::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Mobile responsive - portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.server-drawer {
|
||||
width: 80vw; /* Mobile portrait: 80% de l'écran */
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback pour navigateurs sans backdrop-filter */
|
||||
@supports not (backdrop-filter: blur(30px)) {
|
||||
.server-drawer {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.server-drawer {
|
||||
background: rgba(20, 20, 30, 0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
195
pmoapp/webapp/src/components/unified/ServerTabContent.vue
Normal file
195
pmoapp/webapp/src/components/unified/ServerTabContent.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import MediaBrowser from '@/components/pmocontrol/MediaBrowser.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string
|
||||
}>()
|
||||
|
||||
const { getServerById, fetchServers } = useMediaServers()
|
||||
|
||||
// Container ID actuel (commence à la racine '0')
|
||||
const containerId = ref('0')
|
||||
|
||||
const server = computed(() => getServerById(props.serverId))
|
||||
const isOnline = computed(() => server.value?.online ?? false)
|
||||
|
||||
// Gestion de la navigation dans les containers
|
||||
function handleNavigate(newContainerId: string) {
|
||||
containerId.value = newContainerId
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchServers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="server-tab-content">
|
||||
<!-- Header avec nom du serveur et état -->
|
||||
<header class="server-header">
|
||||
<div class="header-info">
|
||||
<h1 class="server-name">{{ server?.friendly_name || 'Media Server' }}</h1>
|
||||
<p v-if="server?.model_name" class="server-model">{{ server.model_name }}</p>
|
||||
</div>
|
||||
<div class="header-badges">
|
||||
<span v-if="isOnline" class="online-badge">ONLINE</span>
|
||||
<span v-else class="offline-badge">OFFLINE</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Browser de médias -->
|
||||
<div class="browser-section">
|
||||
<MediaBrowser
|
||||
v-if="isOnline"
|
||||
:server-id="serverId"
|
||||
:container-id="containerId"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<div v-else class="offline-message">
|
||||
<p>Ce serveur est actuellement hors ligne</p>
|
||||
<button class="btn btn-secondary" @click="fetchServers(true)">Actualiser</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.server-tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.server-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.server-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.online-badge,
|
||||
.offline-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.online-badge {
|
||||
background: var(--status-playing);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.offline-badge {
|
||||
background: var(--status-offline);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Section browser */
|
||||
.browser-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-xl);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.offline-message p {
|
||||
color: var(--status-offline);
|
||||
font-size: var(--text-lg);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive - Mobile portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.server-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.browser-section {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - 800x600 landscape */
|
||||
@media (min-width: 600px) and (max-width: 1024px) and (orientation: landscape) {
|
||||
.server-header {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.browser-section {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
/* Large desktop */
|
||||
@media (min-width: 1200px) {
|
||||
.browser-section {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
222
pmoapp/webapp/src/composables/useMediaServers.ts
Normal file
222
pmoapp/webapp/src/composables/useMediaServers.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Composable pour gérer les media servers
|
||||
* Architecture simple : l'API est la source de vérité, SSE invalide le cache
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../services/pmocontrol/api'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
id: string
|
||||
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
|
||||
}
|
||||
|
||||
const CACHE_DURATION_MS = 2000
|
||||
|
||||
// Connecter SSE une seule fois
|
||||
let sseConnected = false
|
||||
function ensureSSEConnected() {
|
||||
if (sseConnected) return
|
||||
|
||||
sse.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,
|
||||
friendly_name: event.friendly_name,
|
||||
model_name: event.model_name,
|
||||
online: true,
|
||||
}
|
||||
serversCache.value.set(serverId, server)
|
||||
|
||||
// Fetch la liste complète pour avoir les bonnes infos
|
||||
// On ne le fait pas ici car on pourrait déclencher trop de requêtes
|
||||
// La liste se mettra à jour au prochain refresh automatique
|
||||
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) {
|
||||
existingServer.online = false
|
||||
serversCache.value.set(serverId, existingServer)
|
||||
}
|
||||
|
||||
// Invalider tout le cache browse de ce serveur
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
keysToDelete.forEach(key => browseCache.value.delete(key))
|
||||
break
|
||||
|
||||
case 'global_updated':
|
||||
// Invalider tout le cache de ce serveur
|
||||
console.log(`[useMediaServers] GlobalUpdated pour ${serverId}`)
|
||||
const globalKeysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
globalKeysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
globalKeysToDelete.forEach(key => browseCache.value.delete(key))
|
||||
break
|
||||
|
||||
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}`
|
||||
browseCache.value.delete(key)
|
||||
})
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
sseConnected = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable principal pour gérer les media servers
|
||||
*/
|
||||
export function useMediaServers() {
|
||||
ensureSSEConnected()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters computed
|
||||
const allServers = computed(() => Array.from(serversCache.value.values()))
|
||||
const onlineServers = computed(() => allServers.value.filter(s => s.online))
|
||||
|
||||
// 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()
|
||||
|
||||
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)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Browse container (avec cache automatique)
|
||||
async function browseContainer(serverId: string, containerId: string, useCache = true) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
|
||||
// Vérifier le cache
|
||||
if (useCache && browseCache.value.has(key)) {
|
||||
return browseCache.value.get(key)!
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const data = await api.browseContainer(serverId, containerId)
|
||||
|
||||
// Mettre en cache
|
||||
browseCache.value.set(key, data)
|
||||
|
||||
return data
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur browse container'
|
||||
console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, 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}`
|
||||
return browseCache.value.get(key)
|
||||
}
|
||||
|
||||
// Breadcrumb path management
|
||||
function setPath(path: BreadcrumbItem[]) {
|
||||
currentPath.value = path
|
||||
}
|
||||
|
||||
function clearPath() {
|
||||
currentPath.value = []
|
||||
}
|
||||
|
||||
// Invalidation du cache
|
||||
function invalidateCache(serverId: string, containerId?: string) {
|
||||
if (containerId) {
|
||||
// Invalider un container spécifique
|
||||
const key = `${serverId}/${containerId}`
|
||||
browseCache.value.delete(key)
|
||||
} else {
|
||||
// Invalider tous les containers d'un serveur
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
keysToDelete.forEach(key => browseCache.value.delete(key))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// État
|
||||
loading,
|
||||
error,
|
||||
currentPath,
|
||||
// Getters
|
||||
allServers,
|
||||
onlineServers,
|
||||
getServerById,
|
||||
getBrowseCached,
|
||||
// Actions
|
||||
fetchServers,
|
||||
browseContainer,
|
||||
setPath,
|
||||
clearPath,
|
||||
invalidateCache
|
||||
}
|
||||
}
|
||||
356
pmoapp/webapp/src/composables/useRenderers.ts
Normal file
356
pmoapp/webapp/src/composables/useRenderers.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Composable pour gérer les renderers.
|
||||
* Le ControlPoint est la seule source de vérité :
|
||||
* - Les snapshots complets proviennent de /renderers/{id}/full
|
||||
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
||||
*/
|
||||
import { ref, reactive, computed, type Ref } from 'vue'
|
||||
import { api } from '../services/pmocontrol/api'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
QueueSnapshot,
|
||||
AttachedPlaylistInfo,
|
||||
FullRendererSnapshot,
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
interface RendererSnapshotState {
|
||||
snapshots: Map<string, FullRendererSnapshot>
|
||||
lastSnapshotAt: Map<string, number>
|
||||
lastEventAt: Map<string, number>
|
||||
loadingIds: Set<string>
|
||||
selectedRendererId: string | null
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let sseConnected = false
|
||||
function ensureSSEConnected() {
|
||||
if (sseConnected) return
|
||||
|
||||
sse.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,
|
||||
protocol: 'upnp', // Valeur par défaut, sera mise à jour par le fetch
|
||||
capabilities: {
|
||||
has_avtransport: false,
|
||||
has_avtransport_set_next: false,
|
||||
has_rendering_control: false,
|
||||
has_connection_manager: false,
|
||||
has_linkplay_http: false,
|
||||
has_arylic_tcp: false,
|
||||
has_oh_playlist: false,
|
||||
has_oh_volume: false,
|
||||
has_oh_info: false,
|
||||
has_oh_time: false,
|
||||
has_oh_radio: false,
|
||||
},
|
||||
online: true,
|
||||
}
|
||||
renderersCache.value.set(rendererId, renderer)
|
||||
|
||||
// Fetch la liste complète pour avoir les bonnes infos
|
||||
void fetchRenderers(true)
|
||||
|
||||
// Fetch le snapshot complet pour ce renderer
|
||||
void fetchRendererSnapshot(rendererId, { force: true })
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
renderer.online = false
|
||||
renderersCache.value.set(rendererId, renderer)
|
||||
}
|
||||
|
||||
// Supprimer le snapshot (il n'est plus valide)
|
||||
snapshotState.snapshots.delete(rendererId)
|
||||
snapshotState.lastSnapshotAt.delete(rendererId)
|
||||
snapshotState.lastEventAt.delete(rendererId)
|
||||
return
|
||||
}
|
||||
|
||||
// Pour les autres événements, comportement existant
|
||||
snapshotState.lastEventAt.set(rendererId, timestamp)
|
||||
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
|
||||
if (!snapshotState.snapshots.has(rendererId) || timestamp > lastSnapshot) {
|
||||
void fetchRendererSnapshot(rendererId, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
sseConnected = true
|
||||
}
|
||||
|
||||
const allRenderers = computed(() => Array.from(renderersCache.value.values()))
|
||||
const onlineRenderers = computed(() => allRenderers.value.filter((r) => r.online))
|
||||
const allSnapshots = computed(() => Array.from(snapshotState.snapshots.values()))
|
||||
const playingRenderers = computed(() =>
|
||||
allSnapshots.value
|
||||
.filter((snapshot) => snapshot.state.transport_state === 'PLAYING')
|
||||
.map((snapshot) => snapshot.state),
|
||||
)
|
||||
|
||||
function getRendererById(id: string) {
|
||||
return renderersCache.value.get(id)
|
||||
}
|
||||
|
||||
function getSnapshotById(id: string) {
|
||||
return snapshotState.snapshots.get(id) ?? null
|
||||
}
|
||||
|
||||
function getStateById(id: string): RendererState | null {
|
||||
return snapshotState.snapshots.get(id)?.state ?? null
|
||||
}
|
||||
|
||||
function getQueueById(id: string): QueueSnapshot | null {
|
||||
return snapshotState.snapshots.get(id)?.queue ?? null
|
||||
}
|
||||
|
||||
function getBindingById(id: string): AttachedPlaylistInfo | null {
|
||||
return snapshotState.snapshots.get(id)?.binding ?? null
|
||||
}
|
||||
|
||||
function isSnapshotLoading(id: string) {
|
||||
return snapshotState.loadingIds.has(id)
|
||||
}
|
||||
|
||||
function selectRenderer(id: string | null) {
|
||||
snapshotState.selectedRendererId = id
|
||||
}
|
||||
|
||||
async function fetchRenderers(force = false) {
|
||||
ensureSSEConnected()
|
||||
|
||||
const now = Date.now()
|
||||
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRendererSnapshot(rendererId: string, opts?: { force?: boolean }) {
|
||||
ensureSSEConnected()
|
||||
const force = opts?.force ?? false
|
||||
const hasSnapshot = snapshotState.snapshots.has(rendererId)
|
||||
|
||||
if (!force && hasSnapshot) {
|
||||
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
|
||||
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0
|
||||
if (lastEvent <= lastSnapshot) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshotState.loadingIds.has(rendererId)) {
|
||||
return
|
||||
}
|
||||
|
||||
snapshotState.loadingIds.add(rendererId)
|
||||
try {
|
||||
const snapshot = await api.getRendererFullSnapshot(rendererId)
|
||||
snapshotState.snapshots.set(rendererId, snapshot)
|
||||
snapshotState.lastSnapshotAt.set(rendererId, Date.now())
|
||||
} catch (err) {
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err)
|
||||
} finally {
|
||||
snapshotState.loadingIds.delete(rendererId)
|
||||
}
|
||||
}
|
||||
|
||||
// Transport controls
|
||||
async function play(id: string) {
|
||||
await api.play(id)
|
||||
}
|
||||
|
||||
async function resumeOrPlayFromQueue(id: string) {
|
||||
const snapshot = snapshotState.snapshots.get(id)
|
||||
if (!snapshot) {
|
||||
throw new Error(`Renderer ${id} non trouvé`)
|
||||
}
|
||||
|
||||
const state = snapshot.state
|
||||
if (state.transport_state === 'PAUSED') {
|
||||
return play(id)
|
||||
}
|
||||
|
||||
if (
|
||||
['STOPPED', 'NO_MEDIA'].includes(state.transport_state) &&
|
||||
snapshot.queue.items.length > 0
|
||||
) {
|
||||
return api.resume(id)
|
||||
}
|
||||
|
||||
throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.')
|
||||
}
|
||||
|
||||
async function pause(id: string) {
|
||||
await api.pause(id)
|
||||
}
|
||||
|
||||
async function stop(id: string) {
|
||||
await api.stop(id)
|
||||
}
|
||||
|
||||
async function next(id: string) {
|
||||
await api.next(id)
|
||||
}
|
||||
|
||||
// Volume controls
|
||||
async function setVolume(id: string, volume: number) {
|
||||
await api.setVolume(id, volume)
|
||||
}
|
||||
|
||||
async function volumeUp(id: string) {
|
||||
await api.volumeUp(id)
|
||||
}
|
||||
|
||||
async function volumeDown(id: string) {
|
||||
await api.volumeDown(id)
|
||||
}
|
||||
|
||||
async function toggleMute(id: string) {
|
||||
await api.toggleMute(id)
|
||||
}
|
||||
|
||||
// Playlist binding
|
||||
async function attachPlaylist(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
options?: { autoPlay?: boolean },
|
||||
) {
|
||||
await api.attachPlaylist(rendererId, serverId, containerId, options?.autoPlay ?? false)
|
||||
}
|
||||
|
||||
async function detachPlaylist(rendererId: string) {
|
||||
await api.detachPlaylist(rendererId)
|
||||
}
|
||||
|
||||
async function attachAndPlayPlaylist(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
) {
|
||||
await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true })
|
||||
}
|
||||
|
||||
// Queue content
|
||||
async function playContent(rendererId: string, serverId: string, objectId: string) {
|
||||
await api.playContent(rendererId, serverId, objectId)
|
||||
}
|
||||
|
||||
async function addToQueue(rendererId: string, serverId: string, objectId: string) {
|
||||
await api.addToQueue(rendererId, serverId, objectId)
|
||||
}
|
||||
|
||||
export function useRenderers() {
|
||||
ensureSSEConnected()
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
// Collections
|
||||
allRenderers,
|
||||
onlineRenderers,
|
||||
playingRenderers,
|
||||
// Accessors
|
||||
getRendererById,
|
||||
getSnapshotById,
|
||||
getStateById,
|
||||
getQueueById,
|
||||
getBindingById,
|
||||
isSnapshotLoading,
|
||||
selectRenderer,
|
||||
snapshotState,
|
||||
// Fetchers
|
||||
fetchRenderers,
|
||||
fetchRendererSnapshot,
|
||||
// Transport controls
|
||||
play,
|
||||
resumeOrPlayFromQueue,
|
||||
pause,
|
||||
stop,
|
||||
next,
|
||||
// Volume controls
|
||||
setVolume,
|
||||
volumeUp,
|
||||
volumeDown,
|
||||
toggleMute,
|
||||
// Playlist binding
|
||||
attachPlaylist,
|
||||
detachPlaylist,
|
||||
attachAndPlayPlaylist,
|
||||
// Queue content
|
||||
playContent,
|
||||
addToQueue,
|
||||
}
|
||||
}
|
||||
|
||||
export function useRenderer(rendererId: Ref<string>) {
|
||||
ensureSSEConnected()
|
||||
|
||||
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)
|
||||
|
||||
async function refresh(force = true) {
|
||||
await Promise.all([
|
||||
fetchRenderers(force),
|
||||
fetchRendererSnapshot(rendererId.value, { force: true }),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
renderer,
|
||||
snapshot,
|
||||
state,
|
||||
queue,
|
||||
binding,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
382
pmoapp/webapp/src/composables/useTabs.ts
Normal file
382
pmoapp/webapp/src/composables/useTabs.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Composable pour gérer les onglets dynamiques de l'interface unifiée.
|
||||
* Onglets renderer auto-générés depuis la liste des renderers online.
|
||||
* Onglets server ouverts manuellement via le drawer (fermables).
|
||||
*/
|
||||
import { reactive, computed, watch, onMounted, type Component } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { Radio, Server } from 'lucide-vue-next'
|
||||
import type { RendererSummary, MediaServerSummary } from '../services/pmocontrol/types'
|
||||
|
||||
export interface Tab {
|
||||
id: string // "renderer-{id}", "server-{id}"
|
||||
type: 'renderer' | 'server'
|
||||
title: string // Nom affiché (tronqué sur mobile)
|
||||
fullTitle: string // Nom complet (pour tooltip)
|
||||
icon: Component
|
||||
metadata?: {
|
||||
rendererId?: string
|
||||
serverId?: string
|
||||
}
|
||||
closeable: boolean // renderer: false (auto-géré), server: true (manuel)
|
||||
}
|
||||
|
||||
interface TabsState {
|
||||
tabs: Tab[]
|
||||
activeTabId: string
|
||||
tabHistory: string[] // Pour back/forward navigation
|
||||
}
|
||||
|
||||
const MAX_TABS = 12 // Augmenté car onglets auto-générés
|
||||
const STORAGE_KEY = 'pmo-tabs-state'
|
||||
|
||||
// État global partagé entre toutes les instances du composable
|
||||
const state = reactive<TabsState>({
|
||||
tabs: [],
|
||||
activeTabId: '',
|
||||
tabHistory: [],
|
||||
})
|
||||
|
||||
// Flag pour éviter les boucles de sauvegarde
|
||||
let isRestoringFromStorage = false
|
||||
|
||||
/**
|
||||
* Retourne le titre complet sans troncature
|
||||
* Note: On laisse le CSS gérer l'overflow avec ellipsis pour un affichage stable
|
||||
*/
|
||||
function truncateTitle(title: string): string {
|
||||
// Retourner le titre complet, le CSS gère l'ellipsis de façon stable
|
||||
return title
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde l'état dans localStorage
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaure l'état depuis localStorage
|
||||
* Note: Restaure uniquement les onglets server (les renderer tabs seront auto-générés)
|
||||
*/
|
||||
function restoreFromLocalStorage() {
|
||||
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
|
||||
const serverTabs = (savedState.tabs || [])
|
||||
.filter((tab: Tab) => tab.type === 'server')
|
||||
.map((tab: Tab) => ({
|
||||
...tab,
|
||||
icon: Server,
|
||||
closeable: true,
|
||||
fullTitle: tab.fullTitle || tab.title, // Fallback si fullTitle n'existe pas
|
||||
}))
|
||||
|
||||
// Ajouter les tabs server restaurés (les renderer tabs seront ajoutés par syncWithRenderers)
|
||||
state.tabs.push(...serverTabs)
|
||||
|
||||
state.activeTabId = savedState.activeTabId || ''
|
||||
state.tabHistory = savedState.tabHistory || []
|
||||
|
||||
// Vérifier que l'onglet actif existe toujours (sera validé après syncWithRenderers)
|
||||
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
|
||||
state.activeTabId = ''
|
||||
}
|
||||
|
||||
isRestoringFromStorage = false
|
||||
} catch (error) {
|
||||
console.error('[useTabs] Erreur restauration localStorage:', error)
|
||||
isRestoringFromStorage = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve un onglet par son ID
|
||||
*/
|
||||
function findTab(tabId: string): Tab | undefined {
|
||||
return state.tabs.find((t) => t.id === tabId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre un nouvel onglet ou active un onglet existant
|
||||
*/
|
||||
function openTab(newTab: Omit<Tab, 'id' | 'fullTitle'> & { id?: string; fullTitle?: string }): string {
|
||||
// Générer un ID si non fourni
|
||||
const tabId =
|
||||
newTab.id ||
|
||||
(newTab.type === 'renderer'
|
||||
? `renderer-${newTab.metadata?.rendererId}`
|
||||
: `server-${newTab.metadata?.serverId}`)
|
||||
|
||||
// Si l'onglet existe déjà, on le sélectionne
|
||||
const existingTab = findTab(tabId)
|
||||
if (existingTab) {
|
||||
switchTab(tabId)
|
||||
return tabId
|
||||
}
|
||||
|
||||
// Vérifier la limite max
|
||||
if (state.tabs.length >= MAX_TABS) {
|
||||
console.warn(`[useTabs] Limite max de ${MAX_TABS} onglets atteinte`)
|
||||
return state.activeTabId
|
||||
}
|
||||
|
||||
// Créer le nouvel onglet
|
||||
const tab: Tab = {
|
||||
id: tabId,
|
||||
type: newTab.type,
|
||||
title: truncateTitle(newTab.title),
|
||||
fullTitle: newTab.fullTitle || newTab.title, // Utiliser fullTitle si fourni, sinon title
|
||||
icon: newTab.icon,
|
||||
metadata: newTab.metadata,
|
||||
closeable: newTab.closeable !== false, // true par défaut sauf si explicitement false
|
||||
}
|
||||
|
||||
state.tabs.push(tab)
|
||||
switchTab(tabId)
|
||||
|
||||
return tabId
|
||||
}
|
||||
|
||||
/**
|
||||
* Ferme un onglet (uniquement les onglets server manuels)
|
||||
* Les onglets renderer sont auto-gérés et ne peuvent pas être fermés manuellement
|
||||
*/
|
||||
function closeTab(tabId: string) {
|
||||
const tab = findTab(tabId)
|
||||
if (!tab) return
|
||||
|
||||
// Ne fermer que les onglets server (closeable = true)
|
||||
// Les renderer tabs sont auto-gérés par syncWithRenderers
|
||||
if (!tab.closeable || tab.type === 'renderer') {
|
||||
console.warn('[useTabs] Impossible de fermer un onglet renderer (auto-géré)')
|
||||
return
|
||||
}
|
||||
|
||||
const tabIndex = state.tabs.findIndex((t) => t.id === tabId)
|
||||
if (tabIndex === -1) return
|
||||
|
||||
// Supprimer l'onglet
|
||||
state.tabs.splice(tabIndex, 1)
|
||||
|
||||
// Supprimer de l'historique
|
||||
state.tabHistory = state.tabHistory.filter((id) => id !== tabId)
|
||||
|
||||
// Si c'était l'onglet actif, basculer vers le précédent dans l'historique
|
||||
if (state.activeTabId === tabId) {
|
||||
// Chercher le précédent dans l'historique qui existe encore
|
||||
const previousTab = state.tabHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((id) => state.tabs.some((t) => t.id === id))
|
||||
|
||||
if (previousTab) {
|
||||
switchTab(previousTab)
|
||||
} else {
|
||||
// Fallback sur le premier onglet disponible (ou vide)
|
||||
const firstTab = state.tabs[0]
|
||||
if (firstTab) {
|
||||
switchTab(firstTab.id)
|
||||
} else {
|
||||
state.activeTabId = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change l'onglet actif
|
||||
*/
|
||||
function switchTab(tabId: string) {
|
||||
const tab = findTab(tabId)
|
||||
if (!tab) {
|
||||
console.warn(`[useTabs] Onglet ${tabId} introuvable`)
|
||||
return
|
||||
}
|
||||
|
||||
state.activeTabId = tabId
|
||||
|
||||
// Ajouter à l'historique (en évitant les doublons consécutifs)
|
||||
if (state.tabHistory[state.tabHistory.length - 1] !== tabId) {
|
||||
state.tabHistory.push(tabId)
|
||||
|
||||
// Limiter la taille de l'historique
|
||||
if (state.tabHistory.length > 20) {
|
||||
state.tabHistory.shift()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Onglet suivant (pour swipe gesture)
|
||||
*/
|
||||
function nextTab() {
|
||||
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId)
|
||||
const nextIndex = (currentIndex + 1) % state.tabs.length
|
||||
const nextTabObj = state.tabs[nextIndex]
|
||||
if (nextTabObj) switchTab(nextTabObj.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Onglet précédent (pour swipe gesture)
|
||||
*/
|
||||
function previousTab() {
|
||||
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId)
|
||||
const previousIndex = currentIndex === 0 ? state.tabs.length - 1 : currentIndex - 1
|
||||
const prevTabObj = state.tabs[previousIndex]
|
||||
if (prevTabObj) switchTab(prevTabObj.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronise les onglets avec la liste des renderers online
|
||||
* Les renderer tabs sont automatiquement créés/supprimés selon l'état online
|
||||
*/
|
||||
function syncWithRenderers(renderers: RendererSummary[]) {
|
||||
const onlineRenderers = renderers.filter((r) => r.online)
|
||||
|
||||
// Extraire les tabs renderer actuels
|
||||
const currentRendererTabs = state.tabs.filter((t) => t.type === 'renderer')
|
||||
|
||||
// IDs des renderers online
|
||||
const onlineRendererIds = new Set(onlineRenderers.map((r) => `renderer-${r.id}`))
|
||||
|
||||
// Supprimer les tabs des renderers qui ne sont plus online
|
||||
const renderersToRemove = currentRendererTabs.filter((t) => !onlineRendererIds.has(t.id))
|
||||
renderersToRemove.forEach((tab) => {
|
||||
const index = state.tabs.findIndex((t) => t.id === tab.id)
|
||||
if (index !== -1) state.tabs.splice(index, 1)
|
||||
state.tabHistory = state.tabHistory.filter((id) => id !== tab.id)
|
||||
})
|
||||
|
||||
// Ajouter les nouveaux renderers online
|
||||
const currentRendererIds = new Set(currentRendererTabs.map((t) => t.id))
|
||||
onlineRenderers.forEach((renderer) => {
|
||||
const tabId = `renderer-${renderer.id}`
|
||||
if (!currentRendererIds.has(tabId)) {
|
||||
const newTab: Tab = {
|
||||
id: tabId,
|
||||
type: 'renderer',
|
||||
title: truncateTitle(renderer.friendly_name),
|
||||
fullTitle: renderer.friendly_name,
|
||||
icon: Radio,
|
||||
metadata: { rendererId: renderer.id },
|
||||
closeable: false, // renderer tabs ne sont pas fermables manuellement
|
||||
}
|
||||
state.tabs.unshift(newTab) // Ajouter au début
|
||||
}
|
||||
})
|
||||
|
||||
// Vérifier que l'onglet actif existe toujours
|
||||
if (state.activeTabId && !state.tabs.find((t) => t.id === state.activeTabId)) {
|
||||
// Basculer vers le premier onglet disponible
|
||||
const firstTab = state.tabs[0]
|
||||
if (firstTab) {
|
||||
state.activeTabId = firstTab.id
|
||||
} else {
|
||||
state.activeTabId = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Si aucun onglet actif et qu'il y a des onglets, sélectionner le premier
|
||||
if (!state.activeTabId && state.tabs.length > 0) {
|
||||
const firstTab = state.tabs[0]
|
||||
if (firstTab) {
|
||||
state.activeTabId = firstTab.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre un onglet server (manuel)
|
||||
*/
|
||||
function openServer(server: MediaServerSummary | undefined) {
|
||||
if (!server) return ''
|
||||
|
||||
return openTab({
|
||||
type: 'server',
|
||||
title: server.friendly_name,
|
||||
icon: Server,
|
||||
metadata: { serverId: server.id },
|
||||
closeable: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable principal
|
||||
*/
|
||||
export function useTabs() {
|
||||
// Watch pour sauvegarde automatique (uniquement les server tabs)
|
||||
watch(
|
||||
() => [state.tabs, state.activeTabId, state.tabHistory],
|
||||
() => {
|
||||
saveToLocalStorage()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// Restaurer au montage (uniquement les server tabs)
|
||||
onMounted(() => {
|
||||
// Restaurer seulement si pas déjà fait
|
||||
if (state.tabs.filter((t) => t.type === 'server').length === 0) {
|
||||
restoreFromLocalStorage()
|
||||
}
|
||||
})
|
||||
|
||||
// Détection de la largeur d'écran pour le mode compact
|
||||
// Mode compact sur les écrans < 900px (tablettes et mobiles)
|
||||
const isNarrowScreen = useMediaQuery('(max-width: 900px)')
|
||||
|
||||
// Computed properties
|
||||
const activeTab = computed(() => findTab(state.activeTabId) || state.tabs[0] || null)
|
||||
const hasMultipleTabs = computed(() => state.tabs.length > 1)
|
||||
const canAddTab = computed(() => state.tabs.length < MAX_TABS)
|
||||
const isEmpty = computed(() => state.tabs.length === 0)
|
||||
const compactMode = computed(() => isNarrowScreen.value)
|
||||
|
||||
return {
|
||||
// State
|
||||
tabs: computed(() => state.tabs),
|
||||
activeTabId: computed(() => state.activeTabId),
|
||||
activeTab,
|
||||
tabHistory: computed(() => state.tabHistory),
|
||||
hasMultipleTabs,
|
||||
canAddTab,
|
||||
isEmpty,
|
||||
compactMode,
|
||||
|
||||
// Actions
|
||||
syncWithRenderers, // Nouvelle fonction clé pour auto-sync
|
||||
openTab,
|
||||
closeTab,
|
||||
switchTab,
|
||||
nextTab,
|
||||
previousTab,
|
||||
openServer,
|
||||
findTab,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,45 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
import "./style.css";
|
||||
// Service SSE (les composables se connectent automatiquement)
|
||||
import { sse } from "./services/pmocontrol/sse";
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
// Store UI (garde UIStore pour les notifications et état UI global)
|
||||
import { useUIStore } from "./stores/ui";
|
||||
|
||||
// Styles
|
||||
import "./style.css";
|
||||
import "./assets/styles/variables.css";
|
||||
import "./assets/styles/pmocontrol.css";
|
||||
import "./assets/styles/glass-theme.css";
|
||||
|
||||
// Créer l'application Vue
|
||||
const app = createApp(App);
|
||||
|
||||
// Créer et installer Pinia
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
// 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é");
|
||||
|
||||
@@ -1,20 +1,89 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HelloWorld from "../components/HelloWorld.vue";
|
||||
|
||||
// PMOControl Unified View (nouvelle interface unifiée)
|
||||
import UnifiedControlView from "../views/UnifiedControlView.vue";
|
||||
|
||||
// PMOControl Views (anciennes vues - conservées pour fallback)
|
||||
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";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: HelloWorld },
|
||||
{ path: "/logs", name: "logs", component: LogView },
|
||||
{ path: "/covers-cache", name: "covers-cache", component: CoverCacheManager },
|
||||
{ path: "/audio-cache", name: "audio-cache", component: AudioCacheManager },
|
||||
{ path: "/upnp", name: "upnp", component: UpnpExplorer },
|
||||
{ path: "/api-dashboard", name: "api-dashboard", component: APIDashboard },
|
||||
{ path: "/radio-paradise", name: "radio-paradise", component: RadioParadiseExplorer },
|
||||
// PMOControl Unified Interface (nouvelle interface unifiée avec onglets)
|
||||
{
|
||||
path: "/",
|
||||
name: "Control",
|
||||
component: UnifiedControlView,
|
||||
},
|
||||
|
||||
// Anciennes routes (conservées sous /legacy pour fallback)
|
||||
{
|
||||
path: "/legacy",
|
||||
name: "Dashboard",
|
||||
component: DashboardView,
|
||||
},
|
||||
{
|
||||
path: "/legacy/renderer/:id",
|
||||
name: "Renderer",
|
||||
component: RendererView,
|
||||
},
|
||||
{
|
||||
path: "/legacy/server/:serverId",
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
export interface AudioCacheMetadata {
|
||||
origin_url?: string;
|
||||
local_passthrough?: boolean;
|
||||
local_source_path?: string;
|
||||
source_size?: number;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
@@ -19,11 +22,14 @@ export interface AudioCacheMetadata {
|
||||
bitrate?: number;
|
||||
channels?: number;
|
||||
conversion?: ConversionInfo;
|
||||
cover_pk?: string;
|
||||
cover_url?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AudioCacheEntry {
|
||||
pk: string;
|
||||
lazy_pk?: string | null;
|
||||
id: string | null;
|
||||
hits: number;
|
||||
last_used: string | null;
|
||||
@@ -38,7 +44,8 @@ export interface ConversionInfo {
|
||||
}
|
||||
|
||||
export interface AddTrackRequest {
|
||||
url: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
collection?: string;
|
||||
}
|
||||
|
||||
@@ -125,10 +132,12 @@ export async function getDownloadStatus(pk: string): Promise<DownloadStatus> {
|
||||
/**
|
||||
* Ajoute une nouvelle piste au cache depuis une URL
|
||||
*/
|
||||
export async function addTrack(url: string, collection?: string): Promise<AddTrackResponse> {
|
||||
const body: AddTrackRequest = { url };
|
||||
if (collection) {
|
||||
body.collection = collection;
|
||||
export async function addTrack(body: AddTrackRequest): Promise<AddTrackResponse> {
|
||||
if ((!body.url || body.url.trim().length === 0) && (!body.path || body.path.trim().length === 0)) {
|
||||
throw new Error("Either a URL or a local path must be provided");
|
||||
}
|
||||
if (body.url && body.path) {
|
||||
throw new Error("Provide either a URL or a local path, not both");
|
||||
}
|
||||
|
||||
const response = await fetch("/api/audio", {
|
||||
@@ -228,3 +237,26 @@ export function formatSampleRate(sampleRate?: number): string {
|
||||
if (!sampleRate) return "Unknown";
|
||||
return `${(sampleRate / 1000).toFixed(1)} kHz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère l'URL de la cover d'une piste
|
||||
* Priorité : cover_pk (cache) > cover_url (externe) > undefined
|
||||
*/
|
||||
export function getCoverUrl(metadata?: AudioCacheMetadata | null, size?: number): string | undefined {
|
||||
if (!metadata) return undefined;
|
||||
|
||||
// Priorité 1 : cover en cache via cover_pk
|
||||
if (metadata.cover_pk) {
|
||||
if (size) {
|
||||
return `/covers/image/${metadata.cover_pk}/${size}`;
|
||||
}
|
||||
return `/covers/image/${metadata.cover_pk}`;
|
||||
}
|
||||
|
||||
// Priorité 2 : cover externe via cover_url
|
||||
if (metadata.cover_url) {
|
||||
return metadata.cover_url;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -192,3 +192,42 @@ export function getImageUrl(pk: string, size?: number): string {
|
||||
}
|
||||
return `/covers/image/${pk}`;
|
||||
}
|
||||
|
||||
export function getJpegUrl(pk: string, size?: number): string {
|
||||
if (size) {
|
||||
return `/covers/jpeg/${pk}/${size}`;
|
||||
}
|
||||
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>`;
|
||||
|
||||
/**
|
||||
* 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)}`;
|
||||
}
|
||||
|
||||
55
pmoapp/webapp/src/services/openhomePlaylist.ts
Normal file
55
pmoapp/webapp/src/services/openhomePlaylist.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
OpenHomePlaylistAddRequest,
|
||||
OpenHomePlaylistSnapshot,
|
||||
} from '@/services/pmocontrol/types'
|
||||
|
||||
const API_BASE = '/api/control'
|
||||
|
||||
export async function getOpenHomePlaylist(rendererId: string): Promise<OpenHomePlaylistSnapshot> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist`,
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export async function clearOpenHomePlaylist(rendererId: string): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/clear`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to clear OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function addOpenHomeTrack(
|
||||
rendererId: string,
|
||||
payload: OpenHomePlaylistAddRequest,
|
||||
): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/add`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to add track to OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function playOpenHomeTrack(rendererId: string, trackId: number): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(
|
||||
rendererId,
|
||||
)}/oh/playlist/play/${encodeURIComponent(trackId.toString())}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to play OpenHome track ${trackId}: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
163
pmoapp/webapp/src/services/playlists.ts
Normal file
163
pmoapp/webapp/src/services/playlists.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
export interface PlaylistSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
role: string;
|
||||
persistent: boolean;
|
||||
cover_pk?: string | null;
|
||||
cover_url?: string | null;
|
||||
track_count: number;
|
||||
max_size?: number | null;
|
||||
default_ttl_secs?: number | null;
|
||||
last_change: string;
|
||||
}
|
||||
|
||||
import type { AudioCacheMetadata } from "./audioCache";
|
||||
|
||||
export interface PlaylistTrack {
|
||||
cache_pk: string;
|
||||
added_at: string;
|
||||
ttl_secs?: number | null;
|
||||
lazy_pk?: string | null;
|
||||
metadata?: AudioCacheMetadata | null;
|
||||
cover_url?: string | null;
|
||||
cover_source?: string | null;
|
||||
}
|
||||
|
||||
export interface PlaylistDetail {
|
||||
summary: PlaylistSummary;
|
||||
tracks: PlaylistTrack[];
|
||||
}
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CreatePlaylistPayload {
|
||||
id: string;
|
||||
title?: string;
|
||||
role?: string;
|
||||
cover_pk?: string;
|
||||
persistent?: boolean;
|
||||
max_size?: number;
|
||||
default_ttl_secs?: number;
|
||||
}
|
||||
|
||||
export interface UpdatePlaylistPayload {
|
||||
title?: string;
|
||||
role?: string;
|
||||
max_size?: number | null;
|
||||
default_ttl_secs?: number | null;
|
||||
cover_pk?: string | null;
|
||||
}
|
||||
|
||||
export interface AddTracksPayload {
|
||||
cache_pks: string[];
|
||||
ttl_secs?: number;
|
||||
lazy?: boolean;
|
||||
}
|
||||
|
||||
async function parseJsonOrThrow<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const error: ApiErrorPayload = await response.json();
|
||||
if (error?.message) {
|
||||
message = error.message;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parsing errors and keep default message
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function ensureSuccess(response: Response): Promise<void> {
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const error: ApiErrorPayload = await response.json();
|
||||
if (error?.message) {
|
||||
message = error.message;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPlaylists(): Promise<PlaylistSummary[]> {
|
||||
const response = await fetch("/api/playlists");
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function getPlaylistDetail(id: string): Promise<PlaylistDetail> {
|
||||
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}`);
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function createPlaylist(body: CreatePlaylistPayload): Promise<PlaylistDetail> {
|
||||
const response = await fetch("/api/playlists", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function updatePlaylist(
|
||||
id: string,
|
||||
body: UpdatePlaylistPayload
|
||||
): Promise<PlaylistDetail> {
|
||||
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function deletePlaylist(id: string): Promise<void> {
|
||||
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await ensureSuccess(response);
|
||||
}
|
||||
|
||||
export async function addTracksToPlaylist(
|
||||
id: string,
|
||||
payload: AddTracksPayload
|
||||
): Promise<PlaylistDetail> {
|
||||
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}/tracks`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function flushPlaylist(id: string): Promise<PlaylistDetail> {
|
||||
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}/tracks`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function removeTrackFromPlaylist(id: string, cachePk: string): Promise<PlaylistDetail> {
|
||||
const response = await fetch(
|
||||
`/api/playlists/${encodeURIComponent(id)}/tracks/${encodeURIComponent(cachePk)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
302
pmoapp/webapp/src/services/pmocontrol/api.ts
Normal file
302
pmoapp/webapp/src/services/pmocontrol/api.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
// Client API REST pour PMOControl
|
||||
// Communique avec /api/control/*
|
||||
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
FullRendererSnapshot,
|
||||
QueueSnapshot,
|
||||
AttachedPlaylistInfo,
|
||||
MediaServerSummary,
|
||||
BrowseResponse,
|
||||
VolumeSetRequest,
|
||||
AttachPlaylistRequest,
|
||||
PlayContentRequest,
|
||||
SuccessResponse,
|
||||
ErrorResponse
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Client API REST pour le Control Point PMOMusic
|
||||
*/
|
||||
class PMOControlAPI {
|
||||
private readonly baseURL = '/api/control'
|
||||
|
||||
/**
|
||||
* Effectue une requête HTTP générique
|
||||
*/
|
||||
private async request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${path}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ErrorResponse = await response.json().catch(() => ({
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
}))
|
||||
throw new Error(error.error)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Liste tous les renderers découverts
|
||||
* GET /api/control/renderers
|
||||
*/
|
||||
async getRenderers(): Promise<RendererSummary[]> {
|
||||
return this.request<RendererSummary[]>('/renderers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'état détaillé d'un renderer
|
||||
* GET /api/control/renderers/{id}
|
||||
*/
|
||||
async getRendererState(id: string): Promise<RendererState> {
|
||||
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le snapshot complet d'un renderer
|
||||
* GET /api/control/renderers/{id}/full
|
||||
*/
|
||||
async getRendererFullSnapshot(id: string): Promise<FullRendererSnapshot> {
|
||||
return this.request<FullRendererSnapshot>(`/renderers/${encodeURIComponent(id)}/full`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la queue d'un renderer (avec current_index)
|
||||
* GET /api/control/renderers/{id}/queue
|
||||
*/
|
||||
async getQueue(id: string): Promise<QueueSnapshot> {
|
||||
return this.request<QueueSnapshot>(`/renderers/${encodeURIComponent(id)}/queue`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le binding playlist d'un renderer
|
||||
* GET /api/control/renderers/{id}/binding
|
||||
*/
|
||||
async getBinding(id: string): Promise<AttachedPlaylistInfo | null> {
|
||||
return this.request<AttachedPlaylistInfo | null>(`/renderers/${encodeURIComponent(id)}/binding`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTRÔLE TRANSPORT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Démarre la lecture sur un renderer
|
||||
* POST /api/control/renderers/{id}/play
|
||||
*/
|
||||
async play(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/play`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Met en pause la lecture sur un renderer
|
||||
* POST /api/control/renderers/{id}/pause
|
||||
*/
|
||||
async pause(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/pause`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrête la lecture sur un renderer
|
||||
* POST /api/control/renderers/{id}/stop
|
||||
*/
|
||||
async stop(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/stop`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprend la lecture depuis le morceau actuel de la queue
|
||||
* POST /api/control/renderers/{id}/resume
|
||||
*/
|
||||
async resume(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/resume`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Passe au morceau suivant dans la queue
|
||||
* POST /api/control/renderers/{id}/next
|
||||
*/
|
||||
async next(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/next`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Saute à un index spécifique dans la queue
|
||||
* POST /api/control/renderers/{id}/queue/seek
|
||||
*/
|
||||
async seekQueueIndex(id: string, index: number): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/queue/seek`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ index }),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTRÔLE VOLUME
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Définit le volume d'un renderer (0-100)
|
||||
* POST /api/control/renderers/{id}/volume/set
|
||||
*/
|
||||
async setVolume(id: string, volume: number): Promise<SuccessResponse> {
|
||||
const payload: VolumeSetRequest = { volume }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/set`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Augmente le volume de 5%
|
||||
* POST /api/control/renderers/{id}/volume/up
|
||||
*/
|
||||
async volumeUp(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/up`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Diminue le volume de 5%
|
||||
* POST /api/control/renderers/{id}/volume/down
|
||||
*/
|
||||
async volumeDown(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/down`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule le mute d'un renderer
|
||||
* POST /api/control/renderers/{id}/mute/toggle
|
||||
*/
|
||||
async toggleMute(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/mute/toggle`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYLIST BINDING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Attache la queue d'un renderer à une playlist d'un serveur
|
||||
* POST /api/control/renderers/{id}/binding/attach
|
||||
*/
|
||||
async attachPlaylist(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
autoPlay = false
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: AttachPlaylistRequest = {
|
||||
server_id: serverId,
|
||||
container_id: containerId,
|
||||
auto_play: autoPlay,
|
||||
}
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Détache la queue d'un renderer de sa playlist
|
||||
* POST /api/control/renderers/{id}/binding/detach
|
||||
*/
|
||||
async detachPlaylist(rendererId: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/detach`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUEUE CONTENT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Lire du contenu immédiatement (clear queue + enqueue + play)
|
||||
* POST /api/control/renderers/{id}/queue/play
|
||||
*/
|
||||
async playContent(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: PlayContentRequest = { server_id: serverId, object_id: objectId }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/queue/play`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter du contenu à la queue (sans démarrer la lecture)
|
||||
* POST /api/control/renderers/{id}/queue/add
|
||||
*/
|
||||
async addToQueue(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: PlayContentRequest = { server_id: serverId, object_id: objectId }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/queue/add`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEDIA SERVERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Liste tous les serveurs de médias découverts
|
||||
* GET /api/control/servers
|
||||
*/
|
||||
async getServers(): Promise<MediaServerSummary[]> {
|
||||
return this.request<MediaServerSummary[]>('/servers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse le contenu d'un container sur un serveur
|
||||
* GET /api/control/servers/{serverId}/containers/{containerId}
|
||||
*/
|
||||
async browseContainer(serverId: string, containerId: string): Promise<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const api = new PMOControlAPI()
|
||||
208
pmoapp/webapp/src/services/pmocontrol/sse.ts
Normal file
208
pmoapp/webapp/src/services/pmocontrol/sse.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// Service SSE (Server-Sent Events) pour PMOControl
|
||||
// Gère la connexion temps réel à /api/control/events
|
||||
|
||||
import type { RendererEventPayload, MediaServerEventPayload, UnifiedEventPayload } from './types'
|
||||
|
||||
type RendererEventCallback = (event: RendererEventPayload) => void
|
||||
type MediaServerEventCallback = (event: MediaServerEventPayload) => void
|
||||
type ConnectionCallback = (connected: boolean) => void
|
||||
|
||||
/**
|
||||
* Service SSE pour recevoir les événements du Control Point en temps réel
|
||||
*/
|
||||
export class PMOControlSSE {
|
||||
private eventSource: EventSource | null = null
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectDelay = 30000 // 30 secondes max
|
||||
private reconnectTimer: number | null = null
|
||||
|
||||
private rendererCallbacks: Set<RendererEventCallback> = new Set()
|
||||
private serverCallbacks: Set<MediaServerEventCallback> = new Set()
|
||||
private connectionCallbacks: Set<ConnectionCallback> = new Set()
|
||||
|
||||
private isConnected = false
|
||||
|
||||
/**
|
||||
* Connecte au flux SSE
|
||||
*/
|
||||
connect(): void {
|
||||
if (this.eventSource) {
|
||||
console.warn('[SSE] Connexion déjà active')
|
||||
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)
|
||||
}
|
||||
|
||||
this.eventSource.addEventListener('control', (e: MessageEvent) => {
|
||||
try {
|
||||
const event: UnifiedEventPayload = JSON.parse(e.data)
|
||||
this.handleEvent(event)
|
||||
} catch (error) {
|
||||
console.error('[SSE] Erreur parsing événement:', error)
|
||||
}
|
||||
})
|
||||
|
||||
this.eventSource.onerror = () => {
|
||||
console.error('[SSE] Erreur de connexion')
|
||||
this.isConnected = false
|
||||
this.notifyConnectionCallbacks(false)
|
||||
this.disconnect()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SSE] Erreur création EventSource:', error)
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déconnecte du flux SSE
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
if (this.eventSource) {
|
||||
console.log('[SSE] Déconnexion')
|
||||
this.eventSource.close()
|
||||
this.eventSource = null
|
||||
this.isConnected = false
|
||||
this.notifyConnectionCallbacks(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Programme une reconnexion avec backoff exponentiel
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
|
||||
// Backoff exponentiel: 1s, 2s, 4s, 8s, 16s, 30s (max)
|
||||
const delay = Math.min(
|
||||
1000 * Math.pow(2, this.reconnectAttempts - 1),
|
||||
this.maxReconnectDelay
|
||||
)
|
||||
|
||||
console.log(`[SSE] Reconnexion dans ${delay / 1000}s (tentative ${this.reconnectAttempts})`)
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch un événement aux callbacks appropriés
|
||||
*/
|
||||
private handleEvent(event: UnifiedEventPayload): void {
|
||||
if (event.category === 'renderer') {
|
||||
// Extraire le payload renderer (sans le champ category)
|
||||
const { category, ...rendererEvent } = event
|
||||
this.notifyRendererCallbacks(rendererEvent as RendererEventPayload)
|
||||
} else if (event.category === 'media_server') {
|
||||
// Extraire le payload server (sans le champ category)
|
||||
const { category, ...serverEvent } = event
|
||||
this.notifyServerCallbacks(serverEvent as MediaServerEventPayload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un callback pour les événements renderer
|
||||
*/
|
||||
onRendererEvent(callback: RendererEventCallback): () => void {
|
||||
this.rendererCallbacks.add(callback)
|
||||
// Retourne une fonction de cleanup
|
||||
return () => {
|
||||
this.rendererCallbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un callback pour les événements media server
|
||||
*/
|
||||
onMediaServerEvent(callback: MediaServerEventCallback): () => void {
|
||||
this.serverCallbacks.add(callback)
|
||||
// Retourne une fonction de cleanup
|
||||
return () => {
|
||||
this.serverCallbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un callback pour les changements de connexion
|
||||
*/
|
||||
onConnectionChange(callback: ConnectionCallback): () => void {
|
||||
this.connectionCallbacks.add(callback)
|
||||
// Appeler immédiatement avec l'état actuel
|
||||
callback(this.isConnected)
|
||||
// Retourne une fonction de cleanup
|
||||
return () => {
|
||||
this.connectionCallbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifie tous les callbacks renderer
|
||||
*/
|
||||
private notifyRendererCallbacks(event: RendererEventPayload): void {
|
||||
this.rendererCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(event)
|
||||
} catch (error) {
|
||||
console.error('[SSE] Erreur dans callback renderer:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifie tous les callbacks server
|
||||
*/
|
||||
private notifyServerCallbacks(event: MediaServerEventPayload): void {
|
||||
this.serverCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(event)
|
||||
} catch (error) {
|
||||
console.error('[SSE] Erreur dans callback server:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifie tous les callbacks de connexion
|
||||
*/
|
||||
private notifyConnectionCallbacks(connected: boolean): void {
|
||||
this.connectionCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(connected)
|
||||
} catch (error) {
|
||||
console.error('[SSE] Erreur dans callback connexion:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'état de connexion actuel
|
||||
*/
|
||||
isConnectedState(): boolean {
|
||||
return this.isConnected
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const sse = new PMOControlSSE()
|
||||
208
pmoapp/webapp/src/services/pmocontrol/types.ts
Normal file
208
pmoapp/webapp/src/services/pmocontrol/types.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// Types TypeScript pour l'API PMOControl
|
||||
// Synchronisés avec pmocontrol/src/openapi.rs
|
||||
|
||||
// ============================================================================
|
||||
// RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
export type RendererProtocolSummary = 'upnp' | 'openhome' | 'hybrid'
|
||||
|
||||
export interface RendererCapabilitiesSummary {
|
||||
has_avtransport: boolean
|
||||
has_avtransport_set_next: boolean
|
||||
has_rendering_control: boolean
|
||||
has_connection_manager: boolean
|
||||
has_linkplay_http: boolean
|
||||
has_arylic_tcp: boolean
|
||||
has_oh_playlist: boolean
|
||||
has_oh_volume: boolean
|
||||
has_oh_info: boolean
|
||||
has_oh_time: boolean
|
||||
has_oh_radio: boolean
|
||||
}
|
||||
|
||||
export interface RendererSummary {
|
||||
id: string
|
||||
friendly_name: string
|
||||
model_name: string
|
||||
protocol: RendererProtocolSummary
|
||||
capabilities: RendererCapabilitiesSummary
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export interface RendererState {
|
||||
id: string
|
||||
friendly_name: string
|
||||
transport_state: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN'
|
||||
position_ms: number | null
|
||||
duration_ms: number | null
|
||||
volume: number | null // 0-100
|
||||
mute: boolean | null
|
||||
queue_len: number
|
||||
attached_playlist: AttachedPlaylistInfo | null
|
||||
current_track: CurrentTrackMetadata | null
|
||||
}
|
||||
|
||||
export interface CurrentTrackMetadata {
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
}
|
||||
|
||||
export interface AttachedPlaylistInfo {
|
||||
server_id: string
|
||||
container_id: string
|
||||
has_seen_update: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUEUE (avec current_index)
|
||||
// ============================================================================
|
||||
|
||||
export interface QueueItem {
|
||||
index: number // 0-based
|
||||
uri: string
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
server_id: string | null
|
||||
object_id: string | null
|
||||
}
|
||||
|
||||
export interface QueueSnapshot {
|
||||
renderer_id: string
|
||||
items: QueueItem[]
|
||||
current_index: number | null // Index de la piste en cours (null si rien en lecture)
|
||||
}
|
||||
|
||||
export interface FullRendererSnapshot {
|
||||
state: RendererState
|
||||
queue: QueueSnapshot
|
||||
binding: AttachedPlaylistInfo | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPENHOME PLAYLIST
|
||||
// ============================================================================
|
||||
|
||||
export interface OpenHomePlaylistTrack {
|
||||
id: number
|
||||
uri: string
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
}
|
||||
|
||||
export interface OpenHomePlaylistSnapshot {
|
||||
renderer_id: string
|
||||
current_id: number | null
|
||||
current_index: number | null
|
||||
tracks: OpenHomePlaylistTrack[]
|
||||
}
|
||||
|
||||
export interface OpenHomePlaylistAddRequest {
|
||||
uri: string
|
||||
metadata: string
|
||||
after_id?: number | null
|
||||
play?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEDIA SERVERS
|
||||
// ============================================================================
|
||||
|
||||
export interface MediaServerSummary {
|
||||
id: string
|
||||
friendly_name: string
|
||||
model_name: string
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export interface ContainerEntry {
|
||||
id: string
|
||||
title: string
|
||||
class: string // UPnP class
|
||||
is_container: boolean
|
||||
child_count: number | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null // ⚠️ Nom exact: album_art_uri
|
||||
}
|
||||
|
||||
export interface BrowseResponse {
|
||||
container_id: string
|
||||
entries: ContainerEntry[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMMANDES
|
||||
// ============================================================================
|
||||
|
||||
export interface VolumeSetRequest {
|
||||
volume: number // 0-100
|
||||
}
|
||||
|
||||
export interface AttachPlaylistRequest {
|
||||
server_id: string
|
||||
container_id: string
|
||||
auto_play?: boolean
|
||||
}
|
||||
|
||||
export interface PlayContentRequest {
|
||||
server_id: string
|
||||
object_id: string
|
||||
}
|
||||
|
||||
export interface SuccessResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ÉVÉNEMENTS SSE
|
||||
// ============================================================================
|
||||
|
||||
export type RendererEventPayload =
|
||||
| { type: 'state_changed'; renderer_id: string; state: string; timestamp: string }
|
||||
| { type: 'position_changed'; renderer_id: string; track: number | null; rel_time: string | null; track_duration: string | null; timestamp: string }
|
||||
| { type: 'volume_changed'; renderer_id: string; volume: number; timestamp: string }
|
||||
| { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string }
|
||||
| { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string }
|
||||
| { type: 'queue_updated'; renderer_id: string; queue_length: number; timestamp: string }
|
||||
| { type: 'binding_changed'; renderer_id: string; server_id: string | null; container_id: string | null; timestamp: string }
|
||||
| { type: 'online'; renderer_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string }
|
||||
| { type: 'offline'; renderer_id: string; timestamp: string }
|
||||
|
||||
export type MediaServerEventPayload =
|
||||
| { type: 'global_updated'; server_id: string; system_update_id: number | null; timestamp: string }
|
||||
| { type: 'containers_updated'; server_id: string; container_ids: string[]; timestamp: string }
|
||||
| { type: 'online'; server_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string }
|
||||
| { type: 'offline'; server_id: string; timestamp: string }
|
||||
|
||||
export type UnifiedEventPayload =
|
||||
| { category: 'renderer' } & RendererEventPayload
|
||||
| { category: 'media_server' } & MediaServerEventPayload
|
||||
|
||||
// ============================================================================
|
||||
// MÉTADONNÉES PISTE
|
||||
// ============================================================================
|
||||
|
||||
export interface TrackMetadata {
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
duration_ms: number | null
|
||||
}
|
||||
|
||||
export interface PositionInfo {
|
||||
track: number | null
|
||||
rel_time: string | null // Format HH:MM:SS
|
||||
track_duration: string | null // Format HH:MM:SS
|
||||
}
|
||||
200
pmoapp/webapp/src/services/pmosource.ts
Normal file
200
pmoapp/webapp/src/services/pmosource.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Service pour interagir avec l'API pmosource générique
|
||||
*
|
||||
* Ce service utilise uniquement l'API REST définie dans pmosource::api
|
||||
* et ne dépend d'aucune implémentation spécifique (comme pmoparadise)
|
||||
*/
|
||||
|
||||
const API_BASE = '/api/sources'
|
||||
|
||||
// Types correspondant aux structures de l'API pmosource
|
||||
|
||||
export interface SourceInfo {
|
||||
id: string
|
||||
name: string
|
||||
supports_fifo: boolean
|
||||
capabilities: SourceCapabilities
|
||||
}
|
||||
|
||||
export interface SourceCapabilities {
|
||||
supports_search: boolean
|
||||
supports_favorites: boolean
|
||||
supports_playlists: boolean
|
||||
supports_user_content: boolean
|
||||
supports_high_res_audio: boolean
|
||||
max_sample_rate: number | null
|
||||
supports_multiple_formats: boolean
|
||||
supports_advanced_search: boolean
|
||||
supports_pagination: boolean
|
||||
}
|
||||
|
||||
export interface SourcesList {
|
||||
count: number
|
||||
sources: SourceInfo[]
|
||||
}
|
||||
|
||||
export interface BrowseContainer {
|
||||
id: string
|
||||
parent_id: string
|
||||
title: string
|
||||
class: string
|
||||
child_count: string | null
|
||||
restricted: string | null
|
||||
}
|
||||
|
||||
export interface BrowseItemResource {
|
||||
url: string
|
||||
protocol_info: string
|
||||
duration: string | null
|
||||
}
|
||||
|
||||
export interface BrowseItem {
|
||||
id: string
|
||||
parent_id: string
|
||||
title: string
|
||||
class: string
|
||||
artist: string | null
|
||||
album: string | null
|
||||
creator: string | null
|
||||
album_art: string | null
|
||||
resources: BrowseItemResource[]
|
||||
}
|
||||
|
||||
export interface BrowseResponse {
|
||||
object_id: string
|
||||
containers: BrowseContainer[]
|
||||
items: BrowseItem[]
|
||||
returned_containers: number
|
||||
returned_items: number
|
||||
total: number
|
||||
update_id: number
|
||||
}
|
||||
|
||||
export interface ResolveUriResponse {
|
||||
object_id: string
|
||||
uri: string
|
||||
}
|
||||
|
||||
export interface SourceRootContainer {
|
||||
id: string
|
||||
parent_id: string
|
||||
title: string
|
||||
class: string
|
||||
child_count: string | null
|
||||
searchable: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste toutes les sources musicales enregistrées
|
||||
*/
|
||||
export async function listSources(): Promise<SourcesList> {
|
||||
const response = await fetch(`${API_BASE}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list sources: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les informations d'une source spécifique
|
||||
*/
|
||||
export async function getSource(sourceId: string): Promise<SourceInfo> {
|
||||
const response = await fetch(`${API_BASE}/${sourceId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get source: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le container racine d'une source
|
||||
*/
|
||||
export async function getSourceRoot(sourceId: string): Promise<SourceRootContainer> {
|
||||
const response = await fetch(`${API_BASE}/${sourceId}/root`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get source root: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parcourt un container d'une source
|
||||
*
|
||||
* @param sourceId - ID de la source
|
||||
* @param objectId - ID de l'objet à parcourir (optionnel, par défaut utilise la racine)
|
||||
* @param startingIndex - Index de départ pour la pagination
|
||||
* @param requestedCount - Nombre d'éléments demandés
|
||||
*/
|
||||
export async function browseSource(
|
||||
sourceId: string,
|
||||
objectId?: string,
|
||||
startingIndex?: number,
|
||||
requestedCount?: number
|
||||
): Promise<BrowseResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (objectId) params.set('object_id', objectId)
|
||||
if (startingIndex !== undefined) params.set('starting_index', startingIndex.toString())
|
||||
if (requestedCount !== undefined) params.set('requested_count', requestedCount.toString())
|
||||
|
||||
const url = `${API_BASE}/${sourceId}/browse?${params.toString()}`
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to browse source: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout l'URI réelle d'un objet (pour le streaming)
|
||||
*
|
||||
* @param sourceId - ID de la source
|
||||
* @param objectId - ID de l'objet à résoudre
|
||||
*/
|
||||
export async function resolveUri(sourceId: string, objectId: string): Promise<ResolveUriResponse> {
|
||||
const params = new URLSearchParams({ object_id: objectId })
|
||||
const url = `${API_BASE}/${sourceId}/resolve?${params.toString()}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve URI: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL de l'image par défaut d'une source
|
||||
*
|
||||
* @param sourceId - ID de la source
|
||||
* @returns L'URL de l'image
|
||||
*/
|
||||
export function getSourceImageUrl(sourceId: string): string {
|
||||
return `${API_BASE}/${sourceId}/image`
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les capacités d'une source
|
||||
*/
|
||||
export async function getSourceCapabilities(sourceId: string): Promise<SourceCapabilities> {
|
||||
const response = await fetch(`${API_BASE}/${sourceId}/capabilities`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get source capabilities: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les métadonnées détaillées d'un item spécifique
|
||||
*
|
||||
* @param sourceId - ID de la source
|
||||
* @param objectId - ID de l'item à récupérer
|
||||
*/
|
||||
export async function getItem(sourceId: string, objectId: string): Promise<BrowseItem> {
|
||||
const params = new URLSearchParams({ object_id: objectId })
|
||||
const url = `${API_BASE}/${sourceId}/item?${params.toString()}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get item: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
233
pmoapp/webapp/src/services/radioParadise.ts
Normal file
233
pmoapp/webapp/src/services/radioParadise.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Service API pour interagir avec Radio Paradise
|
||||
*/
|
||||
|
||||
export interface ChannelInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SongInfo {
|
||||
index: number;
|
||||
artist: string;
|
||||
title: string;
|
||||
album: string;
|
||||
year?: number;
|
||||
elapsed_ms: number;
|
||||
duration_ms: number;
|
||||
cover_url?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export interface BlockResponse {
|
||||
event: number;
|
||||
end_event: number;
|
||||
url: string;
|
||||
length_ms: number;
|
||||
songs: SongInfo[];
|
||||
}
|
||||
|
||||
export interface NowPlayingResponse {
|
||||
event: number;
|
||||
end_event: number;
|
||||
stream_url: string;
|
||||
block_length_ms: number;
|
||||
current_song_index?: number;
|
||||
current_song?: SongInfo;
|
||||
songs: SongInfo[];
|
||||
}
|
||||
|
||||
export interface StreamUrlResponse {
|
||||
event: number;
|
||||
stream_url: string;
|
||||
length_ms: number;
|
||||
}
|
||||
|
||||
export interface CoverUrlResponse {
|
||||
event: number;
|
||||
song_index: number;
|
||||
cover_url?: string;
|
||||
cover_type: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les canaux disponibles
|
||||
*/
|
||||
export async function listChannels(): Promise<ChannelInfo[]> {
|
||||
const response = await fetch("/api/radioparadise/channels");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch channels");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le morceau en cours de lecture
|
||||
*/
|
||||
export async function getNowPlaying(channel?: number): Promise<NowPlayingResponse> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/now-playing?channel=${channel}`
|
||||
: "/api/radioparadise/now-playing";
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch now playing");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le block actuel
|
||||
*/
|
||||
export async function getCurrentBlock(channel?: number): Promise<BlockResponse> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/block/current?channel=${channel}`
|
||||
: "/api/radioparadise/block/current";
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch current block");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un block spécifique par son event ID
|
||||
*/
|
||||
export async function getBlockById(eventId: number, channel?: number): Promise<BlockResponse> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/block/${eventId}?channel=${channel}`
|
||||
: `/api/radioparadise/block/${eventId}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch block");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un morceau spécifique d'un block
|
||||
*/
|
||||
export async function getSongByIndex(
|
||||
eventId: number,
|
||||
index: number,
|
||||
channel?: number
|
||||
): Promise<SongInfo> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/block/${eventId}/song/${index}?channel=${channel}`
|
||||
: `/api/radioparadise/block/${eventId}/song/${index}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch song");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL de la pochette d'un morceau
|
||||
*/
|
||||
export async function getCoverUrl(
|
||||
eventId: number,
|
||||
songIndex: number,
|
||||
channel?: number
|
||||
): Promise<CoverUrlResponse> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/cover-url/${eventId}/${songIndex}?channel=${channel}`
|
||||
: `/api/radioparadise/cover-url/${eventId}/${songIndex}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch cover URL");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL de streaming d'un block
|
||||
*/
|
||||
export async function getStreamUrl(
|
||||
eventId: number,
|
||||
channel?: number
|
||||
): Promise<StreamUrlResponse> {
|
||||
const url = channel !== undefined
|
||||
? `/api/radioparadise/stream-url/${eventId}?channel=${channel}`
|
||||
: `/api/radioparadise/stream-url/${eventId}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.message || "Failed to fetch stream URL");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une durée en millisecondes en format MM:SS
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une durée en millisecondes en format H:MM:SS si >= 1h, sinon MM:SS
|
||||
*/
|
||||
export function formatDurationLong(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL de la pochette d'un morceau, avec fallback vers l'image par défaut
|
||||
*/
|
||||
export function getSongCoverUrl(song: SongInfo): string | undefined {
|
||||
return song.cover_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le nom complet d'un canal
|
||||
*/
|
||||
export function getChannelName(channelId: number): string {
|
||||
const channelNames: Record<number, string> = {
|
||||
0: "Main Mix",
|
||||
1: "Mellow Mix",
|
||||
2: "Rock Mix",
|
||||
3: "Eclectic Mix",
|
||||
};
|
||||
return channelNames[channelId] || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la description d'un canal
|
||||
*/
|
||||
export function getChannelDescription(channelId: number): string {
|
||||
const descriptions: Record<number, string> = {
|
||||
0: "Eclectic mix of rock, world, electronica, and more",
|
||||
1: "Mellower, less aggressive music",
|
||||
2: "Heavier, more guitar-driven music",
|
||||
3: "Curated worldwide selection",
|
||||
};
|
||||
return descriptions[channelId] || "";
|
||||
}
|
||||
111
pmoapp/webapp/src/stores/ui.ts
Normal file
111
pmoapp/webapp/src/stores/ui.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// Store Pinia pour l'état UI global
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
type: 'info' | 'success' | 'warning' | 'error'
|
||||
message: string
|
||||
duration?: number // ms, undefined = permanent
|
||||
}
|
||||
|
||||
export const useUIStore = defineStore('ui', () => {
|
||||
// État
|
||||
const selectedRendererId = ref<string | null>(null)
|
||||
const selectedServerId = ref<string | null>(null)
|
||||
const showEventLog = ref(false)
|
||||
const sseConnected = ref(false)
|
||||
const notifications = ref<Notification[]>([])
|
||||
|
||||
// Actions
|
||||
function selectRenderer(id: string | null) {
|
||||
selectedRendererId.value = id
|
||||
}
|
||||
|
||||
function selectServer(id: string | null) {
|
||||
selectedServerId.value = id
|
||||
}
|
||||
|
||||
function toggleEventLog() {
|
||||
showEventLog.value = !showEventLog.value
|
||||
}
|
||||
|
||||
function setSSEConnected(connected: boolean) {
|
||||
sseConnected.value = connected
|
||||
}
|
||||
|
||||
function addNotification(
|
||||
type: Notification['type'],
|
||||
message: string,
|
||||
duration?: number
|
||||
) {
|
||||
const id = `notif-${Date.now()}-${Math.random()}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
type,
|
||||
message,
|
||||
duration,
|
||||
}
|
||||
|
||||
notifications.value.push(notification)
|
||||
|
||||
// Auto-remove après duration (défaut: 5s)
|
||||
const timeout = duration !== undefined ? duration : 5000
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(id)
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function removeNotification(id: string) {
|
||||
const index = notifications.value.findIndex(n => n.id === id)
|
||||
if (index !== -1) {
|
||||
notifications.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function clearNotifications() {
|
||||
notifications.value = []
|
||||
}
|
||||
|
||||
// Raccourcis pour les types de notifications
|
||||
function notifySuccess(message: string, duration?: number) {
|
||||
return addNotification('success', message, duration)
|
||||
}
|
||||
|
||||
function notifyError(message: string, duration?: number) {
|
||||
return addNotification('error', message, duration || 7000) // 7s pour erreurs
|
||||
}
|
||||
|
||||
function notifyWarning(message: string, duration?: number) {
|
||||
return addNotification('warning', message, duration)
|
||||
}
|
||||
|
||||
function notifyInfo(message: string, duration?: number) {
|
||||
return addNotification('info', message, duration)
|
||||
}
|
||||
|
||||
return {
|
||||
// État
|
||||
selectedRendererId,
|
||||
selectedServerId,
|
||||
showEventLog,
|
||||
sseConnected,
|
||||
notifications,
|
||||
// Actions
|
||||
selectRenderer,
|
||||
selectServer,
|
||||
toggleEventLog,
|
||||
setSSEConnected,
|
||||
addNotification,
|
||||
removeNotification,
|
||||
clearNotifications,
|
||||
notifySuccess,
|
||||
notifyError,
|
||||
notifyWarning,
|
||||
notifyInfo,
|
||||
}
|
||||
})
|
||||
239
pmoapp/webapp/src/views/DashboardView.vue
Normal file
239
pmoapp/webapp/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } 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'
|
||||
|
||||
const {
|
||||
allRenderers: renderers,
|
||||
onlineRenderers,
|
||||
getStateById,
|
||||
fetchRenderers,
|
||||
fetchRendererSnapshot
|
||||
} = useRenderers()
|
||||
|
||||
const {
|
||||
allServers: mediaServers,
|
||||
onlineServers,
|
||||
fetchServers
|
||||
} = useMediaServers()
|
||||
|
||||
// Charger les données au montage
|
||||
onMounted(async () => {
|
||||
await fetchRenderers()
|
||||
await fetchServers()
|
||||
|
||||
for (const renderer of renderers.value) {
|
||||
fetchRendererSnapshot(renderer.id, { force: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-view">
|
||||
<!-- Header -->
|
||||
<header class="dashboard-header">
|
||||
<h1 class="dashboard-title">PMOControl Dashboard</h1>
|
||||
<div class="dashboard-stats">
|
||||
<div class="stat">
|
||||
<Radio :size="20" />
|
||||
<span>{{ onlineRenderers.length }} / {{ renderers.length }} renderers</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<Server :size="20" />
|
||||
<span>{{ onlineServers.length }} / {{ mediaServers.length }} serveurs</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Renderers Section -->
|
||||
<section class="dashboard-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<Radio :size="24" />
|
||||
<span>Renderers Audio</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="renderers.length" class="renderers-grid">
|
||||
<RendererCard
|
||||
v-for="renderer in renderers"
|
||||
:key="renderer.id"
|
||||
:renderer="renderer"
|
||||
:state="getStateById(renderer.id) ?? null"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>Aucun renderer découvert</p>
|
||||
<button class="btn btn-secondary" @click="fetchRenderers(true)">
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Media Servers Section -->
|
||||
<section class="dashboard-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<Server :size="24" />
|
||||
<span>Serveurs de Médias</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="mediaServers.length" class="servers-grid">
|
||||
<MediaServerCard
|
||||
v-for="server in mediaServers"
|
||||
:key="server.id"
|
||||
:server="server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>Aucun serveur de médias découvert</p>
|
||||
<button class="btn btn-secondary" @click="fetchServers(true)">
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat svg {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.dashboard-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-title svg {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Grids */
|
||||
.renderers-grid,
|
||||
.servers-grid {
|
||||
display: grid;
|
||||
gap: var(--spacing-lg);
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-xl);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px dashed var(--color-border);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-view {
|
||||
padding: var(--spacing-md);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.renderers-grid,
|
||||
.servers-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1024px) {
|
||||
.renderers-grid,
|
||||
.servers-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.renderers-grid,
|
||||
.servers-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
237
pmoapp/webapp/src/views/MediaServerView.vue
Normal file
237
pmoapp/webapp/src/views/MediaServerView.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import MediaBrowser from '@/components/pmocontrol/MediaBrowser.vue'
|
||||
import { ArrowLeft, Server } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { getServerById } = useMediaServers()
|
||||
|
||||
const serverId = computed(() => route.params.serverId as string)
|
||||
const containerId = ref(route.query.container as string || '0')
|
||||
|
||||
const server = computed(() => getServerById(serverId.value))
|
||||
|
||||
// Watcher sur query.container pour mettre à jour containerId
|
||||
watch(
|
||||
() => route.query.container,
|
||||
(newContainer) => {
|
||||
containerId.value = (newContainer as string) || '0'
|
||||
}
|
||||
)
|
||||
|
||||
function goBack() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function handleNavigate(newContainerId: string) {
|
||||
router.push({
|
||||
name: 'MediaServer',
|
||||
params: { serverId: serverId.value },
|
||||
query: { container: newContainerId }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-server-view">
|
||||
<!-- Header -->
|
||||
<header class="server-header">
|
||||
<button class="btn-back" @click="goBack" title="Retour au dashboard">
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<div class="header-content">
|
||||
<div class="server-info">
|
||||
<Server :size="24" class="server-icon" />
|
||||
<div class="server-details">
|
||||
<h1 class="server-name">{{ server?.friendly_name || 'Chargement...' }}</h1>
|
||||
<p class="server-model">{{ server?.model_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="server" class="server-status" :class="{ online: server.online, offline: !server.online }">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-label">{{ server.online ? 'En ligne' : 'Hors ligne' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="!server" class="loading-state">
|
||||
<p>Chargement du serveur...</p>
|
||||
</div>
|
||||
|
||||
<!-- Offline state -->
|
||||
<div v-else-if="!server.online" class="offline-state">
|
||||
<p>Ce serveur est actuellement hors ligne</p>
|
||||
<button class="btn btn-secondary" @click="goBack">
|
||||
Retour au dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div v-else class="server-content">
|
||||
<MediaBrowser
|
||||
:serverId="serverId"
|
||||
:containerId="containerId"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-server-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.server-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.server-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.server-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.server-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.server-status.online .status-dot {
|
||||
background-color: var(--status-playing);
|
||||
}
|
||||
|
||||
.server-status.online .status-label {
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.server-status.offline .status-dot {
|
||||
background-color: var(--status-offline);
|
||||
}
|
||||
|
||||
.server-status.offline .status-label {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
/* Loading & Offline states */
|
||||
.loading-state,
|
||||
.offline-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.offline-state p {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.server-content {
|
||||
flex: 1;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Responsive - Mobile */
|
||||
@media (max-width: 767px) {
|
||||
.media-server-view {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.server-info {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
516
pmoapp/webapp/src/views/RendererView.vue
Normal file
516
pmoapp/webapp/src/views/RendererView.vue
Normal file
@@ -0,0 +1,516 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch, toRef } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
|
||||
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
|
||||
import VolumeControl from '@/components/pmocontrol/VolumeControl.vue'
|
||||
import QueueViewer from '@/components/pmocontrol/QueueViewer.vue'
|
||||
import PlaylistBindingPanel from '@/components/pmocontrol/PlaylistBindingPanel.vue'
|
||||
import StatusBadge from '@/components/pmocontrol/StatusBadge.vue'
|
||||
import { ArrowLeft, Radio } from 'lucide-vue-next'
|
||||
import {
|
||||
addOpenHomeTrack,
|
||||
clearOpenHomePlaylist,
|
||||
getOpenHomePlaylist,
|
||||
playOpenHomeTrack,
|
||||
} from '@/services/openhomePlaylist'
|
||||
import type { OpenHomePlaylistSnapshot } from '@/services/pmocontrol/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
const rendererId = computed(() => route.params.id as string)
|
||||
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value))
|
||||
const { fetchRenderers, selectRenderer: selectRendererSnapshot } = useRenderers()
|
||||
const openHomeSupported = computed(() => {
|
||||
const current = renderer.value
|
||||
if (!current) return false
|
||||
const caps = current.capabilities
|
||||
return (
|
||||
current.protocol === 'openhome' ||
|
||||
current.protocol === 'hybrid' ||
|
||||
caps?.has_oh_playlist === true
|
||||
)
|
||||
})
|
||||
const ohPlaylist = ref<OpenHomePlaylistSnapshot | null>(null)
|
||||
const ohLoading = ref(false)
|
||||
const ohError = ref<string | null>(null)
|
||||
const newOhUri = ref('')
|
||||
const newOhMeta = ref('')
|
||||
const canAddOhTrack = computed(() => newOhUri.value.trim().length > 0)
|
||||
|
||||
// Charger les données au montage si nécessaire
|
||||
onMounted(async () => {
|
||||
uiStore.selectRenderer(rendererId.value)
|
||||
selectRendererSnapshot(rendererId.value)
|
||||
|
||||
// Charger toutes les données du renderer
|
||||
if (!renderer.value) {
|
||||
await fetchRenderers()
|
||||
}
|
||||
await refresh()
|
||||
})
|
||||
|
||||
watch(
|
||||
openHomeSupported,
|
||||
async supported => {
|
||||
if (supported) {
|
||||
await refreshOhPlaylist()
|
||||
} else {
|
||||
ohPlaylist.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(rendererId, () => {
|
||||
ohPlaylist.value = null
|
||||
ohError.value = null
|
||||
newOhUri.value = ''
|
||||
newOhMeta.value = ''
|
||||
})
|
||||
|
||||
// Nettoyer la sélection au démontage
|
||||
onUnmounted(() => {
|
||||
uiStore.selectRenderer(null)
|
||||
selectRendererSnapshot(null)
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const protocolLabel = computed(() => {
|
||||
if (!renderer.value) return ''
|
||||
switch (renderer.value.protocol) {
|
||||
case 'upnp':
|
||||
return 'UPnP AV'
|
||||
case 'openhome':
|
||||
return 'OpenHome'
|
||||
case 'hybrid':
|
||||
return 'Hybrid (UPnP + OpenHome)'
|
||||
default:
|
||||
return 'Inconnu'
|
||||
}
|
||||
})
|
||||
|
||||
async function refreshOhPlaylist() {
|
||||
if (!renderer.value || !openHomeSupported.value) return
|
||||
ohLoading.value = true
|
||||
ohError.value = null
|
||||
try {
|
||||
ohPlaylist.value = await getOpenHomePlaylist(renderer.value.id)
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to load OpenHome playlist'
|
||||
} finally {
|
||||
ohLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhClear() {
|
||||
if (!renderer.value) return
|
||||
try {
|
||||
await clearOpenHomePlaylist(renderer.value.id)
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to clear OpenHome playlist'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhPlay(trackId: number) {
|
||||
if (!renderer.value) return
|
||||
try {
|
||||
await playOpenHomeTrack(renderer.value.id, trackId)
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : `Failed to play OpenHome track ${trackId}`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhAdd() {
|
||||
if (!renderer.value || !canAddOhTrack.value) return
|
||||
try {
|
||||
await addOpenHomeTrack(renderer.value.id, {
|
||||
uri: newOhUri.value.trim(),
|
||||
metadata: newOhMeta.value,
|
||||
play: false,
|
||||
})
|
||||
newOhUri.value = ''
|
||||
newOhMeta.value = ''
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to add track to OpenHome playlist'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="renderer-view">
|
||||
<!-- Header -->
|
||||
<header class="renderer-header">
|
||||
<button class="btn-back" @click="goBack" title="Retour au dashboard">
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<div class="header-content">
|
||||
<div class="renderer-info">
|
||||
<Radio :size="24" class="renderer-icon" />
|
||||
<div class="renderer-details">
|
||||
<h1 class="renderer-name">{{ renderer?.friendly_name || 'Chargement...' }}</h1>
|
||||
<p class="renderer-model">{{ renderer?.model_name }} • {{ protocolLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="!renderer || !state" class="loading-state">
|
||||
<p>Chargement du renderer...</p>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div v-else class="renderer-content">
|
||||
<!-- Left column (Desktop) / Top (Mobile) -->
|
||||
<div class="left-column">
|
||||
<!-- Current Track -->
|
||||
<section class="content-section">
|
||||
<CurrentTrack :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Transport Controls -->
|
||||
<section class="content-section">
|
||||
<TransportControls :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<section class="content-section">
|
||||
<h3 class="section-subtitle">Volume</h3>
|
||||
<VolumeControl :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Playlist Binding -->
|
||||
<section class="content-section">
|
||||
<PlaylistBindingPanel :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<section v-if="openHomeSupported" class="content-section openhome-playlist">
|
||||
<h2>OpenHome Playlist</h2>
|
||||
|
||||
<div v-if="ohLoading">Chargement de la playlist…</div>
|
||||
<div v-else-if="ohError" class="error">{{ ohError }}</div>
|
||||
|
||||
<div v-else-if="ohPlaylist && ohPlaylist.tracks.length === 0">
|
||||
Playlist vide.
|
||||
</div>
|
||||
|
||||
<div v-else-if="ohPlaylist">
|
||||
<table class="oh-playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Titre</th>
|
||||
<th>Artiste</th>
|
||||
<th>Album</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="track in ohPlaylist.tracks"
|
||||
:key="track.id"
|
||||
:class="{ current: ohPlaylist.current_id === track.id }"
|
||||
>
|
||||
<td>{{ track.id }}</td>
|
||||
<td>{{ track.title || '—' }}</td>
|
||||
<td>{{ track.artist || '—' }}</td>
|
||||
<td>{{ track.album || '—' }}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn btn-secondary btn-icon" @click="handleOhPlay(track.id)" title="Lire ce morceau">
|
||||
▶
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="oh-controls">
|
||||
<button class="btn btn-secondary" @click="refreshOhPlaylist">
|
||||
🔁 Rafraîchir
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="handleOhClear">
|
||||
🗑 Effacer la playlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oh-add-form">
|
||||
<input v-model="newOhUri" placeholder="URI à ajouter" />
|
||||
<textarea v-model="newOhMeta" placeholder="DIDL-Lite (optionnel)" rows="2"></textarea>
|
||||
<button class="btn btn-primary" @click="handleOhAdd" :disabled="!canAddOhTrack">
|
||||
➕ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right column (Desktop) / Bottom (Mobile) -->
|
||||
<div class="right-column">
|
||||
<section class="content-section queue-section">
|
||||
<QueueViewer :rendererId="rendererId" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.renderer-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.renderer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.renderer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-icon {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.renderer-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.renderer-content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: var(--spacing-xl);
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left-column,
|
||||
.right-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.openhome-playlist h2 {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.oh-playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.oh-playlist-table th,
|
||||
.oh-playlist-table td {
|
||||
padding: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.oh-playlist-table tbody tr:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.oh-playlist-table tr.current {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.oh-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.oh-add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.oh-add-form input,
|
||||
.oh-add-form textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.oh-add-form button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--status-error, #dc2626);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive - Desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.renderer-content {
|
||||
grid-template-columns: 400px 1fr;
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - Tablet */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.renderer-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.left-column {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - Mobile */
|
||||
@media (max-width: 767px) {
|
||||
.renderer-view {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.renderer-info {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
334
pmoapp/webapp/src/views/UnifiedControlView.vue
Normal file
334
pmoapp/webapp/src/views/UnifiedControlView.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTabs } from '@/composables/useTabs'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import { useSwipe } from '@vueuse/core'
|
||||
import { api } from '@/services/pmocontrol/api'
|
||||
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||
|
||||
// Import des composants
|
||||
import BottomTabBar from '@/components/unified/BottomTabBar.vue'
|
||||
import EmptyState from '@/components/unified/EmptyState.vue'
|
||||
import ServerDrawer from '@/components/unified/ServerDrawer.vue'
|
||||
import RendererTabContent from '@/components/unified/RendererTabContent.vue'
|
||||
import ServerTabContent from '@/components/unified/ServerTabContent.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { tabs, activeTabId, switchTab, activeTab, syncWithRenderers, isEmpty } = useTabs()
|
||||
const { allRenderers, fetchRenderers } = useRenderers()
|
||||
const { allServers, fetchServers } = useMediaServers()
|
||||
|
||||
// État du drawer server
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
// Ref pour le swipe edge detection
|
||||
const viewRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Swipe depuis le bord gauche pour ouvrir le drawer
|
||||
useSwipe(viewRef, {
|
||||
threshold: 50,
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Gestion de la lecture d'un item depuis le drawer
|
||||
async function handlePlayItem(item: ContainerEntry, serverId: string) {
|
||||
const currentTab = activeTab.value
|
||||
|
||||
if (!currentTab || currentTab.type !== 'renderer' || !currentTab.metadata?.rendererId) {
|
||||
console.error('[UnifiedControlView] Pas de renderer actif')
|
||||
return
|
||||
}
|
||||
|
||||
const rendererId = currentTab.metadata.rendererId
|
||||
|
||||
try {
|
||||
console.log('[UnifiedControlView] Play item:', item.title, 'on renderer:', rendererId)
|
||||
|
||||
await api.attachPlaylist(rendererId, serverId, item.id, true) // autoPlay = true
|
||||
|
||||
// Fermer le drawer après succès
|
||||
drawerOpen.value = false
|
||||
} catch (error) {
|
||||
console.error('[UnifiedControlView] Erreur lors de la lecture:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion de l'ajout d'un item à la queue depuis le drawer
|
||||
async function handleQueueItem(item: ContainerEntry, serverId: string) {
|
||||
const currentTab = activeTab.value
|
||||
|
||||
if (!currentTab || currentTab.type !== 'renderer' || !currentTab.metadata?.rendererId) {
|
||||
console.error('[UnifiedControlView] Pas de renderer actif')
|
||||
return
|
||||
}
|
||||
|
||||
const rendererId = currentTab.metadata.rendererId
|
||||
|
||||
try {
|
||||
console.log('[UnifiedControlView] Queue item:', item.title, 'on renderer:', rendererId)
|
||||
|
||||
await api.attachPlaylist(rendererId, serverId, item.id, false) // autoPlay = false
|
||||
|
||||
// Fermer le drawer après succès
|
||||
drawerOpen.value = false
|
||||
} catch (error) {
|
||||
console.error('[UnifiedControlView] Erreur lors de l\'ajout à la queue:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Nombre de servers online pour afficher dans le badge
|
||||
const onlineServersCount = computed(() => allServers.value.filter((s) => s.online).length)
|
||||
|
||||
// Gestion de l'ouverture du drawer depuis le bouton
|
||||
function handleDrawerOpen() {
|
||||
drawerOpen.value = 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)
|
||||
|
||||
// Restaurer l'onglet actif depuis l'URL
|
||||
const urlTabId = route.query.tab as string
|
||||
if (urlTabId && tabs.value.find((t) => t.id === urlTabId)) {
|
||||
switchTab(urlTabId)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch renderers pour sync automatique des tabs
|
||||
watch(
|
||||
() => allRenderers.value,
|
||||
(newRenderers) => {
|
||||
syncWithRenderers(newRenderers)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// Watch les changements d'URL pour changer d'onglet
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(newTabId) => {
|
||||
if (newTabId && typeof newTabId === 'string') {
|
||||
const tab = tabs.value.find((t) => t.id === newTabId)
|
||||
if (tab && activeTabId.value !== newTabId) {
|
||||
switchTab(newTabId)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Watch les changements d'onglet actif pour mettre à jour l'URL
|
||||
watch(
|
||||
() => activeTabId.value,
|
||||
(newActiveTabId) => {
|
||||
const currentTabId = route.query.tab as string
|
||||
if (currentTabId !== newActiveTabId) {
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
tab: newActiveTabId,
|
||||
tabs: tabs.value.map((t) => t.id).join(','),
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Composant dynamique selon le type d'onglet
|
||||
const currentTabComponent = computed(() => {
|
||||
const tab = activeTab.value
|
||||
if (!tab) return null
|
||||
|
||||
switch (tab.type) {
|
||||
case 'renderer':
|
||||
return RendererTabContent
|
||||
case 'server':
|
||||
return ServerTabContent
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// Props pour le composant actif
|
||||
const currentTabProps = computed(() => {
|
||||
const tab = activeTab.value
|
||||
if (!tab || !tab.metadata) return null
|
||||
|
||||
if (tab.type === 'renderer' && tab.metadata.rendererId) {
|
||||
return { rendererId: tab.metadata.rendererId }
|
||||
}
|
||||
if (tab.type === 'server' && tab.metadata.serverId) {
|
||||
return { serverId: tab.metadata.serverId }
|
||||
}
|
||||
return null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="viewRef" class="unified-control-view">
|
||||
<!-- Zone de contenu -->
|
||||
<main class="content-area">
|
||||
<!-- État vide: aucun renderer détecté -->
|
||||
<EmptyState v-if="isEmpty" />
|
||||
|
||||
<!-- Onglets renderers/servers avec keep-alive -->
|
||||
<keep-alive v-else :max="12">
|
||||
<component
|
||||
v-if="currentTabComponent && currentTabProps"
|
||||
:is="currentTabComponent"
|
||||
:key="activeTab?.id"
|
||||
v-bind="currentTabProps"
|
||||
/>
|
||||
</keep-alive>
|
||||
</main>
|
||||
|
||||
<!-- Barre d'onglets en bas -->
|
||||
<BottomTabBar :online-servers-count="onlineServersCount" @open-drawer="handleDrawerOpen" />
|
||||
|
||||
<!-- Drawer servers (swipe depuis bord gauche) -->
|
||||
<ServerDrawer
|
||||
v-model="drawerOpen"
|
||||
@play-item="handlePlayItem"
|
||||
@queue-item="handleQueueItem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.unified-control-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
padding-bottom: 80px; /* Espace pour la barre fixe en bas (64px + marge) */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Placeholder temporaire */
|
||||
.tab-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-lg);
|
||||
max-width: 500px;
|
||||
padding: var(--spacing-xl);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
color: var(--color-primary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.placeholder-subtitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive pour 800x600 landscape */
|
||||
@media (min-width: 600px) and (orientation: landscape) {
|
||||
.content-area {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive mobile portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.content-area {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation de transition des onglets */
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.v-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,10 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/app/', // Base path pour le déploiement
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user