// 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 ( <> > ); } // ───────────────────────────────────────────────────────────────────── // LiveCinema — the B design, parameterised by track + analyser // ───────────────────────────────────────────────────────────────────── function LiveCinema({ track, // { num, jp, en } current transitionState, // 'stable' | 'outro' | 'intro' transitionKey, // changes on each new intro so CSS animations replay bloomKey, // changes once per transition cycle (at outro start) trackElapsed, // current track elapsed seconds trackDur, // current track duration seconds analyser, // AnalyserNode | null }) { const ringRef = useRef(null); const t0Ref = useRef(performance.now() / 1000); useEffect(() => { const c = ringRef.current; if (!c) return; const ctx = c.getContext('2d'); let raf; const draw = () => { const W = c.width, H = c.height; ctx.clearRect(0, 0, W, H); const cx = W / 2, cy = H / 2; const r0 = Math.min(W, H) * 0.36; const N = 180; let data = null; if (analyser) { data = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(data); } ctx.lineCap = 'round'; for (let i = 0; i < N; i++) { const ang = (i / N) * Math.PI * 2 - Math.PI / 2; let a; if (data) { // mirror the spectrum so the ring is symmetrical const half = N / 2; const idx = Math.floor(((i < half ? i : N - 1 - i) / half) * (data.length * 0.7)); a = (data[idx] || 0) / 255; a = Math.pow(a, 0.7) * 1.05; } else { a = waveAmp(performance.now() / 1000 - t0Ref.current + i * 0.018, i); } const len = 8 + a * 56; const x1 = cx + Math.cos(ang) * r0; const y1 = cy + Math.sin(ang) * r0; const x2 = cx + Math.cos(ang) * (r0 + len); const y2 = cy + Math.sin(ang) * (r0 + len); ctx.strokeStyle = `rgba(245, 235, 215, ${0.3 + a * 0.6})`; ctx.lineWidth = 2.2; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } ctx.strokeStyle = 'rgba(245,235,215,0.22)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, r0 - 14, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, r0 + 70, 0, Math.PI * 2); ctx.stroke(); raf = requestAnimationFrame(draw); }; draw(); return () => cancelAnimationFrame(raf); }, [analyser]); // pulse for credit dot const [pulseT, setPulseT] = useState(0); useEffect(() => { let raf; const tick = () => { setPulseT(performance.now() / 1000); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); const trans = transitionState || 'stable'; const blockClass = `nb-hBlock nb-trans-${trans}`; return (