expertise : simplify video + text scroll logic. related to #52
All checks were successful
Deploy / Deploy to Production (push) Successful in 25s

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 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-04-02 08:39:49 +02:00
parent 0afbcf4088
commit 6ec32dd82a

View file

@ -2,255 +2,190 @@
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { slides } from '@state/slides.svelte' import { slides } from '@state/slides.svelte'
import { navigation } from '@state/navigation.svelte' import { navigation } from '@state/navigation.svelte'
import { createScrollNav } from '@composables/useScrollNav.svelte.js'
let { data } = $props() let { data } = $props()
// --- Constants --- // --- DOM refs ---
const PLAY_DELAY_MS = 300 // ms delay before starting playback on slide activation let videoFwd = $state(null)
let videoRev = $state(null)
let sectionEl = $state(null)
// --- State --- // --- State ---
let currentItem = $state(0) let isReverse = $state(false)
let isReverse = $state(false) let duration = $state(0)
let offsetY = $state(0) let currentItem = $state(0)
let videoDuration = $state(0)
// --- DOM refs --- // --- Non-reactive ---
let videoFwd = $state(null) let playhead = 0 // forward-equivalent position, updated from timeupdate
let videoRev = $state(null) let switching = false
let textContainer = $state(null) let canScroll = true
let itemEls = $state([]) let lockTimer = null
let sectionEl = $state(null)
// --- Plain variables (not reactive, used in event listeners) ---
let fwdTarget = null
let revTarget = null
let currentFwdTime = 0
// --- Derived --- // --- Derived ---
const isActive = $derived(slides.active?.id === 'expertise') const isActive = $derived(slides.active?.id === 'expertise')
const items = $derived(data?.items ?? []) const items = $derived(data?.items ?? [])
const itemCount = $derived(items.length)
$effect(() => { $effect(() => {
if (isActive) navigation.setScrolled(currentItem > 0) if (isActive) navigation.setScrolled(currentItem > 0)
}) })
// segmentEnds[i] = position forward correspondant à l'item i. // --- Video init / reset ---
// 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]
: []
)
// --- Scroll nav composable --- $effect(() => {
const nav = createScrollNav({ if (isActive) {
isActive: () => isActive, initVideo()
onNavigate: (dir) => { } else {
if (segmentEnds.length === 0) return false resetVideo()
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)
},
}) })
// --- Center active item vertically in viewport --- function initVideo() {
function computeOffset() { if (!videoFwd) return
if (!textContainer || !itemEls[currentItem]) return const start = () => {
const wh = window.innerHeight duration = videoFwd.duration
const wrapperRect = textContainer.parentElement.getBoundingClientRect() videoFwd.currentTime = 0
const el = itemEls[currentItem] playhead = 0
offsetY = wh / 2 - wrapperRect.top - el.offsetTop - el.offsetHeight / 2 isReverse = false
} if (videoRev) videoRev.currentTime = duration
// Force first-frame decode (required on mobile Safari)
// --- Video control helpers --- videoFwd.play().then(() => videoFwd.pause()).catch(() => {})
/** 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
} }
if (videoFwd.readyState >= 1) start()
else videoFwd.addEventListener('loadedmetadata', start, { once: true })
} }
function playForward(targetTime) { function resetVideo() {
fwdTarget = targetTime clearTimeout(lockTimer)
switching = false
if (!isReverse) { canScroll = true
// Déjà sur forward : seek + play directement playhead = 0
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
isReverse = false 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 ---
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 = () => { videoFwd?.addEventListener('timeupdate', onFwdTime)
if (!videoFwd || videoFwd.paused || fwdTarget === null) return videoRev?.addEventListener('timeupdate', onRevTime)
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('ended', onFwdEnded) 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) videoRev?.addEventListener('ended', onRevEnded)
const onResize = () => { if (isActive) computeOffset() } // Wheel
window.addEventListener('resize', onResize) let scrollDelta = 0
window.addEventListener('orientationchange', onResize) let lastScrollAt = 0
window.addEventListener('keydown', nav.onKeyDown) 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 () => { return () => {
sectionEl?.removeEventListener('wheel', nav.onWheel) sectionEl?.removeEventListener('wheel', onWheel)
videoFwd?.removeEventListener('timeupdate', onFwdUpdate) sectionEl?.removeEventListener('touchstart', onTouchStart)
sectionEl?.removeEventListener('touchend', onTouchEnd)
window.removeEventListener('keydown', onKeyDown)
videoFwd?.removeEventListener('timeupdate', onFwdTime)
videoRev?.removeEventListener('timeupdate', onRevTime)
videoFwd?.removeEventListener('ended', onFwdEnded) videoFwd?.removeEventListener('ended', onFwdEnded)
videoRev?.removeEventListener('timeupdate', onRevUpdate)
videoRev?.removeEventListener('ended', onRevEnded) videoRev?.removeEventListener('ended', onRevEnded)
window.removeEventListener('resize', onResize) clearTimeout(lockTimer)
window.removeEventListener('orientationchange', onResize)
window.removeEventListener('keydown', nav.onKeyDown)
nav.destroy()
}
})
// --- Effect: react to slide activation / deactivation ---
$effect(() => {
if (isActive) {
initPlayback()
} else {
resetPlayback()
} }
}) })
</script> </script>
@ -259,8 +194,6 @@
class="expertise golden-grid" class="expertise golden-grid"
aria-label="Expertise" aria-label="Expertise"
bind:this={sectionEl} bind:this={sectionEl}
ontouchstart={nav.onTouchStart}
ontouchend={nav.onTouchEnd}
> >
<!-- Video background (decorative) --> <!-- Video background (decorative) -->
<div class="expertise-bg" aria-hidden="true"> <div class="expertise-bg" aria-hidden="true">
@ -289,19 +222,11 @@
{@html data?.pageTitle ?? ''} {@html data?.pageTitle ?? ''}
</h2> </h2>
<!-- Sliding text container --> <!-- Text items -->
<div class="expertise-text-wrapper" aria-live="polite" aria-atomic="true"> <div class="expertise-text-wrapper" aria-live="polite" aria-atomic="true">
<div <div class="expertise-text">
class="expertise-text"
bind:this={textContainer}
style="transform: translateY({offsetY}px)"
>
{#each items as item, i} {#each items as item, i}
<div <div class="expertise-item" class:active={i === currentItem}>
class="expertise-item"
class:active={i === currentItem}
bind:this={itemEls[i]}
>
{@html item.text} {@html item.text}
</div> </div>
{/each} {/each}
@ -343,7 +268,7 @@
grid-area: 8/6 / span 5 / span 5; grid-area: 8/6 / span 5 / span 5;
z-index: var(--z-content); z-index: var(--z-content);
align-self: center; align-self: center;
font-size: var(--font-size-title-hero); font-size: var(--font-size-title-hero);
text-align: left; text-align: left;
color: var(--color-text); color: var(--color-text);
@ -358,14 +283,14 @@
align-items: flex-start; align-items: flex-start;
} }
/* Sliding container */ /* Text container */
.expertise-text { .expertise-text {
position: relative; position: relative;
width: 100%; width: 100%;
} }
.expertise-text, .expertise-item { .expertise-item {
transition: all 0.6s var(--ease-standard); transition: opacity 0.6s var(--ease-standard), transform 0.6s var(--ease-standard);
} }
/* Individual text items */ /* Individual text items */
@ -384,7 +309,6 @@
.expertise-item.active { .expertise-item.active {
opacity: 1; opacity: 1;
transform: scale(1) translateX(0); transform: scale(1) translateX(0);
margin-left: 0;
} }
/* Mobile (≤ 700px) */ /* Mobile (≤ 700px) */
@ -422,7 +346,6 @@
/* Reduced motion */ /* Reduced motion */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.expertise-text,
.expertise-item { .expertise-item {
transition: none; transition: none;
} }