Refactor: lisibilité Expertise + navigation clavier
All checks were successful
Deploy / Deploy to Production (push) Successful in 16s

- 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 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-02-19 20:38:55 +01:00
parent 85b2145bb0
commit d9c45d40fe

View file

@ -4,6 +4,14 @@
let { data } = $props() 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 --- // --- State ---
let currentItem = $state(0) let currentItem = $state(0)
let canScroll = $state(true) let canScroll = $state(true)
@ -19,21 +27,22 @@
let sectionEl = $state(null) let sectionEl = $state(null)
// --- Plain variables (not reactive, used in event listeners) --- // --- Plain variables (not reactive, used in event listeners) ---
let fwdTarget = null // forward video pause target (seconds) let fwdTarget = null // forward video pause target (seconds)
let revTarget = null // reverse video pause target (seconds) let revTarget = null // reverse video pause target (seconds)
let fwdPosition = 0 // current logical position in the forward video timeline // 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 --- // --- Derived ---
const isActive = $derived(slides.active?.id === 'expertise') const isActive = $derived(slides.active?.id === 'expertise')
const items = $derived(data?.items ?? [])
// --- Items & segment ends --- const itemCount = $derived(items.length)
const items = $derived(data?.items ?? [])
const N = $derived(items.length)
// segmentEnds[i] = timestamp where forward video pauses after reaching item i // segmentEnds[i] = timestamp where forward video pauses after reaching item i
const segmentEnds = $derived( const segmentEnds = $derived(
N > 0 && videoDuration > 0 itemCount > 0 && videoDuration > 0
? Array.from({ length: N }, (_, i) => videoDuration * (i + 1) / N) ? Array.from({ length: itemCount }, (_, i) => videoDuration * (i + 1) / itemCount)
: [] : []
) )
@ -48,78 +57,91 @@
offsetY = wh / 2 - wrapperRect.top - el.offsetTop - el.offsetHeight / 2 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 --- // --- Navigate: move one item up or down ---
let scrollLockTimer = null let scrollLockTimer = null // internal state for navigate()
function navigate(direction) { function navigate(direction) {
if (!canScroll || segmentEnds.length === 0) return if (!canScroll || segmentEnds.length === 0) return
const prevItem = currentItem const prevItem = currentItem
const newItem = direction === 'down' const newItem = direction === 'down'
? Math.min(prevItem + 1, N - 1) ? Math.min(prevItem + 1, itemCount - 1)
: Math.max(prevItem - 1, 0) : Math.max(prevItem - 1, 0)
if (newItem === prevItem) return // at boundary, ignore if (newItem === prevItem) return // at boundary, ignore
canScroll = false canScroll = false
clearTimeout(scrollLockTimer) clearTimeout(scrollLockTimer)
scrollLockTimer = setTimeout(() => { canScroll = true }, 650) scrollLockTimer = setTimeout(() => { canScroll = true }, SCROLL_LOCK_MS)
currentItem = newItem currentItem = newItem
if (direction === 'down' && videoFwd && !videoFwd.paused) { if (direction === 'down' && videoFwd && !videoFwd.paused) {
// Fast path: forward video is already playing — just move the stop point forward. // Fast path: forward video already playing — just move the stop point
// No pause, no seek, no stutter.
fwdTarget = segmentEnds[currentItem] fwdTarget = segmentEnds[currentItem]
} else if (direction === 'up' && videoRev && !videoRev.paused) { } else if (direction === 'up' && videoRev && !videoRev.paused) {
// Fast path: reverse video is already playing — just move the stop point. // Fast path: reverse video already playing — just move the stop point.
// Read live currentTime to compute real position (fwdPosition is stale during play). // Read live currentTime because currentFwdTime is stale during play.
const liveFwdPos = videoDuration - videoRev.currentTime const liveFwdPos = videoDuration - videoRev.currentTime
const segBoundary = segmentEnds[currentItem] const segBoundary = segmentEnds[currentItem]
const targetFwd = liveFwdPos > segBoundary ? segBoundary : 0 const targetFwd = liveFwdPos > segBoundary ? segBoundary : 0
revTarget = videoDuration - Math.max(targetFwd, 0.1) revTarget = videoDuration - Math.max(targetFwd, REV_TARGET_MIN)
} else { } else {
// General case: need to start a new play or switch direction. // General case: stop whatever is playing, then start in the right direction
// Capture the real position if a video is mid-play, then stop it. stopActiveVideo()
if (videoFwd && !videoFwd.paused) {
fwdPosition = videoFwd.currentTime
videoFwd.pause()
fwdTarget = null
}
if (videoRev && !videoRev.paused) {
fwdPosition = videoDuration - videoRev.currentTime
videoRev.pause()
revTarget = null
}
if (direction === 'down') { if (direction === 'down') {
fwdTarget = segmentEnds[currentItem] playForward(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(() => {})
}
} else { } else {
const segBoundary = segmentEnds[currentItem] const segBoundary = segmentEnds[currentItem]
const targetFwd = fwdPosition > segBoundary ? segBoundary : 0 const targetFwd = currentFwdTime > segBoundary ? segBoundary : 0
const revStart = Math.max(0, videoDuration - fwdPosition) playReverse(videoDuration - Math.max(targetFwd, REV_TARGET_MIN))
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(() => {})
}
} }
} }
@ -127,51 +149,97 @@
} }
// --- Wheel capture (non-passive, attached in onMount) --- // --- Wheel capture (non-passive, attached in onMount) ---
let scrollDelta = 0 let scrollDelta = 0 // internal state for onWheel()
let lastScrollAt = 0 let lastScrollAt = 0
function onWheel(e) { function onWheel(e) {
e.preventDefault() e.preventDefault()
if (!isActive || !canScroll) return if (!isActive || !canScroll) return
const now = Date.now() const now = Date.now()
if (now - lastScrollAt > 100) scrollDelta = 0 if (now - lastScrollAt > WHEEL_DEBOUNCE_MS) scrollDelta = 0
lastScrollAt = now lastScrollAt = now
scrollDelta += e.deltaY scrollDelta += e.deltaY
if (Math.abs(scrollDelta) >= 25) { if (Math.abs(scrollDelta) >= WHEEL_THRESHOLD) {
navigate(scrollDelta > 0 ? 'down' : 'up') navigate(scrollDelta > 0 ? 'down' : 'up')
scrollDelta = 0 scrollDelta = 0
} }
} }
// --- Touch --- // --- Touch ---
let touchStartY = 0 let touchStartY = 0 // internal state for touch handlers
function onTouchStart(e) { touchStartY = e.touches[0].clientY } function onTouchStart(e) { touchStartY = e.touches[0].clientY }
function onTouchEnd(e) { function onTouchEnd(e) {
if (!isActive || !canScroll) return if (!isActive || !canScroll) return
const diff = touchStartY - e.changedTouches[0].clientY 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: set up video listeners + wheel + resize ---
onMount(() => { onMount(() => {
sectionEl?.addEventListener('wheel', onWheel, { passive: false }) 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 = () => { const onFwdUpdate = () => {
if (!videoFwd || videoFwd.paused || fwdTarget === null) return if (!videoFwd || videoFwd.paused || fwdTarget === null) return
if (videoFwd.currentTime >= fwdTarget) { if (videoFwd.currentTime >= fwdTarget) {
fwdPosition = videoFwd.currentTime currentFwdTime = videoFwd.currentTime
videoFwd.pause() videoFwd.pause()
fwdTarget = null fwdTarget = null
} }
} }
videoFwd?.addEventListener('timeupdate', onFwdUpdate) 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 = () => { const onRevUpdate = () => {
if (!videoRev || videoRev.paused || revTarget === null) return if (!videoRev || videoRev.paused || revTarget === null) return
if (videoRev.currentTime >= revTarget) { if (videoRev.currentTime >= revTarget) {
// fwdPosition is the mirror of where we stopped in the reverse video currentFwdTime = videoDuration - videoRev.currentTime
fwdPosition = videoDuration - videoRev.currentTime
videoRev.pause() videoRev.pause()
revTarget = null revTarget = null
} }
@ -181,6 +249,7 @@
const onResize = () => { if (isActive) computeOffset() } const onResize = () => { if (isActive) computeOffset() }
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
window.addEventListener('orientationchange', onResize) window.addEventListener('orientationchange', onResize)
window.addEventListener('keydown', onKeyDown)
return () => { return () => {
sectionEl?.removeEventListener('wheel', onWheel) sectionEl?.removeEventListener('wheel', onWheel)
@ -188,6 +257,7 @@
videoRev?.removeEventListener('timeupdate', onRevUpdate) videoRev?.removeEventListener('timeupdate', onRevUpdate)
window.removeEventListener('resize', onResize) window.removeEventListener('resize', onResize)
window.removeEventListener('orientationchange', onResize) window.removeEventListener('orientationchange', onResize)
window.removeEventListener('keydown', onKeyDown)
clearTimeout(scrollLockTimer) clearTimeout(scrollLockTimer)
} }
}) })
@ -195,42 +265,9 @@
// --- Effect: react to slide activation / deactivation --- // --- Effect: react to slide activation / deactivation ---
$effect(() => { $effect(() => {
if (isActive) { if (isActive) {
setTimeout(() => { initPlayback()
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)
} else { } else {
// Pause and reset resetPlayback()
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)
} }
}) })
</script> </script>