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:
2026-04-06 17:48:30 +02:00
parent 22b3a67417
commit abe8c1a4b2
10 changed files with 513 additions and 43 deletions

View File

@@ -559,12 +559,24 @@
>
</span>
</div>
<!-- Pagination controls -->
<div v-if="totalPages > 1" class="pagination-controls">
<button @click="prevPage" :disabled="currentPage === 0">
&larr; Previous
</button>
<span class="page-info">
Page {{ currentPage + 1 }} of {{ totalPages }}
</span>
<button @click="nextPage" :disabled="currentPage >= totalPages - 1">
Next &rarr;
</button>
</div>
<div
class="track-grid"
v-if="sortedTracks.length > 0"
v-if="paginatedTracks.length > 0"
>
<article
v-for="track in sortedTracks"
v-for="track in paginatedTracks"
:key="`${track.cache_pk}-${track.added_at}`"
class="track-card"
: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 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;

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
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 QueueItem from "./QueueItem.vue";
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 queueContainer = ref<HTMLElement | null>(null);
const queueContainer = ref<any>(null);
function handleItemClick(item: QueueItemType) {
emit("clickItem", item);
@@ -33,15 +35,7 @@ watch(
queueContainer.value
) {
await nextTick();
const currentItem = queueContainer.value.querySelector(
".queue-item.current",
);
if (currentItem) {
currentItem.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
queueContainer.value.scrollToItem(currentIndex);
}
},
{ immediate: true },
@@ -81,16 +75,23 @@ watch(
</div>
</div>
<!-- Liste des items -->
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
<!-- Liste des items virtualisée -->
<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
v-for="item in queue.items"
:key="item.index"
:item="item"
:is-current="item.index === queue.current_index"
@click="handleItemClick"
/>
</div>
</RecycleScroller>
<!-- État vide -->
<div v-else class="queue-empty">
@@ -105,6 +106,8 @@ watch(
flex-direction: column;
gap: var(--spacing-md);
height: 100%;
width: 100%;
min-width: 0;
}
.queue-header {
@@ -196,20 +199,11 @@ watch(
}
.queue-list {
width: 100%;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding-right: var(--spacing-xs);
}
/* 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;
min-width: 0;
}
/* Scrollbar styling */

View File

@@ -142,6 +142,7 @@ async function handleQueueItemClick(item: QueueItem) {
width: 100%;
height: 100%;
overflow: hidden;
min-width: 0;
}
/* Layout principal - 800x600 landscape (2 colonnes) */
@@ -153,6 +154,7 @@ async function handleQueueItemClick(item: QueueItem) {
/* padding-right: 0 pour coller la scrollbar au bord */
flex: 1;
overflow: hidden;
min-width: 0;
}
/* Colonne gauche - Contrôles */
@@ -185,11 +187,14 @@ async function handleQueueItemClick(item: QueueItem) {
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: 80px;
min-width: 0;
}
.queue-viewer {
flex: 1;
overflow-y: auto;
min-width: 0;
}
/* Queue drawer - masqué sur desktop, visible uniquement sur mobile portrait */
@@ -349,8 +354,6 @@ async function handleQueueItemClick(item: QueueItem) {
@media (min-width: 1200px) {
.renderer-layout {
grid-template-columns: 350px 1fr;
max-width: 1400px;
margin: 0 auto;
}
}

View File

@@ -187,8 +187,6 @@ onMounted(async () => {
/* Large desktop */
@media (min-width: 1200px) {
.browser-section {
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
}

View File

@@ -36,6 +36,7 @@ const searchResults = ref<BrowseState | null>(null)
const searchQuery = ref<string>('')
const CACHE_DURATION_MS = 2000
const BROWSE_WINDOW_SIZE = 200
// Initialiser SSE une seule fois via le composable centralisé
let sseInitialized = false
@@ -197,11 +198,13 @@ export function useMediaServers() {
const offset = state.currentOffset ?? state.entries.length
const data = await api.browseContainer(serverId, containerId, offset)
// Accumuler les nouvelles entrées
state.entries.push(...data.entries)
// Accumuler les nouvelles entrées avec fenêtre glissante
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.currentOffset = state.entries.length
state.hasMore = state.entries.length < state.total_count
state.currentOffset = (state.currentOffset ?? 0) + data.entries.length
state.hasMore = state.currentOffset < state.total_count
// Forcer la réactivité
browseCache.value.set(key, { ...state })
} catch (e) {

View File

@@ -27,6 +27,10 @@ const loadingIds = reactive(new Set<string>());
const queueRefreshingIds = reactive(new Set<string>());
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)
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
const RENDERERS_CACHE_MS = 2000;
@@ -201,8 +205,15 @@ function ensureSSEInitialized() {
snapshot.state.queue_len = event.queue_length;
queueRefreshingIds.delete(rendererId);
// Pour la queue complète, on doit refetch
void fetchRendererSnapshot(rendererId, { force: true });
// 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;
case "binding_changed":

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

View File

@@ -270,6 +270,7 @@ const currentTabProps = computed(() => {
height: 100vh;
overflow: hidden;
background: var(--color-bg);
min-width: 0;
}
.content-area {
@@ -278,6 +279,7 @@ const currentTabProps = computed(() => {
overflow-x: hidden;
padding: 0;
position: relative;
min-width: 0;
}
/* Placeholder temporaire */