world-game/src/views/Expertise.svelte

405 lines
11 KiB
Svelte
Raw Normal View History

<script>
import { onMount } from 'svelte'
import { slides } from '@state/slides.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)
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="/assets/video/BACKGROUND_VIDEO_MISSION.mp4" type="video/mp4" />
</video>
<video
bind:this={videoRev}
class:active={isReverse}
muted
playsinline
preload="auto"
>
<source src="/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);
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);
}
}
/* 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>