diff --git a/src/App.svelte b/src/App.svelte index 911daab..47f7ead 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -53,6 +53,7 @@ let isReady = $state(false) let isResizing = $state(false) let resizeTimer = null + let mediaCheckCleanup = null // Active la transition seulement après le premier paint à la bonne position. // Double rAF : le premier laisse passer un paint avec le bon translateX, @@ -63,6 +64,58 @@ } }) + // Détecte quand les médias critiques de la slide active sont prêts, + // puis déclenche le rendu progressif des autres slides. + $effect(() => { + const idx = slides.activeIndex + const all = slides.all + + // Cleanup du check précédent + if (mediaCheckCleanup) { mediaCheckCleanup(); mediaCheckCleanup = null } + + if (all.length === 0 || !all[idx]?.loaded) return + + // Attendre un tick pour que le composant soit monté dans le DOM + const rafId = requestAnimationFrame(() => { + const section = document.querySelector(`[data-slide="${all[idx].id}"]`) + if (!section) { slides.setActiveMediaReady(true); return } + + const videos = section.querySelectorAll('video') + if (videos.length === 0) { slides.setActiveMediaReady(true); return } + + let settled = false + const settle = () => { + if (settled) return + settled = true + slides.setActiveMediaReady(true) + } + + // Fallback timeout + const timeout = setTimeout(settle, 5000) + + // Attendre canplaythrough sur toutes les vidéos visibles + let remaining = 0 + const onReady = () => { if (--remaining <= 0) settle() } + + for (const v of videos) { + // Ignorer les vidéos cachées (display: none) + if (v.offsetParent === null && getComputedStyle(v).display === 'none') continue + if (v.readyState >= 4) continue + remaining++ + v.addEventListener('canplaythrough', onReady, { once: true }) + } + + if (remaining === 0) settle() + + mediaCheckCleanup = () => { + clearTimeout(timeout) + for (const v of videos) v.removeEventListener('canplaythrough', onReady) + } + }) + + mediaCheckCleanup = () => cancelAnimationFrame(rafId) + }) + onMount(() => { const handleResize = () => { isResizing = true @@ -161,7 +214,7 @@ > {#each slides.all as slide, i}
- {#if slide.loaded} + {#if slide.loaded && slides.isRenderable(i)} s.path !== exceptPath) - .forEach((s) => loadSlide(s.path)); +async function loadAllSlidesInBackground(exceptPath) { + const activeIdx = slides.getIndexByPath(exceptPath) + const remaining = slides.all + .map((s, i) => ({ path: s.path, distance: Math.abs(i - activeIdx) })) + .filter(s => s.path !== exceptPath) + .sort((a, b) => a.distance - b.distance) + + for (const { path } of remaining) { + await loadSlide(path) + } } export function slideTo(path, { skipHistory = false } = {}) { diff --git a/src/state/slides.svelte.js b/src/state/slides.svelte.js index 63bd793..62c26b8 100644 --- a/src/state/slides.svelte.js +++ b/src/state/slides.svelte.js @@ -3,20 +3,64 @@ let activeIndex = $state(0) let pendingPath = $state(null) let standaloneData = $state(null) +// --- Deferred rendering --- +let renderableSet = $state(new Set()) +let activeMediaReady = $state(false) +let progressiveTimers = [] + function getIndexByPath(path) { return slidesData.findIndex(s => s.path === path) } +function renderProgressively() { + progressiveTimers.forEach(clearTimeout) + progressiveTimers = [] + + const total = slidesData.length + if (total === 0) return + + // Build list sorted by distance from active, excluding already renderable + const toRender = [] + for (let dist = 1; dist < total; dist++) { + for (const i of [activeIndex - dist, activeIndex + dist]) { + if (i >= 0 && i < total && !renderableSet.has(i)) { + toRender.push(i) + } + } + } + + toRender.forEach((idx, order) => { + const timer = setTimeout(() => { + renderableSet = new Set([...renderableSet, idx]) + }, (order + 1) * 200) + progressiveTimers.push(timer) + }) +} + export const slides = { get all() { return slidesData }, get activeIndex() { return activeIndex }, get active() { return slidesData[activeIndex] ?? null }, get pendingPath() { return pendingPath }, get standalone() { return standaloneData }, + get activeMediaReady() { return activeMediaReady }, setStandalone(data) { standaloneData = data }, clearStandalone() { standaloneData = null }, + isRenderable(i) { return renderableSet.has(i) }, + + setActiveMediaReady(ready) { + activeMediaReady = ready + if (ready) renderProgressively() + }, + + markRenderable(i) { + if (!renderableSet.has(i)) { + renderableSet = new Set([...renderableSet, i]) + } + }, + init(siteNavigation) { slidesData = siteNavigation.map(nav => ({ id: nav.id, @@ -56,6 +100,8 @@ export const slides = { if (idx === -1) return if (slidesData[idx].loaded) { activeIndex = idx + activeMediaReady = false + renderableSet = new Set([...renderableSet, idx]) pendingPath = null } else { pendingPath = path @@ -64,6 +110,8 @@ export const slides = { setActiveIndex(idx) { activeIndex = idx + activeMediaReady = false + renderableSet = new Set([...renderableSet, idx]) }, resolvePending() { @@ -71,6 +119,8 @@ export const slides = { const idx = getIndexByPath(pendingPath) if (idx !== -1 && slidesData[idx].loaded) { activeIndex = idx + activeMediaReady = false + renderableSet = new Set([...renderableSet, idx]) pendingPath = null } },