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