⚡ 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:
@@ -559,12 +559,24 @@
|
||||
>
|
||||
</span>
|
||||
</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
|
||||
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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -187,8 +187,6 @@ onMounted(async () => {
|
||||
/* Large desktop */
|
||||
@media (min-width: 1200px) {
|
||||
.browser-section {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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":
|
||||
|
||||
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;
|
||||
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 */
|
||||
|
||||
Reference in New Issue
Block a user