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:
parent
93f195ecf3
commit
620ee646d2
2 changed files with 215 additions and 22 deletions
15
AGENTS.md
15
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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue