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

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

View File

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

2
Cargo.lock generated
View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -101,13 +101,15 @@
font-size: var(--text-base);
}
/* 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);
}

View File

@@ -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;
}
}
/* ========================================

View File

@@ -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; }
}
}

View File

@@ -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) */

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>) {

View File

@@ -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)
}
})

View File

@@ -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é`);
}

View File

@@ -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))

View File

@@ -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;
}
}

View File

@@ -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();
});

View File

@@ -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"),

View File

@@ -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();

View File

@@ -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)}`;
}

View File

@@ -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;
}
// ============================================================================

View File

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

View File

@@ -9,6 +9,8 @@ export interface Notification {
duration?: number // ms, undefined = permanent
}
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,
}
})

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -1 +1 @@
0.3.37
0.3.39