feat: implement infinite scroll with pagination for media browsing

Add infinite scroll functionality to MediaBrowser component using IntersectionObserver, introduce BrowseState and pagination support (offset/limit) in API and backend, update version to 0.3.24
This commit is contained in:
2026-03-24 15:24:20 +01:00
parent 76b7c126c2
commit b08112699e
10 changed files with 204 additions and 35 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useMediaServers } from "@/composables/useMediaServers";
import { useRenderers } from "@/composables/useRenderers";
import { useUIStore } from "@/stores/ui";
@@ -16,8 +16,11 @@ const props = defineProps<{
const {
getBrowseCached,
browseContainer,
loadMoreBrowse,
hasMore,
currentPath: breadcrumbPath,
loading,
loadingMore,
error,
} = useMediaServers();
@@ -25,8 +28,9 @@ const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
useRenderers();
const uiStore = useUIStore();
// Flag pour gérer le rechargement automatique
const isRefreshing = ref(false);
const sentinelRef = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const browseData = computed(() =>
getBrowseCached(props.serverId, props.containerId),
@@ -40,6 +44,28 @@ const items = computed(
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
);
const canLoadMore = computed(() => hasMore(props.serverId, props.containerId));
function setupObserver() {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
loadMoreBrowse(props.serverId, props.containerId);
}
},
{ threshold: 0.1 },
);
if (sentinelRef.value) observer.observe(sentinelRef.value);
}
onMounted(() => setupObserver());
onBeforeUnmount(() => observer?.disconnect());
watch(sentinelRef, (el) => {
if (el) setupObserver();
});
// Charger le container au montage et quand containerId change
watch(
() => props.containerId,
@@ -51,15 +77,10 @@ watch(
{ immediate: true },
);
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
// Recharger si le cache est invalidé (ContainersUpdated SSE)
watch(
() => browseData.value,
async (data) => {
// Si browseData devient undefined alors que containerId est présent,
// et qu'on n'est pas déjà en train de charger, recharger immédiatement
if (
!data &&
props.containerId &&
@@ -201,6 +222,14 @@ async function handleQueueItem(itemId: string, rendererId: string) {
>
<p>Ce dossier est vide</p>
</div>
<!-- Sentinel infinite scroll -->
<div ref="sentinelRef" class="scroll-sentinel" />
<!-- Spinner load more -->
<div v-if="loadingMore" class="load-more-spinner">
<Loader2 :size="20" class="spinner" />
</div>
</div>
</div>
</template>
@@ -295,6 +324,17 @@ async function handleQueueItem(itemId: string, rendererId: string) {
padding: var(--spacing-xl);
}
.scroll-sentinel {
height: 1px;
}
.load-more-spinner {
display: flex;
justify-content: center;
padding: var(--spacing-md);
color: var(--color-text-secondary);
}
/* Scrollbar styling */
.browser-content::-webkit-scrollbar {
width: 6px;

View File

@@ -18,9 +18,9 @@ import { useMediaServers } from "@/composables/useMediaServers";
import { useRenderers } from "@/composables/useRenderers";
import type {
MediaServerSummary,
BrowseResponse,
ContainerEntry,
} from "@/services/pmocontrol/types";
import type { BrowseState } from "@/composables/useMediaServers";
const props = defineProps<{
modelValue: boolean; // v-model pour contrôler l'ouverture
@@ -47,7 +47,7 @@ const router = useRouter();
// État de navigation
const currentServer = ref<MediaServerSummary | null>(null);
const browseData = ref<BrowseResponse | null>(null);
const browseData = ref<BrowseState | null>(null);
const isLoading = ref(false);
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)

View File

@@ -7,7 +7,7 @@ import { api } from '../services/pmocontrol/api'
import { sse } from '../services/pmocontrol/sse'
import type {
MediaServerSummary,
BrowseResponse
ContainerEntry,
} from '../services/pmocontrol/types'
export interface BreadcrumbItem {
@@ -15,9 +15,15 @@ export interface BreadcrumbItem {
title: string
}
export interface BrowseState {
container_id: string
entries: ContainerEntry[]
total_count: number
}
// Cache global partagé
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
const browseCache = ref<Map<string, BrowseResponse>>(new Map())
const browseCache = ref<Map<string, BrowseState>>(new Map())
const currentPath = ref<BreadcrumbItem[]>([])
// Timestamps
@@ -100,6 +106,7 @@ export function useMediaServers() {
ensureSSEConnected()
const loading = ref(false)
const loadingMore = ref(false)
const error = ref<string | null>(null)
// Getters computed
@@ -110,7 +117,7 @@ export function useMediaServers() {
async function fetchServers(force = false) {
const now = Date.now()
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
return // Cache encore valide
return
}
try {
@@ -129,11 +136,10 @@ export function useMediaServers() {
}
}
// Browse container (avec cache automatique)
// Charge la première page (remplace le cache)
async function browseContainer(serverId: string, containerId: string, useCache = true) {
const key = `${serverId}/${containerId}`
// Vérifier le cache
if (useCache && browseCache.value.has(key)) {
return browseCache.value.get(key)!
}
@@ -142,12 +148,15 @@ export function useMediaServers() {
loading.value = true
error.value = null
const data = await api.browseContainer(serverId, containerId)
const data = await api.browseContainer(serverId, containerId, 0)
// Mettre en cache
browseCache.value.set(key, data)
browseCache.value.set(key, {
container_id: data.container_id,
entries: data.entries,
total_count: data.total_count,
})
return data
return browseCache.value.get(key)!
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur browse container'
console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, e)
@@ -157,6 +166,33 @@ export function useMediaServers() {
}
}
// Charge la page suivante et accumule (infinite scroll)
async function loadMoreBrowse(serverId: string, containerId: string) {
const key = `${serverId}/${containerId}`
const state = browseCache.value.get(key)
if (!state) return
if (state.entries.length >= state.total_count) return
if (loadingMore.value) return
try {
loadingMore.value = true
const offset = state.entries.length
const data = await api.browseContainer(serverId, containerId, offset)
// Accumuler les nouvelles entrées
state.entries.push(...data.entries)
state.total_count = data.total_count
// Forcer la réactivité
browseCache.value.set(key, { ...state })
} catch (e) {
console.error(`[useMediaServers] Erreur load more ${serverId}/${containerId}:`, e)
} finally {
loadingMore.value = false
}
}
// Getters
function getServerById(id: string) {
return serversCache.value.get(id)
@@ -167,6 +203,13 @@ export function useMediaServers() {
return browseCache.value.get(key)
}
function hasMore(serverId: string, containerId: string): boolean {
const key = `${serverId}/${containerId}`
const state = browseCache.value.get(key)
if (!state) return false
return state.entries.length < state.total_count
}
// Breadcrumb path management
function setPath(path: BreadcrumbItem[]) {
currentPath.value = path
@@ -179,11 +222,9 @@ export function useMediaServers() {
// Invalidation du cache
function invalidateCache(serverId: string, containerId?: string) {
if (containerId) {
// Invalider un container spécifique
const key = `${serverId}/${containerId}`
browseCache.value.delete(key)
} else {
// Invalider tous les containers d'un serveur
const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) {
@@ -197,6 +238,7 @@ export function useMediaServers() {
return {
// État
loading,
loadingMore,
error,
currentPath,
// Getters
@@ -204,9 +246,11 @@ export function useMediaServers() {
onlineServers,
getServerById,
getBrowseCached,
hasMore,
// Actions
fetchServers,
browseContainer,
loadMoreBrowse,
setPath,
clearPath,
invalidateCache

View File

@@ -418,9 +418,11 @@ class PMOControlAPI {
async browseContainer(
serverId: string,
containerId: string,
offset = 0,
limit = 50,
): Promise<BrowseResponse> {
return this.request<BrowseResponse>(
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`,
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}?offset=${offset}&limit=${limit}`,
);
}

View File

@@ -120,6 +120,8 @@ export interface ContainerEntry {
export interface BrowseResponse {
container_id: string;
entries: ContainerEntry[];
total_count: number;
offset: number;
}
// ============================================================================