fix(expertise): refonte logique vidéo forward/reverse sans flash

Problèmes corrigés :
- Flash au changement de direction : l'ancien code attendait un événement
  seeked avant de switcher la visibilité, montrant un mauvais frame.
  Maintenant on positionne la vidéo cible AVANT de la rendre visible.
- Reprise forward depuis mauvaise position : stopActiveVideo() + playForward()
  synchronisent correctement currentFwdTime avant le switch.
- segmentEnds[0] = 0 → currentFwdTime snappé exactement à la cible dans
  les handlers timeupdate (plus de dérive à 0.1).

Changements :
- switchToForward/switchToReverse : positionnent la cible puis changent isReverse
- playForward/playReverse simplifiés : plus de logique seeked conditionnelle
- navigate() simplifié : forward=down, reverse=up, extension de cible si déjà actif
- CSS : opacity 0/1 + transition au lieu de display none/block pour éviter les flashes
- timeupdate : snap exact à la cible au lieu d'approximation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-03-20 14:29:41 +01:00
parent dd69e54746
commit ff5b0028f1

View file

@ -8,7 +8,6 @@
// --- Constants --- // --- Constants ---
const PLAY_DELAY_MS = 300 // ms delay before starting playback on slide activation 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)
@ -37,10 +36,12 @@
if (isActive) navigation.setScrolled(currentItem > 0) 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( const segmentEnds = $derived(
itemCount > 0 && videoDuration > 0 itemCount > 1 && videoDuration > 0
? Array.from({ length: itemCount }, (_, i) => ? Array.from({ length: itemCount }, (_, i) => videoDuration * i / (itemCount - 1))
itemCount === 1 ? videoDuration : videoDuration * i / (itemCount - 1)) : itemCount === 1 ? [0]
: [] : []
) )
@ -68,6 +69,8 @@
} }
// --- Video control helpers --- // --- Video control helpers ---
/** Stoppe la vidéo active et sauvegarde la position forward-équivalente. */
function stopActiveVideo() { function stopActiveVideo() {
if (videoFwd && !videoFwd.paused) { if (videoFwd && !videoFwd.paused) {
currentFwdTime = videoFwd.currentTime currentFwdTime = videoFwd.currentTime
@ -81,53 +84,65 @@
} }
} }
/**
* Switch visuel entre les deux vidéos.
* Positionne la vidéo cible au bon frame AVANT de l'afficher
* pour éliminer les flashes.
*/
function switchToForward() {
if (!isReverse) return
// Positionner forward au bon frame avant de l'afficher
if (videoFwd) videoFwd.currentTime = currentFwdTime
isReverse = false
}
function switchToReverse() {
if (isReverse) return
// Positionner reverse au bon frame avant de l'afficher
const revPos = videoDuration - currentFwdTime
if (videoRev) videoRev.currentTime = revPos
isReverse = true
}
function playForward(targetTime) { function playForward(targetTime) {
fwdTarget = targetTime fwdTarget = targetTime
if (videoFwd) videoFwd.currentTime = currentFwdTime if (videoFwd) videoFwd.currentTime = currentFwdTime
if (isReverse) { // Switcher visuellement, puis lancer la lecture
videoFwd?.addEventListener('seeked', () => { switchToForward()
isReverse = false
videoFwd?.play().catch(() => {}) videoFwd?.play().catch(() => {})
}, { once: true })
} else {
videoFwd?.play().catch(() => {})
}
} }
function playReverse(targetTime) { function playReverse(targetFwdPos) {
const revStart = Math.max(0, videoDuration - currentFwdTime) // targetFwdPos = la position forward-équivalente où on veut arriver
revTarget = targetTime // On la convertit en position dans la vidéo reverse
revTarget = videoDuration - Math.max(targetFwdPos, 0)
const revStart = videoDuration - currentFwdTime
if (videoRev) videoRev.currentTime = revStart if (videoRev) videoRev.currentTime = revStart
if (!isReverse) { // Switcher visuellement, puis lancer la lecture
videoRev?.addEventListener('seeked', () => { switchToReverse()
isReverse = true
videoRev?.play().catch(() => {}) videoRev?.play().catch(() => {})
}, { once: true })
} else {
videoRev?.play().catch(() => {})
}
} }
// --- Navigate: move one item up or down (called by composable) --- // --- Navigate: move one item up or down (called by composable) ---
function navigate(direction, newItem) { function navigate(direction, newItem) {
const prevItem = currentItem
currentItem = newItem currentItem = newItem
const targetPos = segmentEnds[currentItem]
if (direction === 'down' && videoFwd && !videoFwd.paused) { if (direction === 'down') {
fwdTarget = segmentEnds[currentItem] if (videoFwd && !videoFwd.paused) {
} else if (direction === 'up' && videoRev && !videoRev.paused) { // Forward déjà en cours : juste mettre à jour la cible
const liveFwdPos = videoDuration - videoRev.currentTime fwdTarget = targetPos
const segBoundary = segmentEnds[currentItem]
const targetFwd = liveFwdPos > segBoundary ? segBoundary : 0
revTarget = videoDuration - Math.max(targetFwd, REV_TARGET_MIN)
} else { } else {
stopActiveVideo() stopActiveVideo()
if (direction === 'down') { playForward(targetPos)
playForward(segmentEnds[currentItem]) }
} else { } else {
const segBoundary = segmentEnds[currentItem] if (videoRev && !videoRev.paused) {
const targetFwd = currentFwdTime > segBoundary ? segBoundary : 0 // Reverse déjà en cours : juste mettre à jour la cible
playReverse(videoDuration - Math.max(targetFwd, REV_TARGET_MIN)) revTarget = videoDuration - Math.max(targetPos, 0)
} else {
stopActiveVideo()
playReverse(targetPos)
} }
} }
@ -137,7 +152,7 @@
// --- Playback lifecycle --- // --- Playback lifecycle ---
function initPlayback() { function initPlayback() {
setTimeout(() => { setTimeout(() => {
const tryPlay = () => { const init = () => {
const dur = videoFwd?.duration const dur = videoFwd?.duration
if (!dur || itemCount === 0) return if (!dur || itemCount === 0) return
videoDuration = dur videoDuration = dur
@ -148,11 +163,11 @@
requestAnimationFrame(() => computeOffset()) requestAnimationFrame(() => computeOffset())
} }
if (videoFwd?.duration) { if (videoFwd?.duration) {
tryPlay() init()
} else { } else {
videoFwd?.addEventListener('loadedmetadata', () => { videoFwd?.addEventListener('loadedmetadata', () => {
videoDuration = videoFwd.duration videoDuration = videoFwd.duration
tryPlay() init()
}, { once: true }) }, { once: true })
} }
}, PLAY_DELAY_MS) }, PLAY_DELAY_MS)
@ -177,7 +192,8 @@
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) {
currentFwdTime = videoFwd.currentTime currentFwdTime = fwdTarget // snap exactement à la cible
videoFwd.currentTime = fwdTarget
videoFwd.pause() videoFwd.pause()
fwdTarget = null fwdTarget = null
} }
@ -187,7 +203,9 @@
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) {
currentFwdTime = videoDuration - videoRev.currentTime const fwdPos = videoDuration - revTarget
currentFwdTime = Math.max(fwdPos, 0) // snap exactement
videoRev.currentTime = revTarget
videoRev.pause() videoRev.pause()
revTarget = null revTarget = null
} }
@ -303,11 +321,12 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: none; opacity: 0;
transition: opacity 0.15s ease;
} }
.expertise-bg video.active { .expertise-bg video.active {
display: block; opacity: 1;
} }
/* Custom vertical lines */ /* Custom vertical lines */