Refactor: lisibilité Expertise + navigation clavier
All checks were successful
Deploy / Deploy to Production (push) Successful in 16s
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:
parent
85b2145bb0
commit
d9c45d40fe
1 changed files with 139 additions and 102 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue