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 @@ > + +
+ + + Page {{ currentPage + 1 }} of {{ totalPages }} + + +
{ ); }); +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 @@