diff --git a/Blackboard/Todo/frontend_enorme_playlist.md b/Blackboard/Todo/frontend_enorme_playlist.md
new file mode 100644
index 00000000..946950b9
--- /dev/null
+++ b/Blackboard/Todo/frontend_enorme_playlist.md
@@ -0,0 +1,338 @@
+** 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 et symptôme
+
+Le frontend Vue.js du control point PMOMusic (`pmoapp/webapp`) présente des problèmes de
+performance significatifs avec des playlists de ~1000 titres. La plupart des lenteurs sont
+côté UI, indépendamment des optimisations déjà réalisées côté Rust/backend.
+
+Les trois manifestations observées :
+- **Freeze au scroll** dans la file d'attente (QueueViewer) dès ~200 items
+- **Blocage UI temporaire** à l'ouverture d'une playlist dans PlayListManager
+- **Refetches JSON répétés** déclenchés par les événements SSE `queue_updated`
+
+## Causes racines identifiées
+
+### P0 — Pas de virtualisation dans `QueueViewer.vue`
+
+**Fichier** : `src/components/pmocontrol/QueueViewer.vue:85-93`
+
+```vue
+
+
+
+
+```
+
+Pour 1 000 titres : 1 000 nœuds DOM permanents, chacun contenant une image, un composable
+réactif (`useCoverImage`), et des computed properties. Le scroll devient impossible.
+
+**Ironie** : `vue-virtual-scroller@^2.0.0-beta.8` est présent dans `package.json` mais
+**n'est utilisé nulle part** dans la codebase — manifestement prévu puis abandonné.
+
+### P1 — Pas de virtualisation dans `PlayListManager.vue`
+
+**Fichier** : `src/components/PlayListManager.vue:562-724`
+
+```vue
+
+```
+
+Même problème : grid CSS avec 1 000 articles et leurs images (`loading="lazy"`).
+
+Aggravé par la computed `sortedTracks` (ligne 883-890) :
+```typescript
+const sortedTracks = computed(() => {
+ return [...detail.tracks].sort( // copie complète du tableau
+ (a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime()
+ );
+});
+```
+Et `lazyTracksCount` (ligne 892-897) qui filtre les 1 000 items à chaque re-render.
+
+### P2 — `queue_updated` force un refetch JSON complet
+
+**Fichier** : `src/composables/useRenderers.ts:200-206`
+
+```typescript
+case "queue_updated":
+ snapshot.state.queue_len = event.queue_length;
+ queueRefreshingIds.delete(rendererId);
+ // Pour la queue complète, on doit refetch
+ void fetchRendererSnapshot(rendererId, { force: true });
+ break;
+```
+
+`fetchRendererSnapshot()` appelle `api.getRendererFullSnapshot(rendererId)` qui retourne
+le snapshot complet incluant **tous les items de la queue avec leurs métadonnées**.
+
+Pour 1 000 titres, le payload JSON peut atteindre plusieurs centaines de Ko. Si l'utilisateur
+charge une playlist de 1 000 titres depuis un serveur qui émet les items par batch (ex. 64
+par 64 côté Rust), l'événement `queue_updated` est émis plusieurs fois de suite, déclenchant
+autant de refetches consécutifs du même JSON complet.
+
+**Note** : `loadingIds.has(rendererId)` (ligne 354) déduplique les requêtes simultanées,
+mais pas les requêtes consécutives rapprochées.
+
+### P3 — Infinite scroll accumule toutes les pages en mémoire (`MediaBrowser`)
+
+**Fichier** : `src/composables/useMediaServers.ts:200-204`
+
+```typescript
+// Accumuler les nouvelles entrées — jamais purgées
+state.entries.push(...data.entries)
+```
+
+En parcourant un serveur contenant 1 000 titres, toutes les pages de 50 items
+s'accumulent dans `browseCache` sans jamais être libérées. Résultat : après un scroll
+complet, 1 000 entrées sont en mémoire ET en DOM simultanément.
+
+### P4 — `scrollIntoView` sur 1 000 nœuds DOM non virtualisés
+
+**Fichier** : `src/components/pmocontrol/QueueViewer.vue:27-48`
+
+```typescript
+watch(() => queue.value?.current_index, async (currentIndex) => {
+ await nextTick();
+ const currentItem = queueContainer.value.querySelector(".queue-item.current");
+ if (currentItem) {
+ currentItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+}, { immediate: true });
+```
+
+`querySelector` sur un conteneur de 1 000 nœuds + animation CSS `smooth` provoque un
+layout thrashing. Ce watcher est aussi déclenché au montage (`immediate: true`), ce qui
+peut provoquer un re-layout au chargement initial de la page.
+
+## Ce qui fonctionne déjà correctement
+
+- **Déduplication des snapshots simultanés** : `loadingIds.has(rendererId)` évite les
+ requêtes parallèles pour le même renderer — à préserver.
+- **`loading="lazy"` sur les images** dans PlayListManager — efficace une fois que le DOM
+ est virtualisé.
+- **Cache du browse** avec invalidation par conteneur SSE — architecture correcte.
+- **`useCoverImage`** avec retry exponentiel et cleanup — à conserver tel quel dans
+ `QueueItem.vue`.
+- **Connexion SSE unique** partagée entre tous les composants — bonne architecture.
+
+## Plan d'exécution
+
+### Répertoire concerné : `pmoapp/webapp`
+
+---
+
+### Étape 1 — Virtualiser la file d'attente dans `QueueViewer.vue`
+
+**Fichier** : `src/components/pmocontrol/QueueViewer.vue`
+
+`vue-virtual-scroller` est déjà installé. Remplacer le `v-for` nu par `` :
+
+```vue
+
+
+
+
+
+
+
+
+```
+
+`item-size="64"` correspond à la hauteur CSS actuelle de `.queue-item` (padding +
+cover 48px + gap). À ajuster si le CSS change.
+
+**Adapter `scrollIntoView`** : `RecycleScroller` expose une méthode `scrollToItem(index)`.
+Remplacer le `querySelector` + `scrollIntoView` par :
+
+```typescript
+watch(() => queue.value?.current_index, async (currentIndex) => {
+ if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
+ await nextTick();
+ queueContainer.value.scrollToItem(currentIndex);
+ }
+}, { immediate: true });
+```
+
+**Fonctionnalité préservée** : `QueueItem.vue` reste inchangé — `RecycleScroller` recycle
+les nœuds DOM au lieu de les créer tous, mais les props passées à chaque item sont
+identiques.
+
+---
+
+### Étape 2 — Débouncer les refetches `queue_updated`
+
+**Fichier** : `src/composables/useRenderers.ts:200-206`
+
+Le problème : `queue_updated` arrive N fois de suite pendant le chargement d'une grande
+playlist, déclenchant N refetches.
+
+Ajouter un debounce par renderer sur l'appel à `fetchRendererSnapshot` :
+
+```typescript
+// Map des timers de debounce par renderer (à déclarer en module scope)
+const queueUpdateDebounceTimers = new Map>();
+const QUEUE_UPDATE_DEBOUNCE_MS = 300;
+
+// Dans le case "queue_updated" :
+case "queue_updated":
+ snapshot.state.queue_len = event.queue_length;
+ queueRefreshingIds.delete(rendererId);
+
+ // Annuler le timer précédent pour ce renderer
+ const existingTimer = queueUpdateDebounceTimers.get(rendererId);
+ if (existingTimer) clearTimeout(existingTimer);
+
+ // Programmer un seul fetch après stabilisation
+ queueUpdateDebounceTimers.set(rendererId, setTimeout(() => {
+ queueUpdateDebounceTimers.delete(rendererId);
+ void fetchRendererSnapshot(rendererId, { force: true });
+ }, QUEUE_UPDATE_DEBOUNCE_MS));
+ break;
+```
+
+**Fonctionnalité préservée** : Si un seul `queue_updated` arrive (cas normal), le refetch
+est simplement retardé de 300 ms — imperceptible. Si N arrivent en rafale (chargement
+d'une grande playlist), un seul refetch est déclenché à la fin.
+
+**Contrainte** : Ne pas dépasser 500 ms de debounce — l'indicateur `queueRefreshing` dans
+l'UI doit se désactiver rapidement après la fin du chargement.
+
+---
+
+### Étape 3 — Virtualiser la grille dans `PlayListManager.vue`
+
+**Fichier** : `src/components/PlayListManager.vue`
+
+La grille CSS ne peut pas être virtualisée directement avec `RecycleScroller` (liste 1D).
+Remplacer la grid par une liste virtualisée, ou introduire une pagination côté client :
+
+```typescript
+const PAGE_SIZE = 100;
+const currentPage = ref(0);
+
+const paginatedTracks = computed(() =>
+ sortedTracks.value.slice(
+ currentPage.value * PAGE_SIZE,
+ (currentPage.value + 1) * PAGE_SIZE
+ )
+);
+```
+
+Avec des boutons de navigation Précédent / Suivant et un indicateur de page.
+
+**Optimiser `sortedTracks`** : mémoriser le résultat par `playlist.id` pour éviter
+la copie+tri à chaque re-render non lié à la playlist :
+
+```typescript
+const sortedTracksCache = new Map();
+
+const sortedTracks = computed(() => {
+ const detail = selectedPlaylist.value;
+ if (!detail) return [];
+ const cached = sortedTracksCache.get(detail.id);
+ if (cached && cached.length === detail.tracks.length) return cached;
+ const sorted = [...detail.tracks].sort(
+ (a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime()
+ );
+ sortedTracksCache.set(detail.id, sorted);
+ return sorted;
+});
+```
+
+**Simplifier `lazyTracksCount`** : le dériver de `sortedTracks` pour ne pas parcourir
+le tableau original en parallèle :
+
+```typescript
+const lazyTracksCount = computed(() =>
+ sortedTracks.value.filter(isLazyTrack).length
+);
+```
+
+---
+
+### Étape 4 — Limiter l'accumulation dans le browse infini (`MediaBrowser`)
+
+**Fichier** : `src/composables/useMediaServers.ts:183-212`
+
+Implémenter une fenêtre glissante dans `browseCache` : conserver seulement les 200
+derniers items en mémoire :
+
+```typescript
+const BROWSE_WINDOW_SIZE = 200;
+
+async function loadMoreBrowse(serverId: string, containerId: string) {
+ // ... code existant jusqu'à la récupération de data ...
+
+ // Remplacer : state.entries.push(...data.entries)
+ // Par :
+ const combined = [...state.entries, ...data.entries];
+ state.entries = combined.slice(-BROWSE_WINDOW_SIZE);
+ state.total_count = data.total_count;
+ state.currentOffset = (state.currentOffset ?? 0) + data.entries.length;
+ state.hasMore = state.currentOffset < state.total_count;
+ browseCache.value.set(key, { ...state });
+}
+```
+
+**Invariant à préserver** : `state.currentOffset` et `state.hasMore` doivent continuer
+de refléter la position réelle dans la liste serveur, indépendamment de ce qui est
+affiché — leur logique ne change pas.
+
+---
+
+## Ordre d'exécution
+
+1. **Étape 1** — Virtualisation `QueueViewer` (impact le plus visible, composant le plus simple)
+2. **Étape 2** — Debounce `queue_updated` (élimine les refetches en cascade, changement minimal)
+3. **Étape 3** — Optimisation `PlayListManager` (plus complexe, composant de 2039 lignes)
+4. **Étape 4** — Fenêtre glissante `MediaBrowser` (amélioration mémoire, moins critique)
+
+## Périmètre : ce qui ne change pas
+
+- `QueueItem.vue` : aucune modification (recycling géré par le parent)
+- `useCoverImage.ts` : aucune modification (lazy loading + retry déjà corrects)
+- `useSSE.ts` : aucune modification (connexion unique, bonne architecture)
+- `loadingIds` dans `fetchRendererSnapshot` : déduplication conservée
+- `api.getRendererFullSnapshot` : le payload reste complet, pas de pagination API
+- Tous les événements SSE autres que `queue_updated` : aucune modification
+
+## Tests recommandés
+
+Demander à l'humain de :
+
+```bash
+cd pmoapp/webapp
+npm run dev
+```
+
+Puis tester manuellement :
+- Ouvrir la file d'attente d'un renderer OpenHome avec 1 000 titres : scroll fluide ?
+- Vérifier que la piste courante est visible au changement de piste (`scrollToItem`)
+- Charger une playlist de 1 000 titres via PlayListManager : absence de blocage ?
+- Observer les requêtes réseau dans DevTools lors du chargement d'une grande playlist :
+ un seul `GET /renderers/{id}/full` doit être émis après la fin du chargement
diff --git a/Report/frontend_enorme_playlist.md b/Report/frontend_enorme_playlist.md
new file mode 100644
index 00000000..82958a72
--- /dev/null
+++ b/Report/frontend_enorme_playlist.md
@@ -0,0 +1,20 @@
+# Rapport : Optimisation performances frontend playlists ~1000 titres
+
+## Résumé
+Implémentation de quatre optimisations pour traiter les lenteurs UI observées avec de grandes playlists : virtualisation de la file d'attente avec RecycleScroller, debounce des refetches queue_updated, pagination + cache dans PlayListManager, et fenêtre glissante dans MediaBrowser. Ces changements éliminent les freezes au scroll, bloquages UI et refetches JSON répétés.
+
+## Fichiers modifiés
+1. `pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue`
+ - Remplacement du v-for natif par RecycleScroller de vue-virtual-scroller
+ - Migration de querySelector+scrollIntoView vers scrollToItem() exposé par RecycleScroller
+
+2. `pmoapp/webapp/src/composables/useRenderers.ts`
+ - Ajout d'un debounce de 300ms sur les refetches queue_updated pour éviter les refetches en cascade
+
+3. `pmoapp/webapp/src/components/PlayListManager.vue`
+ - Ajout pagination client (100 items/page) avec navigation Précédent/Suivant
+ -Ajout cache mémorisé pour sortedTracks par playlist ID
+ - Simplification lazyTracksCount derivé de sortedTracks
+
+4. `pmoapp/webapp/src/composables/useMediaServers.ts`
+ - Ajout fenêtre glissante limitant le cache browse aux 200 derniers items
\ No newline at end of file
diff --git a/pmoapp/webapp/src/components/PlayListManager.vue b/pmoapp/webapp/src/components/PlayListManager.vue
index 7b2debe3..1dfea6b5 100644
--- a/pmoapp/webapp/src/components/PlayListManager.vue
+++ b/pmoapp/webapp/src/components/PlayListManager.vue
@@ -559,12 +559,24 @@
>
+
+
{
);
});
+const PAGE_SIZE = 100;
+const currentPage = ref(0);
+const sortedTracksCache = new Map();
+
const sortedTracks = computed(() => {
const detail = selectedPlaylist.value;
if (!detail) return [];
- return [...detail.tracks].sort(
+ const playlistId = detail.summary.id;
+ const cached = sortedTracksCache.get(playlistId);
+ if (cached && cached.length === detail.tracks.length) return cached;
+ const sorted = [...detail.tracks].sort(
(a, b) =>
new Date(b.added_at).getTime() - new Date(a.added_at).getTime(),
);
+ sortedTracksCache.set(playlistId, sorted);
+ return sorted;
+});
+
+const paginatedTracks = computed(() =>
+ sortedTracks.value.slice(
+ currentPage.value * PAGE_SIZE,
+ (currentPage.value + 1) * PAGE_SIZE
+ )
+);
+
+const totalPages = computed(() => Math.ceil(sortedTracks.value.length / PAGE_SIZE));
+
+function nextPage() {
+ if (currentPage.value < totalPages.value - 1) {
+ currentPage.value++;
+ }
+}
+
+function prevPage() {
+ if (currentPage.value > 0) {
+ currentPage.value--;
+ }
+}
+
+watch(sortedTracks, () => {
+ currentPage.value = 0;
});
const lazyTracksCount = computed(() =>
- selectedPlaylist.value
- ? selectedPlaylist.value.tracks.filter((track) => isLazyTrack(track))
- .length
- : 0,
+ sortedTracks.value.filter((track) => isLazyTrack(track)).length
);
const updateCoverPreview = computed(
@@ -1881,6 +1924,40 @@ button:disabled {
margin-bottom: var(--spacing-sm);
}
+.pagination-controls {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-md);
+ padding: var(--spacing-md);
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: var(--radius-md);
+ margin-bottom: var(--spacing-md);
+}
+
+.pagination-controls button {
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ color: var(--color-text);
+ cursor: pointer;
+}
+
+.pagination-controls button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.pagination-controls button:hover:not(:disabled) {
+ background: var(--color-bg-tertiary);
+}
+
+.page-info {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+}
+
.track-grid {
display: flex;
flex-direction: column;
diff --git a/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
index 49bd8e82..ea88b10f 100644
--- a/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
+++ b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue
@@ -1,5 +1,7 @@