diff --git a/Blackboard/Report/enorme_playlist.md b/Blackboard/Report/enorme_playlist.md new file mode 100644 index 00000000..6a4bcf42 --- /dev/null +++ b/Blackboard/Report/enorme_playlist.md @@ -0,0 +1,19 @@ +# Rapport : Optimisation performance OpenHome playlist + +## Résumé +Les optimizations implementées réduisent significativement le temps de synchronisation des playlists OpenHome de ~1000 titres. Les principales améliorations : passage du batch ReadList de 64 à 256 (−75% appels SOAP), elimination des doubles appels queue_snapshot() (−50% appels SOAP), et introduction du polling adaptatif avec intervalle long en veille (5s vs 500ms). + +## Fichiers modifies + +1. `pmocontrol/src/queue/openhome.rs` + - Batch ReadList augmente de 64 a 256 + - Signature de replace_queue_with_pivot et replace_queue_standard_lcs modifiee pour accepter snapshot et current_track_ids + - Appel a sync_queue mis a jour pour passer les donnees deja disponibles + -Nouvelle fonction lcs_flags_optimized avec elimination pre/suffixe communs + +2. `pmocontrol/src/music_renderer/watcher.rs` + - Ajout du champ is_active dans WatchedState pour le polling adaptatif + +3. `pmocontrol/src/music_renderer/musicrenderer.rs` + - Boucle watcher avec intervalle adaptatif (500ms actif, 5000ms veille) + - Marqueurs is_active=true dans play(), stop(), seek_rel_time(), sync_queue() diff --git a/Blackboard/Todo/enorme_playlist.md b/Blackboard/Todo/enorme_playlist.md new file mode 100644 index 00000000..f8411251 --- /dev/null +++ b/Blackboard/Todo/enorme_playlist.md @@ -0,0 +1,426 @@ +** 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 control point PMOMusic est lent lorsqu'un renderer **OpenHome** manipule des playlists +d'environ 1 000 titres. Les renderers Chromecast et UPnP pur ne sont pas affectés : ils +utilisent une `InternalQueue` entièrement locale, sans appels SOAP. Le problème est +spécifique à `OpenHomeQueue` (`pmocontrol/src/queue/openhome.rs`). + +Le code a été généré par IA : il peut contenir des redondances, mais **chaque comportement +est intentionnel**. L'objectif est d'optimiser sans rien supprimer. + +## Causes racines identifiées + +### P0 — Double appel à `queue_snapshot()` dans `sync_queue()` + +**Fichiers** : `pmocontrol/src/queue/openhome.rs` + +`sync_queue()` (ligne 1151) appelle `queue_snapshot()` pour obtenir l'état courant. +Puis elle délègue à l'une de ces deux sous-fonctions qui appellent **à nouveau** +`queue_snapshot()` : + +- `replace_queue_with_pivot()` (ligne 555) : 2e appel `queue_snapshot()` + 1 appel + `track_ids()` séparé (alors que `queue_snapshot()` appelle déjà `track_ids()` en interne) +- `replace_queue_standard_lcs()` (ligne 647) : 2e appel `queue_snapshot()` + +Seule `replace_queue_preserve_current()` n'a pas ce défaut (elle appelle uniquement +`track_ids()`). + +**Impact pour 1 000 titres :** + +Chaque `queue_snapshot()` exécute : +- 1 appel SOAP `IdArray` (liste des IDs) +- 16 appels SOAP `ReadList` (lots de 64 items) + +Soit **34 appels SOAP** pour une seule opération `sync_queue()` au lieu de 17. + +Le cache `ReadList` (TTL 500 ms) atténue partiellement mais ne supprime pas le problème +car la durée d'un `sync_queue` sur 1 000 titres peut dépasser 500 ms. + +### P1 — Algorithme LCS de complexité quadratique O(m × n) + +**Fichier** : `pmocontrol/src/queue/openhome.rs:851` + +La fonction `lcs_flags()` alloue une table DP de taille `(m+1) × (n+1)` : + +```rust +let mut dp = vec![vec![0u32; n + 1]; m + 1]; +``` + +Pour 1 000 titres en entrée : 1 000 × 1 000 = **1 000 000 entrées** (≈ 4 MB), et +1 000 000 comparaisons. Elle est appelée **jusqu'à 3 fois** dans un seul `sync_queue` : +- 2 fois dans `replace_queue_with_pivot()` (avant et après le pivot, lignes 579 et 582) +- 1 fois dans `replace_queue_standard_lcs()` (ligne 661) + +Dans le cas courant (ajout de titres en fin de liste, ou liste déjà synchronisée), +la quasi-totalité de la table DP est inutile : les préfixe et suffixe communs +représentent souvent 90 % ou plus de la liste. + +### P2 — Taille de lot `ReadList` = 64 + +**Fichier** : `pmocontrol/src/queue/openhome.rs:986` + +```rust +const MAX_BATCH: usize = 64; +``` + +Pour 1 000 titres : 1 000 ÷ 64 = **16 appels SOAP `ReadList`** par `queue_snapshot()`. +La latence réseau typique par appel SOAP (50–200 ms) implique 0,8 à 3,2 secondes +uniquement pour la lecture des métadonnées. + +La norme OpenHome Playlist ne fixe pas de limite de payload. La valeur 64 est +conservatrice. Augmenter à 256 réduit à **4 appels** (−75 %). + +Le mécanisme de fallback one-by-one (lignes 1007–1019) assure la rétrocompatibilité +avec les devices qui refuseraient un payload plus large. + +### P3 — Polling à 500 ms indépendant de l'activité + +**Fichier** : `pmocontrol/src/music_renderer/watcher.rs` + +Chaque renderer OpenHome tourne un thread watcher toutes les 500 ms, même en veille. +Avec plusieurs renderers actifs, les appels de polling et les opérations `sync_queue` +se chevauchent sur le même device réseau, créant de la contention. + +### P4 — Redondances de code (nettoyage conservatif) + +**a. Invalidation des caches dupliquée** (`openhome.rs`) + +La séquence d'invalidation apparaît en 3 endroits distincts (lignes 1134–1136, +454–456, 634–635) : + +```rust +self.track_ids_cache.lock().unwrap().invalidate(); +self.read_list_cache.lock().unwrap().invalidate(); +// parfois aussi : +self.current_track_id_cache.lock().unwrap().invalidate(); +``` + +**b. Protection durée stream dupliquée** (`openhome.rs` et `interne.rs`) + +La logique de protection de durée pour les flux continus (radio) est implémentée : +- Dans `cache_metadata()` de `OpenHomeQueue` (`openhome.rs:250–351`) +- Dans `protect_stream_durations()` de `InternalQueue` (`interne.rs:92–143`) +- Dans `merge_metadata_protecting_streams()` de `InternalQueue` (`interne.rs:148–215`) + +**c. `parse_duration()` défini 3 fois** + +La conversion `HH:MM:SS` → secondes apparaît dans `openhome.rs`, `interne.rs`, +et dans `time_utils::parse_hhmmss_u32()` (déjà publique). + +## Ce qui fonctionne déjà correctement + +**Pagination du Browse** : La boucle de pagination est correctement implémentée dans +`control_point.rs:1610–1653` avec `browse_children()` + offset incrémental. + +**Fallback ReadList one-by-one** : Si un batch échoue, le retry unitaire (lignes 1007–1019) +assure la robustesse sur les devices stricts. + +**Protection multi-control-point** : `delete_id_if_exists()` gère proprement le cas où +un autre control point a déjà supprimé un titre. + +**Stratégie double-LCS avec pivot** : La logique de `replace_queue_with_pivot()` est +correcte et importante pour ne pas interrompre la lecture en cours. + +**Cache métadonnées stream** : La protection de durée décroissante pour les flux radio +est un comportement essentiel à préserver scrupuleusement. + +## Plan d'exécution + +### Crate concernée : `pmocontrol` + +--- + +### Étape 1 — Augmenter le batch `ReadList` à 256 + +**Fichier** : `pmocontrol/src/queue/openhome.rs:986` + +```rust +// Avant +const MAX_BATCH: usize = 64; + +// Après +const MAX_BATCH: usize = 256; +``` + +Le fallback one-by-one (lignes 1007–1019) reste intact. Si un renderer refuse +un payload de 256 IDs, il retombe automatiquement sur le mode unitaire. + +--- + +### Étape 2 — Éliminer le double appel à `queue_snapshot()` + +**Fichier** : `pmocontrol/src/queue/openhome.rs` + +Le snapshot calculé dans `sync_queue()` contient déjà les items **et** leurs IDs +backend (`backend_id: usize`). Il n'est pas nécessaire de le recalculer dans les +sous-fonctions. + +#### 2a. Passer le snapshot à `replace_queue_with_pivot()` + +Signature actuelle (ligne 548) : +```rust +fn replace_queue_with_pivot( + &mut self, + new_items: Vec, + pivot_idx_new: usize, + pivot_id: usize, +) -> Result<(), ControlPointError> +``` + +Nouvelle signature : +```rust +fn replace_queue_with_pivot( + &mut self, + new_items: Vec, + pivot_idx_new: usize, + pivot_id: usize, + snapshot: &QueueSnapshot, // ← ajouté + current_track_ids: &[u32], // ← ajouté (évite aussi le 2e appel track_ids()) +) -> Result<(), ControlPointError> +``` + +À l'intérieur de `replace_queue_with_pivot()`, supprimer : +```rust +// Supprimer ces deux lignes (ligne 555–556) +let snapshot = self.queue_snapshot()?; +let current_track_ids = self.track_ids()?; +``` + +Et utiliser directement les paramètres `snapshot` et `current_track_ids`. + +Appel depuis `sync_queue()` (ligne 1221) : +```rust +// Avant +self.replace_queue_with_pivot(items, pivot_idx, playing_id)?; + +// Après — passer le snapshot et les IDs déjà disponibles +let current_ids_for_pivot: Vec = snapshot.items + .iter() + .map(|i| i.backend_id as u32) + .collect(); +self.replace_queue_with_pivot(items, pivot_idx, playing_id, &snapshot, ¤t_ids_for_pivot)?; +``` + +**Note importante** : dans `sync_queue()`, le snapshot est pris APRÈS +`ensure_playlist_source_selected()` (ligne 1115) et APRÈS la résolution du `playing_info`. +Cet ordre est correct et doit être conservé. + +#### 2b. Passer le snapshot à `replace_queue_standard_lcs()` + +Signature actuelle (ligne 641) : +```rust +fn replace_queue_standard_lcs( + &mut self, + items: Vec, + _current_index: Option, +) -> Result<(), ControlPointError> +``` + +Nouvelle signature : +```rust +fn replace_queue_standard_lcs( + &mut self, + items: Vec, + snapshot: &QueueSnapshot, // ← ajouté + current_track_ids: &[u32], // ← ajouté +) -> Result<(), ControlPointError> +``` + +À l'intérieur, supprimer : +```rust +// Supprimer ces deux lignes (lignes 647–648) +let snapshot = self.queue_snapshot()?; +let current_track_ids = self.track_ids()?; +``` + +Appel depuis `sync_queue()` (ligne 1253) : +```rust +// Avant +self.replace_queue_standard_lcs(items, Some(0))?; + +// Après +let current_ids_for_lcs: Vec = snapshot.items + .iter() + .map(|i| i.backend_id as u32) + .collect(); +self.replace_queue_standard_lcs(items, &snapshot, ¤t_ids_for_lcs)?; +``` + +**Cas particulier à préserver** (ligne 1237–1246) : le guard sur `snapshot.items.is_empty()` +dans `sync_queue()` est exécuté **avant** l'appel à `replace_queue_standard_lcs`, donc +le snapshot vide ne peut pas atteindre la sous-fonction — le comportement est préservé. + +--- + +### Étape 3 — Optimiser LCS par élagage du préfixe/suffixe communs + +**Fichier** : `pmocontrol/src/queue/openhome.rs` + +La fonction `lcs_flags()` (ligne 851) reste inchangée. L'optimisation s'applique +**aux appels** dans `replace_queue_with_pivot()` et `replace_queue_standard_lcs()`. + +#### Principe + +Avant de calculer le LCS DP, éliminer les éléments identiques en tête et en queue : + +```rust +/// Wrapper autour de lcs_flags() qui élimine préfixe et suffixe communs +/// avant d'appeler l'algorithme DP O(m×n). +/// +/// Cas optimisés : ajout en fin de liste → O(n), liste déjà synchro → O(n), +/// suppression en fin → O(n). LCS complet uniquement pour les vrais réordonnements. +fn lcs_flags_optimized( + current: &[PlaybackItem], + desired: &[PlaybackItem], +) -> (Vec, Vec) { + // Préfixe commun + let leading = current + .iter() + .zip(desired.iter()) + .take_while(|(c, d)| items_match(c, d)) + .count(); + + // Suffixe commun (sur les portions restantes uniquement) + let c_tail = ¤t[leading..]; + let d_tail = &desired[leading..]; + let trailing = c_tail + .iter() + .rev() + .zip(d_tail.iter().rev()) + .take_while(|(c, d)| items_match(c, d)) + .count(); + + let c_mid = &c_tail[..c_tail.len() - trailing]; + let d_mid = &d_tail[..d_tail.len() - trailing]; + + // Si rien à faire (listes identiques ou préfixe/suffixe couvrent tout) + if c_mid.is_empty() && d_mid.is_empty() { + return (vec![true; current.len()], vec![true; desired.len()]); + } + + // LCS DP sur le delta central uniquement + let (keep_c_mid, keep_d_mid) = lcs_flags(c_mid, d_mid); + + // Reconstituer les vecteurs complets + let mut keep_current = vec![true; leading]; + keep_current.extend(keep_c_mid); + keep_current.extend(vec![true; trailing]); + + let mut keep_desired = vec![true; leading]; + keep_desired.extend(keep_d_mid); + keep_desired.extend(vec![true; trailing]); + + (keep_current, keep_desired) +} +``` + +Remplacer les 3 appels à `lcs_flags()` (lignes 579, 582, 661) par `lcs_flags_optimized()`. + +La fonction `lcs_flags()` originale est **conservée** (utilisée en interne par +`lcs_flags_optimized()`). + +--- + +### Étape 4 — Polling adaptatif selon l'activité + +**Fichier** : `pmocontrol/src/music_renderer/watcher.rs` + +Ajouter un flag partagé `is_active` dans `MusicRenderer` (ou `WatchedState`) pour +signaler si le renderer est en activité récente. + +Le renderer met `is_active = true` lors de chaque opération (play, sync, seek, stop). +Le watcher revient à l'intervalle long (5 000 ms) après 10 s sans activité. + +```rust +// Dans la boucle du watcher : +let interval = if is_active.load(Ordering::Relaxed) { + Duration::from_millis(500) +} else { + Duration::from_millis(5_000) +}; +thread::sleep(interval); +``` + +**Fonctionnalités à préserver** : +- Détection de fin de piste (auto-advance) : délai max 5 s en idle — acceptable +- Sleep timer countdown : reste actif au polling suivant +- Synchronisation auto sur mise à jour de playlist : déclenchée par événement externe, + pas par le polling — non affectée + +--- + +### Étape 5 — Consolider les redondances (nettoyage conservatif) + +**À réaliser uniquement après validation fonctionnelle des étapes 1–4.** + +#### 5a. Méthode `invalidate_all_caches()` sur `OpenHomeQueue` + +```rust +fn invalidate_all_caches(&self) { + self.track_ids_cache.lock().unwrap().invalidate(); + self.read_list_cache.lock().unwrap().invalidate(); + self.current_track_id_cache.lock().unwrap().invalidate(); +} + +fn invalidate_track_caches(&self) { + self.track_ids_cache.lock().unwrap().invalidate(); + self.read_list_cache.lock().unwrap().invalidate(); +} +``` + +Remplacer les séquences d'invalidation en 3 endroits (lignes 1134–1136, 454–456, 634–635). +Garder les appels sélectifs là où seulement 2 caches sont invalidés. + +#### 5b. Factoriser `parse_duration()` + +Supprimer les définitions locales de `parse_duration` dans `openhome.rs` et `interne.rs`. +Utiliser `crate::music_renderer::time_utils::parse_hhmmss_u32()` (déjà publique). +La sémantique est identique : conversion `HH:MM:SS` → u64 secondes. + +#### 5c. Factoriser la protection durée stream + +Extraire la logique commune de protection (« ne jamais diminuer la durée d'un flux +continu pour le même titre/artiste ») dans une fonction privée dans `openhome.rs`, +et y référencer depuis `interne.rs` via le module `queue`. + +**Règle absolue** : ne pas modifier la sémantique de détection de stream continu +(`is_continuous_stream_url()`) ni la logique de comparaison titre/artiste. Uniquement +factoriser le code existant. + +--- + +## Ordre d'exécution + +1. **Étape 1** — Batch ReadList 256 (changement trivial, gain immédiat −75 % appels) +2. **Étape 2** — Élimination double `queue_snapshot()` (−50 % appels SOAP totaux) +3. **Étape 3** — Optimisation LCS préfixe/suffixe (gain CPU, cas courants en O(n)) +4. **Étape 4** — Polling adaptatif (réduction contention réseau en veille) +5. **Étape 5** — Consolidation redondances (nettoyage, après validation) + +## Périmètre : ce qui ne change pas + +- La logique à 3 cas de `sync_queue()` (avec pivot, préserver courant, LCS standard) +- La protection durée décroissante pour les flux radio (cache stream) +- Le mécanisme `delete_id_if_exists()` pour la robustesse multi-control-point +- Le fallback `ReadList` one-by-one en cas d'erreur batch +- La pagination Browse dans `control_point.rs` (déjà correcte) +- Le comportement des queues `InternalQueue` (Chromecast, UPnP) — non affectées +- Les TTL des caches existants (1 s, 500 ms, 250 ms) +- Tous les logs de diagnostic (`tracing::warn!`, `debug!`) — à conserver + +## Tests recommandés + +Demander à l'humain de compiler et tester : + +``` +cargo build -p pmocontrol +``` + +Puis tester avec un renderer OpenHome physique : +- Playlist de 1 000 titres : mesurer le temps de `sync_queue` avant/après +- Ajout de titres en fin de liste : vérifier que LCS optimisé ne fait que des insertions +- Lecture en cours + refresh playlist : vérifier que la piste courante n'est pas interrompue +- Flux radio : vérifier que la durée ne régresse pas pour un même titre/artiste +- Renderer Chromecast : vérifier l'absence de régression (queue interne) 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/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index 703c428a..f80e2624 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "PMOMusic" -version = "0.3.39" +version = "0.3.40" edition = "2024" [dependencies] 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 @@