From d9c45d40fe282002190ea9640081b31526547f30 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 19 Feb 2026 20:38:55 +0100 Subject: [PATCH] =?UTF-8?q?Refactor:=20lisibilit=C3=A9=20Expertise=20+=20n?= =?UTF-8?q?avigation=20clavier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renomme N → itemCount et fwdPosition → currentFwdTime - Extrait stopActiveVideo, playForward, playReverse depuis navigate() - Extrait initPlayback et resetPlayback depuis $effect - Nomme les magic numbers en constantes (SCROLL_LOCK_MS, etc.) - Ajoute navigation clavier ArrowUp / ArrowDown Co-Authored-By: Claude Sonnet 4.5 --- src/views/Expertise.svelte | 241 +++++++++++++++++++++---------------- 1 file changed, 139 insertions(+), 102 deletions(-) diff --git a/src/views/Expertise.svelte b/src/views/Expertise.svelte index cb0c822..4599d72 100644 --- a/src/views/Expertise.svelte +++ b/src/views/Expertise.svelte @@ -4,6 +4,14 @@ let { data } = $props() + // --- Constants --- + const SCROLL_LOCK_MS = 650 // ms before next scroll is accepted + const WHEEL_DEBOUNCE_MS = 100 // ms window to accumulate wheel delta + const WHEEL_THRESHOLD = 25 // accumulated px to trigger navigation + const TOUCH_THRESHOLD = 50 // px swipe distance to trigger navigation + const PLAY_DELAY_MS = 300 // ms delay before starting playback on slide activation + const REV_TARGET_MIN = 0.1 // min revTarget to avoid pausing exactly at videoDuration + // --- State --- let currentItem = $state(0) let canScroll = $state(true) @@ -19,21 +27,22 @@ let sectionEl = $state(null) // --- Plain variables (not reactive, used in event listeners) --- - let fwdTarget = null // forward video pause target (seconds) - let revTarget = null // reverse video pause target (seconds) - let fwdPosition = 0 // current logical position in the forward video timeline + let fwdTarget = null // forward video pause target (seconds) + let revTarget = null // reverse video pause target (seconds) + // Position in the forward video timeline. + // Updated from videoFwd.currentTime when going forward, + // and mirrored from videoRev.currentTime when going in reverse. + let currentFwdTime = 0 - // --- Active slide --- - const isActive = $derived(slides.active?.id === 'expertise') - - // --- Items & segment ends --- - const items = $derived(data?.items ?? []) - const N = $derived(items.length) + // --- Derived --- + const isActive = $derived(slides.active?.id === 'expertise') + const items = $derived(data?.items ?? []) + const itemCount = $derived(items.length) // segmentEnds[i] = timestamp where forward video pauses after reaching item i const segmentEnds = $derived( - N > 0 && videoDuration > 0 - ? Array.from({ length: N }, (_, i) => videoDuration * (i + 1) / N) + itemCount > 0 && videoDuration > 0 + ? Array.from({ length: itemCount }, (_, i) => videoDuration * (i + 1) / itemCount) : [] ) @@ -48,78 +57,91 @@ offsetY = wh / 2 - wrapperRect.top - el.offsetTop - el.offsetHeight / 2 } + // --- Video control helpers --- + + // Capture current position from whichever video is playing, then stop both. + 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 + } + } + + // Seek videoFwd to currentFwdTime and play until targetTime. + // If currently showing the reverse video, wait for the seek before switching. + function playForward(targetTime) { + fwdTarget = targetTime + if (videoFwd) videoFwd.currentTime = currentFwdTime + if (isReverse) { + videoFwd?.addEventListener('seeked', () => { + isReverse = false + videoFwd?.play().catch(() => {}) + }, { once: true }) + } else { + videoFwd?.play().catch(() => {}) + } + } + + // Seek videoRev to the mirror of currentFwdTime and play until targetTime. + // If currently showing the forward video, wait for the seek before switching. + function playReverse(targetTime) { + const revStart = Math.max(0, videoDuration - currentFwdTime) + revTarget = targetTime + if (videoRev) videoRev.currentTime = revStart + if (!isReverse) { + videoRev?.addEventListener('seeked', () => { + isReverse = true + videoRev?.play().catch(() => {}) + }, { once: true }) + } else { + videoRev?.play().catch(() => {}) + } + } + // --- Navigate: move one item up or down --- - let scrollLockTimer = null + let scrollLockTimer = null // internal state for navigate() function navigate(direction) { if (!canScroll || segmentEnds.length === 0) return const prevItem = currentItem const newItem = direction === 'down' - ? Math.min(prevItem + 1, N - 1) + ? Math.min(prevItem + 1, itemCount - 1) : Math.max(prevItem - 1, 0) if (newItem === prevItem) return // at boundary, ignore canScroll = false clearTimeout(scrollLockTimer) - scrollLockTimer = setTimeout(() => { canScroll = true }, 650) + scrollLockTimer = setTimeout(() => { canScroll = true }, SCROLL_LOCK_MS) currentItem = newItem if (direction === 'down' && videoFwd && !videoFwd.paused) { - // Fast path: forward video is already playing — just move the stop point forward. - // No pause, no seek, no stutter. + // Fast path: forward video already playing — just move the stop point fwdTarget = segmentEnds[currentItem] } else if (direction === 'up' && videoRev && !videoRev.paused) { - // Fast path: reverse video is already playing — just move the stop point. - // Read live currentTime to compute real position (fwdPosition is stale during play). + // Fast path: reverse video already playing — just move the stop point. + // Read live currentTime because currentFwdTime is stale during play. const liveFwdPos = videoDuration - videoRev.currentTime const segBoundary = segmentEnds[currentItem] const targetFwd = liveFwdPos > segBoundary ? segBoundary : 0 - revTarget = videoDuration - Math.max(targetFwd, 0.1) + revTarget = videoDuration - Math.max(targetFwd, REV_TARGET_MIN) } else { - // General case: need to start a new play or switch direction. - // Capture the real position if a video is mid-play, then stop it. - if (videoFwd && !videoFwd.paused) { - fwdPosition = videoFwd.currentTime - videoFwd.pause() - fwdTarget = null - } - if (videoRev && !videoRev.paused) { - fwdPosition = videoDuration - videoRev.currentTime - videoRev.pause() - revTarget = null - } - + // General case: stop whatever is playing, then start in the right direction + stopActiveVideo() if (direction === 'down') { - fwdTarget = segmentEnds[currentItem] - if (videoFwd) videoFwd.currentTime = fwdPosition - if (isReverse) { - // Switching reverse → forward: wait for seek before showing forward video - videoFwd?.addEventListener('seeked', () => { - isReverse = false - videoFwd?.play().catch(() => {}) - }, { once: true }) - } else { - videoFwd?.play().catch(() => {}) - } + playForward(segmentEnds[currentItem]) } else { const segBoundary = segmentEnds[currentItem] - const targetFwd = fwdPosition > segBoundary ? segBoundary : 0 - const revStart = Math.max(0, videoDuration - fwdPosition) - revTarget = videoDuration - Math.max(targetFwd, 0.1) - - if (videoRev) videoRev.currentTime = revStart - if (!isReverse) { - // Switching forward → reverse: wait for seek before showing reverse video - videoRev?.addEventListener('seeked', () => { - isReverse = true - videoRev?.play().catch(() => {}) - }, { once: true }) - } else { - videoRev?.play().catch(() => {}) - } + const targetFwd = currentFwdTime > segBoundary ? segBoundary : 0 + playReverse(videoDuration - Math.max(targetFwd, REV_TARGET_MIN)) } } @@ -127,51 +149,97 @@ } // --- Wheel capture (non-passive, attached in onMount) --- - let scrollDelta = 0 + let scrollDelta = 0 // internal state for onWheel() let lastScrollAt = 0 function onWheel(e) { e.preventDefault() if (!isActive || !canScroll) return const now = Date.now() - if (now - lastScrollAt > 100) scrollDelta = 0 + if (now - lastScrollAt > WHEEL_DEBOUNCE_MS) scrollDelta = 0 lastScrollAt = now scrollDelta += e.deltaY - if (Math.abs(scrollDelta) >= 25) { + if (Math.abs(scrollDelta) >= WHEEL_THRESHOLD) { navigate(scrollDelta > 0 ? 'down' : 'up') scrollDelta = 0 } } // --- Touch --- - let touchStartY = 0 + let touchStartY = 0 // internal state for touch handlers function onTouchStart(e) { touchStartY = e.touches[0].clientY } function onTouchEnd(e) { if (!isActive || !canScroll) return const diff = touchStartY - e.changedTouches[0].clientY - if (Math.abs(diff) >= 50) navigate(diff > 0 ? 'down' : 'up') + if (Math.abs(diff) >= TOUCH_THRESHOLD) navigate(diff > 0 ? 'down' : 'up') + } + + // --- Keyboard --- + function onKeyDown(e) { + if (!isActive || !canScroll) return + if (e.key === 'ArrowDown') { e.preventDefault(); navigate('down') } + if (e.key === 'ArrowUp') { e.preventDefault(); navigate('up') } + } + + // --- Playback lifecycle --- + function initPlayback() { + setTimeout(() => { + const tryPlay = () => { + const dur = videoFwd?.duration + if (!dur || itemCount === 0) return + videoDuration = dur + videoFwd.currentTime = 0 + currentFwdTime = 0 + isReverse = false + fwdTarget = dur / itemCount // = segmentEnds[0] + videoFwd.play().catch(() => {}) + // Pre-seek reverse video to buffer the region needed for the first UP scroll + if (videoRev) videoRev.currentTime = Math.max(0, dur - dur / itemCount) + requestAnimationFrame(() => computeOffset()) + } + if (videoFwd?.duration) { + tryPlay() + } else { + videoFwd?.addEventListener('loadedmetadata', () => { + videoDuration = videoFwd.duration + tryPlay() + }, { 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 + isReverse = false + canScroll = true + clearTimeout(scrollLockTimer) } // --- onMount: set up video listeners + wheel + resize --- onMount(() => { sectionEl?.addEventListener('wheel', onWheel, { passive: false }) - // Forward video: update fwdPosition and pause when target is reached + // Forward video: update currentFwdTime and pause when target is reached const onFwdUpdate = () => { if (!videoFwd || videoFwd.paused || fwdTarget === null) return if (videoFwd.currentTime >= fwdTarget) { - fwdPosition = videoFwd.currentTime + currentFwdTime = videoFwd.currentTime videoFwd.pause() fwdTarget = null } } videoFwd?.addEventListener('timeupdate', onFwdUpdate) - // Reverse video: update fwdPosition (mirrored) and pause when target is reached + // Reverse video: update currentFwdTime (mirrored) and pause when target is reached const onRevUpdate = () => { if (!videoRev || videoRev.paused || revTarget === null) return if (videoRev.currentTime >= revTarget) { - // fwdPosition is the mirror of where we stopped in the reverse video - fwdPosition = videoDuration - videoRev.currentTime + currentFwdTime = videoDuration - videoRev.currentTime videoRev.pause() revTarget = null } @@ -181,6 +249,7 @@ const onResize = () => { if (isActive) computeOffset() } window.addEventListener('resize', onResize) window.addEventListener('orientationchange', onResize) + window.addEventListener('keydown', onKeyDown) return () => { sectionEl?.removeEventListener('wheel', onWheel) @@ -188,6 +257,7 @@ videoRev?.removeEventListener('timeupdate', onRevUpdate) window.removeEventListener('resize', onResize) window.removeEventListener('orientationchange', onResize) + window.removeEventListener('keydown', onKeyDown) clearTimeout(scrollLockTimer) } }) @@ -195,42 +265,9 @@ // --- Effect: react to slide activation / deactivation --- $effect(() => { if (isActive) { - setTimeout(() => { - const tryPlay = () => { - const dur = videoFwd?.duration - if (!dur || N === 0) return - videoDuration = dur - videoFwd.currentTime = 0 - fwdPosition = 0 - isReverse = false - fwdTarget = dur / N // = segmentEnds[0] - videoFwd.play().catch(() => {}) - // Pre-seek reverse video to its expected start position for the first UP scroll. - // Forces the browser to buffer that region → eliminates first-use stutter. - if (videoRev) videoRev.currentTime = Math.max(0, dur - dur / N) - requestAnimationFrame(() => computeOffset()) - } - if (videoFwd?.duration) { - tryPlay() - } else { - videoFwd?.addEventListener('loadedmetadata', () => { - videoDuration = videoFwd.duration - tryPlay() - }, { once: true }) - } - }, 300) + initPlayback() } else { - // Pause and reset - fwdTarget = null - revTarget = null - fwdPosition = 0 - if (videoFwd) { videoFwd.pause(); videoFwd.currentTime = 0 } - if (videoRev) { videoRev.pause(); videoRev.currentTime = 0 } - currentItem = 0 - offsetY = 0 - isReverse = false - canScroll = true - clearTimeout(scrollLockTimer) + resetPlayback() } })