[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]]
|
[[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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.37"
|
version = "0.3.39"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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é`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1 +1 @@
|
|||||||
0.3.37
|
0.3.39
|
||||||
|
|||||||
Reference in New Issue
Block a user