feat(routing): phone navigation — voice turns, wake lock, arrival, bigger follow map

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).
This commit is contained in:
SkateSpots 2026-09-29 17:16:28 +07:00
parent 93f195ecf3
commit 620ee646d2
2 changed files with 215 additions and 22 deletions

View file

@ -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:

View file

@ -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<string | null>(null);
const watchRef = useRef<GeoWatch | null>(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<Map<number, Set<number>>>(new Map());
const arrivedRef = useRef(false);
const wakeLockRef = useRef<{ release: () => Promise<void> } | null>(null);
/** Route shown while offline came from the cache (with its age). */
const [cachedAge, setCachedAge] = useState<number | null>(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<void> }> };
};
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<number>();
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. */}
<div className="relative h-[38vh] min-h-[220px] shrink-0 sm:h-[400px]">
<div className={cn('relative shrink-0', following ? 'h-[58vh] min-h-[340px] sm:h-[60vh]' : 'h-[38vh] min-h-[220px] sm:h-[400px]')}>
<div
ref={setMapDiv}
className="h-full w-full bg-muted"
@ -636,15 +790,15 @@ export function RouteDialog({
{/* Follow mode: next turn + distance left */}
{following && nav && (
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/85 px-3 py-2 backdrop-blur-sm">
<p className="text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
<p className="text-[11px] font-black uppercase tracking-widest text-[#ccff00]">
Next{nav.stepIndex >= 0 ? ` · step ${nav.stepIndex + 1}` : ''}
</p>
<p className="truncate text-sm font-bold text-white">
<p className="truncate text-base font-black text-white">
{nav.stepIndex >= 0
? route?.steps?.[nav.stepIndex]?.instruction ?? 'Follow the line'
: route?.steps?.[0]?.instruction ?? 'Follow the line'}
</p>
<p className="text-[11px] text-gray-400">
<p className="text-xs text-gray-400">
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
? `${formatDistance(nav.stepKm)} to the turn · `
: ''}
@ -768,8 +922,9 @@ export function RouteDialog({
)}
</div>
{/* 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 && (
<div className="border-t bg-muted/20">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 px-4 pt-3">
<p className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
@ -814,17 +969,26 @@ export function RouteDialog({
))}
</ol>
<p className="px-4 pb-3 text-[10px] text-muted-foreground/60">
Orientation only — no live turn-by-turn or voice guidance yet.
Planning view — tap Start to follow with live turns, voice and arrival.
</p>
</div>
)}
</div>{/* /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 && (
<div className="flex items-center gap-3 border-t bg-muted/30 px-4 py-3">
{following ? (
{arrived ? (
<Button
size="sm"
onClick={() => onOpenChange(false)}
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<Check className="h-3.5 w-3.5" />
Done
</Button>
) : following ? (
<Button
size="sm"
variant="outline"
@ -844,11 +1008,29 @@ export function RouteDialog({
Start
</Button>
)}
<p className="min-w-0 flex-1 text-[10px] leading-snug text-muted-foreground">
{following
? 'Following your position and the next turn. No voice guidance.'
: 'Follows your position and shows the next turn as you roll.'}
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
{arrived
? "You've arrived. 🛹"
: following
? voiceOn
? 'Screen stays on · voice on — turns and arrival are spoken.'
: 'Screen stays on · voice off.'
: 'Turns, voice and arrival as you roll. Keeps the screen on.'}
</p>
<button
type="button"
onClick={toggleVoice}
aria-pressed={voiceOn}
aria-label={voiceOn ? 'Turn voice guidance off' : 'Turn voice guidance on'}
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-full border transition-colors',
voiceOn
? 'border-[#ccff00]/50 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-muted-foreground hover:text-foreground',
)}
>
{voiceOn ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</button>
</div>
)}