// Production player for Night Breeze. // Two components: // • BgVideoStack — two layered video elements that crossfade between // hoshi01/02/03 by weight, sprinkling hoshi05 every ~60s. // • LiveCinema — the B design parameterised by track data. Drives the // circular EQ ring from a real AnalyserNode when supplied; otherwise // falls back to the synthetic waveAmp() from designs.jsx. const { useEffect, useRef, useState, useCallback } = React; // ───────────────────────────────────────────────────────────────────── // Background video stack // ───────────────────────────────────────────────────────────────────── const BG_VARIANTS = [ { src: 'assets/bg/hoshi01.mp4', weight: 5 }, { src: 'assets/bg/hoshi02.mp4', weight: 3 }, { src: 'assets/bg/hoshi03.mp4', weight: 2 }, ]; const BG_SPECIAL = 'assets/bg/hoshi05.mp4'; const BG_SPECIAL_INTERVAL_SEC = 60; const TOTAL_BG_WEIGHT = BG_VARIANTS.reduce((s, v) => s + v.weight, 0); function pickWeighted(excludeSrc) { for (let tries = 0; tries < 8; tries++) { let r = Math.random() * TOTAL_BG_WEIGHT; for (const v of BG_VARIANTS) { r -= v.weight; if (r <= 0) { if (v.src !== excludeSrc) return v.src; break; } } } return BG_VARIANTS[0].src; } function BgVideoStack({ filter = 'contrast(1.08) saturate(0.92) brightness(0.74)' }) { const aRef = useRef(null); const bRef = useRef(null); const state = useRef({ front: 'a', startedAt: performance.now() / 1000, lastSpecialAt: 0, currentSrc: null, }); const nextVariant = useCallback(() => { const s = state.current; const elapsed = performance.now() / 1000 - s.startedAt; if (elapsed - s.lastSpecialAt > BG_SPECIAL_INTERVAL_SEC) { s.lastSpecialAt = elapsed; return BG_SPECIAL; } return pickWeighted(s.currentSrc); }, []); useEffect(() => { const a = aRef.current, b = bRef.current; if (!a || !b) return; a.muted = true; b.muted = true; a.playsInline = true; b.playsInline = true; const v1 = pickWeighted(); const v2 = pickWeighted(v1); a.src = v1; b.src = v2; state.current.currentSrc = v1; a.load(); b.load(); a.style.opacity = '1'; b.style.opacity = '0'; a.play().catch(() => {}); // Hard-cut handoff: when the current clip ends, instantly show the // other element (which has been preloaded + paused at frame 0), then // queue the next variant into the now-hidden element. const onEnd = (which) => () => { const s = state.current; const ending = which === 'a' ? a : b; const starting = which === 'a' ? b : a; try { starting.currentTime = 0; } catch (e) {} const p = starting.play(); if (p && p.catch) p.catch(() => {}); starting.style.opacity = '1'; ending.style.opacity = '0'; s.front = which === 'a' ? 'b' : 'a'; s.currentSrc = starting.src; // Prep the ending element with the next-next variant setTimeout(() => { try { ending.pause(); const fresh = nextVariant(); ending.src = fresh; ending.load(); } catch (e) {} }, 60); }; const onEndA = onEnd('a'); const onEndB = onEnd('b'); a.addEventListener('ended', onEndA); b.addEventListener('ended', onEndB); return () => { a.removeEventListener('ended', onEndA); b.removeEventListener('ended', onEndB); }; }, [nextVariant]); const base = { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', filter, }; return ( <>