[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:
327
Blackboard/Todo/Frontend_Review.md
Normal file
327
Blackboard/Todo/Frontend_Review.md
Normal file
@@ -0,0 +1,327 @@
|
||||
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||
|
||||
## Contexte
|
||||
|
||||
Ce document est le résultat d'une revue de code complète du frontend Vue.js/TypeScript du Control Point
|
||||
(`pmoapp/webapp/src/`). L'application est fonctionnelle mais présente plusieurs classes de problèmes
|
||||
qui peuvent causer des fuites mémoire, des incohérences de réactivité Vue 3, et des difficultés de
|
||||
maintenance à mesure que l'app grandit.
|
||||
|
||||
**Périmètre** : uniquement `pmoapp/webapp/src/` (composants, services, composables, stores, utils, CSS).
|
||||
|
||||
---
|
||||
|
||||
## Problèmes identifiés
|
||||
|
||||
### P0 — Fuites mémoire via listeners SSE jamais nettoyés
|
||||
|
||||
**Fichiers** : `src/composables/useSSE.ts`, `src/composables/useRenderers.ts`,
|
||||
`src/composables/useMediaServers.ts`
|
||||
|
||||
Les abonnements aux événements SSE sont créés lors du premier appel de chaque composable, mais
|
||||
jamais nettoyés si le composable est réutilisé ou le composant détruit. Dans `useSSE.ts`, la
|
||||
fonction `onRendererEvent()` retourne une fonction de cleanup, mais `useRendererEvents()` ignore
|
||||
ce retour — l'abonnement reste actif indefiniment.
|
||||
|
||||
De plus, dans `imageCache.ts`, un `setInterval` de cleanup s'exécute toutes les 5 minutes sans
|
||||
jamais être annulé si l'app est détruite.
|
||||
|
||||
### P1 — Réactivité Vue incohérente avec `shallowRef` + Maps
|
||||
|
||||
**Fichier** : `src/composables/useRenderers.ts` (L23-37)
|
||||
|
||||
`snapshots` et `loadingIds` sont déclarés en `shallowRef<Map<...>>()`. Vue ne détecte pas les
|
||||
mutations d'objets à l'intérieur d'un `shallowRef`. La solution actuelle — `triggerSnapshotReactivity()`
|
||||
qui crée une nouvelle Map à chaque appel — force une re-render complète de tous les composants
|
||||
qui dépendent de `snapshots`, même si seul un renderer a changé.
|
||||
|
||||
### P2 — Race condition à l'initialisation (main.ts)
|
||||
|
||||
**Fichier** : `src/main.ts`
|
||||
|
||||
Le UIStore est initialisé après le montage de l'app et l'appel à `sse.connect()`. Des événements
|
||||
SSE peuvent arriver avant que `useUIStore()` soit appelé dans les composants, et les notifications
|
||||
correspondantes peuvent être perdues.
|
||||
|
||||
### P3 — SSE singleton sans garantie formelle
|
||||
|
||||
**Fichiers** : `src/composables/useRenderers.ts`, `src/composables/useMediaServers.ts`
|
||||
|
||||
Chaque composable maintient son propre flag `sseInitialized` pour éviter les double-abonnements.
|
||||
Le mécanisme repose sur une convention implicite fragile : si deux composables s'abonnent au même
|
||||
type d'événement SSE dans des contextes différents, les callbacks s'accumulent sans être
|
||||
dédupliqués.
|
||||
|
||||
Dans `useSSE.ts`, `setupConnectionListener()` vérifie `connectionCallbacks.size === 0` mais
|
||||
sans lock — deux appels simultanés peuvent installer deux listeners.
|
||||
|
||||
### P4 — Pas de timeout ni retry sur les requêtes `fetch`
|
||||
|
||||
**Fichier** : `src/services/pmocontrol/api.ts`
|
||||
|
||||
Toutes les requêtes `fetch()` sont émises sans `AbortController`. Si le serveur ne répond pas,
|
||||
la promesse pend indéfiniment, bloquant potentiellement les composants qui attendent le résultat.
|
||||
Il n'y a ni timeout configurable ni retry automatique au niveau du service.
|
||||
|
||||
### P5 — Validation absente des réponses API
|
||||
|
||||
**Fichiers** : `src/services/audioCache.ts` (L76), `src/services/coverCache.ts`,
|
||||
`src/services/playlists.ts`, `src/services/pmocontrol/api.ts`
|
||||
|
||||
Les réponses JSON sont acceptées sans vérification de structure. Une assertion de type comme
|
||||
`metadata as { origin_url?: unknown }` ne protège pas contre un changement d'API côté Rust. Si
|
||||
l'API retourne une structure inattendue, le crash survient au runtime, pas à la compilation.
|
||||
|
||||
### P6 — Type assertions dangereuses dans PMOPlayer
|
||||
|
||||
**Fichier** : `src/services/pmosource.ts` / PMOPlayer (L197-214)
|
||||
|
||||
Les messages de commande sont typés `Record<string, unknown>`, puis les propriétés sont castées
|
||||
directement : `msg.url as string`, `msg.timestamp as number`. Si une propriété est absente ou
|
||||
d'un type différent, TypeScript ne le détecte pas.
|
||||
|
||||
### P7 — `useTabs` : watch multiples sans debounce, flag de restauration non-réinitialisé
|
||||
|
||||
**Fichier** : `src/composables/useTabs.ts` (L44, L350-361)
|
||||
|
||||
Trois `watch()` séparées écrivent dans `localStorage`. Sans debounce commun, si 3 onglets
|
||||
changent d'état simultanément, `localStorage` est écrit 3 fois de suite.
|
||||
|
||||
Le flag `isRestoringFromStorage` (L44) empêche la boucle de sauvegarde pendant la restauration,
|
||||
mais sans timeout : si `restoreFromLocalStorage()` lance une exception non-catchée, le flag reste
|
||||
`true` et toutes les sauvegardes futures sont silencieusement ignorées.
|
||||
|
||||
### P8 — Routes de debug exposées en production, pas de lazy loading
|
||||
|
||||
**Fichier** : `src/router/index.ts`
|
||||
|
||||
Les routes debug (CoversCache, AudioCache, UPnP Explorer, etc.) sont accessibles en production
|
||||
sans contrôle d'accès. Par ailleurs, tous les composants sont importés statiquement, augmentant
|
||||
le bundle initial inutilement — les vues debug notamment ne sont jamais utilisées en prod.
|
||||
|
||||
### P9 — `formatMsToShortTime` est un alias inutile
|
||||
|
||||
**Fichier** : `src/utils/time.ts` (L58-59)
|
||||
|
||||
```typescript
|
||||
// Actuellement
|
||||
export function formatMsToShortTime(ms: number | null): string {
|
||||
return formatMsToTime(ms);
|
||||
}
|
||||
```
|
||||
|
||||
Fonction identique à `formatMsToTime`. Tous les appelants peuvent utiliser directement
|
||||
`formatMsToTime`.
|
||||
|
||||
### P10 — `truncate()` dans `string.ts` peut dépasser `maxLength`
|
||||
|
||||
**Fichier** : `src/utils/string.ts` (L46-48)
|
||||
|
||||
```typescript
|
||||
// Actuellement
|
||||
export function truncate(str: string, maxLength: number, suffix = '…'): string {
|
||||
return str.length > maxLength ? str.slice(0, maxLength - suffix.length) + suffix : str;
|
||||
}
|
||||
```
|
||||
|
||||
Si `suffix.length >= maxLength`, `str.slice(0, maxLength - suffix.length)` retourne une chaîne
|
||||
de longueur négative (comportement silencieux en JS, retourne `''`), et le résultat final est
|
||||
plus long que `maxLength`.
|
||||
|
||||
### P11 — `DEFAULT_COVER_SVG` inline dans coverCache.ts
|
||||
|
||||
**Fichier** : `src/services/coverCache.ts` (L206-226)
|
||||
|
||||
Un SVG inline de ~20 lignes est inclus dans chaque bundle qui importe `coverCache`. Il devrait
|
||||
être un fichier `src/assets/default-cover.svg` importé nativement par Vite (ce qui permet le
|
||||
tree-shaking et le caching HTTP séparé).
|
||||
|
||||
### P12 — `animations` CSS sans `prefers-reduced-motion`
|
||||
|
||||
**Fichiers** : `src/assets/styles/glass-theme.css` (L384-401),
|
||||
`src/assets/styles/pmocontrol.css` (L82)
|
||||
|
||||
Les animations `glassShimmer` (2s infini) et le `pulse` du badge de statut `Transitioning`
|
||||
s'exécutent sans tenir compte de `prefers-reduced-motion: reduce`. Sur certains systèmes ou
|
||||
pour des utilisateurs sensibles au mouvement, ces animations sont gênantes.
|
||||
|
||||
### P13 — CSS dupliqué dans drawers.css
|
||||
|
||||
**Fichier** : `src/assets/styles/drawers.css` (L105-149)
|
||||
|
||||
`drawer-close-btn` et `drawer-back-btn` partagent 90% des styles. Un TODO présent en L167
|
||||
("remplacer par la classe globale .section-title") confirme cette dette. La variable
|
||||
`var(--opacity-disabled)` est utilisée mais non définie dans `variables.css`.
|
||||
|
||||
### P14 — `browseContainer` : clés de cache fragiles et pas de pagination
|
||||
|
||||
**Fichier** : `src/composables/useMediaServers.ts` (L145, L239)
|
||||
|
||||
Les clés de cache sont construites comme `${serverId}/${containerId}`. Si un `containerId`
|
||||
contient un slash (séparateur d'URL), la clé est ambigüe. Par exemple, `server1/a/b` peut
|
||||
correspondre à serverId=`server1`, containerId=`a/b` ou serverId=`server1/a`, containerId=`b`.
|
||||
|
||||
La pagination n'est pas implémentée côté composable : `browseContainer` charge toujours
|
||||
offset=0, limit=50. Pour les containers avec 500+ items, les items au-delà de 50 ne sont
|
||||
jamais accessibles.
|
||||
|
||||
### P15 — Notifications sans limite de taille dans `ui.ts`
|
||||
|
||||
**Fichier** : `src/stores/ui.ts` (L50, L55-57)
|
||||
|
||||
Un bug ou une boucle d'erreur peut générer des centaines de notifications. Le tableau
|
||||
`notifications` n'est pas limité. Chaque notification crée un `setTimeout` individuel, et
|
||||
si le store est détruit avant l'expiration, ces callbacks persistent (ghosts).
|
||||
|
||||
---
|
||||
|
||||
## Plan d'exécution
|
||||
|
||||
Les corrections sont groupées par effort et impact. Les P0–P3 concernent la fiabilité
|
||||
(fuites mémoire, réactivité), les P4–P8 la robustesse et maintenabilité, les P9–P15 la
|
||||
qualité et la dette technique.
|
||||
|
||||
### Étape 1 — Corriger les fuites mémoire SSE (P0)
|
||||
|
||||
Dans `useSSE.ts`, stocker et appeler les fonctions de cleanup retournées par `onRendererEvent` /
|
||||
`onMediaServerEvent` :
|
||||
|
||||
```typescript
|
||||
// useSSE.ts – useRendererEvents()
|
||||
onMounted(() => {
|
||||
const cleanup = onRendererEvent(rendererId(), handler);
|
||||
onUnmounted(cleanup); // ← actuellement ignoré
|
||||
});
|
||||
```
|
||||
|
||||
Dans `imageCache.ts`, exporter une fonction `destroyImageCache()` qui appelle `clearInterval`
|
||||
sur le timer de cleanup, et l'appeler dans le `onUnmounted` de l'app root.
|
||||
|
||||
### Étape 2 — Stabiliser la réactivité des snapshots (P1)
|
||||
|
||||
Remplacer `shallowRef<Map<...>>` + `triggerSnapshotReactivity` par `reactive(new Map<...>)`.
|
||||
Vue 3 rend les Maps réactives nativement. Les composants qui lisent `snapshots.get(id)`
|
||||
seront notifiés uniquement si ce `id` change.
|
||||
|
||||
```typescript
|
||||
// Avant
|
||||
const snapshots = shallowRef<Map<string, FullRendererSnapshot>>(new Map());
|
||||
function triggerSnapshotReactivity() {
|
||||
snapshots.value = new Map(snapshots.value);
|
||||
}
|
||||
|
||||
// Après
|
||||
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
|
||||
// Les modifications directes (snapshots.set/delete) déclenchent la réactivité
|
||||
```
|
||||
|
||||
### Étape 3 — Timeout fetch + AbortController (P4)
|
||||
|
||||
Ajouter un helper dans `api.ts` :
|
||||
|
||||
```typescript
|
||||
function fetchWithTimeout(url: string, options?: RequestInit, timeoutMs = 10_000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return fetch(url, { ...options, signal: controller.signal })
|
||||
.finally(() => clearTimeout(id));
|
||||
}
|
||||
```
|
||||
|
||||
Utiliser `fetchWithTimeout` pour toutes les requêtes dans le service API.
|
||||
|
||||
### Étape 4 — Corriger `useTabs` watchs et flag de restauration (P7)
|
||||
|
||||
Fusionner les trois `watch()` en un seul `watchEffect` avec un debounce unique (100ms).
|
||||
Encadrer `isRestoringFromStorage` dans un bloc `try/finally` :
|
||||
|
||||
```typescript
|
||||
async function restoreFromLocalStorage() {
|
||||
isRestoringFromStorage = true;
|
||||
try {
|
||||
// ... logique de restauration
|
||||
} catch (e) {
|
||||
console.error('Tab restore failed:', e);
|
||||
} finally {
|
||||
isRestoringFromStorage = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 5 — Limit de notifications et nettoyage timers (P15)
|
||||
|
||||
```typescript
|
||||
const MAX_NOTIFICATIONS = 5;
|
||||
|
||||
function addNotification(notif: Omit<Notification, 'id'>): void {
|
||||
if (notifications.value.length >= MAX_NOTIFICATIONS) {
|
||||
notifications.value.shift(); // supprimer la plus ancienne
|
||||
}
|
||||
const id = nextId++;
|
||||
const timer = setTimeout(() => removeNotification(id), notif.duration ?? 5000);
|
||||
notificationTimers.set(id, timer);
|
||||
notifications.value.push({ ...notif, id });
|
||||
}
|
||||
|
||||
function $dispose() {
|
||||
notificationTimers.forEach(clearTimeout);
|
||||
notificationTimers.clear();
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 6 — Lazy loading des routes et protection debug (P8)
|
||||
|
||||
```typescript
|
||||
// router/index.ts
|
||||
const DebugView = () => import('../views/DebugView.vue');
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
const routes = [
|
||||
// ... routes normales
|
||||
...(isDev ? [{ path: '/debug', component: DebugView }] : []),
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' }, // wildcard 404
|
||||
];
|
||||
```
|
||||
|
||||
### Étape 7 — Corrections mineures (P9, P10, P11, P12, P13)
|
||||
|
||||
- **P9** : Supprimer `formatMsToShortTime`, remplacer tous les appels par `formatMsToTime`
|
||||
- **P10** : Ajouter un guard dans `truncate` : `if (suffix.length >= maxLength) return str.slice(0, maxLength)`
|
||||
- **P11** : Déplacer le SVG dans `src/assets/default-cover.svg` et l'importer avec `import defaultCover from '../assets/default-cover.svg?raw'`
|
||||
- **P12** : Entourer les animations CSS avec `@media (prefers-reduced-motion: no-preference) { ... }`
|
||||
- **P13** : Factoriser `drawer-close-btn` / `drawer-back-btn` avec une classe `.drawer-icon-btn`. Définir `--opacity-disabled: 0.4` dans `variables.css`
|
||||
|
||||
### Étape 8 — Clés de cache et pagination (P14)
|
||||
|
||||
Encoder les IDs dans les clés de cache :
|
||||
|
||||
```typescript
|
||||
const cacheKey = `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`;
|
||||
```
|
||||
|
||||
Utiliser `:` comme séparateur (absent de l'encoding) pour éviter toute ambigüité.
|
||||
|
||||
Pour la pagination, ajouter une propriété `hasMore: boolean` et `loadMore()` au résultat de
|
||||
`browseContainer`, incrementant offset à chaque appel.
|
||||
|
||||
### Ordre d'exécution
|
||||
|
||||
1. Étape 1 — fuites SSE (P0) — fiabilité critique
|
||||
2. Étape 2 — réactivité Map (P1) — fiabilité
|
||||
3. Étape 3 — timeout fetch (P4) — robustesse réseau
|
||||
4. Étape 4 — useTabs (P7) — fiabilité des onglets
|
||||
5. Étape 5 — notifications (P15) — stabilité UI
|
||||
6. Étape 6 — router (P8) — sécurité + performance bundle
|
||||
7. Étape 7 — corrections mineures (P9–P13)
|
||||
8. Étape 8 — cache keys + pagination (P14)
|
||||
|
||||
## Règle après ces corrections
|
||||
|
||||
**Interdit** : créer un abonnement SSE (`onRendererEvent`, `onMediaServerEvent`) sans stocker
|
||||
et appeler la fonction de cleanup retournée dans `onUnmounted`.
|
||||
|
||||
**Interdit** : utiliser `shallowRef<Map<...>>` avec mutation directe — utiliser `reactive(new Map())`
|
||||
pour les Maps qui doivent déclencher la réactivité Vue sur leurs entrées.
|
||||
|
||||
**Obligatoire** : toute requête `fetch()` dans un service doit utiliser `fetchWithTimeout`
|
||||
avec un AbortController.
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.37"
|
||||
version = "0.3.39"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.37"
|
||||
version = "0.3.39"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
21
pmoapp/webapp/src/assets/default-cover.svg
Normal file
21
pmoapp/webapp/src/assets/default-cover.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgGrad)"/>
|
||||
<g transform="translate(200, 200)">
|
||||
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
|
||||
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
</g>
|
||||
<text x="200" y="360" text-anchor="middle"
|
||||
font-family="system-ui, -apple-system, sans-serif"
|
||||
font-size="20" fill="white" opacity="0.6">
|
||||
No Image Available
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -101,13 +101,15 @@
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
@@ -118,34 +120,31 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
.drawer-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.drawer-close-btn:active {
|
||||
.drawer-icon-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Bouton fermer */
|
||||
.drawer-close-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Bouton retour (ServerDrawer navigation) */
|
||||
.drawer-back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast) ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
|
||||
@@ -381,24 +381,27 @@
|
||||
ANIMATIONS
|
||||
======================================== */
|
||||
|
||||
@keyframes glassShimmer {
|
||||
0% {
|
||||
background-position: -200% center;
|
||||
/* Respecte prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@keyframes glassShimmer {
|
||||
0% {
|
||||
background-position: -200% center;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
100% {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: glassShimmer 2s ease-in-out infinite;
|
||||
.glass-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: glassShimmer 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
|
||||
@@ -79,7 +79,14 @@ body {
|
||||
background-color: var(--status-transitioning-bg);
|
||||
color: var(--status-transitioning);
|
||||
border: 1px solid var(--status-transitioning);
|
||||
animation: pulse 2s infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.status-badge.transitioning {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
@@ -226,52 +233,81 @@ body {
|
||||
/* ========================================
|
||||
Animations
|
||||
======================================== */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
|
||||
/* Respecte prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-opacity {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge.transitioning {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.shuffle-button.loading {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.event-badge {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn var(--transition-base);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
/* Default state (animations disabled) */
|
||||
.status-badge.transitioning,
|
||||
.shuffle-button.loading,
|
||||
.event-badge,
|
||||
.loading-state {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@@ -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 {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
}
|
||||
@@ -89,13 +89,18 @@
|
||||
--transition-base: 300ms ease-in-out;
|
||||
--transition-slow: 500ms ease-in-out;
|
||||
|
||||
/* ========================================
|
||||
Z-index layers
|
||||
======================================== */
|
||||
/* ========================================
|
||||
Z-index layers
|
||||
======================================== */
|
||||
--z-dropdown: 100;
|
||||
--z-modal: 200;
|
||||
--z-toast: 300;
|
||||
--z-tooltip: 400;
|
||||
|
||||
/* ========================================
|
||||
Opacity states
|
||||
======================================== */
|
||||
--opacity-disabled: 0.4;
|
||||
}
|
||||
|
||||
/* Dark mode support (optionnel pour l'avenir) */
|
||||
|
||||
@@ -981,7 +981,14 @@ button.active {
|
||||
padding: 3rem;
|
||||
color: #569cd6;
|
||||
font-size: 1.1rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.loading-state {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -76,7 +76,14 @@ async function handleShuffle() {
|
||||
}
|
||||
|
||||
.shuffle-button.loading {
|
||||
animation: pulse 1s infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.shuffle-button.loading {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -372,7 +372,14 @@ watch(editingVar, (newVar) => {
|
||||
|
||||
.event-badge {
|
||||
font-size: 1rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.event-badge {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -37,9 +37,20 @@ class ImageCacheService {
|
||||
};
|
||||
|
||||
private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000;
|
||||
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
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>) {
|
||||
|
||||
@@ -20,11 +20,17 @@ export interface BrowseState {
|
||||
container_id: string
|
||||
entries: ContainerEntry[]
|
||||
total_count: number
|
||||
hasMore?: boolean
|
||||
currentOffset?: number
|
||||
}
|
||||
|
||||
// Cache global partagé
|
||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseState>>(new Map())
|
||||
|
||||
function browseCacheKey(serverId: string, containerId: string): string {
|
||||
return `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`
|
||||
}
|
||||
const currentPath = ref<BreadcrumbItem[]>([])
|
||||
const searchResults = ref<BrowseState | null>(null)
|
||||
const searchQuery = ref<string>('')
|
||||
@@ -69,9 +75,10 @@ function ensureSSEInitialized() {
|
||||
}
|
||||
|
||||
// Invalider tout le cache browse de ce serveur
|
||||
const encodedServerId = encodeURIComponent(serverId)
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerId + ':')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
@@ -80,9 +87,10 @@ function ensureSSEInitialized() {
|
||||
|
||||
case 'global_updated':
|
||||
// Invalider tout le cache de ce serveur
|
||||
const encodedServerIdGlobal = encodeURIComponent(serverId)
|
||||
const globalKeysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerIdGlobal + ':')) {
|
||||
globalKeysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
@@ -92,7 +100,7 @@ function ensureSSEInitialized() {
|
||||
case 'containers_updated':
|
||||
// Invalider les containers spécifiques
|
||||
event.container_ids.forEach(containerId => {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
browseCache.value.delete(key)
|
||||
})
|
||||
break
|
||||
@@ -142,7 +150,7 @@ export function useMediaServers() {
|
||||
|
||||
// Charge la première page (remplace le cache)
|
||||
async function browseContainer(serverId: string, containerId: string, useCache = true) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
|
||||
if (useCache && browseCache.value.has(key)) {
|
||||
return browseCache.value.get(key)!
|
||||
@@ -152,12 +160,14 @@ export function useMediaServers() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const data = await api.browseContainer(serverId, containerId, 0)
|
||||
const data = await api.browseContainer(serverId, containerId, 0, 50)
|
||||
|
||||
browseCache.value.set(key, {
|
||||
container_id: data.container_id,
|
||||
entries: data.entries,
|
||||
total_count: data.total_count,
|
||||
hasMore: data.entries.length < data.total_count,
|
||||
currentOffset: data.entries.length,
|
||||
})
|
||||
|
||||
return browseCache.value.get(key)!
|
||||
@@ -172,22 +182,26 @@ export function useMediaServers() {
|
||||
|
||||
// Charge la page suivante et accumule (infinite scroll)
|
||||
async function loadMoreBrowse(serverId: string, containerId: string) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
const state = browseCache.value.get(key)
|
||||
|
||||
if (!state) return
|
||||
if (state.entries.length >= state.total_count) return
|
||||
if (!('hasMore' in state) || !state.hasMore) return
|
||||
if (loadingMore.value) return
|
||||
|
||||
try {
|
||||
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)
|
||||
|
||||
// Accumuler les nouvelles entrées
|
||||
state.entries.push(...data.entries)
|
||||
state.total_count = data.total_count
|
||||
state.currentOffset = state.entries.length
|
||||
state.hasMore = state.entries.length < state.total_count
|
||||
// Forcer la réactivité
|
||||
browseCache.value.set(key, { ...state })
|
||||
} catch (e) {
|
||||
@@ -236,12 +250,12 @@ export function useMediaServers() {
|
||||
}
|
||||
|
||||
function getBrowseCached(serverId: string, containerId: string) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
return browseCache.value.get(key)
|
||||
}
|
||||
|
||||
function hasMore(serverId: string, containerId: string): boolean {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
const state = browseCache.value.get(key)
|
||||
if (!state) return false
|
||||
return state.entries.length < state.total_count
|
||||
@@ -259,12 +273,12 @@ export function useMediaServers() {
|
||||
// Invalidation du cache
|
||||
function invalidateCache(serverId: string, containerId?: string) {
|
||||
if (containerId) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
browseCache.value.delete(key)
|
||||
browseCache.value.delete(browseCacheKey(serverId, containerId))
|
||||
} else {
|
||||
const encodedServerId = encodeURIComponent(serverId)
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerId + ':')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* - Les snapshots complets proviennent de /renderers/{id}/full
|
||||
* - 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 { useSSE } from "./useSSE";
|
||||
import { apiCache } from "./apiCache";
|
||||
@@ -19,31 +19,19 @@ import type {
|
||||
} 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
|
||||
const snapshots = shallowRef(new Map<string, FullRendererSnapshot>());
|
||||
const lastSnapshotAt = shallowRef(new Map<string, number>());
|
||||
const lastEventAt = shallowRef(new Map<string, number>());
|
||||
const loadingIds = shallowRef(new Set<string>());
|
||||
const queueRefreshingIds = shallowRef(new Set<string>());
|
||||
// État global des snapshots avec reactive pour une réactivité native Vue sur les Maps
|
||||
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
|
||||
const lastSnapshotAt = reactive(new Map<string, number>());
|
||||
const lastEventAt = reactive(new Map<string, number>());
|
||||
const loadingIds = reactive(new Set<string>());
|
||||
const queueRefreshingIds = reactive(new Set<string>());
|
||||
const selectedRendererId = ref<string | null>(null);
|
||||
|
||||
// Cache des renderers (summary)
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||
const RENDERERS_CACHE_MS = 2000;
|
||||
|
||||
// Helper pour déclencher la réactivité après mutation des Maps
|
||||
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);
|
||||
}
|
||||
// Supprimé : les helpers triggerXXX ne sont plus nécessaires avec reactive
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
@@ -114,17 +102,16 @@ function ensureSSEInitialized() {
|
||||
}
|
||||
|
||||
// Supprimer le snapshot (il n'est plus valide)
|
||||
snapshots.value.delete(rendererId);
|
||||
triggerSnapshotReactivity();
|
||||
lastSnapshotAt.value.delete(rendererId);
|
||||
lastEventAt.value.delete(rendererId);
|
||||
snapshots.delete(rendererId);
|
||||
lastSnapshotAt.delete(rendererId);
|
||||
lastEventAt.delete(rendererId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
if (!snapshot) {
|
||||
@@ -137,11 +124,11 @@ function ensureSSEInitialized() {
|
||||
case "state_changed":
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
if (isTransportState(event.state)) {
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, transport_state: event.state },
|
||||
});
|
||||
triggerSnapshotReactivity();
|
||||
|
||||
} else {
|
||||
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
||||
}
|
||||
@@ -158,7 +145,7 @@ function ensureSSEInitialized() {
|
||||
const durationMs = parseTimeToMs(event.track_duration ?? null);
|
||||
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: {
|
||||
...snapshot.state,
|
||||
@@ -166,12 +153,12 @@ function ensureSSEInitialized() {
|
||||
duration_ms: durationMs,
|
||||
},
|
||||
});
|
||||
triggerSnapshotReactivity();
|
||||
|
||||
break;
|
||||
|
||||
case "volume_changed":
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, volume: event.volume },
|
||||
});
|
||||
@@ -179,7 +166,7 @@ function ensureSSEInitialized() {
|
||||
|
||||
case "mute_changed":
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, mute: event.mute },
|
||||
});
|
||||
@@ -199,21 +186,21 @@ function ensureSSEInitialized() {
|
||||
snapshot.state.current_track.album = event.album;
|
||||
snapshot.state.current_track.album_art_uri = event.album_art_uri;
|
||||
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state },
|
||||
});
|
||||
break;
|
||||
|
||||
case "queue_refreshing":
|
||||
queueRefreshingIds.value.add(rendererId);
|
||||
triggerQueueReactivity();
|
||||
queueRefreshingIds.add(rendererId);
|
||||
|
||||
break;
|
||||
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.value.delete(rendererId);
|
||||
triggerQueueReactivity();
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
|
||||
// Pour la queue complète, on doit refetch
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
@@ -231,7 +218,7 @@ function ensureSSEInitialized() {
|
||||
snapshot.state.attached_playlist = null;
|
||||
}
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.value.set(rendererId, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state },
|
||||
});
|
||||
@@ -240,7 +227,7 @@ function ensureSSEInitialized() {
|
||||
case "stream_state_changed":
|
||||
snapshot.is_stream = event.is_stream;
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.value.set(rendererId, { ...snapshot });
|
||||
snapshots.set(rendererId, { ...snapshot });
|
||||
break;
|
||||
|
||||
case "timer_started":
|
||||
@@ -265,7 +252,7 @@ const onlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r) => r.online),
|
||||
);
|
||||
const allSnapshots = computed(() =>
|
||||
Array.from(snapshots.value.values()),
|
||||
Array.from(snapshots.values()),
|
||||
);
|
||||
const playingRenderers = computed(() =>
|
||||
allSnapshots.value
|
||||
@@ -278,27 +265,27 @@ function getRendererById(id: string) {
|
||||
}
|
||||
|
||||
function getSnapshotById(id: string) {
|
||||
return snapshots.value.get(id) ?? null;
|
||||
return snapshots.get(id) ?? 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 {
|
||||
return snapshots.value.get(id)?.queue ?? null;
|
||||
return snapshots.get(id)?.queue ?? null;
|
||||
}
|
||||
|
||||
function getBindingById(id: string): AttachedPlaylistInfo | null {
|
||||
return snapshots.value.get(id)?.binding ?? null;
|
||||
return snapshots.get(id)?.binding ?? null;
|
||||
}
|
||||
|
||||
function isSnapshotLoading(id: string) {
|
||||
return loadingIds.value.has(id);
|
||||
return loadingIds.has(id);
|
||||
}
|
||||
|
||||
function isQueueRefreshing(id: string) {
|
||||
return queueRefreshingIds.value.has(id);
|
||||
return queueRefreshingIds.has(id);
|
||||
}
|
||||
|
||||
function selectRenderer(id: string | null) {
|
||||
@@ -353,43 +340,43 @@ async function fetchRendererSnapshot(
|
||||
) {
|
||||
ensureSSEInitialized();
|
||||
const force = opts?.force ?? false;
|
||||
const hasSnapshot = snapshots.value.has(rendererId);
|
||||
const hasSnapshot = snapshots.has(rendererId);
|
||||
|
||||
if (!force && hasSnapshot) {
|
||||
const lastSnapshot = lastSnapshotAt.value.get(rendererId) ?? 0;
|
||||
const lastEvent = lastEventAt.value.get(rendererId) ?? 0;
|
||||
const lastSnapshot = lastSnapshotAt.get(rendererId) ?? 0;
|
||||
const lastEvent = lastEventAt.get(rendererId) ?? 0;
|
||||
if (lastEvent <= lastSnapshot) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Éviter les requêtes multiples simultanées pour le même renderer
|
||||
if (loadingIds.value.has(rendererId)) {
|
||||
if (loadingIds.has(rendererId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingIds.value.add(rendererId);
|
||||
triggerLoadingReactivity();
|
||||
loadingIds.add(rendererId);
|
||||
|
||||
|
||||
// Lazy load UI store pour les notifications
|
||||
const uiStore = useUIStore();
|
||||
|
||||
try {
|
||||
const snapshot = await api.getRendererFullSnapshot(rendererId);
|
||||
snapshots.value.set(rendererId, snapshot);
|
||||
lastSnapshotAt.value.set(rendererId, Date.now());
|
||||
triggerSnapshotReactivity();
|
||||
snapshots.set(rendererId, snapshot);
|
||||
lastSnapshotAt.set(rendererId, Date.now());
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
|
||||
// En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative
|
||||
snapshots.value.delete(rendererId);
|
||||
triggerSnapshotReactivity();
|
||||
snapshots.delete(rendererId);
|
||||
|
||||
// Notifier l'utilisateur
|
||||
uiStore.notifyError(`Impossible de récupérer l'état du renderer`);
|
||||
} finally {
|
||||
// Toujours nettoyer le flag de chargement
|
||||
loadingIds.value.delete(rendererId);
|
||||
triggerLoadingReactivity();
|
||||
loadingIds.delete(rendererId);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,7 +426,7 @@ async function play(id: string) {
|
||||
}
|
||||
|
||||
async function resumeOrPlayFromQueue(id: string) {
|
||||
const snapshot = snapshots.value.get(id);
|
||||
const snapshot = snapshots.get(id);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Renderer ${id} non trouvé`);
|
||||
}
|
||||
|
||||
@@ -17,13 +17,17 @@ import type {
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
// État global partagé
|
||||
const connected = ref(sse.isConnectedState())
|
||||
const connectionCallbacks: Set<(connected: boolean) => void> = new Set()
|
||||
const connected = ref(sse.isConnectedState());
|
||||
const connectionCallbacks: Set<(connected: boolean) => void> = new Set();
|
||||
|
||||
// Flag pour éviter les double-connexions SSE avec lock
|
||||
let connectionLock = false;
|
||||
|
||||
// Abonnement à l'état de connexion global
|
||||
function setupConnectionListener() {
|
||||
// S'assurer qu'on ne s'abonne qu'une seule fois
|
||||
if (connectionCallbacks.size === 0) {
|
||||
// Vérifier avec lock pour éviter les conditions de course
|
||||
if (connectionCallbacks.size === 0 && !connectionLock) {
|
||||
connectionLock = true;
|
||||
sse.onConnectionChange((isConnected) => {
|
||||
connected.value = isConnected
|
||||
connectionCallbacks.forEach(cb => cb(isConnected))
|
||||
|
||||
@@ -43,6 +43,10 @@ const state = reactive<TabsState>({
|
||||
// Flag pour éviter les boucles de sauvegarde
|
||||
let isRestoringFromStorage = false;
|
||||
|
||||
// Debounce timer pour la sauvegarde localStorage
|
||||
let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const SAVE_DEBOUNCE_MS = 100;
|
||||
|
||||
/**
|
||||
* Retourne le titre complet sans troncature
|
||||
* Note: On laisse le CSS gérer l'overflow avec ellipsis pour un affichage stable
|
||||
@@ -53,29 +57,38 @@ function truncateTitle(title: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde l'état dans localStorage
|
||||
* Sauvegarde l'état dans localStorage (avec debounce)
|
||||
* Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés)
|
||||
*/
|
||||
function saveToLocalStorage() {
|
||||
if (isRestoringFromStorage) return;
|
||||
|
||||
try {
|
||||
const stateToSave = {
|
||||
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
||||
tabs: state.tabs
|
||||
.filter((tab) => tab.type === "server")
|
||||
.map((tab) => ({
|
||||
...tab,
|
||||
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
||||
icon: undefined,
|
||||
})),
|
||||
activeTabId: state.activeTabId,
|
||||
tabHistory: state.tabHistory,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
|
||||
// Annuler le timer précédent
|
||||
if (saveDebounceTimer !== null) {
|
||||
clearTimeout(saveDebounceTimer);
|
||||
}
|
||||
|
||||
// Débouncer pour éviter les écritures multiples
|
||||
saveDebounceTimer = setTimeout(() => {
|
||||
try {
|
||||
const stateToSave = {
|
||||
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
||||
tabs: state.tabs
|
||||
.filter((tab) => tab.type === "server")
|
||||
.map((tab) => ({
|
||||
...tab,
|
||||
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
||||
icon: undefined,
|
||||
})),
|
||||
activeTabId: state.activeTabId,
|
||||
tabHistory: state.tabHistory,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
|
||||
}
|
||||
saveDebounceTimer = null;
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,11 +96,11 @@ function saveToLocalStorage() {
|
||||
* Note: Restaure uniquement les onglets server (les renderer tabs seront auto-générés)
|
||||
*/
|
||||
function restoreFromLocalStorage() {
|
||||
isRestoringFromStorage = true;
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
|
||||
isRestoringFromStorage = true;
|
||||
const savedState = JSON.parse(saved);
|
||||
|
||||
// Reconstituer uniquement les tabs server avec les bonnes icônes
|
||||
@@ -110,10 +123,9 @@ function restoreFromLocalStorage() {
|
||||
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
|
||||
state.activeTabId = "";
|
||||
}
|
||||
|
||||
isRestoringFromStorage = false;
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur restauration localStorage:", error);
|
||||
} finally {
|
||||
isRestoringFromStorage = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import { sse } from "./services/pmocontrol/sse";
|
||||
// Store UI (garde UIStore pour les notifications et état UI global)
|
||||
import { useUIStore } from "./stores/ui";
|
||||
|
||||
// Image cache (pour cleanup)
|
||||
import { imageCache } from "./composables/imageCache";
|
||||
|
||||
// Styles
|
||||
import "./style.css";
|
||||
import "./assets/styles/variables.css";
|
||||
@@ -24,12 +27,13 @@ const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
// Initialiser UIStore AVANT le montage pour éviter la race condition (P2)
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Monter l'application
|
||||
app.mount("#app");
|
||||
|
||||
// Après montage, initialiser SSE
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Les composables se connectent automatiquement à SSE
|
||||
// Ils gèrent eux-mêmes le re-fetch lors des événements
|
||||
|
||||
@@ -39,3 +43,8 @@ sse.onConnectionChange((connected) => {
|
||||
|
||||
// Démarrer la connexion SSE
|
||||
sse.connect();
|
||||
|
||||
// Cleanup global lors du unload de la page
|
||||
window.addEventListener('beforeunload', () => {
|
||||
imageCache.destroy();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
|
||||
// PMOControl Unified View (nouvelle interface unifiée)
|
||||
import UnifiedControlView from "../views/UnifiedControlView.vue";
|
||||
@@ -8,18 +9,10 @@ import DashboardView from "../views/DashboardView.vue";
|
||||
import RendererView from "../views/RendererView.vue";
|
||||
import MediaServerView from "../views/MediaServerView.vue";
|
||||
|
||||
// Debug Components (anciennes routes)
|
||||
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
|
||||
import LogView from "../components/LogView.vue";
|
||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||
import AudioCacheManager from "../components/AudioCacheManager.vue";
|
||||
import PlayListManager from "../components/PlayListManager.vue";
|
||||
import UpnpExplorer from "../components/UpnpExplorer.vue";
|
||||
import APIDashboard from "../components/APIDashboard.vue";
|
||||
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
||||
import DebugView from "../views/DebugView.vue";
|
||||
// Debug Components - lazy loaded uniquement en mode développement (P8)
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
const routes = [
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// PMOControl Unified Interface (nouvelle interface unifiée avec onglets)
|
||||
{
|
||||
path: "/",
|
||||
@@ -43,57 +36,65 @@ const routes = [
|
||||
name: "MediaServer",
|
||||
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({
|
||||
// history avec base /app
|
||||
history: createWebHistory("/app"),
|
||||
|
||||
@@ -33,6 +33,58 @@ export interface TrackInfo {
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
// Types stricts pour les commandes reçues du backend (P6)
|
||||
interface StreamCommand {
|
||||
type: 'stream';
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PlayCommand {
|
||||
type: 'play';
|
||||
}
|
||||
|
||||
interface PauseCommand {
|
||||
type: 'pause';
|
||||
}
|
||||
|
||||
interface SeekCommand {
|
||||
type: 'seek';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FlushCommand {
|
||||
type: 'flush';
|
||||
}
|
||||
|
||||
interface StopCommand {
|
||||
type: 'stop';
|
||||
}
|
||||
|
||||
type CommandMessage = StreamCommand | PlayCommand | PauseCommand | SeekCommand | FlushCommand | StopCommand;
|
||||
|
||||
function isValidCommand(msg: Record<string, unknown> | unknown): msg is CommandMessage {
|
||||
if (!msg || typeof msg !== 'object') return false;
|
||||
if (!('type' in msg)) return false;
|
||||
|
||||
const type = (msg as Record<string, unknown>).type;
|
||||
if (typeof type !== 'string') return false;
|
||||
|
||||
// Valider les champs selon le type
|
||||
switch (type) {
|
||||
case 'stream':
|
||||
return 'url' in msg && typeof (msg as StreamCommand).url === 'string';
|
||||
case 'seek':
|
||||
return 'timestamp' in msg && typeof (msg as SeekCommand).timestamp === 'number';
|
||||
case 'play':
|
||||
case 'pause':
|
||||
case 'flush':
|
||||
case 'stop':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class PMOPlayer {
|
||||
private audio: HTMLAudioElement;
|
||||
private instanceId: string;
|
||||
@@ -195,11 +247,17 @@ export class PMOPlayer {
|
||||
}
|
||||
|
||||
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) {
|
||||
case 'stream': {
|
||||
this.playStream(msg.url as string);
|
||||
this.playStream(msg.url);
|
||||
break;
|
||||
}
|
||||
case 'play': {
|
||||
@@ -211,7 +269,7 @@ export class PMOPlayer {
|
||||
this.pause();
|
||||
break;
|
||||
case 'seek':
|
||||
this.seek(msg.timestamp as number);
|
||||
this.seek(msg.timestamp);
|
||||
break;
|
||||
case 'flush':
|
||||
this.flush();
|
||||
|
||||
@@ -200,34 +200,11 @@ export function getJpegUrl(pk: string, size?: number): string {
|
||||
return `/covers/jpeg/${pk}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG par défaut pour les images qui ne se chargent pas
|
||||
*/
|
||||
const DEFAULT_COVER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgGrad)"/>
|
||||
<g transform="translate(200, 200)">
|
||||
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
|
||||
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
</g>
|
||||
<text x="200" y="360" text-anchor="middle"
|
||||
font-family="system-ui, -apple-system, sans-serif"
|
||||
font-size="20" fill="white" opacity="0.6">
|
||||
No Image Available
|
||||
</text>
|
||||
</svg>`;
|
||||
import defaultCoverSvg from '../assets/default-cover.svg?raw';
|
||||
|
||||
/**
|
||||
* Retourne l'URL de l'image par défaut comme data URL
|
||||
*/
|
||||
export function getDefaultImageUrl(): string {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(DEFAULT_COVER_SVG)}`;
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(defaultCoverSvg)}`;
|
||||
}
|
||||
|
||||
@@ -17,12 +17,45 @@ import type {
|
||||
ErrorResponse,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Fetch avec timeout et AbortController
|
||||
*/
|
||||
function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeoutMs = 10_000,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return fetch(url, { ...options, signal: controller.signal }).finally(
|
||||
() => clearTimeout(id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client API REST pour le Control Point PMOMusic
|
||||
*/
|
||||
class PMOControlAPI {
|
||||
private readonly baseURL = "/api/control";
|
||||
|
||||
/**
|
||||
* Valide la structure de base d'une réponse
|
||||
* Jette une erreur si la réponse est invalide
|
||||
*/
|
||||
private validateResponse<T>(data: unknown, path: string): T {
|
||||
// Vérification basique : null ou undefined
|
||||
if (data == null) {
|
||||
throw new Error(`[PMOControlAPI] Réponse nulle pour ${path}`);
|
||||
}
|
||||
|
||||
// Vérification que c'est un objet
|
||||
if (typeof data !== 'object') {
|
||||
throw new Error(`[PMOControlAPI] Réponse invalide pour ${path}: attendu un objet`);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une requête HTTP générique
|
||||
*/
|
||||
@@ -32,7 +65,7 @@ class PMOControlAPI {
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -48,10 +81,14 @@ class PMOControlAPI {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Validation de la réponse (P5)
|
||||
const validated = this.validateResponse<T>(data, path);
|
||||
|
||||
if (import.meta.env.DEV && data == null) {
|
||||
console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
|
||||
}
|
||||
return data;
|
||||
return validated;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface BrowseResponse {
|
||||
entries: ContainerEntry[];
|
||||
total_count: number;
|
||||
offset: number;
|
||||
hasMore?: boolean; // Client-side flag pour infinite scroll
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface Notification {
|
||||
duration?: number // ms, undefined = permanent
|
||||
}
|
||||
|
||||
const MAX_NOTIFICATIONS = 5
|
||||
|
||||
export const useUIStore = defineStore('ui', () => {
|
||||
// État
|
||||
const selectedRendererId = ref<string | null>(null)
|
||||
@@ -17,6 +19,9 @@ export const useUIStore = defineStore('ui', () => {
|
||||
const sseConnected = ref(false)
|
||||
const notifications = ref<Notification[]>([])
|
||||
|
||||
// Map pour suivre les timers et permettre le cleanup
|
||||
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
// Actions
|
||||
function selectRenderer(id: string | null) {
|
||||
selectedRendererId.value = id
|
||||
@@ -39,6 +44,18 @@ export const useUIStore = defineStore('ui', () => {
|
||||
message: string,
|
||||
duration?: number
|
||||
) {
|
||||
// Limiter le nombre de notifications (P15)
|
||||
if (notifications.value.length >= MAX_NOTIFICATIONS) {
|
||||
const oldest = notifications.value.shift()
|
||||
if (oldest) {
|
||||
const timer = notificationTimers.get(oldest.id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
notificationTimers.delete(oldest.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const id = `notif-${Date.now()}-${Math.random()}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
@@ -49,12 +66,13 @@ export const useUIStore = defineStore('ui', () => {
|
||||
|
||||
notifications.value.push(notification)
|
||||
|
||||
// Auto-remove après duration (défaut: 5s)
|
||||
// Auto-remove après duration (défaut: 5s) - avec tracking pour cleanup
|
||||
const timeout = duration !== undefined ? duration : 5000
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => {
|
||||
const timer = setTimeout(() => {
|
||||
removeNotification(id)
|
||||
}, timeout)
|
||||
notificationTimers.set(id, timer)
|
||||
}
|
||||
|
||||
return id
|
||||
@@ -65,12 +83,27 @@ export const useUIStore = defineStore('ui', () => {
|
||||
if (index !== -1) {
|
||||
notifications.value.splice(index, 1)
|
||||
}
|
||||
// Nettoyer le timer associated
|
||||
const timer = notificationTimers.get(id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
notificationTimers.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
function clearNotifications() {
|
||||
// Nettoyer tous les timers
|
||||
notificationTimers.forEach(timer => clearTimeout(timer))
|
||||
notificationTimers.clear()
|
||||
notifications.value = []
|
||||
}
|
||||
|
||||
// Cleanup function pour appeler lors du unmount de l'app
|
||||
function $dispose() {
|
||||
notificationTimers.forEach(timer => clearTimeout(timer))
|
||||
notificationTimers.clear()
|
||||
}
|
||||
|
||||
// Raccourcis pour les types de notifications
|
||||
function notifySuccess(message: string, duration?: number) {
|
||||
return addNotification('success', message, duration)
|
||||
@@ -107,5 +140,6 @@ export const useUIStore = defineStore('ui', () => {
|
||||
notifyError,
|
||||
notifyWarning,
|
||||
notifyInfo,
|
||||
$dispose,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,5 +45,9 @@ export function normalizeUrl(url: string): string {
|
||||
*/
|
||||
export function truncate(str: string, maxLength: number, suffix = '...'): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
// Guard: si suffix est plus long que maxLength, retourner juste le suffixe
|
||||
if (suffix.length >= maxLength) {
|
||||
return str.slice(0, maxLength);
|
||||
}
|
||||
return str.slice(0, maxLength - suffix.length) + suffix;
|
||||
}
|
||||
@@ -49,12 +49,3 @@ export function formatMsToTime(ms: number | null): string {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.3.37
|
||||
0.3.39
|
||||
|
||||
Reference in New Issue
Block a user