[FE] Révise la stabilité, réactivité et performance du frontend

- Corrige fuites mémoire SSE via nettoyage des listeners (P0)
- Remplace shallowRef<Map> par reactive(new Map()) pour réactivité native Vue (P1)
- Ajoute timeout/AbortController aux requêtes fetch dans l'API client (P4)
- Déboucle les watch() de useTabs avec debounce unique et try/finally sur isRestoringFromStorage (P7)
- Limite notifications à 5 + nettoyage des timers dans UI store (P15)
- Protège routes debug avec import dynamique uniquement en DEV + wildcard 403 (P8)
- Déplace SVG par défaut dans assets/default-cover.svg et import ?raw (P10)
- Ajoute @media prefers-reduced-motion aux animations CSS globales
- Factorise styles drawer-btns avec .drawer-icon-classe + ajoute --opacity-disabled (P13)
- Encode les clés de cache browse avec encodeURIComponent + ':' séparateur (P14)
- Implémente pagination infinite scroll dans browseContainer/loadMore
+ Supprime formatMsToShortTime alias (P9)
- Corrige truncate() pour éviter dépassement maxLength si suffix >=maxLength (P10)
- Valide structure des commandes PMOPlayer avant traitement
+ Met à jour version Cargo.toml et lock (0.3.39)
This commit is contained in:
2026-04-06 09:50:54 +02:00
parent 78c37e1732
commit 8be250b167
26 changed files with 881 additions and 290 deletions

View File

@@ -0,0 +1,327 @@
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
## Contexte
Ce document est le résultat d'une revue de code complète du frontend Vue.js/TypeScript du Control Point
(`pmoapp/webapp/src/`). L'application est fonctionnelle mais présente plusieurs classes de problèmes
qui peuvent causer des fuites mémoire, des incohérences de réactivité Vue 3, et des difficultés de
maintenance à mesure que l'app grandit.
**Périmètre** : uniquement `pmoapp/webapp/src/` (composants, services, composables, stores, utils, CSS).
---
## Problèmes identifiés
### P0 — Fuites mémoire via listeners SSE jamais nettoyés
**Fichiers** : `src/composables/useSSE.ts`, `src/composables/useRenderers.ts`,
`src/composables/useMediaServers.ts`
Les abonnements aux événements SSE sont créés lors du premier appel de chaque composable, mais
jamais nettoyés si le composable est réutilisé ou le composant détruit. Dans `useSSE.ts`, la
fonction `onRendererEvent()` retourne une fonction de cleanup, mais `useRendererEvents()` ignore
ce retour — l'abonnement reste actif indefiniment.
De plus, dans `imageCache.ts`, un `setInterval` de cleanup s'exécute toutes les 5 minutes sans
jamais être annulé si l'app est détruite.
### P1 — Réactivité Vue incohérente avec `shallowRef` + Maps
**Fichier** : `src/composables/useRenderers.ts` (L23-37)
`snapshots` et `loadingIds` sont déclarés en `shallowRef<Map<...>>()`. Vue ne détecte pas les
mutations d'objets à l'intérieur d'un `shallowRef`. La solution actuelle — `triggerSnapshotReactivity()`
qui crée une nouvelle Map à chaque appel — force une re-render complète de tous les composants
qui dépendent de `snapshots`, même si seul un renderer a changé.
### P2 — Race condition à l'initialisation (main.ts)
**Fichier** : `src/main.ts`
Le UIStore est initialisé après le montage de l'app et l'appel à `sse.connect()`. Des événements
SSE peuvent arriver avant que `useUIStore()` soit appelé dans les composants, et les notifications
correspondantes peuvent être perdues.
### P3 — SSE singleton sans garantie formelle
**Fichiers** : `src/composables/useRenderers.ts`, `src/composables/useMediaServers.ts`
Chaque composable maintient son propre flag `sseInitialized` pour éviter les double-abonnements.
Le mécanisme repose sur une convention implicite fragile : si deux composables s'abonnent au même
type d'événement SSE dans des contextes différents, les callbacks s'accumulent sans être
dédupliqués.
Dans `useSSE.ts`, `setupConnectionListener()` vérifie `connectionCallbacks.size === 0` mais
sans lock — deux appels simultanés peuvent installer deux listeners.
### P4 — Pas de timeout ni retry sur les requêtes `fetch`
**Fichier** : `src/services/pmocontrol/api.ts`
Toutes les requêtes `fetch()` sont émises sans `AbortController`. Si le serveur ne répond pas,
la promesse pend indéfiniment, bloquant potentiellement les composants qui attendent le résultat.
Il n'y a ni timeout configurable ni retry automatique au niveau du service.
### P5 — Validation absente des réponses API
**Fichiers** : `src/services/audioCache.ts` (L76), `src/services/coverCache.ts`,
`src/services/playlists.ts`, `src/services/pmocontrol/api.ts`
Les réponses JSON sont acceptées sans vérification de structure. Une assertion de type comme
`metadata as { origin_url?: unknown }` ne protège pas contre un changement d'API côté Rust. Si
l'API retourne une structure inattendue, le crash survient au runtime, pas à la compilation.
### P6 — Type assertions dangereuses dans PMOPlayer
**Fichier** : `src/services/pmosource.ts` / PMOPlayer (L197-214)
Les messages de commande sont typés `Record<string, unknown>`, puis les propriétés sont castées
directement : `msg.url as string`, `msg.timestamp as number`. Si une propriété est absente ou
d'un type différent, TypeScript ne le détecte pas.
### P7 — `useTabs` : watch multiples sans debounce, flag de restauration non-réinitialisé
**Fichier** : `src/composables/useTabs.ts` (L44, L350-361)
Trois `watch()` séparées écrivent dans `localStorage`. Sans debounce commun, si 3 onglets
changent d'état simultanément, `localStorage` est écrit 3 fois de suite.
Le flag `isRestoringFromStorage` (L44) empêche la boucle de sauvegarde pendant la restauration,
mais sans timeout : si `restoreFromLocalStorage()` lance une exception non-catchée, le flag reste
`true` et toutes les sauvegardes futures sont silencieusement ignorées.
### P8 — Routes de debug exposées en production, pas de lazy loading
**Fichier** : `src/router/index.ts`
Les routes debug (CoversCache, AudioCache, UPnP Explorer, etc.) sont accessibles en production
sans contrôle d'accès. Par ailleurs, tous les composants sont importés statiquement, augmentant
le bundle initial inutilement — les vues debug notamment ne sont jamais utilisées en prod.
### P9 — `formatMsToShortTime` est un alias inutile
**Fichier** : `src/utils/time.ts` (L58-59)
```typescript
// Actuellement
export function formatMsToShortTime(ms: number | null): string {
return formatMsToTime(ms);
}
```
Fonction identique à `formatMsToTime`. Tous les appelants peuvent utiliser directement
`formatMsToTime`.
### P10 — `truncate()` dans `string.ts` peut dépasser `maxLength`
**Fichier** : `src/utils/string.ts` (L46-48)
```typescript
// Actuellement
export function truncate(str: string, maxLength: number, suffix = '…'): string {
return str.length > maxLength ? str.slice(0, maxLength - suffix.length) + suffix : str;
}
```
Si `suffix.length >= maxLength`, `str.slice(0, maxLength - suffix.length)` retourne une chaîne
de longueur négative (comportement silencieux en JS, retourne `''`), et le résultat final est
plus long que `maxLength`.
### P11 — `DEFAULT_COVER_SVG` inline dans coverCache.ts
**Fichier** : `src/services/coverCache.ts` (L206-226)
Un SVG inline de ~20 lignes est inclus dans chaque bundle qui importe `coverCache`. Il devrait
être un fichier `src/assets/default-cover.svg` importé nativement par Vite (ce qui permet le
tree-shaking et le caching HTTP séparé).
### P12 — `animations` CSS sans `prefers-reduced-motion`
**Fichiers** : `src/assets/styles/glass-theme.css` (L384-401),
`src/assets/styles/pmocontrol.css` (L82)
Les animations `glassShimmer` (2s infini) et le `pulse` du badge de statut `Transitioning`
s'exécutent sans tenir compte de `prefers-reduced-motion: reduce`. Sur certains systèmes ou
pour des utilisateurs sensibles au mouvement, ces animations sont gênantes.
### P13 — CSS dupliqué dans drawers.css
**Fichier** : `src/assets/styles/drawers.css` (L105-149)
`drawer-close-btn` et `drawer-back-btn` partagent 90% des styles. Un TODO présent en L167
("remplacer par la classe globale .section-title") confirme cette dette. La variable
`var(--opacity-disabled)` est utilisée mais non définie dans `variables.css`.
### P14 — `browseContainer` : clés de cache fragiles et pas de pagination
**Fichier** : `src/composables/useMediaServers.ts` (L145, L239)
Les clés de cache sont construites comme `${serverId}/${containerId}`. Si un `containerId`
contient un slash (séparateur d'URL), la clé est ambigüe. Par exemple, `server1/a/b` peut
correspondre à serverId=`server1`, containerId=`a/b` ou serverId=`server1/a`, containerId=`b`.
La pagination n'est pas implémentée côté composable : `browseContainer` charge toujours
offset=0, limit=50. Pour les containers avec 500+ items, les items au-delà de 50 ne sont
jamais accessibles.
### P15 — Notifications sans limite de taille dans `ui.ts`
**Fichier** : `src/stores/ui.ts` (L50, L55-57)
Un bug ou une boucle d'erreur peut générer des centaines de notifications. Le tableau
`notifications` n'est pas limité. Chaque notification crée un `setTimeout` individuel, et
si le store est détruit avant l'expiration, ces callbacks persistent (ghosts).
---
## Plan d'exécution
Les corrections sont groupées par effort et impact. Les P0P3 concernent la fiabilité
(fuites mémoire, réactivité), les P4P8 la robustesse et maintenabilité, les P9P15 la
qualité et la dette technique.
### Étape 1 — Corriger les fuites mémoire SSE (P0)
Dans `useSSE.ts`, stocker et appeler les fonctions de cleanup retournées par `onRendererEvent` /
`onMediaServerEvent` :
```typescript
// useSSE.ts useRendererEvents()
onMounted(() => {
const cleanup = onRendererEvent(rendererId(), handler);
onUnmounted(cleanup); // ← actuellement ignoré
});
```
Dans `imageCache.ts`, exporter une fonction `destroyImageCache()` qui appelle `clearInterval`
sur le timer de cleanup, et l'appeler dans le `onUnmounted` de l'app root.
### Étape 2 — Stabiliser la réactivité des snapshots (P1)
Remplacer `shallowRef<Map<...>>` + `triggerSnapshotReactivity` par `reactive(new Map<...>)`.
Vue 3 rend les Maps réactives nativement. Les composants qui lisent `snapshots.get(id)`
seront notifiés uniquement si ce `id` change.
```typescript
// Avant
const snapshots = shallowRef<Map<string, FullRendererSnapshot>>(new Map());
function triggerSnapshotReactivity() {
snapshots.value = new Map(snapshots.value);
}
// Après
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
// Les modifications directes (snapshots.set/delete) déclenchent la réactivité
```
### Étape 3 — Timeout fetch + AbortController (P4)
Ajouter un helper dans `api.ts` :
```typescript
function fetchWithTimeout(url: string, options?: RequestInit, timeoutMs = 10_000): Promise<Response> {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...options, signal: controller.signal })
.finally(() => clearTimeout(id));
}
```
Utiliser `fetchWithTimeout` pour toutes les requêtes dans le service API.
### Étape 4 — Corriger `useTabs` watchs et flag de restauration (P7)
Fusionner les trois `watch()` en un seul `watchEffect` avec un debounce unique (100ms).
Encadrer `isRestoringFromStorage` dans un bloc `try/finally` :
```typescript
async function restoreFromLocalStorage() {
isRestoringFromStorage = true;
try {
// ... logique de restauration
} catch (e) {
console.error('Tab restore failed:', e);
} finally {
isRestoringFromStorage = false;
}
}
```
### Étape 5 — Limit de notifications et nettoyage timers (P15)
```typescript
const MAX_NOTIFICATIONS = 5;
function addNotification(notif: Omit<Notification, 'id'>): void {
if (notifications.value.length >= MAX_NOTIFICATIONS) {
notifications.value.shift(); // supprimer la plus ancienne
}
const id = nextId++;
const timer = setTimeout(() => removeNotification(id), notif.duration ?? 5000);
notificationTimers.set(id, timer);
notifications.value.push({ ...notif, id });
}
function $dispose() {
notificationTimers.forEach(clearTimeout);
notificationTimers.clear();
}
```
### Étape 6 — Lazy loading des routes et protection debug (P8)
```typescript
// router/index.ts
const DebugView = () => import('../views/DebugView.vue');
const isDev = import.meta.env.DEV;
const routes = [
// ... routes normales
...(isDev ? [{ path: '/debug', component: DebugView }] : []),
{ path: '/:pathMatch(.*)*', redirect: '/' }, // wildcard 404
];
```
### Étape 7 — Corrections mineures (P9, P10, P11, P12, P13)
- **P9** : Supprimer `formatMsToShortTime`, remplacer tous les appels par `formatMsToTime`
- **P10** : Ajouter un guard dans `truncate` : `if (suffix.length >= maxLength) return str.slice(0, maxLength)`
- **P11** : Déplacer le SVG dans `src/assets/default-cover.svg` et l'importer avec `import defaultCover from '../assets/default-cover.svg?raw'`
- **P12** : Entourer les animations CSS avec `@media (prefers-reduced-motion: no-preference) { ... }`
- **P13** : Factoriser `drawer-close-btn` / `drawer-back-btn` avec une classe `.drawer-icon-btn`. Définir `--opacity-disabled: 0.4` dans `variables.css`
### Étape 8 — Clés de cache et pagination (P14)
Encoder les IDs dans les clés de cache :
```typescript
const cacheKey = `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`;
```
Utiliser `:` comme séparateur (absent de l'encoding) pour éviter toute ambigüité.
Pour la pagination, ajouter une propriété `hasMore: boolean` et `loadMore()` au résultat de
`browseContainer`, incrementant offset à chaque appel.
### Ordre d'exécution
1. Étape 1 — fuites SSE (P0) — fiabilité critique
2. Étape 2 — réactivité Map (P1) — fiabilité
3. Étape 3 — timeout fetch (P4) — robustesse réseau
4. Étape 4 — useTabs (P7) — fiabilité des onglets
5. Étape 5 — notifications (P15) — stabilité UI
6. Étape 6 — router (P8) — sécurité + performance bundle
7. Étape 7 — corrections mineures (P9P13)
8. Étape 8 — cache keys + pagination (P14)
## Règle après ces corrections
**Interdit** : créer un abonnement SSE (`onRendererEvent`, `onMediaServerEvent`) sans stocker
et appeler la fonction de cleanup retournée dans `onUnmounted`.
**Interdit** : utiliser `shallowRef<Map<...>>` avec mutation directe — utiliser `reactive(new Map())`
pour les Maps qui doivent déclencher la réactivité Vue sur leurs entrées.
**Obligatoire** : toute requête `fetch()` dans un service doit utiliser `fetchWithTimeout`
avec un AbortController.

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "PMOMusic" name = "PMOMusic"
version = "0.3.37" version = "0.3.39"
dependencies = [ dependencies = [
"axum 0.8.7", "axum 0.8.7",
"console-subscriber", "console-subscriber",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "PMOMusic" name = "PMOMusic"
version = "0.3.37" version = "0.3.39"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bgGrad)"/>
<g transform="translate(200, 200)">
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
</g>
<text x="200" y="360" text-anchor="middle"
font-family="system-ui, -apple-system, sans-serif"
font-size="20" fill="white" opacity="0.6">
No Image Available
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -101,13 +101,15 @@
font-size: var(--text-base); font-size: var(--text-base);
} }
/* Bouton fermer */ /* ========================================
.drawer-close-btn { ICONS COMMUNS POUR DRAWERS
======================================== */
/* Classe de base pour les boutons icônes des drawers */
.drawer-icon-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0; flex-shrink: 0;
padding: 0; padding: 0;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
@@ -118,34 +120,31 @@
color: var(--color-text); color: var(--color-text);
} }
.drawer-close-btn:hover { .drawer-icon-btn:hover {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
} }
.drawer-close-btn:active { .drawer-icon-btn:active {
transform: scale(0.95); transform: scale(0.95);
} }
/* Bouton fermer */
.drawer-close-btn {
width: 40px;
height: 40px;
}
.drawer-close-btn:hover {
transform: scale(1.1);
}
/* Bouton retour (ServerDrawer navigation) */ /* Bouton retour (ServerDrawer navigation) */
.drawer-back-btn { .drawer-back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px; width: 36px;
height: 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 var(--transition-fast) ease;
color: var(--color-text);
} }
.drawer-back-btn:hover { .drawer-back-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05); transform: scale(1.05);
} }

View File

@@ -381,24 +381,27 @@
ANIMATIONS ANIMATIONS
======================================== */ ======================================== */
@keyframes glassShimmer { /* Respecte prefers-reduced-motion */
0% { @media (prefers-reduced-motion: no-preference) {
background-position: -200% center; @keyframes glassShimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
} }
100% {
background-position: 200% center;
}
}
.glass-shimmer { .glass-shimmer {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.1) 50%, rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
); );
background-size: 200% 100%; background-size: 200% 100%;
animation: glassShimmer 2s ease-in-out infinite; animation: glassShimmer 2s ease-in-out infinite;
}
} }
/* ======================================== /* ========================================

View File

@@ -79,7 +79,14 @@ body {
background-color: var(--status-transitioning-bg); background-color: var(--status-transitioning-bg);
color: var(--status-transitioning); color: var(--status-transitioning);
border: 1px solid var(--status-transitioning); border: 1px solid var(--status-transitioning);
animation: pulse 2s infinite; animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.status-badge.transitioning {
animation: pulse 2s infinite;
}
} }
/* ======================================== /* ========================================
@@ -226,52 +233,81 @@ body {
/* ======================================== /* ========================================
Animations Animations
======================================== */ ======================================== */
@keyframes pulse {
0%, 100% { /* Respecte prefers-reduced-motion */
opacity: 1; @media (prefers-reduced-motion: no-preference) {
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
} }
50% {
opacity: 0.7; @keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes pulse-opacity {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.status-badge.transitioning {
animation: pulse 2s infinite;
}
.shuffle-button.loading {
animation: pulse 1s infinite;
}
.event-badge {
animation: pulse 2s ease-in-out infinite;
}
.loading-state {
animation: pulse 1.5s ease-in-out infinite;
} }
} }
@keyframes fadeIn { /* Default state (animations disabled) */
from { .status-badge.transitioning,
opacity: 0; .shuffle-button.loading,
transform: translateY(10px); .event-badge,
} .loading-state {
to { animation: none;
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 */ /* Reduced motion support */
@@ -546,10 +582,49 @@ input[type="range"]::-moz-range-thumb:hover {
} }
/* ======================================== /* ========================================
ANIMATIONS ANIMATIONS (Keyframes for no-preference media query)
======================================== */ ======================================== */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes slideInRight {
from {
transform: translateX(20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.fade-in {
animation: fadeIn var(--transition-base);
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes pulse-opacity { @keyframes pulse-opacity {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.5; } 50% { opacity: 0.5; }
} }

View File

@@ -89,13 +89,18 @@
--transition-base: 300ms ease-in-out; --transition-base: 300ms ease-in-out;
--transition-slow: 500ms ease-in-out; --transition-slow: 500ms ease-in-out;
/* ======================================== /* ========================================
Z-index layers Z-index layers
======================================== */ ======================================== */
--z-dropdown: 100; --z-dropdown: 100;
--z-modal: 200; --z-modal: 200;
--z-toast: 300; --z-toast: 300;
--z-tooltip: 400; --z-tooltip: 400;
/* ========================================
Opacity states
======================================== */
--opacity-disabled: 0.4;
} }
/* Dark mode support (optionnel pour l'avenir) */ /* Dark mode support (optionnel pour l'avenir) */

View File

@@ -981,7 +981,14 @@ button.active {
padding: 3rem; padding: 3rem;
color: #569cd6; color: #569cd6;
font-size: 1.1rem; font-size: 1.1rem;
animation: pulse 1.5s ease-in-out infinite; animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.loading-state {
animation: pulse 1.5s ease-in-out infinite;
}
} }
@keyframes pulse { @keyframes pulse {

View File

@@ -76,7 +76,14 @@ async function handleShuffle() {
} }
.shuffle-button.loading { .shuffle-button.loading {
animation: pulse 1s infinite; animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.shuffle-button.loading {
animation: pulse 1s infinite;
}
} }
@keyframes pulse { @keyframes pulse {

View File

@@ -372,7 +372,14 @@ watch(editingVar, (newVar) => {
.event-badge { .event-badge {
font-size: 1rem; font-size: 1rem;
animation: pulse 2s ease-in-out infinite; animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.event-badge {
animation: pulse 2s ease-in-out infinite;
}
} }
@keyframes pulse { @keyframes pulse {

View File

@@ -37,9 +37,20 @@ class ImageCacheService {
}; };
private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000; private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000;
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
constructor() { constructor() {
setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS); this.cleanupIntervalId = setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
}
/**
* Nettoie le timer de cleanup. À appeler lors de la destruction de l'application.
*/
destroy(): void {
if (this.cleanupIntervalId !== null) {
clearInterval(this.cleanupIntervalId);
this.cleanupIntervalId = null;
}
} }
configure(options: Partial<ImageCacheOptions>) { configure(options: Partial<ImageCacheOptions>) {

View File

@@ -20,11 +20,17 @@ export interface BrowseState {
container_id: string container_id: string
entries: ContainerEntry[] entries: ContainerEntry[]
total_count: number total_count: number
hasMore?: boolean
currentOffset?: number
} }
// Cache global partagé // Cache global partagé
const serversCache = ref<Map<string, MediaServerSummary>>(new Map()) const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
const browseCache = ref<Map<string, BrowseState>>(new Map()) const browseCache = ref<Map<string, BrowseState>>(new Map())
function browseCacheKey(serverId: string, containerId: string): string {
return `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`
}
const currentPath = ref<BreadcrumbItem[]>([]) const currentPath = ref<BreadcrumbItem[]>([])
const searchResults = ref<BrowseState | null>(null) const searchResults = ref<BrowseState | null>(null)
const searchQuery = ref<string>('') const searchQuery = ref<string>('')
@@ -69,9 +75,10 @@ function ensureSSEInitialized() {
} }
// Invalider tout le cache browse de ce serveur // Invalider tout le cache browse de ce serveur
const encodedServerId = encodeURIComponent(serverId)
const keysToDelete: string[] = [] const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => { browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) { if (key.startsWith(encodedServerId + ':')) {
keysToDelete.push(key) keysToDelete.push(key)
} }
}) })
@@ -80,9 +87,10 @@ function ensureSSEInitialized() {
case 'global_updated': case 'global_updated':
// Invalider tout le cache de ce serveur // Invalider tout le cache de ce serveur
const encodedServerIdGlobal = encodeURIComponent(serverId)
const globalKeysToDelete: string[] = [] const globalKeysToDelete: string[] = []
browseCache.value.forEach((_, key) => { browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) { if (key.startsWith(encodedServerIdGlobal + ':')) {
globalKeysToDelete.push(key) globalKeysToDelete.push(key)
} }
}) })
@@ -92,7 +100,7 @@ function ensureSSEInitialized() {
case 'containers_updated': case 'containers_updated':
// Invalider les containers spécifiques // Invalider les containers spécifiques
event.container_ids.forEach(containerId => { event.container_ids.forEach(containerId => {
const key = `${serverId}/${containerId}` const key = browseCacheKey(serverId, containerId)
browseCache.value.delete(key) browseCache.value.delete(key)
}) })
break break
@@ -142,7 +150,7 @@ export function useMediaServers() {
// Charge la première page (remplace le cache) // Charge la première page (remplace le cache)
async function browseContainer(serverId: string, containerId: string, useCache = true) { async function browseContainer(serverId: string, containerId: string, useCache = true) {
const key = `${serverId}/${containerId}` const key = browseCacheKey(serverId, containerId)
if (useCache && browseCache.value.has(key)) { if (useCache && browseCache.value.has(key)) {
return browseCache.value.get(key)! return browseCache.value.get(key)!
@@ -152,12 +160,14 @@ export function useMediaServers() {
loading.value = true loading.value = true
error.value = null error.value = null
const data = await api.browseContainer(serverId, containerId, 0) const data = await api.browseContainer(serverId, containerId, 0, 50)
browseCache.value.set(key, { browseCache.value.set(key, {
container_id: data.container_id, container_id: data.container_id,
entries: data.entries, entries: data.entries,
total_count: data.total_count, total_count: data.total_count,
hasMore: data.entries.length < data.total_count,
currentOffset: data.entries.length,
}) })
return browseCache.value.get(key)! return browseCache.value.get(key)!
@@ -172,22 +182,26 @@ export function useMediaServers() {
// Charge la page suivante et accumule (infinite scroll) // Charge la page suivante et accumule (infinite scroll)
async function loadMoreBrowse(serverId: string, containerId: string) { async function loadMoreBrowse(serverId: string, containerId: string) {
const key = `${serverId}/${containerId}` const key = browseCacheKey(serverId, containerId)
const state = browseCache.value.get(key) const state = browseCache.value.get(key)
if (!state) return if (!state) return
if (state.entries.length >= state.total_count) return if (!('hasMore' in state) || !state.hasMore) return
if (loadingMore.value) return if (loadingMore.value) return
try { try {
loadingMore.value = true loadingMore.value = true
const offset = state.entries.length // Le type cast est nécessaire car les anciens cached entries n'ont pas hasMore
const state = browseCache.value.get(key) as BrowseState & { hasMore?: boolean; currentOffset?: number }
const offset = state.currentOffset ?? state.entries.length
const data = await api.browseContainer(serverId, containerId, offset) const data = await api.browseContainer(serverId, containerId, offset)
// Accumuler les nouvelles entrées // Accumuler les nouvelles entrées
state.entries.push(...data.entries) state.entries.push(...data.entries)
state.total_count = data.total_count state.total_count = data.total_count
state.currentOffset = state.entries.length
state.hasMore = state.entries.length < state.total_count
// Forcer la réactivité // Forcer la réactivité
browseCache.value.set(key, { ...state }) browseCache.value.set(key, { ...state })
} catch (e) { } catch (e) {
@@ -236,12 +250,12 @@ export function useMediaServers() {
} }
function getBrowseCached(serverId: string, containerId: string) { function getBrowseCached(serverId: string, containerId: string) {
const key = `${serverId}/${containerId}` const key = browseCacheKey(serverId, containerId)
return browseCache.value.get(key) return browseCache.value.get(key)
} }
function hasMore(serverId: string, containerId: string): boolean { function hasMore(serverId: string, containerId: string): boolean {
const key = `${serverId}/${containerId}` const key = browseCacheKey(serverId, containerId)
const state = browseCache.value.get(key) const state = browseCache.value.get(key)
if (!state) return false if (!state) return false
return state.entries.length < state.total_count return state.entries.length < state.total_count
@@ -259,12 +273,12 @@ export function useMediaServers() {
// Invalidation du cache // Invalidation du cache
function invalidateCache(serverId: string, containerId?: string) { function invalidateCache(serverId: string, containerId?: string) {
if (containerId) { if (containerId) {
const key = `${serverId}/${containerId}` browseCache.value.delete(browseCacheKey(serverId, containerId))
browseCache.value.delete(key)
} else { } else {
const encodedServerId = encodeURIComponent(serverId)
const keysToDelete: string[] = [] const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => { browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) { if (key.startsWith(encodedServerId + ':')) {
keysToDelete.push(key) keysToDelete.push(key)
} }
}) })

View File

@@ -4,7 +4,7 @@
* - 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, shallowRef, computed, type Ref, onUnmounted } from "vue"; import { ref, reactive, computed, 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";
@@ -19,31 +19,19 @@ import type {
} from "../services/pmocontrol/types"; } from "../services/pmocontrol/types";
import { isTransportState } from "../services/pmocontrol/types"; import { isTransportState } from "../services/pmocontrol/types";
// État global des snapshots avec shallowRef pour éviter les problèmes de réactivité avec les Maps // État global des snapshots avec reactive pour une réactivité native Vue sur les Maps
const snapshots = shallowRef(new Map<string, FullRendererSnapshot>()); const snapshots = reactive(new Map<string, FullRendererSnapshot>());
const lastSnapshotAt = shallowRef(new Map<string, number>()); const lastSnapshotAt = reactive(new Map<string, number>());
const lastEventAt = shallowRef(new Map<string, number>()); const lastEventAt = reactive(new Map<string, number>());
const loadingIds = shallowRef(new Set<string>()); const loadingIds = reactive(new Set<string>());
const queueRefreshingIds = shallowRef(new Set<string>()); const queueRefreshingIds = reactive(new Set<string>());
const selectedRendererId = ref<string | null>(null); const selectedRendererId = ref<string | null>(null);
// Cache des renderers (summary) // 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;
// Helper pour déclencher la réactivité après mutation des Maps // Supprimé : les helpers triggerXXX ne sont plus nécessaires avec reactive
function triggerSnapshotReactivity() {
// Créer une nouvelle référence pour déclencher la réactivité
snapshots.value = new Map(snapshots.value);
}
function triggerLoadingReactivity() {
loadingIds.value = new Set(loadingIds.value);
}
function triggerQueueReactivity() {
queueRefreshingIds.value = new Set(queueRefreshingIds.value);
}
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
@@ -114,17 +102,16 @@ function ensureSSEInitialized() {
} }
// Supprimer le snapshot (il n'est plus valide) // Supprimer le snapshot (il n'est plus valide)
snapshots.value.delete(rendererId); snapshots.delete(rendererId);
triggerSnapshotReactivity(); lastSnapshotAt.delete(rendererId);
lastSnapshotAt.value.delete(rendererId); lastEventAt.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
lastEventAt.value.set(rendererId, timestamp); lastEventAt.set(rendererId, timestamp);
const snapshot = snapshots.value.get(rendererId); const snapshot = snapshots.get(rendererId);
// Si pas de snapshot, on doit fetch // Si pas de snapshot, on doit fetch
if (!snapshot) { if (!snapshot) {
@@ -137,11 +124,11 @@ function ensureSSEInitialized() {
case "state_changed": case "state_changed":
// Créer un nouvel objet pour déclencher la réactivité // Créer un nouvel objet pour déclencher la réactivité
if (isTransportState(event.state)) { if (isTransportState(event.state)) {
snapshots.value.set(rendererId, { snapshots.set(rendererId, {
...snapshot, ...snapshot,
state: { ...snapshot.state, transport_state: event.state }, state: { ...snapshot.state, transport_state: event.state },
}); });
triggerSnapshotReactivity();
} else { } else {
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`); console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
} }
@@ -158,7 +145,7 @@ function ensureSSEInitialized() {
const durationMs = parseTimeToMs(event.track_duration ?? null); const durationMs = parseTimeToMs(event.track_duration ?? null);
// Créer un nouvel objet pour déclencher la réactivité // Créer un nouvel objet pour déclencher la réactivité
snapshots.value.set(rendererId, { snapshots.set(rendererId, {
...snapshot, ...snapshot,
state: { state: {
...snapshot.state, ...snapshot.state,
@@ -166,12 +153,12 @@ function ensureSSEInitialized() {
duration_ms: durationMs, duration_ms: durationMs,
}, },
}); });
triggerSnapshotReactivity();
break; break;
case "volume_changed": case "volume_changed":
// Créer un nouvel objet pour déclencher la réactivité // Créer un nouvel objet pour déclencher la réactivité
snapshots.value.set(rendererId, { snapshots.set(rendererId, {
...snapshot, ...snapshot,
state: { ...snapshot.state, volume: event.volume }, state: { ...snapshot.state, volume: event.volume },
}); });
@@ -179,7 +166,7 @@ function ensureSSEInitialized() {
case "mute_changed": case "mute_changed":
// Créer un nouvel objet pour déclencher la réactivité // Créer un nouvel objet pour déclencher la réactivité
snapshots.value.set(rendererId, { snapshots.set(rendererId, {
...snapshot, ...snapshot,
state: { ...snapshot.state, mute: event.mute }, state: { ...snapshot.state, mute: event.mute },
}); });
@@ -199,21 +186,21 @@ 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
snapshots.value.set(rendererId, { snapshots.set(rendererId, {
...snapshot, ...snapshot,
state: { ...snapshot.state }, state: { ...snapshot.state },
}); });
break; break;
case "queue_refreshing": case "queue_refreshing":
queueRefreshingIds.value.add(rendererId); queueRefreshingIds.add(rendererId);
triggerQueueReactivity();
break; break;
case "queue_updated": case "queue_updated":
snapshot.state.queue_len = event.queue_length; snapshot.state.queue_len = event.queue_length;
queueRefreshingIds.value.delete(rendererId); queueRefreshingIds.delete(rendererId);
triggerQueueReactivity();
// 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;
@@ -231,7 +218,7 @@ function ensureSSEInitialized() {
snapshot.state.attached_playlist = null; snapshot.state.attached_playlist = null;
} }
// Créer un nouvel objet pour déclencher la réactivité // Créer un nouvel objet pour déclencher la réactivité
snapshots.value.set(rendererId, { snapshots.set(rendererId, {
...snapshot, ...snapshot,
state: { ...snapshot.state }, state: { ...snapshot.state },
}); });
@@ -240,7 +227,7 @@ function ensureSSEInitialized() {
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é // Créer un nouvel objet pour déclencher la réactivité
snapshots.value.set(rendererId, { ...snapshot }); snapshots.set(rendererId, { ...snapshot });
break; break;
case "timer_started": case "timer_started":
@@ -265,7 +252,7 @@ const onlineRenderers = computed(() =>
allRenderers.value.filter((r) => r.online), allRenderers.value.filter((r) => r.online),
); );
const allSnapshots = computed(() => const allSnapshots = computed(() =>
Array.from(snapshots.value.values()), Array.from(snapshots.values()),
); );
const playingRenderers = computed(() => const playingRenderers = computed(() =>
allSnapshots.value allSnapshots.value
@@ -278,27 +265,27 @@ function getRendererById(id: string) {
} }
function getSnapshotById(id: string) { function getSnapshotById(id: string) {
return snapshots.value.get(id) ?? null; return snapshots.get(id) ?? null;
} }
function getStateById(id: string): RendererState | null { function getStateById(id: string): RendererState | null {
return snapshots.value.get(id)?.state ?? null; return snapshots.get(id)?.state ?? null;
} }
function getQueueById(id: string): QueueSnapshot | null { function getQueueById(id: string): QueueSnapshot | null {
return snapshots.value.get(id)?.queue ?? null; return snapshots.get(id)?.queue ?? null;
} }
function getBindingById(id: string): AttachedPlaylistInfo | null { function getBindingById(id: string): AttachedPlaylistInfo | null {
return snapshots.value.get(id)?.binding ?? null; return snapshots.get(id)?.binding ?? null;
} }
function isSnapshotLoading(id: string) { function isSnapshotLoading(id: string) {
return loadingIds.value.has(id); return loadingIds.has(id);
} }
function isQueueRefreshing(id: string) { function isQueueRefreshing(id: string) {
return queueRefreshingIds.value.has(id); return queueRefreshingIds.has(id);
} }
function selectRenderer(id: string | null) { function selectRenderer(id: string | null) {
@@ -353,43 +340,43 @@ async function fetchRendererSnapshot(
) { ) {
ensureSSEInitialized(); ensureSSEInitialized();
const force = opts?.force ?? false; const force = opts?.force ?? false;
const hasSnapshot = snapshots.value.has(rendererId); const hasSnapshot = snapshots.has(rendererId);
if (!force && hasSnapshot) { if (!force && hasSnapshot) {
const lastSnapshot = lastSnapshotAt.value.get(rendererId) ?? 0; const lastSnapshot = lastSnapshotAt.get(rendererId) ?? 0;
const lastEvent = lastEventAt.value.get(rendererId) ?? 0; const lastEvent = lastEventAt.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 (loadingIds.value.has(rendererId)) { if (loadingIds.has(rendererId)) {
return; return;
} }
loadingIds.value.add(rendererId); loadingIds.add(rendererId);
triggerLoadingReactivity();
// Lazy load UI store pour les notifications // Lazy load UI store pour les notifications
const uiStore = useUIStore(); const uiStore = useUIStore();
try { try {
const snapshot = await api.getRendererFullSnapshot(rendererId); const snapshot = await api.getRendererFullSnapshot(rendererId);
snapshots.value.set(rendererId, snapshot); snapshots.set(rendererId, snapshot);
lastSnapshotAt.value.set(rendererId, Date.now()); lastSnapshotAt.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
snapshots.value.delete(rendererId); snapshots.delete(rendererId);
triggerSnapshotReactivity();
// Notifier l'utilisateur // Notifier l'utilisateur
uiStore.notifyError(`Impossible de récupérer l'état du renderer`); 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
loadingIds.value.delete(rendererId); loadingIds.delete(rendererId);
triggerLoadingReactivity();
} }
} }
@@ -439,7 +426,7 @@ async function play(id: string) {
} }
async function resumeOrPlayFromQueue(id: string) { async function resumeOrPlayFromQueue(id: string) {
const snapshot = snapshots.value.get(id); const snapshot = snapshots.get(id);
if (!snapshot) { if (!snapshot) {
throw new Error(`Renderer ${id} non trouvé`); throw new Error(`Renderer ${id} non trouvé`);
} }

View File

@@ -17,13 +17,17 @@ import type {
} from '../services/pmocontrol/types' } from '../services/pmocontrol/types'
// État global partagé // État global partagé
const connected = ref(sse.isConnectedState()) const connected = ref(sse.isConnectedState());
const connectionCallbacks: Set<(connected: boolean) => void> = new Set() const connectionCallbacks: Set<(connected: boolean) => void> = new Set();
// Flag pour éviter les double-connexions SSE avec lock
let connectionLock = false;
// Abonnement à l'état de connexion global // Abonnement à l'état de connexion global
function setupConnectionListener() { function setupConnectionListener() {
// S'assurer qu'on ne s'abonne qu'une seule fois // Vérifier avec lock pour éviter les conditions de course
if (connectionCallbacks.size === 0) { if (connectionCallbacks.size === 0 && !connectionLock) {
connectionLock = true;
sse.onConnectionChange((isConnected) => { sse.onConnectionChange((isConnected) => {
connected.value = isConnected connected.value = isConnected
connectionCallbacks.forEach(cb => cb(isConnected)) connectionCallbacks.forEach(cb => cb(isConnected))

View File

@@ -43,6 +43,10 @@ const state = reactive<TabsState>({
// Flag pour éviter les boucles de sauvegarde // Flag pour éviter les boucles de sauvegarde
let isRestoringFromStorage = false; let isRestoringFromStorage = false;
// Debounce timer pour la sauvegarde localStorage
let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const SAVE_DEBOUNCE_MS = 100;
/** /**
* Retourne le titre complet sans troncature * Retourne le titre complet sans troncature
* Note: On laisse le CSS gérer l'overflow avec ellipsis pour un affichage stable * Note: On laisse le CSS gérer l'overflow avec ellipsis pour un affichage stable
@@ -53,29 +57,38 @@ function truncateTitle(title: string): string {
} }
/** /**
* Sauvegarde l'état dans localStorage * Sauvegarde l'état dans localStorage (avec debounce)
* Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés) * Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés)
*/ */
function saveToLocalStorage() { function saveToLocalStorage() {
if (isRestoringFromStorage) return; if (isRestoringFromStorage) return;
try { // Annuler le timer précédent
const stateToSave = { if (saveDebounceTimer !== null) {
// Sauvegarder uniquement les onglets server (fermables manuellement) clearTimeout(saveDebounceTimer);
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);
} }
// Débouncer pour éviter les écritures multiples
saveDebounceTimer = setTimeout(() => {
try {
const stateToSave = {
// Sauvegarder uniquement les onglets server (fermables manuellement)
tabs: state.tabs
.filter((tab) => tab.type === "server")
.map((tab) => ({
...tab,
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
icon: undefined,
})),
activeTabId: state.activeTabId,
tabHistory: state.tabHistory,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
} catch (error) {
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
}
saveDebounceTimer = null;
}, SAVE_DEBOUNCE_MS);
} }
/** /**
@@ -83,11 +96,11 @@ function saveToLocalStorage() {
* Note: Restaure uniquement les onglets server (les renderer tabs seront auto-générés) * Note: Restaure uniquement les onglets server (les renderer tabs seront auto-générés)
*/ */
function restoreFromLocalStorage() { function restoreFromLocalStorage() {
isRestoringFromStorage = true;
try { try {
const saved = localStorage.getItem(STORAGE_KEY); const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return; if (!saved) return;
isRestoringFromStorage = true;
const savedState = JSON.parse(saved); const savedState = JSON.parse(saved);
// Reconstituer uniquement les tabs server avec les bonnes icônes // Reconstituer uniquement les tabs server avec les bonnes icônes
@@ -110,10 +123,9 @@ function restoreFromLocalStorage() {
if (!state.tabs.find((t) => t.id === state.activeTabId)) { if (!state.tabs.find((t) => t.id === state.activeTabId)) {
state.activeTabId = ""; state.activeTabId = "";
} }
isRestoringFromStorage = false;
} catch (error) { } catch (error) {
console.error("[useTabs] Erreur restauration localStorage:", error); console.error("[useTabs] Erreur restauration localStorage:", error);
} finally {
isRestoringFromStorage = false; isRestoringFromStorage = false;
} }
} }

View File

@@ -9,6 +9,9 @@ import { sse } from "./services/pmocontrol/sse";
// Store UI (garde UIStore pour les notifications et état UI global) // Store UI (garde UIStore pour les notifications et état UI global)
import { useUIStore } from "./stores/ui"; import { useUIStore } from "./stores/ui";
// Image cache (pour cleanup)
import { imageCache } from "./composables/imageCache";
// Styles // Styles
import "./style.css"; import "./style.css";
import "./assets/styles/variables.css"; import "./assets/styles/variables.css";
@@ -24,12 +27,13 @@ const pinia = createPinia();
app.use(pinia); app.use(pinia);
app.use(router); app.use(router);
// Initialiser UIStore AVANT le montage pour éviter la race condition (P2)
const uiStore = useUIStore();
// Monter l'application // Monter l'application
app.mount("#app"); app.mount("#app");
// Après montage, initialiser SSE // Après montage, initialiser SSE
const uiStore = useUIStore();
// Les composables se connectent automatiquement à SSE // Les composables se connectent automatiquement à SSE
// Ils gèrent eux-mêmes le re-fetch lors des événements // Ils gèrent eux-mêmes le re-fetch lors des événements
@@ -39,3 +43,8 @@ sse.onConnectionChange((connected) => {
// Démarrer la connexion SSE // Démarrer la connexion SSE
sse.connect(); sse.connect();
// Cleanup global lors du unload de la page
window.addEventListener('beforeunload', () => {
imageCache.destroy();
});

View File

@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
// PMOControl Unified View (nouvelle interface unifiée) // PMOControl Unified View (nouvelle interface unifiée)
import UnifiedControlView from "../views/UnifiedControlView.vue"; import UnifiedControlView from "../views/UnifiedControlView.vue";
@@ -8,18 +9,10 @@ import DashboardView from "../views/DashboardView.vue";
import RendererView from "../views/RendererView.vue"; import RendererView from "../views/RendererView.vue";
import MediaServerView from "../views/MediaServerView.vue"; import MediaServerView from "../views/MediaServerView.vue";
// Debug Components (anciennes routes) // Debug Components - lazy loaded uniquement en mode développement (P8)
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue"; const isDev = import.meta.env.DEV;
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";
import DebugView from "../views/DebugView.vue";
const routes = [ const routes: RouteRecordRaw[] = [
// PMOControl Unified Interface (nouvelle interface unifiée avec onglets) // PMOControl Unified Interface (nouvelle interface unifiée avec onglets)
{ {
path: "/", path: "/",
@@ -43,57 +36,65 @@ const routes = [
name: "MediaServer", name: "MediaServer",
component: MediaServerView, component: MediaServerView,
}, },
// Debug hub
{
path: "/debug",
name: "Debug",
component: DebugView,
},
// Debug menu (anciennes routes déplacées sous /debug)
{
path: "/debug/generic-player",
name: "GenericPlayer",
component: GenericMusicPlayer,
},
{
path: "/debug/logs",
name: "Logs",
component: LogView,
},
{
path: "/debug/covers-cache",
name: "CoversCache",
component: CoverCacheManager,
},
{
path: "/debug/audio-cache",
name: "AudioCache",
component: AudioCacheManager,
},
{
path: "/debug/playlists",
name: "PlaylistsManager",
component: PlayListManager,
},
{
path: "/debug/upnp",
name: "UpnpExplorer",
component: UpnpExplorer,
},
{
path: "/debug/api-dashboard",
name: "APIDashboard",
component: APIDashboard,
},
{
path: "/debug/radio-paradise",
name: "RadioParadise",
component: RadioParadiseExplorer,
},
]; ];
// Ajouter les routes de debug uniquement en développement
if (isDev) {
routes.push(
{
path: "/debug",
name: "Debug",
component: () => import("../views/DebugView.vue"),
},
{
path: "/debug/generic-player",
name: "GenericPlayer",
component: () => import("../components/GenericMusicPlayer.vue"),
},
{
path: "/debug/logs",
name: "Logs",
component: () => import("../components/LogView.vue"),
},
{
path: "/debug/covers-cache",
name: "CoversCache",
component: () => import("../components/CoverCacheManager.vue"),
},
{
path: "/debug/audio-cache",
name: "AudioCache",
component: () => import("../components/AudioCacheManager.vue"),
},
{
path: "/debug/playlists",
name: "PlaylistsManager",
component: () => import("../components/PlayListManager.vue"),
},
{
path: "/debug/upnp",
name: "UpnpExplorer",
component: () => import("../components/UpnpExplorer.vue"),
},
{
path: "/debug/api-dashboard",
name: "APIDashboard",
component: () => import("../components/APIDashboard.vue"),
},
{
path: "/debug/radio-paradise",
name: "RadioParadise",
component: () => import("../components/RadioParadiseExplorer.vue"),
}
);
}
// Wildcard redirect pour les routes inconnues
routes.push({
path: "/:pathMatch(.*)*",
redirect: "/",
});
const router = createRouter({ const router = createRouter({
// history avec base /app // history avec base /app
history: createWebHistory("/app"), history: createWebHistory("/app"),

View File

@@ -33,6 +33,58 @@ export interface TrackInfo {
cover?: string; cover?: string;
} }
// Types stricts pour les commandes reçues du backend (P6)
interface StreamCommand {
type: 'stream';
url: string;
}
interface PlayCommand {
type: 'play';
}
interface PauseCommand {
type: 'pause';
}
interface SeekCommand {
type: 'seek';
timestamp: number;
}
interface FlushCommand {
type: 'flush';
}
interface StopCommand {
type: 'stop';
}
type CommandMessage = StreamCommand | PlayCommand | PauseCommand | SeekCommand | FlushCommand | StopCommand;
function isValidCommand(msg: Record<string, unknown> | unknown): msg is CommandMessage {
if (!msg || typeof msg !== 'object') return false;
if (!('type' in msg)) return false;
const type = (msg as Record<string, unknown>).type;
if (typeof type !== 'string') return false;
// Valider les champs selon le type
switch (type) {
case 'stream':
return 'url' in msg && typeof (msg as StreamCommand).url === 'string';
case 'seek':
return 'timestamp' in msg && typeof (msg as SeekCommand).timestamp === 'number';
case 'play':
case 'pause':
case 'flush':
case 'stop':
return true;
default:
return false;
}
}
export class PMOPlayer { export class PMOPlayer {
private audio: HTMLAudioElement; private audio: HTMLAudioElement;
private instanceId: string; private instanceId: string;
@@ -195,11 +247,17 @@ export class PMOPlayer {
} }
private handleCommand(msg: Record<string, unknown>) { private handleCommand(msg: Record<string, unknown>) {
const type = msg.type as string; // Validate command structure before processing (P6)
if (!isValidCommand(msg)) {
console.warn('[PMOPlayer] Invalid command received:', msg);
return;
}
const type = msg.type;
switch (type) { switch (type) {
case 'stream': { case 'stream': {
this.playStream(msg.url as string); this.playStream(msg.url);
break; break;
} }
case 'play': { case 'play': {
@@ -211,7 +269,7 @@ export class PMOPlayer {
this.pause(); this.pause();
break; break;
case 'seek': case 'seek':
this.seek(msg.timestamp as number); this.seek(msg.timestamp);
break; break;
case 'flush': case 'flush':
this.flush(); this.flush();

View File

@@ -200,34 +200,11 @@ export function getJpegUrl(pk: string, size?: number): string {
return `/covers/jpeg/${pk}`; return `/covers/jpeg/${pk}`;
} }
/** import defaultCoverSvg from '../assets/default-cover.svg?raw';
* 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 * Retourne l'URL de l'image par défaut comme data URL
*/ */
export function getDefaultImageUrl(): string { export function getDefaultImageUrl(): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(DEFAULT_COVER_SVG)}`; return `data:image/svg+xml;utf8,${encodeURIComponent(defaultCoverSvg)}`;
} }

View File

@@ -17,12 +17,45 @@ import type {
ErrorResponse, ErrorResponse,
} from "./types"; } from "./types";
/**
* Fetch avec timeout et AbortController
*/
function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeoutMs = 10_000,
): Promise<Response> {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...options, signal: controller.signal }).finally(
() => clearTimeout(id),
);
}
/** /**
* Client API REST pour le Control Point PMOMusic * Client API REST pour le Control Point PMOMusic
*/ */
class PMOControlAPI { class PMOControlAPI {
private readonly baseURL = "/api/control"; private readonly baseURL = "/api/control";
/**
* Valide la structure de base d'une réponse
* Jette une erreur si la réponse est invalide
*/
private validateResponse<T>(data: unknown, path: string): T {
// Vérification basique : null ou undefined
if (data == null) {
throw new Error(`[PMOControlAPI] Réponse nulle pour ${path}`);
}
// Vérification que c'est un objet
if (typeof data !== 'object') {
throw new Error(`[PMOControlAPI] Réponse invalide pour ${path}: attendu un objet`);
}
return data as T;
}
/** /**
* Effectue une requête HTTP générique * Effectue une requête HTTP générique
*/ */
@@ -32,7 +65,7 @@ class PMOControlAPI {
): Promise<T> { ): Promise<T> {
const url = `${this.baseURL}${path}`; const url = `${this.baseURL}${path}`;
const response = await fetch(url, { const response = await fetchWithTimeout(url, {
...options, ...options,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -48,10 +81,14 @@ class PMOControlAPI {
} }
const data = await response.json(); const data = await response.json();
// Validation de la réponse (P5)
const validated = this.validateResponse<T>(data, path);
if (import.meta.env.DEV && data == null) { if (import.meta.env.DEV && data == null) {
console.warn(`[PMOControlAPI] Réponse vide pour ${path}`); console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
} }
return data; return validated;
} }
// ============================================================================ // ============================================================================

View File

@@ -126,6 +126,7 @@ export interface BrowseResponse {
entries: ContainerEntry[]; entries: ContainerEntry[];
total_count: number; total_count: number;
offset: number; offset: number;
hasMore?: boolean; // Client-side flag pour infinite scroll
} }
// ============================================================================ // ============================================================================

View File

@@ -9,6 +9,8 @@ export interface Notification {
duration?: number // ms, undefined = permanent duration?: number // ms, undefined = permanent
} }
const MAX_NOTIFICATIONS = 5
export const useUIStore = defineStore('ui', () => { export const useUIStore = defineStore('ui', () => {
// État // État
const selectedRendererId = ref<string | null>(null) const selectedRendererId = ref<string | null>(null)
@@ -17,6 +19,9 @@ export const useUIStore = defineStore('ui', () => {
const sseConnected = ref(false) const sseConnected = ref(false)
const notifications = ref<Notification[]>([]) const notifications = ref<Notification[]>([])
// Map pour suivre les timers et permettre le cleanup
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>()
// Actions // Actions
function selectRenderer(id: string | null) { function selectRenderer(id: string | null) {
selectedRendererId.value = id selectedRendererId.value = id
@@ -39,6 +44,18 @@ export const useUIStore = defineStore('ui', () => {
message: string, message: string,
duration?: number duration?: number
) { ) {
// Limiter le nombre de notifications (P15)
if (notifications.value.length >= MAX_NOTIFICATIONS) {
const oldest = notifications.value.shift()
if (oldest) {
const timer = notificationTimers.get(oldest.id)
if (timer) {
clearTimeout(timer)
notificationTimers.delete(oldest.id)
}
}
}
const id = `notif-${Date.now()}-${Math.random()}` const id = `notif-${Date.now()}-${Math.random()}`
const notification: Notification = { const notification: Notification = {
id, id,
@@ -49,12 +66,13 @@ export const useUIStore = defineStore('ui', () => {
notifications.value.push(notification) notifications.value.push(notification)
// Auto-remove après duration (défaut: 5s) // Auto-remove après duration (défaut: 5s) - avec tracking pour cleanup
const timeout = duration !== undefined ? duration : 5000 const timeout = duration !== undefined ? duration : 5000
if (timeout > 0) { if (timeout > 0) {
setTimeout(() => { const timer = setTimeout(() => {
removeNotification(id) removeNotification(id)
}, timeout) }, timeout)
notificationTimers.set(id, timer)
} }
return id return id
@@ -65,12 +83,27 @@ export const useUIStore = defineStore('ui', () => {
if (index !== -1) { if (index !== -1) {
notifications.value.splice(index, 1) notifications.value.splice(index, 1)
} }
// Nettoyer le timer associated
const timer = notificationTimers.get(id)
if (timer) {
clearTimeout(timer)
notificationTimers.delete(id)
}
} }
function clearNotifications() { function clearNotifications() {
// Nettoyer tous les timers
notificationTimers.forEach(timer => clearTimeout(timer))
notificationTimers.clear()
notifications.value = [] notifications.value = []
} }
// Cleanup function pour appeler lors du unmount de l'app
function $dispose() {
notificationTimers.forEach(timer => clearTimeout(timer))
notificationTimers.clear()
}
// Raccourcis pour les types de notifications // Raccourcis pour les types de notifications
function notifySuccess(message: string, duration?: number) { function notifySuccess(message: string, duration?: number) {
return addNotification('success', message, duration) return addNotification('success', message, duration)
@@ -107,5 +140,6 @@ export const useUIStore = defineStore('ui', () => {
notifyError, notifyError,
notifyWarning, notifyWarning,
notifyInfo, notifyInfo,
$dispose,
} }
}) })

View File

@@ -45,5 +45,9 @@ export function normalizeUrl(url: string): string {
*/ */
export function truncate(str: string, maxLength: number, suffix = '...'): string { export function truncate(str: string, maxLength: number, suffix = '...'): string {
if (str.length <= maxLength) return str; if (str.length <= maxLength) return str;
// Guard: si suffix est plus long que maxLength, retourner juste le suffixe
if (suffix.length >= maxLength) {
return str.slice(0, maxLength);
}
return str.slice(0, maxLength - suffix.length) + suffix; return str.slice(0, maxLength - suffix.length) + suffix;
} }

View File

@@ -49,12 +49,3 @@ export function formatMsToTime(ms: number | null): string {
return `${h}${m}${s}`; return `${h}${m}${s}`;
} }
/**
* Convertit des millisecondes en format court (pour l'affichage progress)
* @param ms - Durée en millisecondes
* @returns Durée au format "X:XX" ou "X:XX:XX"
*/
export function formatMsToShortTime(ms: number | null): string {
return formatMsToTime(ms);
}

View File

@@ -1 +1 @@
0.3.37 0.3.39