From 6ec32dd82acc24233297226963505c736e7df7f7 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 2 Apr 2026 08:39:49 +0200 Subject: [PATCH] expertise : simplify video + text scroll logic. related to #52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace complex segment-based video control with a simpler model: - Scroll down → advance one text item (650ms lock) + play video forward - Scroll up → go back one text item + play video in reverse - Video plays continuously in chosen direction, decoupled from text items - Remove useScrollNav, segmentEnds, offsetY, computeOffset, fwdTarget/revTarget Co-Authored-By: Claude Sonnet 4.6 --- src/views/Expertise.svelte | 395 +++++++++++++++---------------------- 1 file changed, 159 insertions(+), 236 deletions(-) diff --git a/src/views/Expertise.svelte b/src/views/Expertise.svelte index 185d575..23772be 100644 --- a/src/views/Expertise.svelte +++ b/src/views/Expertise.svelte @@ -2,255 +2,190 @@ import { onMount } from 'svelte' import { slides } from '@state/slides.svelte' import { navigation } from '@state/navigation.svelte' - import { createScrollNav } from '@composables/useScrollNav.svelte.js' let { data } = $props() - // --- Constants --- - const PLAY_DELAY_MS = 300 // ms delay before starting playback on slide activation + // --- DOM refs --- + let videoFwd = $state(null) + let videoRev = $state(null) + let sectionEl = $state(null) // --- State --- - let currentItem = $state(0) - let isReverse = $state(false) - let offsetY = $state(0) - let videoDuration = $state(0) + let isReverse = $state(false) + let duration = $state(0) + let currentItem = $state(0) - // --- DOM refs --- - let videoFwd = $state(null) - let videoRev = $state(null) - let textContainer = $state(null) - let itemEls = $state([]) - let sectionEl = $state(null) - - // --- Plain variables (not reactive, used in event listeners) --- - let fwdTarget = null - let revTarget = null - let currentFwdTime = 0 + // --- Non-reactive --- + let playhead = 0 // forward-equivalent position, updated from timeupdate + let switching = false + let canScroll = true + let lockTimer = null // --- Derived --- - const isActive = $derived(slides.active?.id === 'expertise') - const items = $derived(data?.items ?? []) - const itemCount = $derived(items.length) + const isActive = $derived(slides.active?.id === 'expertise') + const items = $derived(data?.items ?? []) $effect(() => { if (isActive) navigation.setScrolled(currentItem > 0) }) - // segmentEnds[i] = position forward correspondant à l'item i. - // Item 0 = début de vidéo (0), item final = fin de vidéo (duration). - const segmentEnds = $derived( - itemCount > 1 && videoDuration > 0 - ? Array.from({ length: itemCount }, (_, i) => videoDuration * i / (itemCount - 1)) - : itemCount === 1 ? [0] - : [] - ) + // --- Video init / reset --- - // --- Scroll nav composable --- - const nav = createScrollNav({ - isActive: () => isActive, - onNavigate: (dir) => { - if (segmentEnds.length === 0) return false - const prevItem = currentItem - const newItem = dir === 'down' - ? Math.min(prevItem + 1, itemCount - 1) - : Math.max(prevItem - 1, 0) - if (newItem === prevItem) return false - navigate(dir, newItem) - }, + $effect(() => { + if (isActive) { + initVideo() + } else { + resetVideo() + } }) - // --- Center active item vertically in viewport --- - function computeOffset() { - if (!textContainer || !itemEls[currentItem]) return - const wh = window.innerHeight - const wrapperRect = textContainer.parentElement.getBoundingClientRect() - const el = itemEls[currentItem] - offsetY = wh / 2 - wrapperRect.top - el.offsetTop - el.offsetHeight / 2 - } - - // --- Video control helpers --- - - /** Stoppe la vidéo active et sauvegarde la position forward-équivalente. */ - function stopActiveVideo() { - if (videoFwd && !videoFwd.paused) { - currentFwdTime = videoFwd.currentTime - videoFwd.pause() - fwdTarget = null - } - if (videoRev && !videoRev.paused) { - currentFwdTime = videoDuration - videoRev.currentTime - videoRev.pause() - revTarget = null + function initVideo() { + if (!videoFwd) return + const start = () => { + duration = videoFwd.duration + videoFwd.currentTime = 0 + playhead = 0 + isReverse = false + if (videoRev) videoRev.currentTime = duration + // Force first-frame decode (required on mobile Safari) + videoFwd.play().then(() => videoFwd.pause()).catch(() => {}) } + if (videoFwd.readyState >= 1) start() + else videoFwd.addEventListener('loadedmetadata', start, { once: true }) } - function playForward(targetTime) { - fwdTarget = targetTime - - if (!isReverse) { - // Déjà sur forward : seek + play directement - if (videoFwd) videoFwd.currentTime = currentFwdTime - videoFwd?.play().catch(() => {}) - return - } - - // Changement de direction : seek forward, attendre seeked, puis switcher - if (videoFwd) videoFwd.currentTime = currentFwdTime - videoFwd?.addEventListener('seeked', () => { - isReverse = false // maintenant forward est au bon frame → on l'affiche - videoFwd?.play().catch(() => {}) - }, { once: true }) - } - - function playReverse(targetFwdPos) { - revTarget = videoDuration - Math.max(targetFwdPos, 0) - const revStart = videoDuration - currentFwdTime - - if (isReverse) { - // Déjà sur reverse : seek + play directement - if (videoRev) videoRev.currentTime = revStart - videoRev?.play().catch(() => {}) - return - } - - // Changement de direction : seek reverse, attendre seeked, puis switcher - if (videoRev) videoRev.currentTime = revStart - videoRev?.addEventListener('seeked', () => { - isReverse = true // maintenant reverse est au bon frame → on l'affiche - videoRev?.play().catch(() => {}) - }, { once: true }) - } - - // --- Navigate: move one item up or down (called by composable) --- - function navigate(direction, newItem) { - currentItem = newItem - const targetPos = segmentEnds[currentItem] - - if (direction === 'down') { - if (videoFwd && !videoFwd.paused) { - // Forward déjà en cours : juste mettre à jour la cible - fwdTarget = targetPos - } else { - stopActiveVideo() - playForward(targetPos) - } - } else { - if (videoRev && !videoRev.paused) { - // Reverse déjà en cours : juste mettre à jour la cible - revTarget = videoDuration - Math.max(targetPos, 0) - } else { - stopActiveVideo() - playReverse(targetPos) - } - } - - requestAnimationFrame(() => { if (isActive) computeOffset() }) - } - - // --- Playback lifecycle --- - function initPlayback() { - setTimeout(() => { - const init = () => { - const dur = videoFwd?.duration - if (!dur || itemCount === 0) return - videoDuration = dur - videoFwd.currentTime = 0 - currentFwdTime = 0 - isReverse = false - if (videoRev) videoRev.currentTime = dur - requestAnimationFrame(() => computeOffset()) - // Force le décodage de la première frame (nécessaire sur mobile) - videoFwd.play().then(() => { - videoFwd.pause() - videoFwd.currentTime = 0 - }).catch(() => {}) - } - if (videoFwd?.duration) { - init() - } else { - videoFwd?.addEventListener('loadedmetadata', () => { - videoDuration = videoFwd.duration - init() - }, { once: true }) - } - }, PLAY_DELAY_MS) - } - - function resetPlayback() { - fwdTarget = null - revTarget = null - currentFwdTime = 0 - if (videoFwd) { videoFwd.pause(); videoFwd.currentTime = 0 } - if (videoRev) { videoRev.pause(); videoRev.currentTime = 0 } - currentItem = 0 - offsetY = 0 + function resetVideo() { + clearTimeout(lockTimer) + switching = false + canScroll = true + playhead = 0 isReverse = false - nav.reset() + currentItem = 0 + if (videoFwd) { videoFwd.pause(); videoFwd.currentTime = 0 } + if (videoRev) { videoRev.pause() } + } + + // --- Playback --- + + function pauseAll() { + videoFwd?.pause() + videoRev?.pause() + } + + function playForward() { + if (switching) return + if (!isReverse) { + videoFwd?.play().catch(() => {}) + return + } + // Switch reverse → forward + switching = true + pauseAll() + const pos = duration - (videoRev?.currentTime ?? 0) + playhead = pos + if (!videoFwd) { switching = false; return } + videoFwd.currentTime = pos + videoFwd.addEventListener('seeked', () => { + isReverse = false + switching = false + videoFwd.play().catch(() => {}) + }, { once: true }) + } + + function playReverse() { + if (switching) return + if (isReverse) { + videoRev?.play().catch(() => {}) + return + } + // Switch forward → reverse + switching = true + pauseAll() + const pos = videoFwd?.currentTime ?? playhead + playhead = pos + if (!videoRev) { switching = false; return } + videoRev.currentTime = duration - pos + videoRev.addEventListener('seeked', () => { + isReverse = true + switching = false + videoRev.play().catch(() => {}) + }, { once: true }) + } + + function navigate(dir) { + if (!isActive || !canScroll) return + + const next = dir === 'down' + ? Math.min(currentItem + 1, items.length - 1) + : Math.max(currentItem - 1, 0) + + if (next === currentItem) return // à la limite, ne rien faire + + currentItem = next + canScroll = false + clearTimeout(lockTimer) + lockTimer = setTimeout(() => { canScroll = true }, 650) + + dir === 'down' ? playForward() : playReverse() } // --- onMount --- onMount(() => { - sectionEl?.addEventListener('wheel', nav.onWheel, { passive: false }) + // Maintenir playhead (non-réactif) pour les direction switches + const onFwdTime = () => { if (videoFwd && !videoFwd.paused) playhead = videoFwd.currentTime } + const onRevTime = () => { if (videoRev && !videoRev.paused) playhead = duration - videoRev.currentTime } + const onFwdEnded = () => { playhead = duration } + const onRevEnded = () => { playhead = 0 } - const onFwdUpdate = () => { - if (!videoFwd || videoFwd.paused || fwdTarget === null) return - if (videoFwd.currentTime >= fwdTarget - 0.05) { - currentFwdTime = fwdTarget - videoFwd.pause() - fwdTarget = null - } - } - videoFwd?.addEventListener('timeupdate', onFwdUpdate) - - // Quand la vidéo forward atteint la fin naturellement, timeupdate - // peut ne pas fire avec currentTime >= duration. ended le garantit. - const onFwdEnded = () => { - currentFwdTime = videoDuration - fwdTarget = null - } + videoFwd?.addEventListener('timeupdate', onFwdTime) + videoRev?.addEventListener('timeupdate', onRevTime) videoFwd?.addEventListener('ended', onFwdEnded) - - const onRevUpdate = () => { - if (!videoRev || videoRev.paused || revTarget === null) return - if (videoRev.currentTime >= revTarget - 0.05) { - const fwdPos = videoDuration - revTarget - currentFwdTime = Math.max(fwdPos, 0) - videoRev.pause() - revTarget = null - } - } - videoRev?.addEventListener('timeupdate', onRevUpdate) - - const onRevEnded = () => { - currentFwdTime = 0 - revTarget = null - } videoRev?.addEventListener('ended', onRevEnded) - const onResize = () => { if (isActive) computeOffset() } - window.addEventListener('resize', onResize) - window.addEventListener('orientationchange', onResize) - window.addEventListener('keydown', nav.onKeyDown) + // Wheel + let scrollDelta = 0 + let lastScrollAt = 0 + const onWheel = (e) => { + e.preventDefault() + const now = Date.now() + if (now - lastScrollAt > 100) scrollDelta = 0 + lastScrollAt = now + scrollDelta += e.deltaY + if (Math.abs(scrollDelta) >= 25) { + navigate(scrollDelta > 0 ? 'down' : 'up') + scrollDelta = 0 + } + } + sectionEl?.addEventListener('wheel', onWheel, { passive: false }) + + // Touch + let touchStartY = 0 + const onTouchStart = (e) => { touchStartY = e.touches[0].clientY } + const onTouchEnd = (e) => { + const delta = touchStartY - e.changedTouches[0].clientY + if (Math.abs(delta) > 50) navigate(delta > 0 ? 'down' : 'up') + } + sectionEl?.addEventListener('touchstart', onTouchStart, { passive: true }) + sectionEl?.addEventListener('touchend', onTouchEnd, { passive: true }) + + // Keyboard + const onKeyDown = (e) => { + if (!isActive) return + if (e.key === 'ArrowDown') { e.preventDefault(); navigate('down') } + if (e.key === 'ArrowUp') { e.preventDefault(); navigate('up') } + } + window.addEventListener('keydown', onKeyDown) return () => { - sectionEl?.removeEventListener('wheel', nav.onWheel) - videoFwd?.removeEventListener('timeupdate', onFwdUpdate) + sectionEl?.removeEventListener('wheel', onWheel) + sectionEl?.removeEventListener('touchstart', onTouchStart) + sectionEl?.removeEventListener('touchend', onTouchEnd) + window.removeEventListener('keydown', onKeyDown) + videoFwd?.removeEventListener('timeupdate', onFwdTime) + videoRev?.removeEventListener('timeupdate', onRevTime) videoFwd?.removeEventListener('ended', onFwdEnded) - videoRev?.removeEventListener('timeupdate', onRevUpdate) videoRev?.removeEventListener('ended', onRevEnded) - window.removeEventListener('resize', onResize) - window.removeEventListener('orientationchange', onResize) - window.removeEventListener('keydown', nav.onKeyDown) - nav.destroy() - } - }) - - // --- Effect: react to slide activation / deactivation --- - $effect(() => { - if (isActive) { - initPlayback() - } else { - resetPlayback() + clearTimeout(lockTimer) } }) @@ -259,8 +194,6 @@ class="expertise golden-grid" aria-label="Expertise" bind:this={sectionEl} - ontouchstart={nav.onTouchStart} - ontouchend={nav.onTouchEnd} >