From 620ee646d232deeb4f477b0b199a66a65b0ffe83 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 29 Sep 2026 17:16:28 +0700 Subject: [PATCH] =?UTF-8?q?feat(routing):=20phone=20navigation=20=E2=80=94?= =?UTF-8?q?=20voice=20turns,=20wake=20lock,=20arrival,=20bigger=20follow?= =?UTF-8?q?=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow mode was a live dot on a north-up map you had to keep watching. Now it guides: - Voice: device TTS via speechSynthesis (free, offline, no server, no permission), preferring an on-device voice in the user's language. Speaks at ~150 m and ~40 m before each turn (once per band, per step) and on arrival. Unlocked by the Start tap; mute toggle persisted in skatespots:route-voice. - Wake lock: navigator.wakeLock keeps the screen on while following, released on stop/unmount and re-acquired on visibilitychange. - Arrival: within ~30 m -> "You've arrived", haptic, auto-stop, Done button. - Haptics: navigator.vibrate on the 150 m / 40 m bands and arrival. - Layout: while following the map grows to 58vh (60vh sm) and the planning step-list steps out of the way; the NEXT banner is bigger. - Copy updated: no longer claims 'no voice guidance'. Still absent: auto-reroute if you leave the route. True screen-off navigation needs a native foreground service (app must stay foregrounded). --- AGENTS.md | 15 ++- src/components/RouteDialog.tsx | 222 ++++++++++++++++++++++++++++++--- 2 files changed, 215 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5834087..78cc05e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,10 +284,21 @@ session with **no other memory of this project**. So: transient watch error (timeout/temporarily unavailable) must **not** tear down the watcher — only `denied` does (headless CDP testing made this obvious: moving the mocked position emits a transient error that recovered). - Still deliberately absent: voice guidance and re-routing if you leave the route. + **Phone navigation (2026-09-29):** while following we also keep the screen + awake (`navigator.wakeLock`, re-acquired on `visibilitychange`), speak turns via + **device TTS** (`speechSynthesis`, prefer a `localService` voice in the user's + language — free/offline/no server) at ~150 m and ~40 m bands (announced once + each, tracked in `announcedRef`), buzz haptics (`navigator.vibrate`), detect + **arrival** (~30 m → "You've arrived", auto-stop), and show a **bigger map** + while following with the planning step-list hidden. Voice has a mute toggle + persisted in `skatespots:route-voice`; TTS is unlocked by the Start tap. + **Still deliberately absent: auto-reroute if you leave the route.** True + screen-off/background navigation needs a native foreground service — the app + must stay foregrounded to guide. - **Dialog layout (2026-09-27):** app-like flex column — `flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden`: header, mode row, - "From" row and the **map** are `shrink-0` (map `h-[38vh] sm:h-[400px]`), the + "From" row and the **map** are `shrink-0` (map `h-[38vh] sm:h-[400px]`, or + `h-[58vh]`/`sm:h-[60vh]` while following), the middle (stat tiles + steps) is the only scroll region (`min-h-0 flex-1 overflow-y-auto`), and the **Start/Stop bar is pinned** at the bottom so it's always reachable. ⚠️ Don't go back to a single `overflow-hidden` content box: diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index 027d1cf..8759251 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import L from 'leaflet'; -import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed, Play, Square, Crosshair } from 'lucide-react'; +import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed, Play, Square, Crosshair, Volume2, VolumeX, Check } from 'lucide-react'; import { Dialog, DialogContent, @@ -174,6 +174,20 @@ export function RouteDialog({ const [followError, setFollowError] = useState(null); const watchRef = useRef(null); + // ── Voice guidance, wake lock, arrival (phone navigation) ────────────────── + const [voiceOn, setVoiceOn] = useState(() => { + try { + return localStorage.getItem('skatespots:route-voice') !== 'off'; + } catch { + return true; + } + }); + const [arrived, setArrived] = useState(false); + /** stepIndex → set of announced distance bands (150 m / 40 m). */ + const announcedRef = useRef>>(new Map()); + const arrivedRef = useRef(false); + const wakeLockRef = useRef<{ release: () => Promise } | null>(null); + /** Route shown while offline came from the cache (with its age). */ const [cachedAge, setCachedAge] = useState(null); @@ -205,24 +219,113 @@ export function RouteDialog({ map.fitBounds(L.latLngBounds(route.coordinates), { padding: [48, 48], maxZoom: 16 }); }, [route]); - // A new route (mode/origin change) invalidates the tapped-step focus. + // A new route (mode/origin change) invalidates the tapped-step focus and the + // once-only voice/haptic announcements. useEffect(() => { setActiveStep(null); + announcedRef.current.clear(); + arrivedRef.current = false; + setArrived(false); }, [route]); + // ── Voice guidance (device TTS — free, offline, no server, no permission) ─── + const speak = useCallback( + (text: string) => { + if (!voiceOn) return; + if (typeof window === 'undefined' || !('speechSynthesis' in window)) return; + try { + const synth = window.speechSynthesis; + const utter = new SpeechSynthesisUtterance(text); + utter.rate = 1; + const lang = (navigator.language || 'en').slice(0, 2).toLowerCase(); + const voices = synth.getVoices(); + // Prefer an on-device voice in the user's language (offline, no network). + const voice = + voices.find((v) => v.localService && v.lang.toLowerCase().startsWith(lang)) ?? + voices.find((v) => v.lang.toLowerCase().startsWith(lang)); + if (voice) { + utter.voice = voice; + utter.lang = voice.lang; + } else { + utter.lang = lang; + } + synth.cancel(); // never let prompts queue up and lag behind you + synth.speak(utter); + } catch { + // No TTS — haptics still guide. + } + }, + [voiceOn], + ); + + const toggleVoice = useCallback(() => { + setVoiceOn((on) => { + const next = !on; + try { + localStorage.setItem('skatespots:route-voice', next ? 'on' : 'off'); + } catch { + // ignore + } + if (!next && typeof window !== 'undefined' && 'speechSynthesis' in window) { + window.speechSynthesis.cancel(); + } + return next; + }); + }, []); + + /** Short haptic pulse (Android). Silent no-op where unsupported. */ + const buzz = useCallback((pattern: number | number[]) => { + try { + if (typeof navigator.vibrate === 'function') navigator.vibrate(pattern); + } catch { + // ignore + } + }, []); + + // ── Wake lock: keep the screen on while navigating ────────────────────────── + const acquireWakeLock = useCallback(async () => { + const navWithWakeLock = navigator as Navigator & { + wakeLock?: { request: (type: 'screen') => Promise<{ release: () => Promise }> }; + }; + try { + const sentinel = await navWithWakeLock.wakeLock?.request('screen'); + if (sentinel) wakeLockRef.current = sentinel; + } catch { + // Unsupported / denied — navigation still works, the screen may sleep. + } + }, []); + + const releaseWakeLock = useCallback(() => { + try { + void wakeLockRef.current?.release(); + } catch { + // ignore + } + wakeLockRef.current = null; + }, []); + // ── Follow mode ──────────────────────────────────────────────────────────── - // Explicit only: the user taps Start, we watch their position, and we stop the - // watcher the moment they stop or the dialog closes. No voice guidance. + // Explicit only: the user taps Start, we watch their position, we keep the + // screen awake, and we stop the watcher the moment they stop or the dialog + // closes. Voice + haptics guide; voice is the point — you can't watch a + // screen while skating. const stopFollowing = useCallback(() => { watchRef.current?.stop(); watchRef.current = null; setFollowing(false); - }, []); + releaseWakeLock(); + }, [releaseWakeLock]); const startFollowing = useCallback(() => { if (watchRef.current) return; setFollowError(null); + setArrived(false); + arrivedRef.current = false; + announcedRef.current.clear(); setFollowing(true); + void acquireWakeLock(); + // Spoken from the Start tap (a user gesture) — this also unlocks TTS. + speak('Starting navigation'); watchRef.current = watchPosition( (coords) => { setLive(coords); @@ -240,7 +343,7 @@ export function RouteDialog({ } }, ); - }, [stopFollowing]); + }, [stopFollowing, acquireWakeLock, speak]); // Never leave the GPS running: stop on close and on unmount. useEffect(() => { @@ -250,10 +353,22 @@ export function RouteDialog({ () => () => { watchRef.current?.stop(); watchRef.current = null; + releaseWakeLock(); }, - [], + [releaseWakeLock], ); + // Browsers drop the wake lock whenever the page is hidden. Re-acquire it when + // the user comes back to a navigation that is still running. + useEffect(() => { + if (!following) return; + const onVisibility = () => { + if (document.visibilityState === 'visible') void acquireWakeLock(); + }; + document.addEventListener('visibilitychange', onVisibility); + return () => document.removeEventListener('visibilitychange', onVisibility); + }, [following, acquireWakeLock]); + // While following, keep the live dot centred. useEffect(() => { if (!following || !live) return; @@ -279,6 +394,45 @@ export function RouteDialog({ const currentStepIndex = following && nav && nav.stepIndex >= 0 ? nav.stepIndex : activeStep; + // ── Voice + haptics + arrival while following ─────────────────────────────── + useEffect(() => { + if (!following || !nav || !route) return; + + // Arrival: within ~30 m of the destination. + if (nav.toDestKm < 0.03) { + if (!arrivedRef.current) { + arrivedRef.current = true; + speak("You've arrived"); + buzz([120, 80, 120]); + setArrived(true); + stopFollowing(); + } + return; + } + + const idx = nav.stepIndex; + const km = nav.stepKm; + if (idx < 0 || !Number.isFinite(km)) return; + + const steps = route.steps ?? []; + const bands = announcedRef.current.get(idx) ?? new Set(); + + if (km <= 0.15 && km > 0.04 && !bands.has(150)) { + // ~150 m out — a heads-up so you know what's coming. + bands.add(150); + announcedRef.current.set(idx, bands); + const metres = Math.max(50, Math.round((km * 1000) / 10) * 10); + speak(`In ${metres} metres, ${steps[idx]?.instruction ?? 'continue'}`); + buzz(60); + } else if (km <= 0.04 && !bands.has(40)) { + // ~40 m out — the turn itself. + bands.add(40); + announcedRef.current.set(idx, bands); + speak(steps[idx]?.instruction ?? 'Continue'); + buzz(140); + } + }, [following, nav, route, speak, buzz, stopFollowing]); + // ─── Get the user's location when the dialog opens ───────────────────────── const locate = useCallback(async () => { setLocating(true); @@ -589,7 +743,7 @@ export function RouteDialog({ `.leaflet-container { height: 100% }` override beats a `h-64` on the map element itself, and against a height-less parent that resolved to 0 — a fully invisible map with no route line. */} -
+
-

+

Next{nav.stepIndex >= 0 ? ` · step ${nav.stepIndex + 1}` : ''}

-

+

{nav.stepIndex >= 0 ? route?.steps?.[nav.stepIndex]?.instruction ?? 'Follow the line' : route?.steps?.[0]?.instruction ?? 'Follow the line'}

-

+

{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm) ? `${formatDistance(nav.stepKm)} to the turn · ` : ''} @@ -768,8 +922,9 @@ export function RouteDialog({ )}

- {/* Turn-by-turn orientation — a static list. Honestly NOT live navigation. */} - {mode !== 'transit' && route?.steps && route.steps.length > 1 && ( + {/* Turn-by-turn orientation — the planning list. While following, the map + and the live NEXT banner take over (so the list steps out of the way). */} + {!following && mode !== 'transit' && route?.steps && route.steps.length > 1 && (

@@ -814,17 +969,26 @@ export function RouteDialog({ ))}

- Orientation only — no live turn-by-turn or voice guidance yet. + Planning view — tap Start to follow with live turns, voice and arrival.

)}
{/* /scrollable details */} - {/* Follow mode: explicit, and honest that there's no voice guidance. */} + {/* Follow mode: live turns + voice, with the screen kept awake. */} {route && !route.transit && (
- {following ? ( + {arrived ? ( + + ) : following ? (
)}