diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml
index d58ef9a..3739116 100644
--- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml
+++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml
@@ -11,6 +11,12 @@
+
+
+
+
+
+
= 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) {
context.startForegroundService(intent)
} else {
@@ -50,6 +58,12 @@ class AndroidBridge(private val context: Context) {
}
}
+ /** Backwards-compatible overload for calls without TTS config */
+ @JavascriptInterface
+ fun startLocationService() {
+ startLocationService("{}")
+ }
+
@JavascriptInterface
fun stopLocationService() {
val intent = Intent(context, LocationForegroundService::class.java)
@@ -65,4 +79,62 @@ class AndroidBridge(private val context: Context) {
fun isTracking(): Boolean {
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()
+ }
}
diff --git a/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt b/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt
index 53063b2..807d2d7 100644
--- a/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt
+++ b/src-tauri/gen/android/app/src/main/java/org/bocken/app/LocationForegroundService.kt
@@ -10,24 +10,77 @@ import android.content.Intent
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
+import android.os.Handler
import android.os.IBinder
+import android.os.Looper
+import android.speech.tts.TextToSpeech
+import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.util.Collections
+import java.util.Locale
import kotlin.math.*
-class LocationForegroundService : Service() {
+private const val TAG = "BockenTTS"
+
+class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
private var locationManager: LocationManager? = null
private var locationListener: LocationListener? = null
private var notificationManager: NotificationManager? = null
private var pendingIntent: PendingIntent? = null
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 lastLng: Double = Double.NaN
private var lastTimestamp: Long = 0L
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 = 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 {
const val CHANNEL_ID = "gps_tracking"
const val NOTIFICATION_ID = 1001
@@ -35,8 +88,12 @@ class LocationForegroundService : Service() {
const val MIN_DISTANCE_M = 0f
private val pointBuffer = Collections.synchronizedList(mutableListOf())
+ var instance: LocationForegroundService? = null
+ private set
var tracking = false
private set
+ var paused = false
+ private set
var totalDistanceKm: Double = 0.0
private set
@@ -71,12 +128,21 @@ class LocationForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startTimeMs = System.currentTimeMillis()
+ pausedAccumulatedMs = 0L
+ pausedSinceMs = 0L
+ paused = false
totalDistanceKm = 0.0
lastLat = Double.NaN
lastLng = Double.NaN
lastTimestamp = 0L
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 {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
@@ -95,10 +161,200 @@ class LocationForegroundService : Service() {
startLocationUpdates()
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
}
+ // --- 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()
+
+ 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 {
if (paceMinKm <= 0 || paceMinKm > 60) return ""
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 {
- val secs = (System.currentTimeMillis() - startTimeMs) / 1000
+ val secs = activeElapsedSecs()
val h = secs / 3600
val m = (secs % 3600) / 60
val s = secs % 60
@@ -158,9 +421,22 @@ class LocationForegroundService : Service() {
locationListener = LocationListener { location ->
val lat = location.latitude
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
- val now = location.time
if (!lastLat.isNaN()) {
val segmentKm = haversineKm(lastLat, lastLng, lat, lng)
totalDistanceKm += segmentKm
@@ -173,16 +449,10 @@ class LocationForegroundService : Service() {
lastLng = lng
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()
+
+ // Check distance-based TTS trigger
+ checkDistanceTrigger()
}
locationManager?.requestLocationUpdates(
@@ -195,9 +465,21 @@ class LocationForegroundService : Service() {
override fun onDestroy() {
tracking = false
+ paused = false
+ instance = null
locationListener?.let { locationManager?.removeUpdates(it) }
locationListener = 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()
}
diff --git a/src/lib/js/gps.svelte.ts b/src/lib/js/gps.svelte.ts
index 2840978..4be406f 100644
--- a/src/lib/js/gps.svelte.ts
+++ b/src/lib/js/gps.svelte.ts
@@ -13,11 +13,25 @@ export interface GpsPoint {
timestamp: number;
}
+export interface VoiceGuidanceConfig {
+ enabled: boolean;
+ triggerType: 'distance' | 'time';
+ triggerValue: number;
+ metrics: string[];
+ language: string;
+ voiceId?: string;
+}
+
interface AndroidBridge {
- startLocationService(): void;
+ startLocationService(ttsConfigJson: string): void;
stopLocationService(): void;
getPoints(): string;
isTracking(): boolean;
+ getAvailableTtsVoices(): string;
+ hasTtsEngine(): boolean;
+ installTtsEngine(): void;
+ pauseTracking(): void;
+ resumeTracking(): void;
}
function checkTauri(): boolean {
@@ -98,7 +112,7 @@ export function createGpsTracker() {
}
}
- async function start() {
+ async function start(voiceGuidance?: VoiceGuidanceConfig) {
_debugMsg = 'starting...';
if (!checkTauri() || isTracking) {
_debugMsg = `bail: tauri=${checkTauri()} tracking=${isTracking}`;
@@ -130,7 +144,8 @@ export function createGpsTracker() {
const bridge = getAndroidBridge();
if (bridge) {
_debugMsg = 'starting native GPS service...';
- bridge.startLocationService();
+ const ttsConfig = JSON.stringify(voiceGuidance ?? {});
+ bridge.startLocationService(ttsConfig);
// Poll the native side for collected points
_pollTimer = setInterval(pollPoints, POLL_INTERVAL_MS);
_debugMsg = 'native GPS service started, polling...';
@@ -175,6 +190,37 @@ export function createGpsTracker() {
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 {
get track() { return track; },
get isTracking() { return isTracking; },
@@ -186,7 +232,12 @@ export function createGpsTracker() {
get debug() { return _debugMsg; },
start,
stop,
- reset
+ reset,
+ getAvailableTtsVoices,
+ hasTtsEngine,
+ installTtsEngine,
+ pauseTracking,
+ resumeTracking
};
}
diff --git a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte
index b708de6..b23e6c2 100644
--- a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte
+++ b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte
@@ -1,7 +1,7 @@