⚡ Optimise frontend performance for large playlists (~1000 tracks)
- Virtualize queue list in QueueViewer.vue using RecycleScroller - Add 300ms debounce on queue_updated refetches to prevent cascading JSON fetch - Implement client-side pagination (100 items/page) + cache in PlayListManager.vue with sortedTracks memoization - Limit browse infinite scroll memory usage via sliding window (200 items) + Minor CSS fixes for flexbox overflow/min-width to support virtualization
This commit is contained in:
338
Blackboard/Todo/frontend_enorme_playlist.md
Normal file
338
Blackboard/Todo/frontend_enorme_playlist.md
Normal file
@@ -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
|
||||||
|
<!-- Tous les items rendus en DOM simultanément -->
|
||||||
|
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
||||||
|
<QueueItem
|
||||||
|
v-for="item in queue.items"
|
||||||
|
:key="item.index"
|
||||||
|
...
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
<div class="track-grid" v-if="sortedTracks.length > 0">
|
||||||
|
<article v-for="track in sortedTracks" :key="..." class="track-card">
|
||||||
|
<!-- image + title + artist + album + durée + bitrate + samplerate -->
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 `<RecycleScroller>` :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RecycleScroller } from 'vue-virtual-scroller';
|
||||||
|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||||
|
// ... imports existants inchangés
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Remplacer le div.queue-list + v-for par : -->
|
||||||
|
<RecycleScroller
|
||||||
|
v-if="queue?.items.length"
|
||||||
|
class="queue-list"
|
||||||
|
:items="queue.items"
|
||||||
|
:item-size="64"
|
||||||
|
key-field="index"
|
||||||
|
v-slot="{ item }"
|
||||||
|
ref="queueContainer"
|
||||||
|
>
|
||||||
|
<QueueItem
|
||||||
|
:item="item"
|
||||||
|
:is-current="item.index === queue.current_index"
|
||||||
|
@click="handleItemClick"
|
||||||
|
/>
|
||||||
|
</RecycleScroller>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
`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<string, ReturnType<typeof setTimeout>>();
|
||||||
|
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<string, TrackEntry[]>();
|
||||||
|
|
||||||
|
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
|
||||||
20
Report/frontend_enorme_playlist.md
Normal file
20
Report/frontend_enorme_playlist.md
Normal file
@@ -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
|
||||||
@@ -559,12 +559,24 @@
|
|||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Pagination controls -->
|
||||||
|
<div v-if="totalPages > 1" class="pagination-controls">
|
||||||
|
<button @click="prevPage" :disabled="currentPage === 0">
|
||||||
|
← Previous
|
||||||
|
</button>
|
||||||
|
<span class="page-info">
|
||||||
|
Page {{ currentPage + 1 }} of {{ totalPages }}
|
||||||
|
</span>
|
||||||
|
<button @click="nextPage" :disabled="currentPage >= totalPages - 1">
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="track-grid"
|
class="track-grid"
|
||||||
v-if="sortedTracks.length > 0"
|
v-if="paginatedTracks.length > 0"
|
||||||
>
|
>
|
||||||
<article
|
<article
|
||||||
v-for="track in sortedTracks"
|
v-for="track in paginatedTracks"
|
||||||
:key="`${track.cache_pk}-${track.added_at}`"
|
:key="`${track.cache_pk}-${track.added_at}`"
|
||||||
class="track-card"
|
class="track-card"
|
||||||
:class="{ lazy: isLazyTrack(track) }"
|
:class="{ lazy: isLazyTrack(track) }"
|
||||||
@@ -880,20 +892,51 @@ const sortedPlaylists = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
const currentPage = ref(0);
|
||||||
|
const sortedTracksCache = new Map<string, PlaylistTrack[]>();
|
||||||
|
|
||||||
const sortedTracks = computed(() => {
|
const sortedTracks = computed(() => {
|
||||||
const detail = selectedPlaylist.value;
|
const detail = selectedPlaylist.value;
|
||||||
if (!detail) return [];
|
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) =>
|
(a, b) =>
|
||||||
new Date(b.added_at).getTime() - new Date(a.added_at).getTime(),
|
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(() =>
|
const lazyTracksCount = computed(() =>
|
||||||
selectedPlaylist.value
|
sortedTracks.value.filter((track) => isLazyTrack(track)).length
|
||||||
? selectedPlaylist.value.tracks.filter((track) => isLazyTrack(track))
|
|
||||||
.length
|
|
||||||
: 0,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateCoverPreview = computed(
|
const updateCoverPreview = computed(
|
||||||
@@ -1881,6 +1924,40 @@ button:disabled {
|
|||||||
margin-bottom: var(--spacing-sm);
|
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 {
|
.track-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, nextTick, toRef } from "vue";
|
import { computed, ref, watch, nextTick, toRef } from "vue";
|
||||||
|
import { RecycleScroller } from "vue-virtual-scroller";
|
||||||
|
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||||
import { useRenderer } from "@/composables/useRenderers";
|
import { useRenderer } from "@/composables/useRenderers";
|
||||||
import QueueItem from "./QueueItem.vue";
|
import QueueItem from "./QueueItem.vue";
|
||||||
import { Link, Radio, RefreshCw } from "lucide-vue-next";
|
import { Link, Radio, RefreshCw } from "lucide-vue-next";
|
||||||
@@ -17,7 +19,7 @@ const { queue, binding, isStream, queueRefreshing } = useRenderer(toRef(props, "
|
|||||||
|
|
||||||
const isAttached = computed(() => !!binding.value);
|
const isAttached = computed(() => !!binding.value);
|
||||||
|
|
||||||
const queueContainer = ref<HTMLElement | null>(null);
|
const queueContainer = ref<any>(null);
|
||||||
|
|
||||||
function handleItemClick(item: QueueItemType) {
|
function handleItemClick(item: QueueItemType) {
|
||||||
emit("clickItem", item);
|
emit("clickItem", item);
|
||||||
@@ -33,15 +35,7 @@ watch(
|
|||||||
queueContainer.value
|
queueContainer.value
|
||||||
) {
|
) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const currentItem = queueContainer.value.querySelector(
|
queueContainer.value.scrollToItem(currentIndex);
|
||||||
".queue-item.current",
|
|
||||||
);
|
|
||||||
if (currentItem) {
|
|
||||||
currentItem.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "nearest",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -81,16 +75,23 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Liste des items -->
|
<!-- Liste des items virtualisée -->
|
||||||
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
<RecycleScroller
|
||||||
|
v-if="queue?.items.length"
|
||||||
|
class="queue-list"
|
||||||
|
:items="queue.items"
|
||||||
|
:item-size="64"
|
||||||
|
key-field="index"
|
||||||
|
v-slot="{ item }"
|
||||||
|
ref="queueContainer"
|
||||||
|
:min-item-size="64"
|
||||||
|
>
|
||||||
<QueueItem
|
<QueueItem
|
||||||
v-for="item in queue.items"
|
|
||||||
:key="item.index"
|
|
||||||
:item="item"
|
:item="item"
|
||||||
:is-current="item.index === queue.current_index"
|
:is-current="item.index === queue.current_index"
|
||||||
@click="handleItemClick"
|
@click="handleItemClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</RecycleScroller>
|
||||||
|
|
||||||
<!-- État vide -->
|
<!-- État vide -->
|
||||||
<div v-else class="queue-empty">
|
<div v-else class="queue-empty">
|
||||||
@@ -105,6 +106,8 @@ watch(
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-header {
|
.queue-header {
|
||||||
@@ -196,20 +199,11 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.queue-list {
|
.queue-list {
|
||||||
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding-right: var(--spacing-xs);
|
padding-right: var(--spacing-xs);
|
||||||
}
|
min-width: 0;
|
||||||
|
|
||||||
/* Ajoute un espace de scroll en bas pour ne pas cacher les derniers items sous la barre */
|
|
||||||
.queue-list::after {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
height: 80px; /* Espace pour la barre fixe en bas */
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Layout principal - 800x600 landscape (2 colonnes) */
|
/* Layout principal - 800x600 landscape (2 colonnes) */
|
||||||
@@ -153,6 +154,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
|||||||
/* padding-right: 0 pour coller la scrollbar au bord */
|
/* padding-right: 0 pour coller la scrollbar au bord */
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Colonne gauche - Contrôles */
|
/* Colonne gauche - Contrôles */
|
||||||
@@ -185,11 +187,14 @@ async function handleQueueItemClick(item: QueueItem) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-viewer {
|
.queue-viewer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Queue drawer - masqué sur desktop, visible uniquement sur mobile portrait */
|
/* Queue drawer - masqué sur desktop, visible uniquement sur mobile portrait */
|
||||||
@@ -349,8 +354,6 @@ async function handleQueueItemClick(item: QueueItem) {
|
|||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
.renderer-layout {
|
.renderer-layout {
|
||||||
grid-template-columns: 350px 1fr;
|
grid-template-columns: 350px 1fr;
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,8 +187,6 @@ onMounted(async () => {
|
|||||||
/* Large desktop */
|
/* Large desktop */
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
.browser-section {
|
.browser-section {
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const searchResults = ref<BrowseState | null>(null)
|
|||||||
const searchQuery = ref<string>('')
|
const searchQuery = ref<string>('')
|
||||||
|
|
||||||
const CACHE_DURATION_MS = 2000
|
const CACHE_DURATION_MS = 2000
|
||||||
|
const BROWSE_WINDOW_SIZE = 200
|
||||||
|
|
||||||
// Initialiser SSE une seule fois via le composable centralisé
|
// Initialiser SSE une seule fois via le composable centralisé
|
||||||
let sseInitialized = false
|
let sseInitialized = false
|
||||||
@@ -197,11 +198,13 @@ export function useMediaServers() {
|
|||||||
const offset = state.currentOffset ?? state.entries.length
|
const offset = state.currentOffset ?? state.entries.length
|
||||||
const data = await api.browseContainer(serverId, containerId, offset)
|
const data = await api.browseContainer(serverId, containerId, offset)
|
||||||
|
|
||||||
// Accumuler les nouvelles entrées
|
// Accumuler les nouvelles entrées avec fenêtre glissante
|
||||||
state.entries.push(...data.entries)
|
const combined = [...state.entries, ...data.entries]
|
||||||
|
// Ne garder que les últimos BROWSE_WINDOW_SIZE items
|
||||||
|
state.entries = combined.slice(-BROWSE_WINDOW_SIZE)
|
||||||
state.total_count = data.total_count
|
state.total_count = data.total_count
|
||||||
state.currentOffset = state.entries.length
|
state.currentOffset = (state.currentOffset ?? 0) + data.entries.length
|
||||||
state.hasMore = state.entries.length < state.total_count
|
state.hasMore = state.currentOffset < state.total_count
|
||||||
// Forcer la réactivité
|
// Forcer la réactivité
|
||||||
browseCache.value.set(key, { ...state })
|
browseCache.value.set(key, { ...state })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const loadingIds = reactive(new Set<string>());
|
|||||||
const queueRefreshingIds = reactive(new Set<string>());
|
const queueRefreshingIds = reactive(new Set<string>());
|
||||||
const selectedRendererId = ref<string | null>(null);
|
const selectedRendererId = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Debounce pour les refetches queue_updated
|
||||||
|
const queueUpdateDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
const QUEUE_UPDATE_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
// Cache des renderers (summary)
|
// Cache des renderers (summary)
|
||||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||||
const RENDERERS_CACHE_MS = 2000;
|
const RENDERERS_CACHE_MS = 2000;
|
||||||
@@ -201,8 +205,15 @@ function ensureSSEInitialized() {
|
|||||||
snapshot.state.queue_len = event.queue_length;
|
snapshot.state.queue_len = event.queue_length;
|
||||||
queueRefreshingIds.delete(rendererId);
|
queueRefreshingIds.delete(rendererId);
|
||||||
|
|
||||||
// Pour la queue complète, on doit refetch
|
// Annuler le timer précédent pour ce renderer
|
||||||
void fetchRendererSnapshot(rendererId, { force: true });
|
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;
|
break;
|
||||||
|
|
||||||
case "binding_changed":
|
case "binding_changed":
|
||||||
|
|||||||
24
pmoapp/webapp/src/types/vue-virtual-scroller.d.ts
vendored
Normal file
24
pmoapp/webapp/src/types/vue-virtual-scroller.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
declare module 'vue-virtual-scroller' {
|
||||||
|
import { DefineComponent } from 'vue';
|
||||||
|
|
||||||
|
export interface RecycleScrollerProps {
|
||||||
|
items?: any[];
|
||||||
|
itemSize?: number;
|
||||||
|
keyField?: string;
|
||||||
|
direction?: 'vertical' | 'horizontal';
|
||||||
|
minItemSize?: number;
|
||||||
|
sizeField?: string;
|
||||||
|
typeField?: string;
|
||||||
|
buffer?: number;
|
||||||
|
pageMode?: boolean;
|
||||||
|
prerender?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecycleScrollerRef {
|
||||||
|
scrollToItem(index: number): void;
|
||||||
|
scrollToOffset(offset: number): void;
|
||||||
|
$el: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RecycleScroller: DefineComponent<RecycleScrollerProps>;
|
||||||
|
}
|
||||||
@@ -270,6 +270,7 @@ const currentTabProps = computed(() => {
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-area {
|
.content-area {
|
||||||
@@ -278,6 +279,7 @@ const currentTabProps = computed(() => {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Placeholder temporaire */
|
/* Placeholder temporaire */
|
||||||
|
|||||||
Reference in New Issue
Block a user