feat: add TTS voice guidance during GPS-tracked workouts

Voice announcements run entirely in the Android foreground service
(works with screen locked). Configurable via web UI before starting
GPS: time-based or distance-based intervals, selectable metrics
(total time, distance, avg/split/current pace), language (en/de).

Also syncs workout pause/resume state to the native service — pausing
the workout timer now freezes the Android-side elapsed time, distance
accumulation, and TTS triggers.

Includes TTS engine detection with install prompt if none found, and
Android 11+ package visibility query for TTS service discovery.
This commit is contained in:
2026-03-25 10:13:12 +01:00
parent a5f2a1d6de
commit d75e2354f6
5 changed files with 660 additions and 21 deletions

View File

@@ -1,7 +1,7 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin } from 'lucide-svelte';
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2 } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
@@ -41,6 +41,41 @@
let useGps = $state(gps.isTracking);
// Voice guidance config
let vgEnabled = $state(false);
let vgTriggerType = $state('distance');
let vgTriggerValue = $state(1);
let vgMetrics = $state(['totalTime', 'totalDistance', 'avgPace']);
let vgLanguage = $state('en');
let vgShowPanel = $state(false);
const availableMetrics = [
{ id: 'totalTime', label: 'Total Time' },
{ id: 'totalDistance', label: 'Total Distance' },
{ id: 'avgPace', label: 'Average Pace' },
{ id: 'splitPace', label: 'Split Pace' },
{ id: 'currentPace', label: 'Current Pace' },
];
function getVoiceGuidanceConfig() {
if (!vgEnabled) return undefined;
return {
enabled: true,
triggerType: vgTriggerType,
triggerValue: vgTriggerValue,
metrics: vgMetrics,
language: vgLanguage
};
}
function toggleMetric(id) {
if (vgMetrics.includes(id)) {
vgMetrics = vgMetrics.filter(m => m !== id);
} else {
vgMetrics = [...vgMetrics, id];
}
}
/** @type {any} */
let liveMap = null;
/** @type {any} */
@@ -102,7 +137,7 @@
if (gps.isTracking) {
useGps = true;
} else {
useGps = await gps.start();
useGps = await gps.start(getVoiceGuidanceConfig());
}
} else {
await gps.stop();
@@ -119,6 +154,16 @@
}
}
// Sync workout pause state to native GPS/TTS service
$effect(() => {
if (!gps.isTracking) return;
if (workout.paused) {
gps.pauseTracking();
} else {
gps.resumeTracking();
}
});
$effect(() => {
const len = gps.track.length;
if (len > prevTrackLen && liveMap && gps.latestPoint) {
@@ -174,7 +219,7 @@
// Auto-start GPS when adding a cardio exercise
const exercise = getExerciseById(exerciseId);
if (exercise?.bodyPart === 'cardio' && gps.available && !useGps && !gps.isTracking) {
useGps = await gps.start();
useGps = await gps.start(getVoiceGuidanceConfig());
}
}
@@ -729,6 +774,76 @@
<span class="gps-spinner"></span> {t('initializing_gps', lang) ?? 'Initializing GPS…'}
</div>
{/if}
{#if !useGps}
<button class="vg-toggle-row" onclick={() => vgShowPanel = !vgShowPanel} type="button">
<Volume2 size={14} />
<span class="gps-toggle-track" class:checked={vgEnabled}></span>
<span>Voice Guidance</span>
</button>
{#if vgShowPanel}
<div class="vg-panel">
{#if !gps.hasTtsEngine()}
<div class="vg-no-engine">
<span>No text-to-speech engine installed.</span>
<button class="vg-install-btn" onclick={() => gps.installTtsEngine()} type="button">
Install TTS Engine
</button>
</div>
{:else}
<label class="vg-row">
<input type="checkbox" bind:checked={vgEnabled} />
<span>Enable voice announcements</span>
</label>
{#if vgEnabled}
<div class="vg-group">
<span class="vg-label">Announce every</span>
<div class="vg-trigger-row">
<input
class="vg-number"
type="number"
min="0.1"
step="0.5"
bind:value={vgTriggerValue}
/>
<select class="vg-select" bind:value={vgTriggerType}>
<option value="distance">km</option>
<option value="time">min</option>
</select>
</div>
</div>
<div class="vg-group">
<span class="vg-label">Metrics</span>
<div class="vg-metrics">
{#each availableMetrics as m (m.id)}
<button
class="vg-metric-chip"
class:selected={vgMetrics.includes(m.id)}
onclick={() => toggleMetric(m.id)}
type="button"
>
{m.label}
</button>
{/each}
</div>
</div>
<div class="vg-group">
<span class="vg-label">Language</span>
<select class="vg-select" bind:value={vgLanguage}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</div>
{/if}
{/if}
</div>
{/if}
{/if}
{#if useGps}
<div class="gps-bar active">
<span class="gps-distance">{gps.distance.toFixed(2)} km</span>
@@ -737,6 +852,12 @@
{/if}
<span class="gps-label">{gps.track.length} pts</span>
</div>
{#if vgEnabled}
<div class="vg-active-badge">
<Volume2 size={12} />
<span>Voice: every {vgTriggerValue} {vgTriggerType === 'distance' ? 'km' : 'min'}</span>
</div>
{/if}
<div class="live-map" use:mountMap></div>
{/if}
</div>
@@ -1315,4 +1436,111 @@
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Voice Guidance */
.vg-toggle-row {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--color-text-primary);
cursor: pointer;
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.9rem;
}
.vg-panel {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.5rem 0 0;
border-top: 1px solid var(--color-border);
}
.vg-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.vg-row input[type="checkbox"] {
accent-color: var(--nord14);
}
.vg-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.vg-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.vg-trigger-row {
display: flex;
gap: 0.4rem;
}
.vg-number {
width: 70px;
padding: 0.3rem 0.4rem;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 0.9rem;
}
.vg-select {
padding: 0.3rem 0.4rem;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 0.9rem;
}
.vg-metrics {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.vg-metric-chip {
padding: 0.25rem 0.6rem;
border-radius: 20px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s ease;
}
.vg-metric-chip.selected {
background: var(--nord14);
color: var(--nord0);
border-color: var(--nord14);
}
.vg-no-engine {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.vg-install-btn {
padding: 0.4rem 0.8rem;
border-radius: 6px;
border: 1px solid var(--nord14);
background: transparent;
color: var(--nord14);
font-size: 0.85rem;
cursor: pointer;
align-self: flex-start;
}
.vg-active-badge {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--nord14);
opacity: 0.8;
}
</style>