world-game/src/views/Expertise.svelte
isUnknown a0798e71d0 Feat: navbar frosted glass au scroll
- navigation.svelte.js : ajout isScrolled + setScrolled()
- Header : scroll listener (capture) sur .page-scrollable > 100px,
  reset au changement de slide, classe navbar--scrolled conditionnelle,
  transition 0.4s sur background-color et backdrop-filter
- Expertise : $effect notifie quand currentItem > 0
- Portfolio : $effect notifie quand currentIndex > 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:05:25 +01:00

411 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script>
import { onMount } from 'svelte'
import { slides } from '@state/slides.svelte'
import { navigation } from '@state/navigation.svelte'
import { createScrollNav } from '@composables/useScrollNav.svelte.js'
let { data } = $props()
// --- Constants ---
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 isReverse = $state(false)
let offsetY = $state(0)
let videoDuration = $state(0)
// --- DOM refs ---
let videoFwd = $state(null)
let videoRev = $state(null)
let textContainer = $state(null)
let itemEls = $state([])
let sectionEl = $state(null)
// --- Plain variables (not reactive, used in event listeners) ---
let fwdTarget = null
let revTarget = null
let currentFwdTime = 0
// --- Derived ---
const isActive = $derived(slides.active?.id === 'expertise')
const items = $derived(data?.items ?? [])
const itemCount = $derived(items.length)
$effect(() => {
if (isActive) navigation.setScrolled(currentItem > 0)
})
const segmentEnds = $derived(
itemCount > 0 && videoDuration > 0
? Array.from({ length: itemCount }, (_, i) => videoDuration * (i + 1) / itemCount)
: []
)
// --- Scroll nav composable ---
const nav = createScrollNav({
isActive: () => isActive,
onNavigate: (dir) => {
if (segmentEnds.length === 0) return false
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 computeOffset() {
if (!textContainer || !itemEls[currentItem]) return
const wh = window.innerHeight
const wrapperRect = textContainer.parentElement.getBoundingClientRect()
const el = itemEls[currentItem]
offsetY = wh / 2 - wrapperRect.top - el.offsetTop - el.offsetHeight / 2
}
// --- Video control helpers ---
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
}
}
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(() => {})
}
}
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 (called by composable) ---
function navigate(direction, newItem) {
const prevItem = currentItem
currentItem = newItem
if (direction === 'down' && videoFwd && !videoFwd.paused) {
fwdTarget = segmentEnds[currentItem]
} else if (direction === 'up' && videoRev && !videoRev.paused) {
const liveFwdPos = videoDuration - videoRev.currentTime
const segBoundary = segmentEnds[currentItem]
const targetFwd = liveFwdPos > segBoundary ? segBoundary : 0
revTarget = videoDuration - Math.max(targetFwd, REV_TARGET_MIN)
} else {
stopActiveVideo()
if (direction === 'down') {
playForward(segmentEnds[currentItem])
} else {
const segBoundary = segmentEnds[currentItem]
const targetFwd = currentFwdTime > segBoundary ? segBoundary : 0
playReverse(videoDuration - Math.max(targetFwd, REV_TARGET_MIN))
}
}
requestAnimationFrame(() => { if (isActive) computeOffset() })
}
// --- 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
videoFwd.play().catch(() => {})
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
nav.reset()
}
// --- onMount ---
onMount(() => {
sectionEl?.addEventListener('wheel', nav.onWheel, { passive: false })
const onFwdUpdate = () => {
if (!videoFwd || videoFwd.paused || fwdTarget === null) return
if (videoFwd.currentTime >= fwdTarget) {
currentFwdTime = videoFwd.currentTime
videoFwd.pause()
fwdTarget = null
}
}
videoFwd?.addEventListener('timeupdate', onFwdUpdate)
const onRevUpdate = () => {
if (!videoRev || videoRev.paused || revTarget === null) return
if (videoRev.currentTime >= revTarget) {
currentFwdTime = videoDuration - videoRev.currentTime
videoRev.pause()
revTarget = null
}
}
videoRev?.addEventListener('timeupdate', onRevUpdate)
const onResize = () => { if (isActive) computeOffset() }
window.addEventListener('resize', onResize)
window.addEventListener('orientationchange', onResize)
window.addEventListener('keydown', nav.onKeyDown)
return () => {
sectionEl?.removeEventListener('wheel', nav.onWheel)
videoFwd?.removeEventListener('timeupdate', onFwdUpdate)
videoRev?.removeEventListener('timeupdate', onRevUpdate)
window.removeEventListener('resize', onResize)
window.removeEventListener('orientationchange', onResize)
window.removeEventListener('keydown', nav.onKeyDown)
nav.destroy()
}
})
// --- Effect: react to slide activation / deactivation ---
$effect(() => {
if (isActive) {
initPlayback()
} else {
resetPlayback()
}
})
</script>
<section
class="expertise golden-grid"
aria-label="Expertise"
bind:this={sectionEl}
ontouchstart={nav.onTouchStart}
ontouchend={nav.onTouchEnd}
>
<!-- Video background (decorative) -->
<div class="expertise-bg" aria-hidden="true">
<video
bind:this={videoFwd}
class:active={!isReverse}
muted
playsinline
preload="auto"
>
<source src={data.backgroundVideo ?? '/assets/video/BACKGROUND_VIDEO_MISSION.mp4'} type="video/mp4" />
</video>
<video
bind:this={videoRev}
class:active={isReverse}
muted
playsinline
preload="auto"
>
<source src={data.backgroundVideoReverse ?? '/assets/video/BACKGROUND_VIDEO_MISSION_REVERSE.mp4'} type="video/mp4" />
</video>
</div>
<!-- Decorative vertical lines -->
<div class="vertical-line-start" aria-hidden="true"></div>
<div class="vertical-line vertical-line-col8" aria-hidden="true"></div>
<div class="vertical-line-center" aria-hidden="true"></div>
<div class="vertical-line vertical-line-col14" aria-hidden="true"></div>
<div class="vertical-line-end" aria-hidden="true"></div>
<!-- Title -->
<h2 class="expertise-title font-face-terminal">
{@html data?.pageTitle ?? ''}
</h2>
<!-- Sliding text container -->
<div class="expertise-text-wrapper" aria-live="polite" aria-atomic="true">
<div
class="expertise-text"
bind:this={textContainer}
style="transform: translateY({offsetY}px)"
>
{#each items as item, i}
<div
class="expertise-item"
class:active={i === currentItem}
bind:this={itemEls[i]}
>
{@html item.text}
</div>
{/each}
</div>
</div>
</section>
<style>
.expertise {
background-color: #000;
}
:global(.expertise-item p) {
margin-bottom: 1rem;
}
/* Full-grid video background */
.expertise-bg {
grid-area: 1/1 / span 20 / span 20;
position: relative;
overflow: hidden;
}
.expertise-bg video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: none;
}
.expertise-bg video.active {
display: block;
}
/* Custom vertical lines */
.vertical-line-col8 {
grid-area: 1/8 / span 20 / span 1;
}
.vertical-line-col14 {
grid-area: 1/14 / span 20 / span 1;
}
/* Title */
.expertise-title {
grid-area: 8/6 / span 5 / span 5;
z-index: var(--z-content);
text-align: left;
font-size: var(--font-size-title-hero);
color: var(--color-text);
align-self: center;
line-height: 1;
}
/* Wrapper clips the sliding container */
.expertise-text-wrapper {
grid-area: 8/11 / span 7 / span 6;
z-index: var(--z-content);
display: flex;
align-items: flex-start;
}
/* Sliding container */
.expertise-text {
position: relative;
width: 100%;
}
.expertise-text, .expertise-item {
transition: all 0.6s var(--ease-standard);
}
/* Individual text items */
.expertise-item {
font-size: var(--font-size-expertise);
font-weight: 350;
color: var(--color-text);
text-align: left;
line-height: 1.4;
margin-bottom: 50px;
opacity: 0.3;
transform: scale(0.75) translateX(4rem);
transform-origin: left center;
}
.expertise-item.active {
opacity: 1;
transform: scale(1) translateX(0);
margin-left: 0;
}
/* Mobile (≤ 700px) */
@media (max-width: 700px) {
.expertise-title {
grid-area: 5/4 / span 1 / span 7;
font-size: var(--font-size-title-main-mobile);
}
.expertise-text-wrapper {
grid-area: 6/4 / span 8 / span 14;
}
.expertise-item {
font-size: var(--font-size-expertise-mobile);
transform: scale(0.75) translateX(2rem);
}
}
/* Tablet (701912px) */
@media (min-width: 701px) and (max-width: 912px) {
.expertise-title {
grid-area: 5/6 / span 4 / span 12;
font-size: var(--font-size-title-main-tablet);
}
.expertise-text-wrapper {
grid-area: 8/6 / span 8 / span 10;
}
.expertise-item {
font-size: var(--font-size-expertise-tablet);
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.expertise-text,
.expertise-item {
transition: none;
}
}
</style>