/* global React */ const { useState: useStateMP, useEffect: useEffectMP, useRef: useRefMP } = React; // Audio source: in the standalone bundle window.__resources.trackAudio is an // inlined blob URL; otherwise fall back to the on-disk file. The bundle runtime // populates window.__resources asynchronously, so resolve lazily at play time. function resolveAudioSrc() { if (typeof window !== 'undefined' && window.__resources && window.__resources.trackAudio) { return window.__resources.trackAudio; } return 'music/track-loop.mp3'; // dev / on-disk fallback } function MusicPlayer() { const [muted, setMuted] = useStateMP(true); const [playing, setPlaying] = useStateMP(false); const [pulse, setPulse] = useStateMP(true); // gentle pulse until the user interacts const [hint, setHint] = useStateMP(true); // one-time "tap for music" invitation const audioRef = useRefMP(null); const startedRef = useRefMP(false); // Browsers will NOT *start* unmuted audio before a real user gesture, and — // crucially — scroll & wheel do NOT count as audio-activation gestures, so a // cold play() fired from a scroll handler gets rejected (that's why before it // only worked when you actually clicked the hero). // // The reliable fix: start the track MUTED right away. Muted autoplay is // allowed on every browser (desktop + iOS/Android), so the element is really // playing/looping silently. Once it is already playing we can flip it audible // by just clearing `muted` — and unmuting an already-playing element does NOT // require a fresh activation gesture, so it works on scroll/wheel too. On the // rare mobile browser that blocks even muted autoplay, the element is still // paused, so the first genuine tap calls play() inside that gesture instead. useEffectMP(() => { const audio = audioRef.current; if (!audio) return; audio.volume = 0.45; audio.loop = true; audio.muted = true; audio.setAttribute('playsinline', ''); // Attach a source up-front so the element can begin buffering immediately. if (!audio.src || audio.src === window.location.href) { audio.src = resolveAudioSrc(); audio.load(); } const syncPlay = () => setPlaying(true); const syncPause = () => setPlaying(false); audio.addEventListener('play', syncPlay); audio.addEventListener('pause', syncPause); // (1) Kick off muted playback now. Retry on canplay in case the source // wasn't ready on the first attempt. function primeMuted() { const a = audioRef.current; if (!a) return; a.muted = true; const p = a.play(); if (p && p.catch) p.catch(() => {}); } primeMuted(); audio.addEventListener('canplay', primeMuted, { once: true }); // (2) Turn the sound ON. If muted-autoplay already has us playing we simply // unmute (works from scroll/wheel). If still paused, call play() — this // path only runs from a real gesture handler, so audible play is allowed. function start() { const a = audioRef.current; if (!a) return; if (!a.src || a.src === window.location.href) { a.src = resolveAudioSrc(); a.load(); } a.muted = false; a.volume = 0.45; if (a.paused) { const p = a.play(); if (p && p.catch) p.catch(() => { // last-ditch retry, still in-gesture try { a.load(); a.play().catch(() => {}); } catch (e) {} }); } // NB: we do NOT dismiss the gate here. The UI only flips to "on" once we // CONFIRM real audible playback (see confirmAudible) — so a desktop scroll // that the browser quietly refuses keeps the prompt visible instead of // hiding it and leaving the guest in silence. } // expose so the gate (and anyone else) can trigger playback window.__playMusic = start; // Single source of truth for "the music is actually audible now." Fires // from real playback (timeupdate only advances when not paused). Once the // element is unmuted AND playing, we light up the UI, drop the gate, and // stop listening for gestures. function confirmAudible() { const a = audioRef.current; if (!a || a.muted || a.paused) return; setMuted(false); setPulse(false); setHint(false); if (!startedRef.current) { startedRef.current = true; window.dispatchEvent(new Event('wedding-music-started')); } detach(); a.removeEventListener('timeupdate', confirmAudible); } audio.addEventListener('timeupdate', confirmAudible); // Catch interaction anywhere on the page. We listen broadly — taps, clicks, // keys, and scroll/wheel all attempt to light up the sound. Listeners stay // attached (start is idempotent) until confirmAudible tears them down, so a // gesture that fails to start audio doesn't burn our only chance. const gestureEvents = ['pointerdown', 'pointerup', 'mousedown', 'touchstart', 'touchend', 'click', 'keydown', 'scroll', 'wheel']; // Events the browser treats as a real activation gesture: these are // GUARANTEED to grant audible playback, so we drop the gate immediately // Events the browser treats as a real activation gesture: these are // GUARANTEED to grant audible playback, so we drop the gate immediately // (waiting on buffering would make the prompt feel dead and invite repeat // taps). We deliberately EXCLUDE pointerdown/touchstart: those also fire at // the START of a swipe-scroll, and a swipe is not a tap — dismissing then // would put the original mobile bug right back. scroll & wheel are likewise // not guaranteed; for those the gate stays until confirmAudible verifies it. const activationEvents = ['pointerup', 'mousedown', 'touchend', 'click', 'keydown']; function onFirstGesture(e) { start(); if (e && activationEvents.indexOf(e.type) !== -1) { setMuted(false); setPulse(false); setHint(false); } } function attach() { gestureEvents.forEach(ev => { window.addEventListener(ev, onFirstGesture, { capture: true, passive: true }); document.addEventListener(ev, onFirstGesture, { capture: true, passive: true }); // the scroller is a nested element — its scroll doesn't bubble to window }); const scroller = document.querySelector('.snap-scroll'); if (scroller) scroller.addEventListener('scroll', onFirstGesture, { capture: true, passive: true }); } function detach() { gestureEvents.forEach(ev => { window.removeEventListener(ev, onFirstGesture, { capture: true }); document.removeEventListener(ev, onFirstGesture, { capture: true }); }); const scroller = document.querySelector('.snap-scroll'); if (scroller) scroller.removeEventListener('scroll', onFirstGesture, { capture: true }); } attach(); // .snap-scroll can mount a tick after us — re-bind its scroll hook shortly. const scrollerHook = setTimeout(attach, 0); return () => { clearTimeout(scrollerHook); audio.removeEventListener('play', syncPlay); audio.removeEventListener('pause', syncPause); audio.removeEventListener('canplay', primeMuted); audio.removeEventListener('timeupdate', confirmAudible); detach(); if (window.__playMusic === start) delete window.__playMusic; }; }, []); // The visible pill: play / pause toggle once things are running. function toggle() { const audio = audioRef.current; if (!audio) return; setPulse(false); setHint(false); if (audio.paused || audio.muted) { window.__playMusic && window.__playMusic(); } else { audio.pause(); setPlaying(false); } } const isOn = playing && !muted; return ( <> {hint && (
Tap anywhere to play music
)}
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } }}>
); } window.MusicPlayer = MusicPlayer;