// Wander — Active tour player with real audio support function PlayerScreen({ tourId, onBack, onComplete, dense }) { const { TOURS, stopsForTour } = window.WANDER_DATA; const tour = TOURS.find(t => t.id===tourId) || TOURS[0]; const STOPS = stopsForTour(tour.id); const [playing, setPlaying] = React.useState(false); const [stopIdx, setStopIdx] = React.useState(0); const stop = STOPS[stopIdx]; const [progress, setProgress] = React.useState(0); const [tab, setTab] = React.useState('story'); const audioRef = React.useRef(null); // ── Live location: drive auto-advance by physically arriving at a stop ─────── const ARRIVE_M = 45; // geofence radius const [geo, setGeo] = React.useState(() => (window.WanderGeo ? window.WanderGeo.get() : null)); const [nudge, setNudge] = React.useState(null); // {name, n} when you reach a stop const arrivedRef = React.useRef(0); // highest stop index already reached // Point the location service at THIS tour's route (sim walks it off-site). React.useEffect(() => { const g = window.WanderGeo; if (!g) return; const route = STOPS.filter(s => s.lat != null).map(s => ({ lat: s.lat, lng: s.lng })); g.ensure({ route, dwell: 8000, speed: 10, loop: false }); return g.subscribe(setGeo); }, [tour.id]); // Manual skips shouldn't let geofencing re-trigger an already-passed stop. React.useEffect(() => { if (stopIdx > arrivedRef.current) arrivedRef.current = stopIdx; }, [stopIdx]); // Geofence: when you walk within range of the next unvisited stop, advance to // it and start its narration automatically. React.useEffect(() => { if (!geo || !geo.coords) return; for (let j = arrivedRef.current + 1; j < STOPS.length; j++) { const s = STOPS[j]; if (s.lat == null) continue; if (geo.distance(geo.coords, { lat: s.lat, lng: s.lng }) <= ARRIVE_M) { arrivedRef.current = j; setStopIdx(j); setPlaying(true); setNudge({ name: s.name, n: j + 1 }); break; } } }, [geo]); // Auto-dismiss the arrival nudge. React.useEffect(() => { if (!nudge) return; const t = setTimeout(() => setNudge(null), 4200); return () => clearTimeout(t); }, [nudge]); const nextStop = STOPS[stopIdx + 1]; const distToNext = (geo && geo.coords && nextStop && nextStop.lat != null) ? geo.distance(geo.coords, { lat: nextStop.lat, lng: nextStop.lng }) : null; const distToCurrent = (geo && geo.coords && stop.lat != null) ? geo.distance(geo.coords, { lat: stop.lat, lng: stop.lng }) : null; const atCurrent = distToCurrent != null && distToCurrent <= ARRIVE_M; // Text-to-speech narration (used when no recorded audio file is attached) const synthRef = React.useRef(typeof window !== 'undefined' ? window.speechSynthesis : null); const utterRef = React.useRef(null); const spokenIdxRef = React.useRef(-1); const ttsActive = !tour.audio_url && !!synthRef.current && !!stop.narration; const speakCurrent = React.useCallback(() => { const synth = synthRef.current; if (!synth || !stop.narration) return; const text = stop.narration; const u = new SpeechSynthesisUtterance(text); u.rate = 0.95; u.pitch = 1.0; const voices = synth.getVoices ? synth.getVoices() : []; const pref = voices.find(v => /en[-_]GB/i.test(v.lang)) || voices.find(v => /^en/i.test(v.lang)); if (pref) u.voice = pref; u.onboundary = (e) => { if (e.charIndex != null) setProgress(Math.min(1, e.charIndex / text.length)); }; u.onend = () => { if (utterRef.current !== u) return; // ignore cancelled/superseded utterances setProgress(1); setPlaying(false); }; utterRef.current = u; spokenIdxRef.current = stopIdx; setProgress(0); synth.cancel(); synth.speak(u); }, [stop.narration, stopIdx]); // Drive narration playback via the Web Speech API React.useEffect(() => { if (!ttsActive) return; const synth = synthRef.current; if (playing) { if (synth.paused && spokenIdxRef.current === stopIdx) synth.resume(); else speakCurrent(); } else if (synth.speaking) { synth.pause(); } }, [playing, stopIdx, ttsActive, speakCurrent]); // Stop any narration when leaving the player React.useEffect(() => () => { if (synthRef.current) synthRef.current.cancel(); }, []); // Reset the progress bar whenever the stop changes React.useEffect(() => { setProgress(0); }, [stopIdx]); // Keep-alive: Chrome silently cuts off long utterances after ~15s; nudge it. React.useEffect(() => { if (!ttsActive || !playing) return; const id = setInterval(() => { const synth = synthRef.current; if (synth && synth.speaking && !synth.paused) { synth.pause(); synth.resume(); } }, 10000); return () => clearInterval(id); }, [ttsActive, playing]); // Simulated progress only when there's neither a recorded file nor speech React.useEffect(() => { if (tour.audio_url || ttsActive) return; if (!playing) return; const id = setInterval(() => setProgress(p => p>=1 ? 0 : Math.min(1, p+0.004)), 100); return () => clearInterval(id); }, [playing, tour.audio_url, ttsActive]); // Sync real audio React.useEffect(() => { const el = audioRef.current; if (!el) return; if (playing) el.play().catch(()=>{}); else el.pause(); }, [playing]); const handleAudioTimeUpdate = (e) => { const el = e.target; if (el.duration) setProgress(el.currentTime / el.duration); }; return (
{stop.narration}
)}