🔧 Frontend review fixes & quality improvements
- Fixed race condition in apiCache TTL handling by passing ttl per-entry
- Removed double snapshot reassignment bug after switch cases in useRenderers.ts
- Replaced `as any` cast on transport_state with runtime guard (isTransportState)
- Fixed invalidate() to preserve subscriptions instead of deleting them
- Migrated reactive(Map) → shallowRef + explicit reactivity triggers (triggerSnapshotReactive, triggerLoadingReactice)
- Added onUnmounted cleanup for debounce timer in useRenderer()
- Exposed resetSSE() to allow reinitialization after SSE reconnect
- Split deep watch in useTabs.ts into separate lightweight watches without {deep: true}
- Fixed swipe gesture to capture startX in onSwipeStart instead of using final clientX
- Added minimal JSON validation log for null responses (DEV mode only)
- Implemented ARIA labels on transport controls and volume slider
- Added UI notifications for network errors via uiStore.notifyError()
+ Removed obsolete toRaw() usage and import after shallowRef migration
- Added missing reactivity triggers for state_changed, queue_refreshing/updated cases
This commit is contained in:
646
Blackboard/Todo/Frontend_Review.md
Normal file
646
Blackboard/Todo/Frontend_Review.md
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Le frontend de PMOMusic est une application Vue 3 + TypeScript avec Pinia, organisée autour
|
||||||
|
de composables réactifs, d'un client SSE centralisé et d'un cache API à plusieurs niveaux.
|
||||||
|
L'architecture générale est solide : séparation claire composables/services/vues, typage strict
|
||||||
|
(`strict: true` dans `tsconfig.app.json`), reconnexion SSE avec backoff exponentiel.
|
||||||
|
|
||||||
|
Cette revue documente les bugs avérés, les fragilités de conception et les axes d'amélioration
|
||||||
|
relevés lors d'une lecture complète des fichiers `pmoapp/webapp/src/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bugs
|
||||||
|
|
||||||
|
### 1. `apiCache.ts:130-133` — Mutation globale du TTL non réentrante
|
||||||
|
|
||||||
|
**Problème** : la méthode `fetch()` accepte un `ttl` optionnel par appel. Pour l'appliquer,
|
||||||
|
elle modifie `this.options.ttl` globalement avant d'appeler `this.set()`, puis le restaure :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apiCache.ts:130-133
|
||||||
|
if (ttl) {
|
||||||
|
const originalTtl = this.options.ttl;
|
||||||
|
this.options.ttl = ttl; // (A) modification globale
|
||||||
|
this.set(endpoint, data, params);
|
||||||
|
this.options.ttl = originalTtl; // (B) restauration
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : `fetcher()` est `await`-é (ligne 128) avant ce bloc. Pendant cet await, d'autres
|
||||||
|
microtasks peuvent s'intercaler et appeler `isFresh()` ou `set()`, qui lisent `this.options.ttl`.
|
||||||
|
Si deux appels `fetch()` avec des `ttl` différents sont en vol simultanément, la restauration
|
||||||
|
de (B) peut effacer la valeur posée par le second appel concurrent, ou (A) peut lire un TTL
|
||||||
|
modifié par un autre appel.
|
||||||
|
|
||||||
|
**Solution** : passer le `ttl` directement à `set()` comme paramètre, sans modifier l'état
|
||||||
|
partagé :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Dans set() : ajouter un paramètre ttl optionnel
|
||||||
|
set<T>(endpoint: string, data: T, params?: ..., etag?: string, ttl?: number): void {
|
||||||
|
const key = this.makeKey(endpoint, params);
|
||||||
|
this.cache.set(key, {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
ttl: ttl ?? this.options.ttl, // TTL par entrée, pas global
|
||||||
|
etag,
|
||||||
|
});
|
||||||
|
this.notifySubscribers(key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dans isFresh() : lire le ttl de l'entrée
|
||||||
|
private isFresh(key: string): boolean {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
return Date.now() - entry.timestamp < (entry.ttl ?? this.options.ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dans fetch() : supprimer le bloc de mutation globale
|
||||||
|
this.set(endpoint, data, params, undefined, ttl);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `useRenderers.ts:151 + 222` — Réassignation post-switch écrase le nouvel objet
|
||||||
|
|
||||||
|
**Problème** : le handler `onRendererEvent` termine par une ligne inconditionnelle :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRenderers.ts:222
|
||||||
|
snapshotState.snapshots.set(rendererId, snapshot);
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette ligne s'exécute pour **tous** les types d'événements après le `switch`, y compris pour
|
||||||
|
`position_changed` et `metadata_changed` qui ont déjà créé et stocké un nouvel objet dans le
|
||||||
|
Map à l'intérieur du switch :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// position_changed — ligne 151 : stocke newSnapshot
|
||||||
|
snapshotState.snapshots.set(rendererId, newSnapshot);
|
||||||
|
break;
|
||||||
|
// → puis ligne 222 écrase avec snapshot (proxy d'origine)
|
||||||
|
|
||||||
|
// metadata_changed — ligne 176 : stocke un spread
|
||||||
|
snapshotState.snapshots.set(rendererId, { ...snapshot, state: { ...snapshot.state } });
|
||||||
|
break;
|
||||||
|
// → puis ligne 222 écrase avec snapshot (proxy d'origine)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : `break` sort du `switch` mais pas de la fonction. La ligne 222 est atteinte dans
|
||||||
|
tous les cas.
|
||||||
|
|
||||||
|
**Conséquence** : les objets créés pour forcer la détection de changement par Vue sont
|
||||||
|
immédiatement écrasés. Le mécanisme de réactivité fonctionne malgré tout (le proxy muté est
|
||||||
|
re-stocké), mais la logique est trompeuse et fragile : si Vue venait à optimiser la détection
|
||||||
|
d'identité des objets réactifs, cette redondance deviendrait un bug visible.
|
||||||
|
|
||||||
|
**Solution** : supprimer la ligne 222 et s'assurer que chaque branche du switch stocke
|
||||||
|
explicitement son résultat dans la Map. Les branches `volume_changed`, `mute_changed` et
|
||||||
|
`binding_changed` qui mutent directement `snapshot` doivent aussi créer un nouvel objet :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
case "volume_changed":
|
||||||
|
snapshotState.snapshots.set(rendererId, {
|
||||||
|
...snapshot,
|
||||||
|
state: { ...snapshot.state, volume: event.volume },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "mute_changed":
|
||||||
|
snapshotState.snapshots.set(rendererId, {
|
||||||
|
...snapshot,
|
||||||
|
state: { ...snapshot.state, mute: event.mute },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
// idem pour binding_changed, stream_state_changed
|
||||||
|
```
|
||||||
|
|
||||||
|
Supprimer la ligne 222. Chaque case devient responsable de son stockage, ce qui élimine aussi
|
||||||
|
le besoin de `toRaw()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `useRenderers.ts:122` — `as any` sur `transport_state`
|
||||||
|
|
||||||
|
**Problème** :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRenderers.ts:122
|
||||||
|
snapshot.state.transport_state = event.state as any;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : `event.state` est typé `string` (type SSE générique), alors que
|
||||||
|
`transport_state` est une union littérale (`"PLAYING" | "PAUSED" | "STOPPED" | ...`).
|
||||||
|
Le cast `as any` contourne la vérification de type.
|
||||||
|
|
||||||
|
**Conséquence** : si le backend envoie une valeur non prévue (ex. `"TRANSITIONING"`), elle
|
||||||
|
sera stockée sans validation. Les composants qui comparent `transport_state === "PLAYING"`
|
||||||
|
ne matcheront pas et l'UI restera muette.
|
||||||
|
|
||||||
|
**Solution** : définir un guard de type ou une assertion dans `types.ts` :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// services/pmocontrol/types.ts
|
||||||
|
export type TransportState = "PLAYING" | "PAUSED" | "STOPPED" | "NO_MEDIA" | "TRANSITIONING";
|
||||||
|
|
||||||
|
export function isTransportState(s: string): s is TransportState {
|
||||||
|
return ["PLAYING", "PAUSED", "STOPPED", "NO_MEDIA", "TRANSITIONING"].includes(s);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRenderers.ts — case state_changed
|
||||||
|
case "state_changed":
|
||||||
|
if (isTransportState(event.state)) {
|
||||||
|
snapshot.state.transport_state = event.state;
|
||||||
|
} else {
|
||||||
|
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `apiCache.ts:181-200` — `invalidate()` supprime silencieusement les subscriptions actives
|
||||||
|
|
||||||
|
**Problème** :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apiCache.ts:197-200
|
||||||
|
keysToDelete.forEach(key => {
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.subscriptions.delete(key); // ← subscriptions perdues sans notification
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : lors d'une invalidation (ex. après un SSE event), les callbacks enregistrés via
|
||||||
|
`subscribe()` sont supprimés de la Map. Les composants ne sont pas notifiés de la suppression
|
||||||
|
et ne reçoivent plus les futures mises à jour même après un refetch.
|
||||||
|
|
||||||
|
**Conséquence** : un composant qui a appelé `apiCache.subscribe(...)` et qui survit à une
|
||||||
|
invalidation devient « sourd » sans le savoir.
|
||||||
|
|
||||||
|
**Solution** : conserver les subscriptions lors d'une invalidation — seule la donnée en cache
|
||||||
|
est périmée, pas les abonnés :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
invalidate(pattern: string): void {
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
// ... construction de keysToDelete inchangée ...
|
||||||
|
keysToDelete.forEach(key => {
|
||||||
|
this.cache.delete(key);
|
||||||
|
// NE PAS supprimer this.subscriptions.get(key)
|
||||||
|
// Les abonnés seront notifiés lors du prochain set()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Si l'on veut notifier les abonnés d'une invalidation (pour qu'ils affichent un état de
|
||||||
|
chargement), ajouter un callback optionnel `onInvalidate` dans l'interface de subscription.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragilités de conception
|
||||||
|
|
||||||
|
### 5. `useRenderers.ts:7,143-151` — `toRaw()` comme contournement de réactivité Vue
|
||||||
|
|
||||||
|
**Problème** : le code utilise `toRaw()` pour extraire l'objet brut d'un proxy Vue avant de
|
||||||
|
faire un spread, afin que la copie ne contienne pas de getters réactifs qui pointent vers
|
||||||
|
l'objet original :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRenderers.ts:143-151
|
||||||
|
const rawState = toRaw(snapshot.state);
|
||||||
|
const newState = { ...rawState };
|
||||||
|
const newSnapshot = { ...snapshot, state: newState };
|
||||||
|
snapshotState.snapshots.set(rendererId, newSnapshot);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : les Maps imbriquées dans un objet `reactive()` ont un comportement de réactivité
|
||||||
|
peu prévisible dans Vue 3. Vue ne détecte pas les mutations d'éléments d'une Map réactive si
|
||||||
|
la référence de la Map elle-même ne change pas.
|
||||||
|
|
||||||
|
**Solution recommandée** : remplacer `reactive(new Map())` par `shallowRef(new Map())` pour
|
||||||
|
les Maps qui contiennent des données complexes. La réactivité se déclenche en remplaçant la
|
||||||
|
Map entière (ou en forçant un `triggerRef`) :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Au lieu de :
|
||||||
|
const snapshotState = reactive<RendererSnapshotState>({ snapshots: reactive(new Map()), ... });
|
||||||
|
|
||||||
|
// Utiliser :
|
||||||
|
const snapshots = shallowRef(new Map<string, FullRendererSnapshot>());
|
||||||
|
|
||||||
|
// Pour déclencher la réactivité après mutation :
|
||||||
|
snapshots.value = new Map(snapshots.value); // ou triggerRef(snapshots)
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela rend la propagation de réactivité explicite et élimine le besoin de `toRaw()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `useRenderers.ts:552-570` — Timer de debounce non nettoyé dans `useRenderer()`
|
||||||
|
|
||||||
|
**Problème** : `useRenderer()` crée un timer de debounce local qui n'est jamais nettoyé si
|
||||||
|
le composant parent est démonté :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRenderers.ts:553-566
|
||||||
|
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const REFRESH_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
async function refresh(force = true) {
|
||||||
|
if (refreshDebounceTimer !== null) return;
|
||||||
|
refreshDebounceTimer = setTimeout(() => {
|
||||||
|
refreshDebounceTimer = null;
|
||||||
|
}, REFRESH_DEBOUNCE_MS);
|
||||||
|
await Promise.all([...]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : pas d'appel à `clearTimeout` dans un `onUnmounted`. Si le composant est démonté
|
||||||
|
pendant les 500 ms du debounce, le timer continue de s'exécuter.
|
||||||
|
|
||||||
|
**Conséquence** : fuite mémoire potentielle ; dans des cas extrêmes (navigation rapide), le
|
||||||
|
callback peut tenter de déclencher un fetch sur un composant déjà démonté.
|
||||||
|
|
||||||
|
**Solution** :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
// Dans useRenderer() :
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshDebounceTimer !== null) {
|
||||||
|
clearTimeout(refreshDebounceTimer);
|
||||||
|
refreshDebounceTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. `useRenderers.ts:45-46` — Singleton SSE initialisé par flag de module non réinitialisable
|
||||||
|
|
||||||
|
**Problème** :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRenderers.ts:45-46
|
||||||
|
let sseInitialized = false;
|
||||||
|
function ensureSSEInitialized() {
|
||||||
|
if (sseInitialized) return;
|
||||||
|
// ...
|
||||||
|
sseInitialized = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : ce flag de module est persistant pour toute la durée de vie de la page. Si la
|
||||||
|
connexion SSE est perdue puis rétablie avec un nouvel objet `PMOControlSSE`, le handler
|
||||||
|
`onRendererEvent` précédent peut ne plus être actif, mais `sseInitialized` empêche sa
|
||||||
|
re-enregistration.
|
||||||
|
|
||||||
|
**Conséquence** : après une déconnexion et reconnexion SSE, les événements renderer peuvent
|
||||||
|
ne plus être reçus par `useRenderers` jusqu'à un rechargement de page.
|
||||||
|
|
||||||
|
**Solution** : exposer une fonction `resetSSE()` qui remet `sseInitialized = false` et la
|
||||||
|
connecter à l'événement de reconnexion du service SSE. Alternativement, utiliser le pattern
|
||||||
|
`provide/inject` ou un store Pinia pour gérer le cycle de vie SSE explicitement, en lieu et
|
||||||
|
place du flag de module.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Qualité du code
|
||||||
|
|
||||||
|
### 8. `useTabs.ts` — Deep watch déclenchant une sérialisation localStorage à chaque mutation
|
||||||
|
|
||||||
|
**Problème** : le watch qui persiste l'état des onglets utilise `{ deep: true }` sur un
|
||||||
|
tableau qui peut contenir jusqu'à 12 entrées avec des métadonnées :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useTabs.ts:349-355
|
||||||
|
watch(
|
||||||
|
() => [state.tabs, state.activeTabId, state.tabHistory],
|
||||||
|
() => { saveToLocalStorage(); },
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : `{ deep: true }` traverse récursivement toutes les propriétés observées.
|
||||||
|
`saveToLocalStorage()` appelle `JSON.stringify` sur l'ensemble des tabs à chaque mutation,
|
||||||
|
même mineure (ex. changement de `activeTabId`).
|
||||||
|
|
||||||
|
**Solution** : surveiller les propriétés individuellement et sérialiser uniquement ce qui
|
||||||
|
change, ou utiliser un computed pour construire la clé de changement :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Watch séparés, sans deep
|
||||||
|
watch(() => state.activeTabId, saveToLocalStorage);
|
||||||
|
watch(() => state.tabHistory.length, saveToLocalStorage);
|
||||||
|
watch(
|
||||||
|
() => state.tabs.map(t => t.id + t.type + (t.metadata?.rendererId ?? '')).join('|'),
|
||||||
|
saveToLocalStorage,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. `UnifiedControlView.vue:40-49` — Swipe : `clientX` final au lieu de la position initiale
|
||||||
|
|
||||||
|
**Problème** :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// UnifiedControlView.vue:40-49
|
||||||
|
useSwipe(viewRef, {
|
||||||
|
threshold: 50,
|
||||||
|
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||||
|
if (swipeDirection === "right" && !drawerOpen.value) {
|
||||||
|
const touch = _e.changedTouches[0];
|
||||||
|
if (touch && touch.clientX < 50) { // ← position finale du doigt
|
||||||
|
drawerOpen.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : `onSwipeEnd` reçoit l'événement `touchend`. Dans `changedTouches`, `clientX`
|
||||||
|
est la position **finale** du doigt (après le swipe), pas la position initiale. Un swipe
|
||||||
|
commençant à `x=30` et terminant à `x=150` a `clientX=150` dans `touchend` — la condition
|
||||||
|
`< 50` ne sera jamais vraie pour un swipe horizontal significatif.
|
||||||
|
|
||||||
|
**Conséquence** : le geste de swipe depuis le bord gauche ne fonctionne probablement pas
|
||||||
|
sur les appareils tactiles.
|
||||||
|
|
||||||
|
**Solution** : capturer la position initiale dans `onSwipeStart` :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const swipeStartX = ref(0);
|
||||||
|
|
||||||
|
useSwipe(viewRef, {
|
||||||
|
threshold: 50,
|
||||||
|
onSwipeStart(e: TouchEvent) {
|
||||||
|
swipeStartX.value = e.touches[0]?.clientX ?? 0;
|
||||||
|
},
|
||||||
|
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||||
|
if (swipeDirection === "right" && !drawerOpen.value && swipeStartX.value < 50) {
|
||||||
|
drawerOpen.value = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. `api.ts:50` — Réponse JSON non validée avant le cast TypeScript
|
||||||
|
|
||||||
|
**Problème** :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// api.ts:50
|
||||||
|
return response.json(); // retour typé T par inférence, sans validation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause** : `response.json()` retourne `Promise<any>`. TypeScript accepte le retour car la
|
||||||
|
méthode `request<T>` promet `Promise<T>`, mais aucune validation de structure n'est effectuée.
|
||||||
|
|
||||||
|
**Conséquence** : si le backend renvoie un schéma légèrement différent (champ renommé, type
|
||||||
|
changé), le bug se manifestera loin du point d'appel avec un message cryptique. En
|
||||||
|
développement avec plusieurs instances en parallèle (`udn_prefix` différent), une requête
|
||||||
|
dirigée vers la mauvaise instance peut retourner un format inattendu.
|
||||||
|
|
||||||
|
**Solution pragmatique** : ajouter une validation légère avec un type guard pour les réponses
|
||||||
|
critiques, ou au minimum loguer la réponse brute en mode développement :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
// ...
|
||||||
|
const data = await response.json();
|
||||||
|
if (import.meta.env.DEV && data == null) {
|
||||||
|
console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour les endpoints critiques (`getRendererFullSnapshot`, `getRenderers`), envisager un
|
||||||
|
schéma de validation Zod ou une assertion runtime minimale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibilité et feedback utilisateur
|
||||||
|
|
||||||
|
### 11. Absence de labels ARIA sur les contrôles transport
|
||||||
|
|
||||||
|
Les composants `TransportControls.vue` et `VolumeControl.vue` contiennent des boutons
|
||||||
|
iconiques (play, pause, stop, volume) sans attributs `aria-label`. Les lecteurs d'écran
|
||||||
|
ne peuvent pas identifier la fonction de ces contrôles.
|
||||||
|
|
||||||
|
**Correction minimale** :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- TransportControls.vue -->
|
||||||
|
<button @click="play" aria-label="Lecture">
|
||||||
|
<PlayIcon />
|
||||||
|
</button>
|
||||||
|
<button @click="pause" aria-label="Pause">
|
||||||
|
<PauseIcon />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. Erreurs réseau silencieuses sans feedback utilisateur
|
||||||
|
|
||||||
|
Plusieurs appels critiques sont lancés en fire-and-forget sans propagation vers l'UI :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRenderers.ts:86
|
||||||
|
void fetchRenderers(true); // erreur loggée en console uniquement
|
||||||
|
|
||||||
|
// useRenderers.ts:89
|
||||||
|
void fetchRendererSnapshot(rendererId, { force: true }); // idem
|
||||||
|
```
|
||||||
|
|
||||||
|
Le store `ui.ts` dispose d'un système de notifications toast (`addNotification`). Les erreurs
|
||||||
|
de réseau devraient y être propagées pour informer l'utilisateur :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useUIStore } from '@/stores/ui';
|
||||||
|
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
|
// Dans le handler SSE :
|
||||||
|
try {
|
||||||
|
await fetchRenderers(true);
|
||||||
|
} catch {
|
||||||
|
uiStore.addNotification({
|
||||||
|
message: 'Impossible de rafraîchir la liste des renderers',
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Points forts à conserver
|
||||||
|
|
||||||
|
Ces patterns sont bien conçus et ne doivent pas être modifiés dans les corrections ci-dessus :
|
||||||
|
|
||||||
|
- **Déduplication des requêtes en vol** (`apiCache.ts:100-112`) : évite les appels réseau
|
||||||
|
redondants quand plusieurs composants demandent la même ressource simultanément.
|
||||||
|
- **Watch sélectif sur les IDs** (`UnifiedControlView.vue:139-150`) : calcule une clé
|
||||||
|
synthétique `ids.join(',')` au lieu d'un deep watch sur le tableau de renderers.
|
||||||
|
- **Backoff exponentiel SSE** (`sse.ts`) : reconnexion progressive 1s→2s→4s→8s→16s→30s
|
||||||
|
avec cap. Implémentation robuste.
|
||||||
|
- **Fetch batch contrôlé** (`useRenderers.ts:351-383`) : `fetchBatchSnapshots()` avec
|
||||||
|
concurrence limitée (défaut 3) et délai inter-batches. Évite de saturer le réseau au
|
||||||
|
démarrage.
|
||||||
|
- **`filterRenderers` par UDN** (`UnifiedControlView.vue:107-120`) : filtre les WebRenderers
|
||||||
|
étrangers en comparant le UDN normalisé. Logique correcte avec gestion du préfixe `uuid:`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## État d'avancement — corrections appliquées (commit 2026-04-06)
|
||||||
|
|
||||||
|
Les 12 points de la revue ont été traités. Le tableau ci-dessous récapitule ce qui a été
|
||||||
|
fait et ce qui reste à finir.
|
||||||
|
|
||||||
|
| # | Problème | État |
|
||||||
|
|---|----------|------|
|
||||||
|
| 1 | `apiCache` — TTL mutation globale | ✅ Corrigé (`ttl` par entrée dans `CacheEntry`, `isFresh()` lit `entry.ttl`) |
|
||||||
|
| 2 | `useRenderers` — double réassignation post-switch | ✅ Corrigé (chaque `case` responsable, ligne 222 supprimée) |
|
||||||
|
| 3 | `useRenderers` — `as any` sur `transport_state` | ✅ Corrigé (`isTransportState` guard dans `types.ts`) |
|
||||||
|
| 4 | `apiCache` — `invalidate()` détruisait les subscriptions | ✅ Corrigé (`this.subscriptions.delete` supprimé) |
|
||||||
|
| 5 | `useRenderers` — `toRaw()` / `reactive(Map)` fragile | ✅ Migré vers `shallowRef` + helpers `triggerSnapshotReactivity()` / `triggerLoadingReactivity()` |
|
||||||
|
| 6 | `useRenderer()` — timer debounce non nettoyé | ✅ Corrigé (`onUnmounted` + `clearTimeout`) |
|
||||||
|
| 7 | Singleton SSE non réinitialisable | ✅ Corrigé (`resetSSE()` exposé dans le retour de `useRenderers()`) |
|
||||||
|
| 8 | `useTabs` — deep watch coûteux | ✅ Corrigé (watches séparés sans `deep: true`) |
|
||||||
|
| 9 | Swipe — `clientX` final au lieu d'initial | ✅ Corrigé (`swipeStartX` capturé dans `onSwipeStart`) |
|
||||||
|
| 10 | `api.ts` — JSON non validé | ✅ Corrigé (log dev-mode pour réponse nulle) |
|
||||||
|
| 11 | Absence de labels ARIA | ✅ Corrigé (4 boutons transport + bouton mute + slider volume) |
|
||||||
|
| 12 | Erreurs réseau silencieuses | ✅ Corrigé (`uiStore.notifyError()` dans `fetchRenderers` et `fetchRendererSnapshot`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tâches restantes
|
||||||
|
|
||||||
|
Trois problèmes résiduels ont été introduits ou laissés lors de l'implémentation du commit
|
||||||
|
ci-dessus. Ils doivent être corrigés.
|
||||||
|
|
||||||
|
### A. `useRenderers.ts` — `state_changed` : mutation directe sans déclenchement de réactivité
|
||||||
|
|
||||||
|
**Problème** : avec la migration vers `shallowRef`, les objets dans la Map ne sont plus des
|
||||||
|
proxies Vue. La mutation directe de `snapshot.state.transport_state` ne déclenche aucune
|
||||||
|
réactivité — les composants ne se mettront pas à jour quand l'état de transport change :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRenderers.ts — case state_changed (code actuel)
|
||||||
|
case "state_changed":
|
||||||
|
if (isTransportState(event.state)) {
|
||||||
|
snapshot.state.transport_state = event.state; // ← mutation directe, pas de trigger
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution** : créer un nouvel objet, comme pour `volume_changed` et `mute_changed` :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
case "state_changed":
|
||||||
|
if (isTransportState(event.state)) {
|
||||||
|
snapshots.value.set(rendererId, {
|
||||||
|
...snapshot,
|
||||||
|
state: { ...snapshot.state, transport_state: event.state },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B. `useRenderers.ts` — `queue_refreshing` / `queue_updated` : mutations de `queueRefreshingIds` sans trigger
|
||||||
|
|
||||||
|
**Problème** : `queueRefreshingIds` est un `shallowRef<Set>`. Les appels `.add()` et
|
||||||
|
`.delete()` sur `.value` ne déclenchent pas la réactivité de `shallowRef` :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRenderers.ts — case queue_refreshing (code actuel)
|
||||||
|
case "queue_refreshing":
|
||||||
|
queueRefreshingIds.value.add(rendererId); // ← pas de trigger
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "queue_updated":
|
||||||
|
queueRefreshingIds.value.delete(rendererId); // ← pas de trigger
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
Le composable `isQueueRefreshing(id)` retourne `queueRefreshingIds.value.has(id)`. Sans
|
||||||
|
trigger, les templates qui dépendent de cette valeur ne se recalculeront pas.
|
||||||
|
|
||||||
|
**Solution** : ajouter une fonction `triggerQueueReactivity()` analogue à
|
||||||
|
`triggerLoadingReactivity()` et l'appeler après chaque mutation :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function triggerQueueReactivity() {
|
||||||
|
queueRefreshingIds.value = new Set(queueRefreshingIds.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dans le switch :
|
||||||
|
case "queue_refreshing":
|
||||||
|
queueRefreshingIds.value.add(rendererId);
|
||||||
|
triggerQueueReactivity();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "queue_updated":
|
||||||
|
snapshot.state.queue_len = event.queue_length;
|
||||||
|
queueRefreshingIds.value.delete(rendererId);
|
||||||
|
triggerQueueReactivity();
|
||||||
|
void fetchRendererSnapshot(rendererId, { force: true });
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C. `useRenderers.ts` — import `toRaw` et commentaire obsolètes
|
||||||
|
|
||||||
|
**Problème** : avec `shallowRef`, les objets stockés dans `snapshots.value` sont de simples
|
||||||
|
objets JavaScript (jamais des proxies Vue). L'appel `toRaw(snapshot.state)` dans
|
||||||
|
`position_changed` est devenu un no-op, et le commentaire qui le justifie est trompeur :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// useRenderers.ts:160-163 (commentaire et import obsolètes)
|
||||||
|
// IMPORTANT: Utiliser toRaw() pour obtenir l'objet brut non-réactif avant de copier
|
||||||
|
// sinon Vue copie les getters réactifs qui continuent à pointer vers l'objet d'origine
|
||||||
|
const rawState = toRaw(snapshot.state);
|
||||||
|
const newState = { ...rawState };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution** : supprimer le `toRaw()`, simplifier en spread direct, retirer `toRaw` de
|
||||||
|
l'import ligne 7 :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Remplacer :
|
||||||
|
import { ref, shallowRef, computed, toRaw, type Ref, onUnmounted } from "vue";
|
||||||
|
|
||||||
|
// Par :
|
||||||
|
import { ref, shallowRef, computed, type Ref, onUnmounted } from "vue";
|
||||||
|
|
||||||
|
// Dans position_changed :
|
||||||
|
const newSnapshot = {
|
||||||
|
...snapshot,
|
||||||
|
state: {
|
||||||
|
...snapshot.state,
|
||||||
|
position_ms: positionMs ?? 0,
|
||||||
|
duration_ms: durationMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
snapshots.value.set(rendererId, newSnapshot);
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette simplification rend aussi le cas `position_changed` cohérent avec les autres cases
|
||||||
|
(`volume_changed`, `mute_changed`, etc.) qui construisent directement l'objet final sans
|
||||||
|
passer par une variable intermédiaire.
|
||||||
@@ -68,6 +68,7 @@ async function handleNext() {
|
|||||||
:disabled="isPlaying"
|
:disabled="isPlaying"
|
||||||
@click="handlePlay"
|
@click="handlePlay"
|
||||||
title="Lecture"
|
title="Lecture"
|
||||||
|
aria-label="Lecture"
|
||||||
>
|
>
|
||||||
<Play :size="20" />
|
<Play :size="20" />
|
||||||
</button>
|
</button>
|
||||||
@@ -77,6 +78,7 @@ async function handleNext() {
|
|||||||
:disabled="isPaused || isStopped"
|
:disabled="isPaused || isStopped"
|
||||||
@click="handlePause"
|
@click="handlePause"
|
||||||
title="Pause"
|
title="Pause"
|
||||||
|
aria-label="Pause"
|
||||||
>
|
>
|
||||||
<Pause :size="20" />
|
<Pause :size="20" />
|
||||||
</button>
|
</button>
|
||||||
@@ -86,6 +88,7 @@ async function handleNext() {
|
|||||||
:disabled="isStopped"
|
:disabled="isStopped"
|
||||||
@click="handleStop"
|
@click="handleStop"
|
||||||
title="Stop"
|
title="Stop"
|
||||||
|
aria-label="Arrêter"
|
||||||
>
|
>
|
||||||
<Square :size="20" />
|
<Square :size="20" />
|
||||||
</button>
|
</button>
|
||||||
@@ -95,6 +98,7 @@ async function handleNext() {
|
|||||||
:disabled="!state?.queue_len"
|
:disabled="!state?.queue_len"
|
||||||
@click="handleNext"
|
@click="handleNext"
|
||||||
title="Suivant"
|
title="Suivant"
|
||||||
|
aria-label="Morceau suivant"
|
||||||
>
|
>
|
||||||
<SkipForward :size="20" />
|
<SkipForward :size="20" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ async function handleToggleMute() {
|
|||||||
class="btn btn-icon"
|
class="btn btn-icon"
|
||||||
@click="handleToggleMute"
|
@click="handleToggleMute"
|
||||||
:title="state?.mute ? 'Réactiver le son' : 'Couper le son'"
|
:title="state?.mute ? 'Réactiver le son' : 'Couper le son'"
|
||||||
|
:aria-label="state?.mute ? 'Réactiver le son' : 'Couper le son'"
|
||||||
>
|
>
|
||||||
<VolumeX v-if="state?.mute" :size="20" />
|
<VolumeX v-if="state?.mute" :size="20" />
|
||||||
<Volume2 v-else :size="20" />
|
<Volume2 v-else :size="20" />
|
||||||
@@ -78,6 +79,7 @@ async function handleToggleMute() {
|
|||||||
@input="handleVolumeChange"
|
@input="handleVolumeChange"
|
||||||
class="volume-slider"
|
class="volume-slider"
|
||||||
:disabled="state?.mute ?? false"
|
:disabled="state?.mute ?? false"
|
||||||
|
aria-label="Volume"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span class="volume-value">{{ localVolume }}</span>
|
<span class="volume-value">{{ localVolume }}</span>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
export interface CacheEntry<T> {
|
export interface CacheEntry<T> {
|
||||||
data: T;
|
data: T;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
ttl?: number;
|
||||||
etag?: string;
|
etag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +52,9 @@ class ApiCacheService {
|
|||||||
private isFresh(key: string): boolean {
|
private isFresh(key: string): boolean {
|
||||||
const entry = this.cache.get(key);
|
const entry = this.cache.get(key);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
return Date.now() - entry.timestamp < this.options.ttl;
|
// Lire le TTL de l'entrée, sinon utiliser le TTL global par défaut
|
||||||
|
const ttl = entry.ttl ?? this.options.ttl;
|
||||||
|
return Date.now() - entry.timestamp < ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
get<T>(endpoint: string, params?: Record<string, string | number | boolean>): T | null {
|
get<T>(endpoint: string, params?: Record<string, string | number | boolean>): T | null {
|
||||||
@@ -66,12 +69,13 @@ class ApiCacheService {
|
|||||||
return entry.data;
|
return entry.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
set<T>(endpoint: string, data: T, params?: Record<string, string | number | boolean>, etag?: string): void {
|
set<T>(endpoint: string, data: T, params?: Record<string, string | number | boolean>, etag?: string, ttl?: number): void {
|
||||||
const key = this.makeKey(endpoint, params);
|
const key = this.makeKey(endpoint, params);
|
||||||
|
|
||||||
this.cache.set(key, {
|
this.cache.set(key, {
|
||||||
data,
|
data,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
ttl,
|
||||||
etag,
|
etag,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,14 +131,9 @@ class ApiCacheService {
|
|||||||
try {
|
try {
|
||||||
const data = await fetcher();
|
const data = await fetcher();
|
||||||
|
|
||||||
if (ttl) {
|
// Passer le TTL directement à set() pour éviter les problèmes de race condition
|
||||||
const originalTtl = this.options.ttl;
|
// avec la modification globale de this.options.ttl
|
||||||
this.options.ttl = ttl;
|
this.set(endpoint, data, params, undefined, ttl);
|
||||||
this.set(endpoint, data, params);
|
|
||||||
this.options.ttl = originalTtl;
|
|
||||||
} else {
|
|
||||||
this.set(endpoint, data, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvePromise(data);
|
resolvePromise(data);
|
||||||
|
|
||||||
@@ -196,7 +195,8 @@ class ApiCacheService {
|
|||||||
|
|
||||||
keysToDelete.forEach(key => {
|
keysToDelete.forEach(key => {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
this.subscriptions.delete(key);
|
// NE PAS supprimer this.subscriptions.get(key)
|
||||||
|
// Les abonnés seront notifiés lors du prochain set() après un refetch
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
* - Les snapshots complets proviennent de /renderers/{id}/full
|
* - Les snapshots complets proviennent de /renderers/{id}/full
|
||||||
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
||||||
*/
|
*/
|
||||||
import { ref, reactive, computed, toRaw, type Ref } from "vue";
|
import { ref, shallowRef, computed, toRaw, type Ref, onUnmounted } from "vue";
|
||||||
import { api } from "../services/pmocontrol/api";
|
import { api } from "../services/pmocontrol/api";
|
||||||
import { useSSE } from "./useSSE";
|
import { useSSE } from "./useSSE";
|
||||||
import { apiCache } from "./apiCache";
|
import { apiCache } from "./apiCache";
|
||||||
import { parseTimeToMs } from "../utils/time";
|
import { parseTimeToMs } from "../utils/time";
|
||||||
|
import { useUIStore } from "@/stores/ui";
|
||||||
import type {
|
import type {
|
||||||
RendererSummary,
|
RendererSummary,
|
||||||
RendererState,
|
RendererState,
|
||||||
@@ -16,33 +17,43 @@ import type {
|
|||||||
AttachedPlaylistInfo,
|
AttachedPlaylistInfo,
|
||||||
FullRendererSnapshot,
|
FullRendererSnapshot,
|
||||||
} from "../services/pmocontrol/types";
|
} from "../services/pmocontrol/types";
|
||||||
|
import { isTransportState } from "../services/pmocontrol/types";
|
||||||
|
|
||||||
interface RendererSnapshotState {
|
// État global des snapshots avec shallowRef pour éviter les problèmes de réactivité avec les Maps
|
||||||
snapshots: Map<string, FullRendererSnapshot>;
|
const snapshots = shallowRef(new Map<string, FullRendererSnapshot>());
|
||||||
lastSnapshotAt: Map<string, number>;
|
const lastSnapshotAt = shallowRef(new Map<string, number>());
|
||||||
lastEventAt: Map<string, number>;
|
const lastEventAt = shallowRef(new Map<string, number>());
|
||||||
loadingIds: Set<string>;
|
const loadingIds = shallowRef(new Set<string>());
|
||||||
queueRefreshingIds: Set<string>;
|
const queueRefreshingIds = shallowRef(new Set<string>());
|
||||||
selectedRendererId: string | null;
|
const selectedRendererId = ref<string | null>(null);
|
||||||
}
|
|
||||||
|
|
||||||
|
// Cache des renderers (summary)
|
||||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||||
const RENDERERS_CACHE_MS = 2000;
|
const RENDERERS_CACHE_MS = 2000;
|
||||||
|
|
||||||
const snapshotState = reactive<RendererSnapshotState>({
|
// Helper pour déclencher la réactivité après mutation des Maps
|
||||||
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
|
function triggerSnapshotReactivity() {
|
||||||
lastSnapshotAt: reactive(new Map<string, number>()),
|
// Créer une nouvelle référence pour déclencher la réactivité
|
||||||
lastEventAt: reactive(new Map<string, number>()),
|
snapshots.value = new Map(snapshots.value);
|
||||||
loadingIds: reactive(new Set<string>()),
|
}
|
||||||
queueRefreshingIds: reactive(new Set<string>()),
|
|
||||||
selectedRendererId: null,
|
function triggerLoadingReactivity() {
|
||||||
});
|
loadingIds.value = new Set(loadingIds.value);
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
// Utiliser le composable SSE centralisé
|
// Utiliser le composable SSE centralisé
|
||||||
let sseInitialized = false;
|
let sseInitialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réinitialise le flag SSE pour permettre une nouvelle connexion après reconnexion
|
||||||
|
*/
|
||||||
|
function resetSSE() {
|
||||||
|
sseInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureSSEInitialized() {
|
function ensureSSEInitialized() {
|
||||||
if (sseInitialized) return;
|
if (sseInitialized) return;
|
||||||
|
|
||||||
@@ -99,16 +110,17 @@ function ensureSSEInitialized() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Supprimer le snapshot (il n'est plus valide)
|
// Supprimer le snapshot (il n'est plus valide)
|
||||||
snapshotState.snapshots.delete(rendererId);
|
snapshots.value.delete(rendererId);
|
||||||
snapshotState.lastSnapshotAt.delete(rendererId);
|
triggerSnapshotReactivity();
|
||||||
snapshotState.lastEventAt.delete(rendererId);
|
lastSnapshotAt.value.delete(rendererId);
|
||||||
|
lastEventAt.value.delete(rendererId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pour les autres événements, mettre à jour le snapshot local directement
|
// Pour les autres événements, mettre à jour le snapshot local directement
|
||||||
snapshotState.lastEventAt.set(rendererId, timestamp);
|
lastEventAt.value.set(rendererId, timestamp);
|
||||||
|
|
||||||
const snapshot = snapshotState.snapshots.get(rendererId);
|
const snapshot = snapshots.value.get(rendererId);
|
||||||
|
|
||||||
// Si pas de snapshot, on doit fetch
|
// Si pas de snapshot, on doit fetch
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
@@ -119,7 +131,12 @@ function ensureSSEInitialized() {
|
|||||||
// Sinon, mettre à jour le snapshot localement selon le type d'événement
|
// Sinon, mettre à jour le snapshot localement selon le type d'événement
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "state_changed":
|
case "state_changed":
|
||||||
snapshot.state.transport_state = event.state as any;
|
// Utiliser le guard de type pour valider le transport_state
|
||||||
|
if (isTransportState(event.state)) {
|
||||||
|
snapshot.state.transport_state = event.state;
|
||||||
|
} else {
|
||||||
|
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "position_changed":
|
case "position_changed":
|
||||||
@@ -148,15 +165,23 @@ function ensureSSEInitialized() {
|
|||||||
...snapshot,
|
...snapshot,
|
||||||
state: newState,
|
state: newState,
|
||||||
};
|
};
|
||||||
snapshotState.snapshots.set(rendererId, newSnapshot);
|
snapshots.value.set(rendererId, newSnapshot);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "volume_changed":
|
case "volume_changed":
|
||||||
snapshot.state.volume = event.volume;
|
// Créer un nouvel objet pour déclencher la réactivité
|
||||||
|
snapshots.value.set(rendererId, {
|
||||||
|
...snapshot,
|
||||||
|
state: { ...snapshot.state, volume: event.volume },
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "mute_changed":
|
case "mute_changed":
|
||||||
snapshot.state.mute = event.mute;
|
// Créer un nouvel objet pour déclencher la réactivité
|
||||||
|
snapshots.value.set(rendererId, {
|
||||||
|
...snapshot,
|
||||||
|
state: { ...snapshot.state, mute: event.mute },
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "metadata_changed":
|
case "metadata_changed":
|
||||||
@@ -173,19 +198,19 @@ function ensureSSEInitialized() {
|
|||||||
snapshot.state.current_track.album = event.album;
|
snapshot.state.current_track.album = event.album;
|
||||||
snapshot.state.current_track.album_art_uri = event.album_art_uri;
|
snapshot.state.current_track.album_art_uri = event.album_art_uri;
|
||||||
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
||||||
snapshotState.snapshots.set(rendererId, {
|
snapshots.value.set(rendererId, {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
state: { ...snapshot.state },
|
state: { ...snapshot.state },
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "queue_refreshing":
|
case "queue_refreshing":
|
||||||
snapshotState.queueRefreshingIds.add(rendererId);
|
queueRefreshingIds.value.add(rendererId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "queue_updated":
|
case "queue_updated":
|
||||||
snapshot.state.queue_len = event.queue_length;
|
snapshot.state.queue_len = event.queue_length;
|
||||||
snapshotState.queueRefreshingIds.delete(rendererId);
|
queueRefreshingIds.value.delete(rendererId);
|
||||||
// Pour la queue complète, on doit refetch
|
// Pour la queue complète, on doit refetch
|
||||||
void fetchRendererSnapshot(rendererId, { force: true });
|
void fetchRendererSnapshot(rendererId, { force: true });
|
||||||
break;
|
break;
|
||||||
@@ -202,10 +227,17 @@ function ensureSSEInitialized() {
|
|||||||
snapshot.binding = null;
|
snapshot.binding = null;
|
||||||
snapshot.state.attached_playlist = null;
|
snapshot.state.attached_playlist = null;
|
||||||
}
|
}
|
||||||
|
// Créer un nouvel objet pour déclencher la réactivité
|
||||||
|
snapshots.value.set(rendererId, {
|
||||||
|
...snapshot,
|
||||||
|
state: { ...snapshot.state },
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "stream_state_changed":
|
case "stream_state_changed":
|
||||||
snapshot.is_stream = event.is_stream;
|
snapshot.is_stream = event.is_stream;
|
||||||
|
// Créer un nouvel objet pour déclencher la réactivité
|
||||||
|
snapshots.value.set(rendererId, { ...snapshot });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "timer_started":
|
case "timer_started":
|
||||||
@@ -218,8 +250,8 @@ function ensureSSEInitialized() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger reactivity
|
// Note: chaque case est maintenant responsable de stocker le snapshot dans la Map
|
||||||
snapshotState.snapshots.set(rendererId, snapshot);
|
// Plus de réassignation finale après le switch
|
||||||
});
|
});
|
||||||
|
|
||||||
sseInitialized = true;
|
sseInitialized = true;
|
||||||
@@ -230,7 +262,7 @@ const onlineRenderers = computed(() =>
|
|||||||
allRenderers.value.filter((r) => r.online),
|
allRenderers.value.filter((r) => r.online),
|
||||||
);
|
);
|
||||||
const allSnapshots = computed(() =>
|
const allSnapshots = computed(() =>
|
||||||
Array.from(snapshotState.snapshots.values()),
|
Array.from(snapshots.value.values()),
|
||||||
);
|
);
|
||||||
const playingRenderers = computed(() =>
|
const playingRenderers = computed(() =>
|
||||||
allSnapshots.value
|
allSnapshots.value
|
||||||
@@ -243,35 +275,38 @@ function getRendererById(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSnapshotById(id: string) {
|
function getSnapshotById(id: string) {
|
||||||
return snapshotState.snapshots.get(id) ?? null;
|
return snapshots.value.get(id) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStateById(id: string): RendererState | null {
|
function getStateById(id: string): RendererState | null {
|
||||||
return snapshotState.snapshots.get(id)?.state ?? null;
|
return snapshots.value.get(id)?.state ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getQueueById(id: string): QueueSnapshot | null {
|
function getQueueById(id: string): QueueSnapshot | null {
|
||||||
return snapshotState.snapshots.get(id)?.queue ?? null;
|
return snapshots.value.get(id)?.queue ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBindingById(id: string): AttachedPlaylistInfo | null {
|
function getBindingById(id: string): AttachedPlaylistInfo | null {
|
||||||
return snapshotState.snapshots.get(id)?.binding ?? null;
|
return snapshots.value.get(id)?.binding ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSnapshotLoading(id: string) {
|
function isSnapshotLoading(id: string) {
|
||||||
return snapshotState.loadingIds.has(id);
|
return loadingIds.value.has(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isQueueRefreshing(id: string) {
|
function isQueueRefreshing(id: string) {
|
||||||
return snapshotState.queueRefreshingIds.has(id);
|
return queueRefreshingIds.value.has(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRenderer(id: string | null) {
|
function selectRenderer(id: string | null) {
|
||||||
snapshotState.selectedRendererId = id;
|
selectedRendererId.value = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRenderers(force = false, retries = 2) {
|
async function fetchRenderers(force = false, retries = 2) {
|
||||||
ensureSSEInitialized();
|
ensureSSEInitialized();
|
||||||
|
|
||||||
|
// Créer le store UI pour les notifications (lazy import pour éviter les effets de bord)
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
@@ -304,6 +339,9 @@ async function fetchRenderers(force = false, retries = 2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
error.value = lastError?.message ?? "Erreur fetch renderers";
|
error.value = lastError?.message ?? "Erreur fetch renderers";
|
||||||
|
|
||||||
|
// Notifier l'utilisateur en cas d'erreur finale
|
||||||
|
uiStore.notifyError("Impossible de rafraîchir la liste des renderers");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRendererSnapshot(
|
async function fetchRendererSnapshot(
|
||||||
@@ -312,33 +350,43 @@ async function fetchRendererSnapshot(
|
|||||||
) {
|
) {
|
||||||
ensureSSEInitialized();
|
ensureSSEInitialized();
|
||||||
const force = opts?.force ?? false;
|
const force = opts?.force ?? false;
|
||||||
const hasSnapshot = snapshotState.snapshots.has(rendererId);
|
const hasSnapshot = snapshots.value.has(rendererId);
|
||||||
|
|
||||||
if (!force && hasSnapshot) {
|
if (!force && hasSnapshot) {
|
||||||
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0;
|
const lastSnapshot = lastSnapshotAt.value.get(rendererId) ?? 0;
|
||||||
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0;
|
const lastEvent = lastEventAt.value.get(rendererId) ?? 0;
|
||||||
if (lastEvent <= lastSnapshot) {
|
if (lastEvent <= lastSnapshot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Éviter les requêtes multiples simultanées pour le même renderer
|
// Éviter les requêtes multiples simultanées pour le même renderer
|
||||||
if (snapshotState.loadingIds.has(rendererId)) {
|
if (loadingIds.value.has(rendererId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshotState.loadingIds.add(rendererId);
|
loadingIds.value.add(rendererId);
|
||||||
|
triggerLoadingReactivity();
|
||||||
|
|
||||||
|
// Lazy load UI store pour les notifications
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await api.getRendererFullSnapshot(rendererId);
|
const snapshot = await api.getRendererFullSnapshot(rendererId);
|
||||||
snapshotState.snapshots.set(rendererId, snapshot);
|
snapshots.value.set(rendererId, snapshot);
|
||||||
snapshotState.lastSnapshotAt.set(rendererId, Date.now());
|
lastSnapshotAt.value.set(rendererId, Date.now());
|
||||||
|
triggerSnapshotReactivity();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
|
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
|
||||||
// En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative
|
// En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative
|
||||||
snapshotState.snapshots.delete(rendererId);
|
snapshots.value.delete(rendererId);
|
||||||
|
triggerSnapshotReactivity();
|
||||||
|
// Notifier l'utilisateur
|
||||||
|
uiStore.notifyError(`Impossible de récupérer l'état du renderer`);
|
||||||
} finally {
|
} finally {
|
||||||
// Toujours nettoyer le flag de chargement
|
// Toujours nettoyer le flag de chargement
|
||||||
snapshotState.loadingIds.delete(rendererId);
|
loadingIds.value.delete(rendererId);
|
||||||
|
triggerLoadingReactivity();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +436,7 @@ async function play(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resumeOrPlayFromQueue(id: string) {
|
async function resumeOrPlayFromQueue(id: string) {
|
||||||
const snapshot = snapshotState.snapshots.get(id);
|
const snapshot = snapshots.value.get(id);
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
throw new Error(`Renderer ${id} non trouvé`);
|
throw new Error(`Renderer ${id} non trouvé`);
|
||||||
}
|
}
|
||||||
@@ -510,7 +558,6 @@ export function useRenderers() {
|
|||||||
isSnapshotLoading,
|
isSnapshotLoading,
|
||||||
isQueueRefreshing,
|
isQueueRefreshing,
|
||||||
selectRenderer,
|
selectRenderer,
|
||||||
snapshotState,
|
|
||||||
// Fetchers
|
// Fetchers
|
||||||
fetchRenderers,
|
fetchRenderers,
|
||||||
fetchRendererSnapshot,
|
fetchRendererSnapshot,
|
||||||
@@ -534,6 +581,8 @@ export function useRenderers() {
|
|||||||
playContent,
|
playContent,
|
||||||
addToQueue,
|
addToQueue,
|
||||||
addAfterCurrent,
|
addAfterCurrent,
|
||||||
|
// SSE
|
||||||
|
resetSSE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +602,14 @@ export function useRenderer(rendererId: Ref<string>) {
|
|||||||
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const REFRESH_DEBOUNCE_MS = 500;
|
const REFRESH_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
|
// Nettoyer le timer debounce si le composant est démonté
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshDebounceTimer !== null) {
|
||||||
|
clearTimeout(refreshDebounceTimer);
|
||||||
|
refreshDebounceTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function refresh(force = true) {
|
async function refresh(force = true) {
|
||||||
const currentRendererId = rendererId.value;
|
const currentRendererId = rendererId.value;
|
||||||
|
|
||||||
|
|||||||
@@ -346,12 +346,18 @@ function openServer(server: MediaServerSummary | undefined) {
|
|||||||
*/
|
*/
|
||||||
export function useTabs() {
|
export function useTabs() {
|
||||||
// Watch pour sauvegarde automatique (uniquement les server tabs)
|
// Watch pour sauvegarde automatique (uniquement les server tabs)
|
||||||
|
// Watch séparés sans deep pour éviter la sérialisation complète à chaque mutation
|
||||||
|
watch(() => state.activeTabId, () => {
|
||||||
|
saveToLocalStorage();
|
||||||
|
});
|
||||||
|
watch(() => state.tabHistory.length, () => {
|
||||||
|
saveToLocalStorage();
|
||||||
|
});
|
||||||
watch(
|
watch(
|
||||||
() => [state.tabs, state.activeTabId, state.tabHistory],
|
() => state.tabs.map(t => t.id + t.type + (t.metadata?.rendererId ?? '') + (t.metadata?.serverId ?? '')).join('|'),
|
||||||
() => {
|
() => {
|
||||||
saveToLocalStorage();
|
saveToLocalStorage();
|
||||||
},
|
},
|
||||||
{ deep: true },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Restaurer au montage (uniquement les server tabs)
|
// Restaurer au montage (uniquement les server tabs)
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ class PMOControlAPI {
|
|||||||
throw new Error(error.error);
|
throw new Error(error.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
const data = await response.json();
|
||||||
|
if (import.meta.env.DEV && data == null) {
|
||||||
|
console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -39,13 +39,7 @@ export interface RendererSummary {
|
|||||||
export interface RendererState {
|
export interface RendererState {
|
||||||
id: string;
|
id: string;
|
||||||
friendly_name: string;
|
friendly_name: string;
|
||||||
transport_state:
|
transport_state: TransportState;
|
||||||
| "PLAYING"
|
|
||||||
| "PAUSED"
|
|
||||||
| "STOPPED"
|
|
||||||
| "TRANSITIONING"
|
|
||||||
| "NO_MEDIA"
|
|
||||||
| "UNKNOWN";
|
|
||||||
position_ms: number | null;
|
position_ms: number | null;
|
||||||
duration_ms: number | null;
|
duration_ms: number | null;
|
||||||
volume: number | null; // 0-100
|
volume: number | null; // 0-100
|
||||||
@@ -55,6 +49,15 @@ export interface RendererState {
|
|||||||
current_track: CurrentTrackMetadata | null;
|
current_track: CurrentTrackMetadata | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TransportState = "PLAYING" | "PAUSED" | "STOPPED" | "TRANSITIONING" | "NO_MEDIA" | "UNKNOWN";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard de type pour valider que string est un TransportState valide
|
||||||
|
*/
|
||||||
|
export function isTransportState(s: string): s is TransportState {
|
||||||
|
return ["PLAYING", "PAUSED", "STOPPED", "TRANSITIONING", "NO_MEDIA", "UNKNOWN"].includes(s);
|
||||||
|
}
|
||||||
|
|
||||||
export interface CurrentTrackMetadata {
|
export interface CurrentTrackMetadata {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
artist: string | null;
|
artist: string | null;
|
||||||
|
|||||||
@@ -34,17 +34,19 @@ const rendererDrawerOpen = ref(false);
|
|||||||
// Ref pour le swipe edge detection
|
// Ref pour le swipe edge detection
|
||||||
const viewRef = ref<HTMLElement | null>(null);
|
const viewRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// Position initiale du swipe pour détecter un swipe depuis le bord gauche
|
||||||
|
const swipeStartX = ref(0);
|
||||||
|
|
||||||
// Swipe depuis le bord gauche pour ouvrir le drawer
|
// Swipe depuis le bord gauche pour ouvrir le drawer
|
||||||
useSwipe(viewRef, {
|
useSwipe(viewRef, {
|
||||||
threshold: 50,
|
threshold: 50,
|
||||||
|
onSwipeStart(e: TouchEvent) {
|
||||||
|
swipeStartX.value = e.touches[0]?.clientX ?? 0;
|
||||||
|
},
|
||||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||||
// Swipe right depuis le bord gauche → ouvrir drawer
|
// Swipe right depuis le bord gauche → ouvrir drawer
|
||||||
if (swipeDirection === "right" && !drawerOpen.value) {
|
if (swipeDirection === "right" && !drawerOpen.value && swipeStartX.value < 50) {
|
||||||
const touch = _e.changedTouches[0];
|
drawerOpen.value = true;
|
||||||
// Vérifier que le swipe commence depuis le bord gauche (< 50px)
|
|
||||||
if (touch && touch.clientX < 50) {
|
|
||||||
drawerOpen.value = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user