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:
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user