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

@@ -11,6 +11,12 @@
<!-- AndroidTV support --> <!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
<application <application
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"

View File

@@ -6,14 +6,20 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.speech.tts.TextToSpeech
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
class AndroidBridge(private val context: Context) { class AndroidBridge(private val context: Context) {
private var ttsForVoices: TextToSpeech? = null
@JavascriptInterface @JavascriptInterface
fun startLocationService() { fun startLocationService(ttsConfigJson: String) {
if (context is Activity) { if (context is Activity) {
// Request notification permission on Android 13+ (required for foreground service notification) // Request notification permission on Android 13+ (required for foreground service notification)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -42,7 +48,9 @@ class AndroidBridge(private val context: Context) {
} }
} }
val intent = Intent(context, LocationForegroundService::class.java) val intent = Intent(context, LocationForegroundService::class.java).apply {
putExtra("ttsConfig", ttsConfigJson)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent) context.startForegroundService(intent)
} else { } else {
@@ -50,6 +58,12 @@ class AndroidBridge(private val context: Context) {
} }
} }
/** Backwards-compatible overload for calls without TTS config */
@JavascriptInterface
fun startLocationService() {
startLocationService("{}")
}
@JavascriptInterface @JavascriptInterface
fun stopLocationService() { fun stopLocationService() {
val intent = Intent(context, LocationForegroundService::class.java) val intent = Intent(context, LocationForegroundService::class.java)
@@ -65,4 +79,62 @@ class AndroidBridge(private val context: Context) {
fun isTracking(): Boolean { fun isTracking(): Boolean {
return LocationForegroundService.tracking return LocationForegroundService.tracking
} }
@JavascriptInterface
fun pauseTracking() {
LocationForegroundService.instance?.doPause()
}
@JavascriptInterface
fun resumeTracking() {
LocationForegroundService.instance?.doResume()
}
/** Returns true if at least one TTS engine is installed on the device. */
@JavascriptInterface
fun hasTtsEngine(): Boolean {
val dummy = TextToSpeech(context, null)
val hasEngine = dummy.engines.isNotEmpty()
dummy.shutdown()
return hasEngine
}
/** Opens the Android TTS install intent (prompts user to install a TTS engine). */
@JavascriptInterface
fun installTtsEngine() {
val intent = Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
/**
* Returns available TTS voices as a JSON array.
* Each entry: { "id": "...", "name": "...", "language": "en-US" }
* This initializes a temporary TTS engine; the result is returned asynchronously
* via a callback, but since @JavascriptInterface is synchronous we block briefly.
*/
@JavascriptInterface
fun getAvailableTtsVoices(): String {
val result = JSONArray()
try {
val latch = java.util.concurrent.CountDownLatch(1)
var engine: TextToSpeech? = null
engine = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
engine?.voices?.forEach { voice ->
val obj = JSONObject().apply {
put("id", voice.name)
put("name", voice.name)
put("language", voice.locale.toLanguageTag())
}
result.put(obj)
}
}
latch.countDown()
}
latch.await(3, java.util.concurrent.TimeUnit.SECONDS)
engine.shutdown()
} catch (_: Exception) {}
return result.toString()
}
} }

View File

@@ -10,24 +10,77 @@ import android.content.Intent
import android.location.LocationListener import android.location.LocationListener
import android.location.LocationManager import android.location.LocationManager
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.speech.tts.TextToSpeech
import android.util.Log
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.Collections import java.util.Collections
import java.util.Locale
import kotlin.math.* import kotlin.math.*
class LocationForegroundService : Service() { private const val TAG = "BockenTTS"
class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
private var locationManager: LocationManager? = null private var locationManager: LocationManager? = null
private var locationListener: LocationListener? = null private var locationListener: LocationListener? = null
private var notificationManager: NotificationManager? = null private var notificationManager: NotificationManager? = null
private var pendingIntent: PendingIntent? = null private var pendingIntent: PendingIntent? = null
private var startTimeMs: Long = 0L private var startTimeMs: Long = 0L
private var pausedAccumulatedMs: Long = 0L // total time spent paused
private var pausedSinceMs: Long = 0L // timestamp when last paused (0 = not paused)
private var lastLat: Double = Double.NaN private var lastLat: Double = Double.NaN
private var lastLng: Double = Double.NaN private var lastLng: Double = Double.NaN
private var lastTimestamp: Long = 0L private var lastTimestamp: Long = 0L
private var currentPaceMinKm: Double = 0.0 private var currentPaceMinKm: Double = 0.0
// TTS
private var tts: TextToSpeech? = null
private var ttsReady = false
private var ttsConfig: TtsConfig? = null
private var ttsTimeHandler: Handler? = null
private var ttsTimeRunnable: Runnable? = null
private var lastAnnouncementDistanceKm: Double = 0.0
private var lastAnnouncementTimeMs: Long = 0L
private var splitDistanceAtLastAnnouncement: Double = 0.0
private var splitTimeAtLastAnnouncement: Long = 0L
data class TtsConfig(
val enabled: Boolean = false,
val triggerType: String = "distance", // "distance" or "time"
val triggerValue: Double = 1.0, // km or minutes
val metrics: List<String> = listOf("totalTime", "totalDistance", "avgPace"),
val language: String = "en",
val voiceId: String? = null
) {
companion object {
fun fromJson(json: String): TtsConfig {
return try {
val obj = JSONObject(json)
val metricsArr = obj.optJSONArray("metrics")
val metrics = if (metricsArr != null) {
(0 until metricsArr.length()).map { metricsArr.getString(it) }
} else {
listOf("totalTime", "totalDistance", "avgPace")
}
TtsConfig(
enabled = obj.optBoolean("enabled", false),
triggerType = obj.optString("triggerType", "distance"),
triggerValue = obj.optDouble("triggerValue", 1.0),
metrics = metrics,
language = obj.optString("language", "en"),
voiceId = obj.optString("voiceId", null)
)
} catch (_: Exception) {
TtsConfig()
}
}
}
}
companion object { companion object {
const val CHANNEL_ID = "gps_tracking" const val CHANNEL_ID = "gps_tracking"
const val NOTIFICATION_ID = 1001 const val NOTIFICATION_ID = 1001
@@ -35,8 +88,12 @@ class LocationForegroundService : Service() {
const val MIN_DISTANCE_M = 0f const val MIN_DISTANCE_M = 0f
private val pointBuffer = Collections.synchronizedList(mutableListOf<JSONObject>()) private val pointBuffer = Collections.synchronizedList(mutableListOf<JSONObject>())
var instance: LocationForegroundService? = null
private set
var tracking = false var tracking = false
private set private set
var paused = false
private set
var totalDistanceKm: Double = 0.0 var totalDistanceKm: Double = 0.0
private set private set
@@ -71,12 +128,21 @@ class LocationForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startTimeMs = System.currentTimeMillis() startTimeMs = System.currentTimeMillis()
pausedAccumulatedMs = 0L
pausedSinceMs = 0L
paused = false
totalDistanceKm = 0.0 totalDistanceKm = 0.0
lastLat = Double.NaN lastLat = Double.NaN
lastLng = Double.NaN lastLng = Double.NaN
lastTimestamp = 0L lastTimestamp = 0L
currentPaceMinKm = 0.0 currentPaceMinKm = 0.0
// Parse TTS config from intent
val configJson = intent?.getStringExtra("ttsConfig") ?: "{}"
Log.d(TAG, "TTS config JSON: $configJson")
ttsConfig = TtsConfig.fromJson(configJson)
Log.d(TAG, "TTS enabled=${ttsConfig?.enabled}, trigger=${ttsConfig?.triggerType}/${ttsConfig?.triggerValue}, metrics=${ttsConfig?.metrics}")
val notifIntent = Intent(this, MainActivity::class.java).apply { val notifIntent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
} }
@@ -95,10 +161,200 @@ class LocationForegroundService : Service() {
startLocationUpdates() startLocationUpdates()
tracking = true tracking = true
instance = this
// Initialize TTS *after* startForeground — using applicationContext for reliable engine binding
if (ttsConfig?.enabled == true) {
Log.d(TAG, "Initializing TTS engine (post-startForeground)...")
lastAnnouncementDistanceKm = 0.0
lastAnnouncementTimeMs = startTimeMs
splitDistanceAtLastAnnouncement = 0.0
splitTimeAtLastAnnouncement = startTimeMs
// Log available TTS engines
val dummyTts = TextToSpeech(applicationContext, null)
val engines = dummyTts.engines
Log.d(TAG, "Available TTS engines: ${engines.map { "${it.label} (${it.name})" }}")
dummyTts.shutdown()
// Try with explicit engine if available
if (engines.isNotEmpty()) {
val engineName = engines[0].name
Log.d(TAG, "Trying TTS with explicit engine: $engineName")
tts = TextToSpeech(applicationContext, this, engineName)
} else {
Log.e(TAG, "No TTS engines found on device!")
tts = TextToSpeech(applicationContext, this)
}
}
return START_STICKY return START_STICKY
} }
// --- TTS ---
override fun onInit(status: Int) {
Log.d(TAG, "TTS onInit status=$status (SUCCESS=${TextToSpeech.SUCCESS})")
if (status == TextToSpeech.SUCCESS) {
val config = ttsConfig ?: return
val locale = Locale.forLanguageTag(config.language)
val langResult = tts?.setLanguage(locale)
Log.d(TAG, "TTS setLanguage($locale) result=$langResult")
// Set specific voice if requested
if (!config.voiceId.isNullOrEmpty()) {
tts?.voices?.find { it.name == config.voiceId }?.let { voice ->
tts?.voice = voice
}
}
ttsReady = true
Log.d(TAG, "TTS ready! triggerType=${config.triggerType}, triggerValue=${config.triggerValue}")
// Set up time-based trigger if configured
if (config.triggerType == "time") {
startTimeTrigger(config.triggerValue)
}
} else {
Log.e(TAG, "TTS init FAILED with status=$status")
}
}
private fun startTimeTrigger(intervalMinutes: Double) {
val intervalMs = (intervalMinutes * 60 * 1000).toLong()
Log.d(TAG, "Starting time trigger: every ${intervalMs}ms (${intervalMinutes} min)")
ttsTimeHandler = Handler(Looper.getMainLooper())
ttsTimeRunnable = object : Runnable {
override fun run() {
Log.d(TAG, "Time trigger fired!")
announceMetrics()
ttsTimeHandler?.postDelayed(this, intervalMs)
}
}
ttsTimeHandler?.postDelayed(ttsTimeRunnable!!, intervalMs)
}
// --- Pause / Resume ---
fun doPause() {
if (paused) return
paused = true
pausedSinceMs = System.currentTimeMillis()
Log.d(TAG, "Tracking paused")
// Pause TTS time trigger
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
// Update notification to show paused state
val notification = buildNotification(formatElapsed(), "%.2f km".format(totalDistanceKm), "PAUSED")
notificationManager?.notify(NOTIFICATION_ID, notification)
}
fun doResume() {
if (!paused) return
// Accumulate paused duration
pausedAccumulatedMs += System.currentTimeMillis() - pausedSinceMs
pausedSinceMs = 0L
paused = false
Log.d(TAG, "Tracking resumed (total paused: ${pausedAccumulatedMs / 1000}s)")
// Reset last position so we don't accumulate drift during pause
lastLat = Double.NaN
lastLng = Double.NaN
lastTimestamp = 0L
// Resume TTS time trigger
val config = ttsConfig
if (ttsReady && config != null && config.triggerType == "time") {
val intervalMs = (config.triggerValue * 60 * 1000).toLong()
ttsTimeRunnable?.let { ttsTimeHandler?.postDelayed(it, intervalMs) }
}
updateNotification()
}
private fun checkDistanceTrigger() {
val config = ttsConfig ?: return
if (!ttsReady || config.triggerType != "distance") return
val sinceLast = totalDistanceKm - lastAnnouncementDistanceKm
if (sinceLast >= config.triggerValue) {
announceMetrics()
lastAnnouncementDistanceKm = totalDistanceKm
}
}
private fun announceMetrics() {
if (!ttsReady) return
val config = ttsConfig ?: return
val now = System.currentTimeMillis()
val activeSecs = activeElapsedSecs()
val parts = mutableListOf<String>()
for (metric in config.metrics) {
when (metric) {
"totalTime" -> {
val h = activeSecs / 3600
val m = (activeSecs % 3600) / 60
val s = activeSecs % 60
val timeStr = if (h > 0) {
"$h hours $m minutes"
} else {
"$m minutes $s seconds"
}
parts.add("Time: $timeStr")
}
"totalDistance" -> {
val distStr = "%.2f".format(totalDistanceKm)
parts.add("Distance: $distStr kilometers")
}
"avgPace" -> {
val elapsedMin = activeSecs / 60.0
if (totalDistanceKm > 0.01) {
val avgPace = elapsedMin / totalDistanceKm
val mins = avgPace.toInt()
val secs = ((avgPace - mins) * 60).toInt()
parts.add("Average pace: $mins minutes $secs seconds per kilometer")
}
}
"splitPace" -> {
val splitDist = totalDistanceKm - splitDistanceAtLastAnnouncement
val splitTimeMin = (now - splitTimeAtLastAnnouncement) / 60000.0
if (splitDist > 0.01) {
val splitPace = splitTimeMin / splitDist
val mins = splitPace.toInt()
val secs = ((splitPace - mins) * 60).toInt()
parts.add("Split pace: $mins minutes $secs seconds per kilometer")
}
}
"currentPace" -> {
if (currentPaceMinKm > 0 && currentPaceMinKm <= 60) {
val mins = currentPaceMinKm.toInt()
val secs = ((currentPaceMinKm - mins) * 60).toInt()
parts.add("Current pace: $mins minutes $secs seconds per kilometer")
}
}
}
}
// Update split tracking
splitDistanceAtLastAnnouncement = totalDistanceKm
splitTimeAtLastAnnouncement = now
lastAnnouncementTimeMs = now
if (parts.isNotEmpty()) {
val text = parts.joinToString(". ")
Log.d(TAG, "Announcing: $text")
val result = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "workout_announcement")
Log.d(TAG, "TTS speak() result=$result (SUCCESS=${TextToSpeech.SUCCESS})")
} else {
Log.d(TAG, "announceMetrics: no parts to announce")
}
}
// --- Notification / Location (unchanged) ---
private fun formatPace(paceMinKm: Double): String { private fun formatPace(paceMinKm: Double): String {
if (paceMinKm <= 0 || paceMinKm > 60) return "" if (paceMinKm <= 0 || paceMinKm > 60) return ""
val mins = paceMinKm.toInt() val mins = paceMinKm.toInt()
@@ -130,8 +386,15 @@ class LocationForegroundService : Service() {
} }
} }
/** Returns active (non-paused) elapsed time in seconds. */
private fun activeElapsedSecs(): Long {
val now = System.currentTimeMillis()
val totalPaused = pausedAccumulatedMs + if (pausedSinceMs > 0) (now - pausedSinceMs) else 0L
return (now - startTimeMs - totalPaused) / 1000
}
private fun formatElapsed(): String { private fun formatElapsed(): String {
val secs = (System.currentTimeMillis() - startTimeMs) / 1000 val secs = activeElapsedSecs()
val h = secs / 3600 val h = secs / 3600
val m = (secs % 3600) / 60 val m = (secs % 3600) / 60
val s = secs % 60 val s = secs % 60
@@ -158,9 +421,22 @@ class LocationForegroundService : Service() {
locationListener = LocationListener { location -> locationListener = LocationListener { location ->
val lat = location.latitude val lat = location.latitude
val lng = location.longitude val lng = location.longitude
val now = location.time
// Always buffer GPS points (for track drawing) even when paused
val point = JSONObject().apply {
put("lat", lat)
put("lng", lng)
if (location.hasAltitude()) put("altitude", location.altitude)
if (location.hasSpeed()) put("speed", location.speed.toDouble())
put("timestamp", location.time)
}
pointBuffer.add(point)
// Skip distance/pace accumulation and TTS triggers when paused
if (paused) return@LocationListener
// Accumulate distance and compute pace // Accumulate distance and compute pace
val now = location.time
if (!lastLat.isNaN()) { if (!lastLat.isNaN()) {
val segmentKm = haversineKm(lastLat, lastLng, lat, lng) val segmentKm = haversineKm(lastLat, lastLng, lat, lng)
totalDistanceKm += segmentKm totalDistanceKm += segmentKm
@@ -173,16 +449,10 @@ class LocationForegroundService : Service() {
lastLng = lng lastLng = lng
lastTimestamp = now lastTimestamp = now
val point = JSONObject().apply {
put("lat", lat)
put("lng", lng)
if (location.hasAltitude()) put("altitude", location.altitude)
if (location.hasSpeed()) put("speed", location.speed.toDouble())
put("timestamp", location.time)
}
pointBuffer.add(point)
updateNotification() updateNotification()
// Check distance-based TTS trigger
checkDistanceTrigger()
} }
locationManager?.requestLocationUpdates( locationManager?.requestLocationUpdates(
@@ -195,9 +465,21 @@ class LocationForegroundService : Service() {
override fun onDestroy() { override fun onDestroy() {
tracking = false tracking = false
paused = false
instance = null
locationListener?.let { locationManager?.removeUpdates(it) } locationListener?.let { locationManager?.removeUpdates(it) }
locationListener = null locationListener = null
locationManager = null locationManager = null
// Clean up TTS
ttsTimeRunnable?.let { ttsTimeHandler?.removeCallbacks(it) }
ttsTimeHandler = null
ttsTimeRunnable = null
tts?.stop()
tts?.shutdown()
tts = null
ttsReady = false
super.onDestroy() super.onDestroy()
} }

View File

@@ -13,11 +13,25 @@ export interface GpsPoint {
timestamp: number; timestamp: number;
} }
export interface VoiceGuidanceConfig {
enabled: boolean;
triggerType: 'distance' | 'time';
triggerValue: number;
metrics: string[];
language: string;
voiceId?: string;
}
interface AndroidBridge { interface AndroidBridge {
startLocationService(): void; startLocationService(ttsConfigJson: string): void;
stopLocationService(): void; stopLocationService(): void;
getPoints(): string; getPoints(): string;
isTracking(): boolean; isTracking(): boolean;
getAvailableTtsVoices(): string;
hasTtsEngine(): boolean;
installTtsEngine(): void;
pauseTracking(): void;
resumeTracking(): void;
} }
function checkTauri(): boolean { function checkTauri(): boolean {
@@ -98,7 +112,7 @@ export function createGpsTracker() {
} }
} }
async function start() { async function start(voiceGuidance?: VoiceGuidanceConfig) {
_debugMsg = 'starting...'; _debugMsg = 'starting...';
if (!checkTauri() || isTracking) { if (!checkTauri() || isTracking) {
_debugMsg = `bail: tauri=${checkTauri()} tracking=${isTracking}`; _debugMsg = `bail: tauri=${checkTauri()} tracking=${isTracking}`;
@@ -130,7 +144,8 @@ export function createGpsTracker() {
const bridge = getAndroidBridge(); const bridge = getAndroidBridge();
if (bridge) { if (bridge) {
_debugMsg = 'starting native GPS service...'; _debugMsg = 'starting native GPS service...';
bridge.startLocationService(); const ttsConfig = JSON.stringify(voiceGuidance ?? {});
bridge.startLocationService(ttsConfig);
// Poll the native side for collected points // Poll the native side for collected points
_pollTimer = setInterval(pollPoints, POLL_INTERVAL_MS); _pollTimer = setInterval(pollPoints, POLL_INTERVAL_MS);
_debugMsg = 'native GPS service started, polling...'; _debugMsg = 'native GPS service started, polling...';
@@ -175,6 +190,37 @@ export function createGpsTracker() {
track = []; track = [];
} }
function getAvailableTtsVoices(): Array<{ id: string; name: string; language: string }> {
const bridge = getAndroidBridge();
if (!bridge) return [];
try {
return JSON.parse(bridge.getAvailableTtsVoices());
} catch {
return [];
}
}
function hasTtsEngine(): boolean {
const bridge = getAndroidBridge();
if (!bridge) return false;
return bridge.hasTtsEngine();
}
function installTtsEngine(): void {
const bridge = getAndroidBridge();
bridge?.installTtsEngine();
}
function pauseTracking(): void {
const bridge = getAndroidBridge();
bridge?.pauseTracking();
}
function resumeTracking(): void {
const bridge = getAndroidBridge();
bridge?.resumeTracking();
}
return { return {
get track() { return track; }, get track() { return track; },
get isTracking() { return isTracking; }, get isTracking() { return isTracking; },
@@ -186,7 +232,12 @@ export function createGpsTracker() {
get debug() { return _debugMsg; }, get debug() { return _debugMsg; },
start, start,
stop, stop,
reset reset,
getAvailableTtsVoices,
hasTtsEngine,
installTtsEngine,
pauseTracking,
resumeTracking
}; };
} }

View File

@@ -1,7 +1,7 @@
<script> <script>
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; 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'; import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname)); const lang = $derived(detectFitnessLang($page.url.pathname));
@@ -41,6 +41,41 @@
let useGps = $state(gps.isTracking); 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} */ /** @type {any} */
let liveMap = null; let liveMap = null;
/** @type {any} */ /** @type {any} */
@@ -102,7 +137,7 @@
if (gps.isTracking) { if (gps.isTracking) {
useGps = true; useGps = true;
} else { } else {
useGps = await gps.start(); useGps = await gps.start(getVoiceGuidanceConfig());
} }
} else { } else {
await gps.stop(); 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(() => { $effect(() => {
const len = gps.track.length; const len = gps.track.length;
if (len > prevTrackLen && liveMap && gps.latestPoint) { if (len > prevTrackLen && liveMap && gps.latestPoint) {
@@ -174,7 +219,7 @@
// Auto-start GPS when adding a cardio exercise // Auto-start GPS when adding a cardio exercise
const exercise = getExerciseById(exerciseId); const exercise = getExerciseById(exerciseId);
if (exercise?.bodyPart === 'cardio' && gps.available && !useGps && !gps.isTracking) { 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…'} <span class="gps-spinner"></span> {t('initializing_gps', lang) ?? 'Initializing GPS…'}
</div> </div>
{/if} {/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} {#if useGps}
<div class="gps-bar active"> <div class="gps-bar active">
<span class="gps-distance">{gps.distance.toFixed(2)} km</span> <span class="gps-distance">{gps.distance.toFixed(2)} km</span>
@@ -737,6 +852,12 @@
{/if} {/if}
<span class="gps-label">{gps.track.length} pts</span> <span class="gps-label">{gps.track.length} pts</span>
</div> </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> <div class="live-map" use:mountMap></div>
{/if} {/if}
</div> </div>
@@ -1315,4 +1436,111 @@
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } 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> </style>