diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index c8c0f9d..2e7067e 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -62,5 +62,8 @@ dependencies {
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.androidx.dynamicanimation)
+ implementation(libs.androidx.compose.foundation.layout)
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
+ implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
+ debugImplementation(libs.androidx.compose.ui.tooling)
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c960427..08024b0 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,7 +7,8 @@
android:required="false" />
-
+
@@ -32,6 +33,8 @@
tools:ignore="ScopedStorage" />
+
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt
deleted file mode 100644
index bb9c9d8..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * LibrePods - AirPods liberated from Apple’s ecosystem
- *
- * Copyright (C) 2025 LibrePods contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-package me.kavishdevar.librepods
-
-import android.annotation.SuppressLint
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
-import androidx.navigation.compose.rememberNavController
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
-import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
-import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
-
-@ExperimentalHazeMaterialsApi
-class CustomDevice : ComponentActivity() {
- @SuppressLint("MissingPermission")
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- setContent {
- LibrePodsTheme {
- val navController = rememberNavController()
-
- NavHost(navController = navController, startDestination = "main") {
- composable("main") {
- AccessibilitySettingsScreen()
- }
- }
- }
- }
- }
-
- override fun onDestroy() {
- super.onDestroy()
- }
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
index 0395239..9ffc793 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
@@ -108,12 +108,14 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.constants.AirPodsNotifications
+import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
+import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.RenameScreen
@@ -382,6 +384,12 @@ fun Main() {
composable("onboarding") {
Onboarding(navController, context)
}
+ composable("accessibility") {
+ AccessibilitySettingsScreen(navController)
+ }
+ composable("transparency_customization") {
+ TransparencySettingsScreen(navController)
+ }
composable("hearing_aid") {
HearingAidScreen(navController)
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt
deleted file mode 100644
index 2141a01..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- * LibrePods - AirPods liberated from Apple’s ecosystem
- *
- * Copyright (C) 2025 LibrePods contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-package me.kavishdevar.librepods.composables
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Slider
-import androidx.compose.material3.SliderDefaults
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.Font
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import me.kavishdevar.librepods.R
-import kotlin.math.roundToInt
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun AccessibilitySlider(
- label: String? = null,
- value: Float,
- onValueChange: (Float) -> Unit,
- valueRange: ClosedFloatingPointRange,
- widthFrac: Float = 1f
-) {
- val isDarkTheme = isSystemInDarkTheme()
-
- val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
- val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
- val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
- val labelTextColor = if (isDarkTheme) Color.White else Color.Black
-
- Column(
- modifier = Modifier.fillMaxWidth(widthFrac),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- if (label != null) {
- Text(
- text = label,
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- color = labelTextColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(Font(R.font.sf_pro))
- )
- )
- }
-
- Slider(
- value = value,
- onValueChange = onValueChange,
- valueRange = valueRange,
- onValueChangeFinished = {
- // Round to 2 decimal places
- onValueChange((value * 100).roundToInt() / 100f)
- },
- modifier = Modifier
- .fillMaxWidth()
- .height(36.dp),
- colors = SliderDefaults.colors(
- thumbColor = thumbColor,
- activeTrackColor = activeTrackColor,
- inactiveTrackColor = trackColor
- ),
- thumb = {
- Box(
- modifier = Modifier
- .size(24.dp)
- .shadow(4.dp, CircleShape)
- .background(thumbColor, CircleShape)
- )
- },
- track = {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(12.dp),
- contentAlignment = Alignment.CenterStart
- ) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(4.dp)
- .background(trackColor, RoundedCornerShape(4.dp))
- )
- Box(
- modifier = Modifier
- .fillMaxWidth((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
- .height(4.dp)
- .background(activeTrackColor, RoundedCornerShape(4.dp))
- )
- }
- }
- )
- }
-}
-
-@Preview
-@Composable
-fun AccessibilitySliderPreview() {
- AccessibilitySlider(
- label = "Test Slider",
- value = 1.0f,
- onValueChange = {},
- valueRange = 0f..2f
- )
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt
index 76d6070..c485c3a 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt
@@ -61,13 +61,16 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
+@ExperimentalHazeMaterialsApi
@Composable
-fun CallControlSettings() {
+fun CallControlSettings(hazeState: HazeState) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -306,7 +309,8 @@ fun CallControlSettings() {
0x03
) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes)
- }
+ },
+ hazeState = hazeState
)
}
}
@@ -433,7 +437,8 @@ fun CallControlSettings() {
0x02
) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes)
- }
+ },
+ hazeState = hazeState
)
}
}
@@ -441,8 +446,9 @@ fun CallControlSettings() {
}
}
+@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun CallControlSettingsPreview() {
- CallControlSettings()
+ CallControlSettings(HazeState())
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt
index 5587501..1378c33 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt
@@ -1,6 +1,7 @@
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
@@ -25,7 +27,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -51,18 +52,24 @@ fun ConfirmationDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false },
hazeState: HazeState,
- isDarkTheme: Boolean,
- textColor: Color,
- activeTrackColor: Color
) {
+ val isDarkTheme = isSystemInDarkTheme()
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+ val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
if (showDialog.value) {
Dialog(onDismissRequest = { showDialog.value = false }) {
Box(
modifier = Modifier
- .fillMaxWidth()
- .background(if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f), RoundedCornerShape(14.dp))
+ .fillMaxWidth(0.75f)
+ .requiredWidthIn(min = 200.dp, max = 360.dp)
+ .background(Color.Transparent, RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp))
- .hazeEffect(hazeState, CupertinoMaterials.regular())
+ .hazeEffect(
+ hazeState,
+ style = CupertinoMaterials.regular(
+ containerColor = if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
+ )
+ )
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp))
@@ -155,7 +162,7 @@ fun ConfirmationDialog(
.background(if (leftPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) {
- Text(dismissText, color = activeTrackColor)
+ Text(dismissText, color = accentColor)
}
Box(
modifier = Modifier
@@ -170,7 +177,7 @@ fun ConfirmationDialog(
.background(if (rightPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) {
- Text(confirmText, color = activeTrackColor)
+ Text(confirmText, color = accentColor)
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt
index 5ff5f38..8e10609 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt
@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.composables
+import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -70,19 +71,29 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
+import dev.chrisbanes.haze.HazeEffectScope
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.HazeTint
+import dev.chrisbanes.haze.hazeEffect
+import dev.chrisbanes.haze.materials.CupertinoMaterials
+import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
+@ExperimentalHazeMaterialsApi
@Composable
-fun MicrophoneSettings() {
+fun MicrophoneSettings(hazeState: HazeState) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -287,19 +298,22 @@ fun MicrophoneSettings() {
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte())
)
- }
+ },
+ hazeState = hazeState
)
}
}
}
}
+@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun MicrophoneSettingsPreview() {
- MicrophoneSettings()
+ MicrophoneSettings(HazeState())
}
+@ExperimentalHazeMaterialsApi
@Composable
fun DragSelectableDropdown(
expanded: Boolean,
@@ -311,7 +325,8 @@ fun DragSelectableDropdown(
onOptionSelected: (String) -> Unit,
externalHoveredIndex: Int? = null,
externalDragActive: Boolean = false,
- modifier: Modifier = Modifier
+ hazeState: HazeState,
+ @SuppressLint("ModifierParameter") modifier: Modifier = Modifier
) {
if (expanded) {
val relativeOffset = touchOffset?.let { it - boxPosition } ?: Offset.Zero
@@ -328,9 +343,7 @@ fun DragSelectableDropdown(
modifier = modifier
.padding(8.dp)
.width(300.dp)
- .background(
- if (isSystemInDarkTheme()) Color(0xFF2C2C2E) else Color(0xFFFFFFFF)
- )
+ .background(Color.Transparent)
.clip(RoundedCornerShape(8.dp)),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
@@ -389,14 +402,13 @@ fun DragSelectableDropdown(
} else {
index == hoveredIndex
}
+ val isSystemInDarkTheme = isSystemInDarkTheme()
Box(
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.background(
- if (isHovered) (if (isSystemInDarkTheme()) Color(0xFF3A3A3C) else Color(
- 0xFFD1D1D6
- )) else Color.Transparent
+ Color.Transparent
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
@@ -405,6 +417,22 @@ fun DragSelectableDropdown(
onOptionSelected(text)
onDismissRequest()
}
+ .hazeEffect(
+ state = hazeState,
+ style = CupertinoMaterials.regular(),
+ block = fun HazeEffectScope.() {
+ alpha = 1f
+ backgroundColor = if (isSystemInDarkTheme) {
+ Color(0xB02C2C2E)
+ } else {
+ Color(0xB0FFFFFF)
+ }
+ tints = if (isHovered) listOf(
+ HazeTint(
+ color = if (isSystemInDarkTheme) Color(0x338A8A8A) else Color(0x40D9D9D9)
+ )
+ ) else listOf()
+ })
.padding(horizontal = 12.dp),
contentAlignment = Alignment.CenterStart
) {
@@ -415,7 +443,11 @@ fun DragSelectableDropdown(
) {
Text(
text,
- color = if (isSystemInDarkTheme()) Color.White else Color.Black
+ style = TextStyle(
+ fontSize = 16.sp,
+ color = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.75f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ )
)
Checkbox(
checked = text == selectedOption,
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt
new file mode 100644
index 0000000..a86e401
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt
@@ -0,0 +1,364 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package me.kavishdevar.librepods.composables
+
+import android.annotation.SuppressLint
+import android.content.res.Configuration
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.gestures.rememberDraggableState
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableFloatState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.BlurEffect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.TileMode
+import androidx.compose.ui.graphics.drawOutline
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.layer.CompositingStrategy
+import androidx.compose.ui.graphics.layer.drawLayer
+import androidx.compose.ui.graphics.rememberGraphicsLayer
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.util.fastCoerceIn
+import androidx.compose.ui.util.fastRoundToInt
+import androidx.compose.ui.util.lerp
+import com.kyant.backdrop.Backdrop
+import com.kyant.backdrop.backdrop
+import com.kyant.backdrop.drawBackdrop
+import com.kyant.backdrop.effects.refractionWithDispersion
+import com.kyant.backdrop.highlight.Highlight
+import com.kyant.backdrop.rememberBackdrop
+import com.kyant.backdrop.rememberCombinedBackdropDrawer
+import com.kyant.backdrop.shadow.Shadow
+import kotlinx.coroutines.launch
+import me.kavishdevar.librepods.R
+import kotlin.math.roundToInt
+
+@Composable
+fun StyledSlider(
+ label: String? = null,
+ mutableFloatState: MutableFloatState,
+ onValueChange: (Float) -> Unit,
+ valueRange: ClosedFloatingPointRange,
+ backdrop: Backdrop = rememberBackdrop(),
+ snapPoints: List = emptyList(),
+ snapThreshold: Float = 0.05f,
+ startIcon: String? = null,
+ endIcon: String? = null,
+ startLabel: String? = null,
+ endLabel: String? = null,
+ independent: Boolean = false,
+ @SuppressLint("ModifierParameter") modifier: Modifier = Modifier
+) {
+ val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ val isLightTheme = !isSystemInDarkTheme()
+ val accentColor =
+ if (isLightTheme) Color(0xFF0088FF)
+ else Color(0xFF0091FF)
+ val trackColor =
+ if (isLightTheme) Color(0xFF787878).copy(0.2f)
+ else Color(0xFF787880).copy(0.36f)
+ val labelTextColor = if (isLightTheme) Color.Black else Color.White
+
+ val fraction by remember {
+ derivedStateOf {
+ ((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start))
+ .fastCoerceIn(0f, 1f)
+ }
+ }
+
+ val animationScope = rememberCoroutineScope()
+ val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
+ val progressAnimation = remember { Animatable(0f) }
+
+ val trackBackdrop = rememberBackdrop()
+ val innerShadowLayer =
+ rememberGraphicsLayer().apply {
+ compositingStrategy = CompositingStrategy.Offscreen
+ }
+
+ val content = @Composable {
+ Column(
+ modifier = modifier.fillMaxWidth(1f).padding(vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ if (label != null) {
+ Text(
+ text = label,
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = labelTextColor,
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ )
+ )
+ }
+
+ if (startLabel != null || endLabel != null) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = startLabel ?: "",
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Normal,
+ color = labelTextColor,
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ )
+ )
+ Text(
+ text = endLabel ?: "",
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Normal,
+ color = labelTextColor,
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ )
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(0.dp)
+ ) {
+ if (startIcon != null) {
+ Text(
+ text = startIcon,
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Normal,
+ color = labelTextColor,
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
+ modifier = Modifier.padding(horizontal = 12.dp)
+ )
+ }
+ BoxWithConstraints(
+ Modifier
+ .weight(1f),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ val density = LocalDensity.current
+ val trackWidth = constraints.maxWidth
+
+ Box(Modifier.backdrop(trackBackdrop)) {
+ Box(
+ Modifier
+ .clip(RoundedCornerShape(28.dp))
+ .background(trackColor)
+ .height(6f.dp)
+ .fillMaxWidth()
+ )
+
+ Box(
+ Modifier
+ .clip(RoundedCornerShape(28.dp))
+ .background(accentColor)
+ .height(6f.dp)
+ .layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints)
+ val fraction = fraction
+ val width = (fraction * constraints.maxWidth).fastRoundToInt()
+ layout(width, placeable.height) {
+ placeable.place(0, 0)
+ }
+ }
+ )
+ }
+
+ Box(
+ Modifier
+ .graphicsLayer {
+ val fraction = fraction
+ translationX =
+ (-size.width / 2f + fraction * trackWidth)
+ .fastCoerceIn(
+ -size.width / 4f,
+ trackWidth - size.width * 3f / 4f
+ )
+ }
+ .draggable(
+ rememberDraggableState { delta ->
+ val trackWidth = trackWidth - with(density) { 40f.dp.toPx() }
+ val targetFraction = fraction + delta / trackWidth
+ val targetValue =
+ lerp(valueRange.start, valueRange.endInclusive, targetFraction)
+ .fastCoerceIn(valueRange.start, valueRange.endInclusive)
+ val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
+ targetValue,
+ snapPoints,
+ snapThreshold
+ ) else targetValue
+ onValueChange(snappedValue)
+ },
+ Orientation.Horizontal,
+ startDragImmediately = true,
+ onDragStarted = {
+ animationScope.launch {
+ progressAnimation.animateTo(1f, progressAnimationSpec)
+ }
+ },
+ onDragStopped = {
+ animationScope.launch {
+ progressAnimation.animateTo(0f, progressAnimationSpec)
+ onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
+ }
+ }
+ )
+ .drawBackdrop(
+ rememberCombinedBackdropDrawer(backdrop, trackBackdrop),
+ { RoundedCornerShape(28.dp) },
+ highlight = {
+ val progress = progressAnimation.value
+ Highlight.AmbientDefault.copy(alpha = progress)
+ },
+ shadow = {
+ Shadow(
+ elevation = 4f.dp,
+ color = Color.Black.copy(0.08f)
+ )
+ },
+ layer = {
+ val progress = progressAnimation.value
+ val scale = lerp(1f, 1.5f, progress)
+ scaleX = scale
+ scaleY = scale
+ },
+ onDrawSurface = {
+ val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
+
+ val shape = RoundedCornerShape(28.dp)
+ val outline = shape.createOutline(size, layoutDirection, this)
+ val innerShadowOffset = 4f.dp.toPx()
+ val innerShadowBlurRadius = 4f.dp.toPx()
+
+ innerShadowLayer.alpha = progress
+ innerShadowLayer.renderEffect =
+ BlurEffect(
+ innerShadowBlurRadius,
+ innerShadowBlurRadius,
+ TileMode.Decal
+ )
+ innerShadowLayer.record {
+ drawOutline(outline, Color.Black.copy(0.2f))
+ translate(0f, innerShadowOffset) {
+ drawOutline(
+ outline,
+ Color.Transparent,
+ blendMode = BlendMode.Clear
+ )
+ }
+ }
+ drawLayer(innerShadowLayer)
+
+ drawRect(Color.White.copy(1f - progress))
+ }
+ ) {
+ refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
+ }
+ .size(40f.dp, 24f.dp)
+ )
+ }
+ if (endIcon != null) {
+ Text(
+ text = endIcon,
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Normal,
+ color = labelTextColor,
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
+ modifier = Modifier.padding(horizontal = 12.dp)
+ )
+ }
+ }
+ }
+ }
+
+ if (independent) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(backgroundColor, RoundedCornerShape(14.dp))
+ .padding(horizontal = 8.dp, vertical = 0.dp)
+ .heightIn(min = 55.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ content()
+ }
+ } else {
+ content()
+ }
+}
+
+private fun snapIfClose(value: Float, points: List, threshold: Float = 0.05f): Float {
+ val nearest = points.minByOrNull { kotlin.math.abs(it - value) } ?: value
+ return if (kotlin.math.abs(nearest - value) <= threshold) nearest else value
+}
+
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun StyledSliderPreview() {
+ StyledSlider(
+ mutableFloatState = remember {mutableFloatStateOf(1f)},
+ onValueChange = {},
+ valueRange = 0f..2f,
+ independent = true,
+ startIcon = "A",
+ endIcon = "B"
+ )
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt
index ba7a67c..5b01cc5 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt
@@ -1,17 +1,17 @@
/*
* LibrePods - AirPods liberated from Apple’s ecosystem
- *
+ *
* Copyright (C) 2025 LibrePods contributors
- *
+ *
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
- *
+ *
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
- *
+ *
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
@@ -85,4 +85,4 @@ fun StyledSwitch(
@Composable
fun StyledSwitchPreview() {
StyledSwitch(checked = true, onCheckedChange = {})
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt
deleted file mode 100644
index 98ef02a..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt
+++ /dev/null
@@ -1,190 +0,0 @@
-/*
- * LibrePods - AirPods liberated from Apple’s ecosystem
- *
- * Copyright (C) 2025 LibrePods contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-@file:OptIn(ExperimentalEncodingApi::class)
-
-package me.kavishdevar.librepods.composables
-
-import android.util.Log
-import androidx.compose.foundation.background
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Slider
-import androidx.compose.material3.SliderDefaults
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.Font
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.AACPManager
-import kotlin.io.encoding.ExperimentalEncodingApi
-import kotlin.math.roundToInt
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ToneVolumeSlider() {
- val service = ServiceManager.getService()!!
- val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)
- val sliderValue = remember { mutableFloatStateOf(
- sliderValueFromAACP?.toFloat() ?: -1f
- ) }
- val listener = object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value) {
- val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()
- if (newValue != null) {
- sliderValue.floatValue = newValue
- }
- }
- }
- }
- LaunchedEffect(Unit) {
- service.aacpManager.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, listener)
- }
- DisposableEffect(Unit) {
- onDispose {
- service.aacpManager.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, listener)
- }
- }
- Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}")
-
- val isDarkTheme = isSystemInDarkTheme()
-
- val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
- val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
- val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
- val labelTextColor = if (isDarkTheme) Color.White else Color.Black
-
- Row(
- modifier = Modifier
- .fillMaxWidth(0.95f),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = "\uDBC0\uDEA1",
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Light,
- color = labelTextColor
- ),
- modifier = Modifier.padding(start = 4.dp)
- )
- Slider(
- value = sliderValue.floatValue,
- onValueChange = {
- sliderValue.floatValue = snapIfClose(it, listOf(100f))
- },
- valueRange = 0f..125f,
- onValueChangeFinished = {
- sliderValue.floatValue = snapIfClose(sliderValue.floatValue.roundToInt().toFloat(), listOf(100f))
- service.aacpManager.sendControlCommand(
- identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
- value = byteArrayOf(sliderValue.floatValue.toInt().toByte(),
- 0x50.toByte()
- )
- )
- },
- modifier = Modifier
- .weight(1f)
- .height(36.dp),
- colors = SliderDefaults.colors(
- thumbColor = thumbColor,
- activeTrackColor = activeTrackColor,
- inactiveTrackColor = trackColor
- ),
- thumb = {
- Box(
- modifier = Modifier
- .size(24.dp)
- .shadow(4.dp, CircleShape)
- .background(thumbColor, CircleShape)
- )
- },
- track = {
- Box (
- modifier = Modifier
- .fillMaxWidth()
- .height(12.dp),
- contentAlignment = Alignment.CenterStart
- )
- {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(4.dp)
- .background(trackColor, RoundedCornerShape(4.dp))
- )
- Box(
- modifier = Modifier
- .fillMaxWidth(sliderValue.floatValue / 125)
- .height(4.dp)
- .background(activeTrackColor, RoundedCornerShape(4.dp))
- )
- }
- }
- )
- Text(
- text = "\uDBC0\uDEA9",
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Light,
- color = labelTextColor
- ),
- modifier = Modifier.padding(end = 4.dp)
- )
- }
-}
-
-@Preview
-@Composable
-fun ToneVolumeSliderPreview() {
- ToneVolumeSlider()
-}
-
-private fun snapIfClose(value: Float, points: List, threshold: Float = 0.05f): Float {
- val nearest = points.minByOrNull { kotlin.math.abs(it - value) } ?: value
- return if (kotlin.math.abs(nearest - value) <= threshold) nearest else value
-}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
index 56021a5..b6fc44f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
@@ -19,6 +19,7 @@
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
+import android.bluetooth.BluetoothDevice
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
@@ -31,15 +32,13 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CenterAlignedTopAppBar
@@ -62,27 +61,27 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
-import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
@@ -95,50 +94,43 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.composables.AccessibilitySlider
+import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch
+import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.SinglePodANCSwitch
import me.kavishdevar.librepods.composables.StyledSwitch
-import me.kavishdevar.librepods.composables.ToneVolumeSlider
import me.kavishdevar.librepods.composables.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.ATTManager
-import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.AACPManager
+import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings
import java.io.IOException
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
-private var debounceJob: Job? = null
private var phoneMediaDebounceJob: Job? = null
+private var toneVolumeDebounceJob: Job? = null
private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun AccessibilitySettingsScreen() {
+fun AccessibilitySettingsScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
- val verticalScrollState = rememberScrollState()
+ val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
- val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
- // get the AACP manager if available (used for EQ read/write)
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
- val context = LocalContext.current
- val radareOffsetFinder = remember { RadareOffsetFinder(context) }
- val isSdpOffsetAvailable = remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
+ val isSdpOffsetAvailable =
+ remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
- val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Scaffold(
containerColor = if (isSystemInDarkTheme()) Color(
@@ -216,7 +208,7 @@ fun AccessibilitySettingsScreen() {
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
- val initialReadAttempts = remember { mutableStateOf(0) }
+ val initialReadAttempts = remember { mutableIntStateOf(0) }
val transparencySettings = remember {
mutableStateOf(
@@ -247,7 +239,8 @@ fun AccessibilitySettingsScreen() {
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
- ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
+ ambientNoiseReductionSliderValue.floatValue =
+ parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
eq.value = parsed.leftEQ.copyOf()
Log.d(TAG, "Updated transparency settings from notification")
@@ -263,22 +256,34 @@ fun AccessibilitySettingsScreen() {
1.toByte() to "Slower",
2.toByte() to "Slowest"
)
- val selectedPressSpeedValue = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
- var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]) }
+ val selectedPressSpeedValue =
+ aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
+ ?.get(0)
+ var selectedPressSpeed by remember {
+ mutableStateOf(
+ pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
+ )
+ }
val selectedPressSpeedListener = object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) {
- val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
- selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0]
- }
+ override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
+ if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) {
+ val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
+ selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0]
}
}
+ }
LaunchedEffect(Unit) {
- aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, selectedPressSpeedListener)
+ aacpManager?.registerControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
+ selectedPressSpeedListener
+ )
}
DisposableEffect(Unit) {
onDispose {
- aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, selectedPressSpeedListener)
+ aacpManager?.unregisterControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
+ selectedPressSpeedListener
+ )
}
}
@@ -287,22 +292,36 @@ fun AccessibilitySettingsScreen() {
1.toByte() to "Slower",
2.toByte() to "Slowest"
)
- val selectedPressAndHoldDurationValue = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
- var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] ?: pressAndHoldDurationOptions[0]) }
+ val selectedPressAndHoldDurationValue =
+ aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
+ ?.get(0)
+ var selectedPressAndHoldDuration by remember {
+ mutableStateOf(
+ pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
+ ?: pressAndHoldDurationOptions[0]
+ )
+ }
val selectedPressAndHoldDurationListener = object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) {
- val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
- selectedPressAndHoldDuration = pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0]
- }
+ override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
+ if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) {
+ val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
+ selectedPressAndHoldDuration =
+ pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0]
}
}
+ }
LaunchedEffect(Unit) {
- aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, selectedPressAndHoldDurationListener)
+ aacpManager?.registerControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
+ selectedPressAndHoldDurationListener
+ )
}
DisposableEffect(Unit) {
onDispose {
- aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, selectedPressAndHoldDurationListener)
+ aacpManager?.unregisterControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
+ selectedPressAndHoldDurationListener
+ )
}
}
@@ -311,123 +330,36 @@ fun AccessibilitySettingsScreen() {
2.toByte() to "Longer",
3.toByte() to "Longest"
)
- val selectedVolumeSwipeSpeedValue = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
- var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] ?: volumeSwipeSpeedOptions[1]) }
+ val selectedVolumeSwipeSpeedValue =
+ aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
+ ?.get(0)
+ var selectedVolumeSwipeSpeed by remember {
+ mutableStateOf(
+ volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
+ ?: volumeSwipeSpeedOptions[1]
+ )
+ }
val selectedVolumeSwipeSpeedListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
- selectedVolumeSwipeSpeed = volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1]
+ selectedVolumeSwipeSpeed =
+ volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1]
}
}
}
LaunchedEffect(Unit) {
- aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, selectedVolumeSwipeSpeedListener)
- }
- DisposableEffect(Unit) {
- onDispose {
- aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, selectedVolumeSwipeSpeedListener)
- }
- }
-
- LaunchedEffect(enabled.value, amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, eq.value, initialLoadComplete.value, initialReadSucceeded.value) {
- if (!initialLoadComplete.value) {
- Log.d(TAG, "Initial device load not complete - skipping send")
- return@LaunchedEffect
- }
-
- if (!initialReadSucceeded.value) {
- Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
- return@LaunchedEffect
- }
-
- transparencySettings.value = TransparencySettings(
- enabled = enabled.value,
- leftEQ = eq.value,
- rightEQ = eq.value,
- leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
- rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
- leftTone = toneSliderValue.floatValue,
- rightTone = toneSliderValue.floatValue,
- leftConversationBoost = conversationBoostEnabled.value,
- rightConversationBoost = conversationBoostEnabled.value,
- leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
- rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
- netAmplification = amplificationSliderValue.floatValue,
- balance = balanceSliderValue.floatValue
+ aacpManager?.registerControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
+ selectedVolumeSwipeSpeedListener
)
- Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
- sendTransparencySettings(attManager, transparencySettings.value)
}
-
DisposableEffect(Unit) {
onDispose {
- attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
- }
- }
-
- LaunchedEffect(Unit) {
- Log.d(TAG, "Connecting to ATT...")
- try {
- attManager.enableNotifications(ATTHandles.TRANSPARENCY)
- attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener)
-
- // If we have an AACP manager, prefer its EQ data to populate EQ controls first
- try {
- if (aacpManager != null) {
- Log.d(TAG, "Found AACPManager, reading cached EQ data")
- val aacpEQ = aacpManager.eqData
- if (aacpEQ.isNotEmpty()) {
- eq.value = aacpEQ.copyOf()
- phoneMediaEQ.value = aacpEQ.copyOf()
- phoneEQEnabled.value = aacpManager.eqOnPhone
- mediaEQEnabled.value = aacpManager.eqOnMedia
- Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
- } else {
- Log.d(TAG, "AACPManager EQ data empty")
- }
- } else {
- Log.d(TAG, "No AACPManager available")
- }
- } catch (e: Exception) {
- Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
- }
-
- var parsedSettings: TransparencySettings? = null
- for (attempt in 1..3) {
- initialReadAttempts.value = attempt
- try {
- val data = attManager.read(ATTHandles.TRANSPARENCY)
- parsedSettings = parseTransparencySettingsResponse(data = data)
- if (parsedSettings != null) {
- Log.d(TAG, "Parsed settings on attempt $attempt")
- break
- } else {
- Log.d(TAG, "Parsing returned null on attempt $attempt")
- }
- } catch (e: Exception) {
- Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
- }
- delay(200)
- }
-
- if (parsedSettings != null) {
- Log.d(TAG, "Initial transparency settings: $parsedSettings")
- enabled.value = parsedSettings.enabled
- amplificationSliderValue.floatValue = parsedSettings.netAmplification
- balanceSliderValue.floatValue = parsedSettings.balance
- toneSliderValue.floatValue = parsedSettings.leftTone
- ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
- conversationBoostEnabled.value = parsedSettings.leftConversationBoost
- eq.value = parsedSettings.leftEQ.copyOf()
- initialReadSucceeded.value = true
- } else {
- Log.d(TAG, "Failed to read/parse initial transparency settings after ${initialReadAttempts.value} attempts")
- }
- } catch (e: IOException) {
- e.printStackTrace()
- } finally {
- initialLoadComplete.value = true
+ aacpManager?.unregisterControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
+ selectedVolumeSwipeSpeedListener
+ )
}
}
@@ -444,251 +376,63 @@ fun AccessibilitySettingsScreen() {
try {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
- Log.d(TAG, "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})")
+ Log.d(
+ TAG,
+ "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
+ )
manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
}
}
}
-
- // Only show transparency mode section if SDP offset is available
- if (isSdpOffsetAvailable.value) {
- AccessibilityToggle(
- text = stringResource(R.string.transparency_mode),
- mutableState = enabled,
- independent = true,
- description = stringResource(R.string.customize_transparency_mode_description)
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = stringResource(R.string.amplification).uppercase(),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(8.dp, bottom = 0.dp)
- )
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(horizontal = 8.dp, vertical = 0.dp)
- .height(55.dp)
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier.fillMaxSize()
- ) {
- Text(
- text = "",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(start = 4.dp)
- )
- AccessibilitySlider(
- valueRange = -1f..1f,
- value = amplificationSliderValue.floatValue,
- onValueChange = {
- amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f))
- },
- widthFrac = 0.90f,
- )
- Text(
- text = "",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(end = 4.dp)
+ val toneVolumeValue = remember { mutableFloatStateOf(
+ aacpManager?.controlCommandStatusList?.find {
+ it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
+ }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() ?: 75f
+ ) }
+ LaunchedEffect(toneVolumeValue.floatValue) {
+ toneVolumeDebounceJob?.cancel()
+ toneVolumeDebounceJob = CoroutineScope(Dispatchers.IO).launch {
+ delay(150)
+ val manager = ServiceManager.getService()?.aacpManager
+ if (manager == null) {
+ Log.w(TAG, "Cannot write tone volume: AACPManager not available")
+ return@launch
+ }
+ try {
+ manager.sendControlCommand(
+ identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
+ value = byteArrayOf(toneVolumeValue.floatValue.toInt().toByte(), 0x50.toByte())
)
+ } catch (e: Exception) {
+ Log.w(TAG, "Error sending tone volume: ${e.message}")
}
}
-
- Text(
- text = stringResource(R.string.balance).uppercase(),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(8.dp, bottom = 0.dp)
- )
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(horizontal = 8.dp, vertical = 0.dp)
- ) {
- Column {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = stringResource(R.string.left),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = stringResource(R.string.right),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- AccessibilitySlider(
- valueRange = -1f..1f,
- value = balanceSliderValue.floatValue,
- onValueChange = {
- balanceSliderValue.floatValue = snapIfClose(it, listOf(0f))
- },
- )
- }
- }
-
- Text(
- text = stringResource(R.string.tone).uppercase(),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(8.dp, bottom = 0.dp)
- )
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(horizontal = 8.dp, vertical = 0.dp)
- ) {
- Column {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = stringResource(R.string.darker),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = stringResource(R.string.brighter),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- AccessibilitySlider(
- valueRange = -1f..1f,
- value = toneSliderValue.floatValue,
- onValueChange = {
- toneSliderValue.floatValue = snapIfClose(it, listOf(0f))
- },
- )
- }
- }
-
- Text(
- text = stringResource(R.string.ambient_noise_reduction).uppercase(),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(8.dp, bottom = 0.dp)
- )
-
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(horizontal = 8.dp, vertical = 0.dp)
- ) {
- Column {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = stringResource(R.string.less),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = stringResource(R.string.more),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- AccessibilitySlider(
- valueRange = 0f..1f,
- value = ambientNoiseReductionSliderValue.floatValue,
- onValueChange = {
- ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f))
- },
- )
- }
- }
-
- AccessibilityToggle(
- text = stringResource(R.string.conversation_boost),
- mutableState = conversationBoostEnabled,
- independent = true,
- description = stringResource(R.string.conversation_boost_description)
- )
}
Text(
- text = stringResource(R.string.audio).uppercase(),
+ text = stringResource(R.string.tone_volume).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
- color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
+ color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
- modifier = Modifier.padding(8.dp, bottom = 2.dp)
+ modifier = Modifier.padding(8.dp, bottom = 0.dp)
)
+ StyledSlider(
+ mutableFloatState = toneVolumeValue,
+ onValueChange = {
+ toneVolumeValue.floatValue = it
+ },
+ valueRange = 0f..125f,
+ snapPoints = listOf(100f),
+ startIcon = "\uDBC0\uDEA1",
+ endIcon = "\uDBC0\uDEA9",
+ independent = true
+ )
+
Column(
modifier = Modifier
.fillMaxWidth()
@@ -697,164 +441,73 @@ fun AccessibilitySettingsScreen() {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
- Text(
- text = stringResource(R.string.tone_volume),
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Light,
- color = textColor
- ),
- modifier = Modifier
- .padding(horizontal = 8.dp, vertical = 4.dp)
- .fillMaxWidth()
- )
- ToneVolumeSlider()
SinglePodANCSwitch()
VolumeControlSwitch()
LoudSoundReductionSwitch()
DropdownMenuComponent(
label = stringResource(R.string.press_speed),
- options = listOf(stringResource(R.string.default_option), stringResource(R.string.slower), stringResource(R.string.slowest)),
+ options = listOf(
+ stringResource(R.string.default_option),
+ stringResource(R.string.slower),
+ stringResource(R.string.slowest)
+ ),
selectedOption = selectedPressSpeed.toString(),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
aacpManager?.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
- value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
+ value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
+ ?: 0.toByte()
)
},
textColor = textColor
)
DropdownMenuComponent(
label = stringResource(R.string.press_and_hold_duration),
- options = listOf(stringResource(R.string.default_option), stringResource(R.string.slower), stringResource(R.string.slowest)),
+ options = listOf(
+ stringResource(R.string.default_option),
+ stringResource(R.string.slower),
+ stringResource(R.string.slowest)
+ ),
selectedOption = selectedPressAndHoldDuration.toString(),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
aacpManager?.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
- value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
+ value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
+ ?: 0.toByte()
)
},
textColor = textColor
)
DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed),
- options = listOf(stringResource(R.string.default_option), stringResource(R.string.longer), stringResource(R.string.longest)),
+ options = listOf(
+ stringResource(R.string.default_option),
+ stringResource(R.string.longer),
+ stringResource(R.string.longest)
+ ),
selectedOption = selectedVolumeSwipeSpeed.toString(),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
aacpManager?.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
- value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte()
+ value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
+ ?: 1.toByte()
)
},
textColor = textColor
)
}
+
+ NavigationButton(
+ to = "transparency_customization",
+ name = stringResource(R.string.customize_transparency_mode),
+ navController = navController
+ )
+
Spacer(modifier = Modifier.height(2.dp))
-
- // Only show transparency mode EQ section if SDP offset is available
- if (isSdpOffsetAvailable.value) {
- Text(
- text = stringResource(R.string.equalizer).uppercase(),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(8.dp, bottom = 2.dp)
- )
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.SpaceBetween
- ) {
- for (i in 0 until 8) {
- val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth()
- .height(38.dp)
- ) {
- Text(
- text = String.format("%.2f", eqValue.floatValue),
- fontSize = 12.sp,
- color = textColor,
- modifier = Modifier.padding(bottom = 4.dp)
- )
-
- Slider(
- value = eqValue.floatValue,
- onValueChange = { newVal ->
- eqValue.floatValue = newVal
- val newEQ = eq.value.copyOf()
- newEQ[i] = eqValue.floatValue
- eq.value = newEQ
- },
- valueRange = 0f..100f,
- modifier = Modifier
- .fillMaxWidth(0.9f)
- .height(36.dp),
- colors = SliderDefaults.colors(
- thumbColor = thumbColor,
- activeTrackColor = activeTrackColor,
- inactiveTrackColor = trackColor
- ),
- thumb = {
- Box(
- modifier = Modifier
- .size(24.dp)
- .shadow(4.dp, CircleShape)
- .background(thumbColor, CircleShape)
- )
- },
- track = {
- Box (
- modifier = Modifier
- .fillMaxWidth()
- .height(12.dp),
- contentAlignment = Alignment.CenterStart
- )
- {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(4.dp)
- .background(trackColor, RoundedCornerShape(4.dp))
- )
- Box(
- modifier = Modifier
- .fillMaxWidth(eqValue.floatValue / 100f)
- .height(4.dp)
- .background(activeTrackColor, RoundedCornerShape(4.dp))
- )
- }
- }
- )
-
- Text(
- text = stringResource(R.string.band_label, i + 1),
- fontSize = 12.sp,
- color = textColor,
- modifier = Modifier.padding(top = 4.dp)
- )
- }
- }
- }
-
- Spacer(modifier = Modifier.height(16.dp))
- }
-
Text(
text = stringResource(R.string.apply_eq_to).uppercase(),
style = TextStyle(
@@ -874,8 +527,17 @@ fun AccessibilitySettingsScreen() {
val darkModeLocal = isSystemInDarkTheme()
val phoneShape = RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
- var phoneBackgroundColor by remember { mutableStateOf(if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
- val phoneAnimatedBackgroundColor by animateColorAsState(targetValue = phoneBackgroundColor, animationSpec = tween(durationMillis = 500))
+ var phoneBackgroundColor by remember {
+ mutableStateOf(
+ if (darkModeLocal) Color(
+ 0xFF1C1C1E
+ ) else Color(0xFFFFFFFF)
+ )
+ }
+ val phoneAnimatedBackgroundColor by animateColorAsState(
+ targetValue = phoneBackgroundColor,
+ animationSpec = tween(durationMillis = 500)
+ )
Row(
modifier = Modifier
@@ -885,9 +547,11 @@ fun AccessibilitySettingsScreen() {
.pointerInput(Unit) {
detectTapGestures(
onPress = {
- phoneBackgroundColor = if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
+ phoneBackgroundColor =
+ if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
- phoneBackgroundColor = if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ phoneBackgroundColor =
+ if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
phoneEQEnabled.value = !phoneEQEnabled.value
}
)
@@ -925,8 +589,17 @@ fun AccessibilitySettingsScreen() {
)
val mediaShape = RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
- var mediaBackgroundColor by remember { mutableStateOf(if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
- val mediaAnimatedBackgroundColor by animateColorAsState(targetValue = mediaBackgroundColor, animationSpec = tween(durationMillis = 500))
+ var mediaBackgroundColor by remember {
+ mutableStateOf(
+ if (darkModeLocal) Color(
+ 0xFF1C1C1E
+ ) else Color(0xFFFFFFFF)
+ )
+ }
+ val mediaAnimatedBackgroundColor by animateColorAsState(
+ targetValue = mediaBackgroundColor,
+ animationSpec = tween(durationMillis = 500)
+ )
Row(
modifier = Modifier
@@ -936,9 +609,11 @@ fun AccessibilitySettingsScreen() {
.pointerInput(Unit) {
detectTapGestures(
onPress = {
- mediaBackgroundColor = if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
+ mediaBackgroundColor =
+ if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
- mediaBackgroundColor = if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ mediaBackgroundColor =
+ if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
mediaEQEnabled.value = !mediaEQEnabled.value
}
)
@@ -979,7 +654,8 @@ fun AccessibilitySettingsScreen() {
horizontalAlignment = Alignment.CenterHorizontally
) {
for (i in 0 until 8) {
- val eqPhoneValue = remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
+ val eqPhoneValue =
+ remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -1020,7 +696,7 @@ fun AccessibilitySettingsScreen() {
)
},
track = {
- Box (
+ Box(
modifier = Modifier
.fillMaxWidth()
.height(12.dp),
@@ -1058,10 +734,25 @@ fun AccessibilitySettingsScreen() {
@Composable
-fun AccessibilityToggle(text: String, mutableState: MutableState, independent: Boolean = false, description: String? = null, title: String? = null) {
+fun AccessibilityToggle(
+ text: String,
+ mutableState: MutableState,
+ independent: Boolean = false,
+ description: String? = null,
+ title: String? = null
+) {
val isDarkTheme = isSystemInDarkTheme()
- var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
- val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
+ var backgroundColor by remember {
+ mutableStateOf(
+ if (isDarkTheme) Color(0xFF1C1C1E) else Color(
+ 0xFFFFFFFF
+ )
+ )
+ }
+ val animatedBackgroundColor by animateColorAsState(
+ targetValue = backgroundColor,
+ animationSpec = tween(durationMillis = 500)
+ )
val textColor = if (isDarkTheme) Color.White else Color.Black
val cornerShape = if (independent) RoundedCornerShape(14.dp) else RoundedCornerShape(0.dp)
@@ -1082,15 +773,17 @@ fun AccessibilityToggle(text: String, mutableState: MutableState, indep
)
Spacer(modifier = Modifier.height(4.dp))
}
- Box (
+ Box(
modifier = Modifier
.background(animatedBackgroundColor, cornerShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
- backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
+ backgroundColor =
+ if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
- backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ backgroundColor =
+ if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
mutableState.value = !mutableState.value
@@ -1139,11 +832,6 @@ fun AccessibilityToggle(text: String, mutableState: MutableState, indep
}
}
-private fun snapIfClose(value: Float, points: List, threshold: Float = 0.05f): Float {
- val nearest = points.minByOrNull { kotlin.math.abs(it - value) } ?: value
- return if (kotlin.math.abs(nearest - value) <= threshold) nearest else value
-}
-
@Composable
private fun DropdownMenuComponent(
label: String,
@@ -1154,7 +842,7 @@ private fun DropdownMenuComponent(
) {
var expanded by remember { mutableStateOf(false) }
- Column (
+ Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
@@ -1195,23 +883,4 @@ private fun DropdownMenuComponent(
}
}
}
-}
-
-// Debounced send helper for phone/media EQ (if needed elsewhere)
-private fun sendPhoneMediaEQ(aacpManager: me.kavishdevar.librepods.utils.AACPManager?, eq: FloatArray, phoneEnabled: Boolean, mediaEnabled: Boolean) {
- phoneMediaDebounceJob?.cancel()
- phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
- delay(100)
- try {
- if (aacpManager == null) {
- Log.w(TAG, "AACPManger is null; cannot send phone/media EQ")
- return@launch
- }
- val phoneByte = if (phoneEnabled) 0x01.toByte() else 0x02.toByte()
- val mediaByte = if (mediaEnabled) 0x01.toByte() else 0x02.toByte()
- aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
- } catch (e: Exception) {
- Log.w(TAG, "Error in sendPhoneMediaEQ: ${e.message}")
- }
- }
-}
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
index 8a1419d..56ec24f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
@@ -84,14 +84,13 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import dev.chrisbanes.haze.HazeEffectScope
-import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
+import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.CustomDevice
import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings
@@ -146,7 +145,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
}
val verticalScrollState = rememberScrollState()
- val hazeState = remember { HazeState() }
+ val hazeState = rememberHazeState( blurEnabled = true )
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
@@ -250,7 +249,8 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
block = fun HazeEffectScope.() {
alpha =
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
- })
+ }
+ )
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
@@ -364,7 +364,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
NoiseControlSettings(service = service)
Spacer(modifier = Modifier.height(16.dp))
- CallControlSettings()
+ CallControlSettings(hazeState = hazeState)
// camera control goes here, airpods side is done, i just need to figure out how to listen to app open/close events
@@ -378,7 +378,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
ConnectionSettings()
Spacer(modifier = Modifier.height(16.dp))
- MicrophoneSettings()
+ MicrophoneSettings(hazeState)
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
@@ -388,15 +388,12 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
)
-
+
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(to = "head_tracking", stringResource(R.string.head_gestures), navController)
Spacer(modifier = Modifier.height(16.dp))
- NavigationButton(to = "", "Accessibility", navController = navController, onClick = {
- val intent = Intent(context, CustomDevice::class.java)
- context.startActivity(intent)
- })
+ NavigationButton(to = "accessibility", "Accessibility", navController = navController)
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt
index 25b2eba..695965f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt
@@ -21,37 +21,16 @@ package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CenterAlignedTopAppBar
-import androidx.compose.material3.Checkbox
-import androidx.compose.material3.CheckboxDefaults
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Slider
-import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
@@ -59,22 +38,14 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
-import androidx.compose.ui.draw.rotate
-import androidx.compose.ui.draw.scale
-import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -96,17 +67,12 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.composables.AccessibilitySlider
+import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.IndependentToggle
-import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch
-import me.kavishdevar.librepods.composables.SinglePodANCSwitch
-import me.kavishdevar.librepods.composables.StyledSwitch
-import me.kavishdevar.librepods.composables.ToneVolumeSlider
-import me.kavishdevar.librepods.composables.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.ATTManager
-import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.AACPManager
+import me.kavishdevar.librepods.utils.ATTHandles
+import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import java.io.IOException
import java.nio.ByteBuffer
@@ -114,14 +80,13 @@ import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
-private var phoneMediaDebounceJob: Job? = null
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun HearingAidAdjustmentsScreen(navController: NavController) {
+fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
@@ -131,14 +96,14 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val context = LocalContext.current
- val radareOffsetFinder = remember { RadareOffsetFinder(context) }
- val isSdpOffsetAvailable = remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
+ remember { RadareOffsetFinder(context) }
+ remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val service = ServiceManager.getService()
- val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
- val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
- val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
- val labelTextColor = if (isDarkTheme) Color.White else Color.Black
+ if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
+ if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
+ if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
+ if (isDarkTheme) Color.White else Color.Black
Scaffold(
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
@@ -192,9 +157,9 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
.verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val enabled = remember { mutableStateOf(false) }
+ remember { mutableStateOf(false) }
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
@@ -210,9 +175,9 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
- val initialReadAttempts = remember { mutableStateOf(0) }
+ val initialReadAttempts = remember { mutableIntStateOf(0) }
- val HearingAidSettings = remember {
+ val hearingAidSettings = remember {
mutableStateOf(
HearingAidSettings(
leftEQ = eq.value,
@@ -295,7 +260,7 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
return@LaunchedEffect
}
- HearingAidSettings.value = HearingAidSettings(
+ hearingAidSettings.value = HearingAidSettings(
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
@@ -310,8 +275,8 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
- Log.d(TAG, "Updated settings: ${HearingAidSettings.value}")
- sendHearingAidSettings(attManager, HearingAidSettings.value)
+ Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
+ sendHearingAidSettings(attManager, hearingAidSettings.value)
}
LaunchedEffect(Unit) {
@@ -342,7 +307,7 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
- initialReadAttempts.value = attempt
+ initialReadAttempts.intValue = attempt
try {
val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data)
@@ -369,7 +334,7 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
initialReadSucceeded.value = true
} else {
- Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.value} attempts")
+ Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
}
} catch (e: IOException) {
e.printStackTrace()
@@ -378,10 +343,6 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
}
}
- val isDarkThemeLocal = isSystemInDarkTheme()
- var backgroundColorHA by remember { mutableStateOf(if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
- val animatedBackgroundColorHA by animateColorAsState(targetValue = backgroundColorHA, animationSpec = tween(durationMillis = 500))
-
Text(
text = stringResource(R.string.amplification).uppercase(),
style = TextStyle(
@@ -392,48 +353,16 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
),
modifier = Modifier.padding(8.dp, bottom = 0.dp)
)
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(horizontal = 8.dp, vertical = 0.dp)
- .height(55.dp)
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier.fillMaxSize()
- ) {
- Text(
- text = "",
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(start = 4.dp)
- )
- AccessibilitySlider(
- valueRange = -1f..1f,
- value = amplificationSliderValue.floatValue,
- onValueChange = {
- amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f))
- },
- widthFrac = 0.90f
- )
- Text(
- text = "",
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(end = 4.dp)
- )
- }
- }
+ StyledSlider(
+ valueRange = -1f..1f,
+ mutableFloatState = amplificationSliderValue,
+ onValueChange = {
+ amplificationSliderValue.floatValue = it
+ },
+ startIcon = "",
+ endIcon = "",
+ independent = true,
+ )
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
@@ -455,47 +384,17 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
),
modifier = Modifier.padding(8.dp, bottom = 0.dp)
)
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(horizontal = 8.dp, vertical = 0.dp)
- ) {
- Column {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = stringResource(R.string.left),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = stringResource(R.string.right),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- AccessibilitySlider(
- valueRange = -1f..1f,
- value = balanceSliderValue.floatValue,
- onValueChange = {
- balanceSliderValue.floatValue = snapIfClose(it, listOf(0f))
- },
- )
- }
- }
+ StyledSlider(
+ valueRange = -1f..1f,
+ mutableFloatState = balanceSliderValue,
+ onValueChange = {
+ balanceSliderValue.floatValue = it
+ },
+ snapPoints = listOf(0f),
+ startLabel = stringResource(R.string.left),
+ endLabel = stringResource(R.string.right),
+ independent = true,
+ )
Text(
text = stringResource(R.string.tone).uppercase(),
@@ -507,47 +406,16 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
),
modifier = Modifier.padding(8.dp, bottom = 0.dp)
)
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(horizontal = 8.dp, vertical = 0.dp)
- ) {
- Column {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = stringResource(R.string.darker),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = stringResource(R.string.brighter),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- AccessibilitySlider(
- valueRange = -1f..1f,
- value = toneSliderValue.floatValue,
- onValueChange = {
- toneSliderValue.floatValue = snapIfClose(it, listOf(0f))
- },
- )
- }
- }
+ StyledSlider(
+ valueRange = -1f..1f,
+ mutableFloatState = toneSliderValue,
+ onValueChange = {
+ toneSliderValue.floatValue = it
+ },
+ startLabel = stringResource(R.string.darker),
+ endLabel = stringResource(R.string.brighter),
+ independent = true,
+ )
Text(
text = stringResource(R.string.ambient_noise_reduction).uppercase(),
@@ -560,47 +428,16 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
modifier = Modifier.padding(8.dp, bottom = 0.dp)
)
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(horizontal = 8.dp, vertical = 0.dp)
- ) {
- Column {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(8.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = stringResource(R.string.less),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = stringResource(R.string.more),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- AccessibilitySlider(
- valueRange = 0f..1f,
- value = ambientNoiseReductionSliderValue.floatValue,
- onValueChange = {
- ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f))
- },
- )
- }
- }
+ StyledSlider(
+ valueRange = 0f..1f,
+ mutableFloatState = ambientNoiseReductionSliderValue,
+ onValueChange = {
+ ambientNoiseReductionSliderValue.floatValue = it
+ },
+ startLabel = stringResource(R.string.less),
+ endLabel = stringResource(R.string.more),
+ independent = true,
+ )
AccessibilityToggle(
text = stringResource(R.string.conversation_boost),
@@ -668,8 +505,6 @@ private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings
if (data.size < 104) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
- val phoneEnabled = buffer.get() == 0x01.toByte()
- val mediaEnabled = buffer.get() == 0x01.toByte()
buffer.getShort() // skip 0x60 0x00
val leftEQ = FloatArray(8)
@@ -718,7 +553,7 @@ private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings
private fun sendHearingAidSettings(
attManager: ATTManager,
- HearingAidSettings: HearingAidSettings
+ hearingAidSettings: HearingAidSettings
) {
debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch {
@@ -736,19 +571,19 @@ private fun sendHearingAidSettings(
buffer.put(2, 0x64)
// Left ear adjustments
- buffer.putFloat(36, HearingAidSettings.leftAmplification)
- buffer.putFloat(40, HearingAidSettings.leftTone)
- buffer.putFloat(44, if (HearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
- buffer.putFloat(48, HearingAidSettings.leftAmbientNoiseReduction)
+ buffer.putFloat(36, hearingAidSettings.leftAmplification)
+ buffer.putFloat(40, hearingAidSettings.leftTone)
+ buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
+ buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction)
// Right ear adjustments
- buffer.putFloat(84, HearingAidSettings.rightAmplification)
- buffer.putFloat(88, HearingAidSettings.rightTone)
- buffer.putFloat(92, if (HearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
- buffer.putFloat(96, HearingAidSettings.rightAmbientNoiseReduction)
+ buffer.putFloat(84, hearingAidSettings.rightAmplification)
+ buffer.putFloat(88, hearingAidSettings.rightTone)
+ buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
+ buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction)
// Own voice amplification
- buffer.putFloat(100, HearingAidSettings.ownVoiceAmplification)
+ buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification)
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
@@ -758,8 +593,3 @@ private fun sendHearingAidSettings(
}
}
}
-
-private fun snapIfClose(value: Float, points: List, threshold: Float = 0.05f): Float {
- val nearest = points.minByOrNull { kotlin.math.abs(it - value) } ?: value
- return if (kotlin.math.abs(nearest - value) <= threshold) nearest else value
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt
index bdd1cc5..27752ec 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt
@@ -19,7 +19,6 @@
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
-import android.content.Context
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
@@ -28,43 +27,30 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.CenterAlignedTopAppBar
-import androidx.compose.material3.Checkbox
-import androidx.compose.material3.CheckboxDefaults
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Slider
-import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
@@ -72,16 +58,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
-import androidx.compose.ui.draw.rotate
-import androidx.compose.ui.draw.scale
-import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -99,27 +79,15 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.composables.AccessibilitySlider
import me.kavishdevar.librepods.composables.ConfirmationDialog
-import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch
-import me.kavishdevar.librepods.composables.SinglePodANCSwitch
import me.kavishdevar.librepods.composables.StyledSwitch
-import me.kavishdevar.librepods.composables.ToneVolumeSlider
-import me.kavishdevar.librepods.composables.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.ATTManager
-import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.AACPManager
-import me.kavishdevar.librepods.utils.RadareOffsetFinder
-import me.kavishdevar.librepods.utils.TransparencySettings
+import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings
-import java.io.IOException
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@@ -139,15 +107,6 @@ fun HearingAidScreen(navController: NavController) {
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
- val context = LocalContext.current
- val radareOffsetFinder = remember { RadareOffsetFinder(context) }
- val isSdpOffsetAvailable = remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
- val service = ServiceManager.getService()
-
- val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
- val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
- val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
- val labelTextColor = if (isDarkTheme) Color.White else Color.Black
val showDialog = remember { mutableStateOf(false) }
@@ -385,7 +344,11 @@ fun HearingAidScreen(navController: NavController) {
onAdjustMediaChange(!adjustMediaEnabled.value)
}
)
- },
+ }
+ .background(
+ animatedBackgroundColorAdjustMedia,
+ RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
+ ),
verticalAlignment = Alignment.CenterVertically
) {
Text(
@@ -430,7 +393,11 @@ fun HearingAidScreen(navController: NavController) {
onAdjustPhoneChange(!adjustPhoneEnabled.value)
}
)
- },
+ }
+ .background(
+ animatedBackgroundColorAdjustPhone,
+ RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
+ ),
verticalAlignment = Alignment.CenterVertically
) {
Text(
@@ -466,11 +433,10 @@ fun HearingAidScreen(navController: NavController) {
if (!enrolled) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
} else {
- aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
+ aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
}
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte())
hearingAidEnabled.value = true
- // Disable transparency mode
CoroutineScope(Dispatchers.IO).launch {
try {
val data = attManager.read(ATTHandles.TRANSPARENCY)
@@ -484,9 +450,6 @@ fun HearingAidScreen(navController: NavController) {
}
}
},
- hazeState = hazeState,
- isDarkTheme = isDarkTheme,
- textColor = textColor,
- activeTrackColor = activeTrackColor
+ hazeState = hazeState
)
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt
new file mode 100644
index 0000000..dc2353a
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt
@@ -0,0 +1,563 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package me.kavishdevar.librepods.screens
+
+import android.annotation.SuppressLint
+import android.util.Log
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CheckboxDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavController
+import dev.chrisbanes.haze.HazeEffectScope
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.hazeEffect
+import dev.chrisbanes.haze.hazeSource
+import dev.chrisbanes.haze.materials.CupertinoMaterials
+import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.composables.StyledSlider
+import me.kavishdevar.librepods.services.ServiceManager
+import me.kavishdevar.librepods.utils.ATTHandles
+import me.kavishdevar.librepods.utils.ATTManager
+import me.kavishdevar.librepods.utils.RadareOffsetFinder
+import me.kavishdevar.librepods.utils.TransparencySettings
+import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
+import me.kavishdevar.librepods.utils.sendTransparencySettings
+import java.io.IOException
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+private const val TAG = "TransparencySettings"
+
+@SuppressLint("DefaultLocale")
+@ExperimentalHazeMaterialsApi
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
+@Composable
+fun TransparencySettingsScreen(navController: NavController) {
+ val isDarkTheme = isSystemInDarkTheme()
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+ val verticalScrollState = rememberScrollState()
+ val hazeState = remember { HazeState() }
+ val snackbarHostState = remember { SnackbarHostState() }
+ val attManager = ServiceManager.getService()?.attManager ?: return
+ val aacpManager = remember { ServiceManager.getService()?.aacpManager }
+ val isSdpOffsetAvailable =
+ remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
+
+ val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
+ val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
+ val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
+
+ Scaffold(
+ containerColor = if (isSystemInDarkTheme()) Color(
+ 0xFF000000
+ ) else Color(
+ 0xFFF2F2F7
+ ),
+ topBar = {
+ val darkMode = isSystemInDarkTheme()
+ val mDensity = remember { mutableFloatStateOf(1f) }
+
+ CenterAlignedTopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.customize_transparency_mode),
+ style = TextStyle(
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Medium,
+ color = if (darkMode) Color.White else Color.Black,
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ )
+ )
+ },
+ modifier = Modifier
+ .hazeEffect(
+ state = hazeState,
+ style = CupertinoMaterials.thick(),
+ block = fun HazeEffectScope.() {
+ alpha =
+ if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
+ })
+ .drawBehind {
+ mDensity.floatValue = density
+ val strokeWidth = 0.7.dp.value * density
+ val y = size.height - strokeWidth / 2
+ if (verticalScrollState.value > 60.dp.value * density) {
+ drawLine(
+ if (darkMode) Color.DarkGray else Color.LightGray,
+ Offset(0f, y),
+ Offset(size.width, y),
+ strokeWidth
+ )
+ }
+ },
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = Color.Transparent
+ )
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .hazeSource(hazeState)
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(horizontal = 16.dp)
+ .verticalScroll(verticalScrollState),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+
+ val enabled = remember { mutableStateOf(false) }
+ val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
+ val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
+ val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
+ val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
+ val conversationBoostEnabled = remember { mutableStateOf(false) }
+ val eq = remember { mutableStateOf(FloatArray(8)) }
+ val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
+
+ val initialLoadComplete = remember { mutableStateOf(false) }
+
+ val initialReadSucceeded = remember { mutableStateOf(false) }
+ val initialReadAttempts = remember { mutableIntStateOf(0) }
+
+ val transparencySettings = remember {
+ mutableStateOf(
+ TransparencySettings(
+ enabled = enabled.value,
+ leftEQ = eq.value,
+ rightEQ = eq.value,
+ leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
+ rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
+ leftTone = toneSliderValue.floatValue,
+ rightTone = toneSliderValue.floatValue,
+ leftConversationBoost = conversationBoostEnabled.value,
+ rightConversationBoost = conversationBoostEnabled.value,
+ leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
+ rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
+ netAmplification = amplificationSliderValue.floatValue,
+ balance = balanceSliderValue.floatValue
+ )
+ )
+ }
+
+ val transparencyListener = remember {
+ object : (ByteArray) -> Unit {
+ override fun invoke(value: ByteArray) {
+ val parsed = parseTransparencySettingsResponse(value)
+ if (parsed != null) {
+ enabled.value = parsed.enabled
+ amplificationSliderValue.floatValue = parsed.netAmplification
+ balanceSliderValue.floatValue = parsed.balance
+ toneSliderValue.floatValue = parsed.leftTone
+ ambientNoiseReductionSliderValue.floatValue =
+ parsed.leftAmbientNoiseReduction
+ conversationBoostEnabled.value = parsed.leftConversationBoost
+ eq.value = parsed.leftEQ.copyOf()
+ Log.d(TAG, "Updated transparency settings from notification")
+ } else {
+ Log.w(TAG, "Failed to parse transparency settings from notification")
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(
+ enabled.value,
+ amplificationSliderValue.floatValue,
+ balanceSliderValue.floatValue,
+ toneSliderValue.floatValue,
+ conversationBoostEnabled.value,
+ ambientNoiseReductionSliderValue.floatValue,
+ eq.value,
+ initialLoadComplete.value,
+ initialReadSucceeded.value
+ ) {
+ if (!initialLoadComplete.value) {
+ Log.d(TAG, "Initial device load not complete - skipping send")
+ return@LaunchedEffect
+ }
+
+ if (!initialReadSucceeded.value) {
+ Log.d(
+ TAG,
+ "Initial device read not successful yet - skipping send until read succeeds"
+ )
+ return@LaunchedEffect
+ }
+
+ transparencySettings.value = TransparencySettings(
+ enabled = enabled.value,
+ leftEQ = eq.value,
+ rightEQ = eq.value,
+ leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
+ rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
+ leftTone = toneSliderValue.floatValue,
+ rightTone = toneSliderValue.floatValue,
+ leftConversationBoost = conversationBoostEnabled.value,
+ rightConversationBoost = conversationBoostEnabled.value,
+ leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
+ rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
+ netAmplification = amplificationSliderValue.floatValue,
+ balance = balanceSliderValue.floatValue
+ )
+ Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
+ sendTransparencySettings(attManager, transparencySettings.value)
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ Log.d(TAG, "Connecting to ATT...")
+ try {
+ attManager.enableNotifications(ATTHandles.TRANSPARENCY)
+ attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener)
+
+ // If we have an AACP manager, prefer its EQ data to populate EQ controls first
+ try {
+ if (aacpManager != null) {
+ Log.d(TAG, "Found AACPManager, reading cached EQ data")
+ val aacpEQ = aacpManager.eqData
+ if (aacpEQ.isNotEmpty()) {
+ eq.value = aacpEQ.copyOf()
+ phoneMediaEQ.value = aacpEQ.copyOf()
+ Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
+ } else {
+ Log.d(TAG, "AACPManager EQ data empty")
+ }
+ } else {
+ Log.d(TAG, "No AACPManager available")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
+ }
+
+ var parsedSettings: TransparencySettings? = null
+ for (attempt in 1..3) {
+ initialReadAttempts.intValue = attempt
+ try {
+ val data = attManager.read(ATTHandles.TRANSPARENCY)
+ parsedSettings = parseTransparencySettingsResponse(data = data)
+ if (parsedSettings != null) {
+ Log.d(TAG, "Parsed settings on attempt $attempt")
+ break
+ } else {
+ Log.d(TAG, "Parsing returned null on attempt $attempt")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
+ }
+ delay(200)
+ }
+
+ if (parsedSettings != null) {
+ Log.d(TAG, "Initial transparency settings: $parsedSettings")
+ enabled.value = parsedSettings.enabled
+ amplificationSliderValue.floatValue = parsedSettings.netAmplification
+ balanceSliderValue.floatValue = parsedSettings.balance
+ toneSliderValue.floatValue = parsedSettings.leftTone
+ ambientNoiseReductionSliderValue.floatValue =
+ parsedSettings.leftAmbientNoiseReduction
+ conversationBoostEnabled.value = parsedSettings.leftConversationBoost
+ eq.value = parsedSettings.leftEQ.copyOf()
+ initialReadSucceeded.value = true
+ } else {
+ Log.d(
+ TAG,
+ "Failed to read/parse initial transparency settings after ${initialReadAttempts.intValue} attempts"
+ )
+ }
+ } catch (e: IOException) {
+ e.printStackTrace()
+ } finally {
+ initialLoadComplete.value = true
+ }
+ }
+
+ // Only show transparency mode section if SDP offset is available
+ if (isSdpOffsetAvailable.value) {
+ AccessibilityToggle(
+ text = stringResource(R.string.transparency_mode),
+ mutableState = enabled,
+ independent = true,
+ description = stringResource(R.string.customize_transparency_mode_description)
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(R.string.amplification).uppercase(),
+ style = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
+ modifier = Modifier.padding(8.dp, bottom = 0.dp)
+ )
+ StyledSlider(
+ valueRange = -1f..1f,
+ mutableFloatState = amplificationSliderValue,
+ onValueChange = {
+ amplificationSliderValue.floatValue = it
+ },
+ startIcon = "",
+ endIcon = "",
+ independent = true
+ )
+
+ Text(
+ text = stringResource(R.string.balance).uppercase(),
+ style = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
+ modifier = Modifier.padding(8.dp, bottom = 0.dp)
+ )
+ StyledSlider(
+ valueRange = -1f..1f,
+ mutableFloatState = balanceSliderValue,
+ onValueChange = {
+ balanceSliderValue.floatValue = it
+ },
+ snapPoints = listOf(0f),
+ startLabel = stringResource(R.string.left),
+ endLabel = stringResource(R.string.right),
+ independent = true,
+ )
+
+ Text(
+ text = stringResource(R.string.tone).uppercase(),
+ style = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
+ modifier = Modifier.padding(8.dp, bottom = 0.dp)
+ )
+ StyledSlider(
+ valueRange = -1f..1f,
+ mutableFloatState = toneSliderValue,
+ onValueChange = {
+ toneSliderValue.floatValue = it
+ },
+ startLabel = stringResource(R.string.darker),
+ endLabel = stringResource(R.string.brighter),
+ independent = true,
+ )
+
+ Text(
+ text = stringResource(R.string.ambient_noise_reduction).uppercase(),
+ style = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
+ modifier = Modifier.padding(8.dp, bottom = 0.dp)
+ )
+
+ StyledSlider(
+ valueRange = 0f..1f,
+ mutableFloatState = ambientNoiseReductionSliderValue,
+ onValueChange = {
+ ambientNoiseReductionSliderValue.floatValue = it
+ },
+ startLabel = stringResource(R.string.less),
+ endLabel = stringResource(R.string.more),
+ independent = true,
+ )
+
+ AccessibilityToggle(
+ text = stringResource(R.string.conversation_boost),
+ mutableState = conversationBoostEnabled,
+ independent = true,
+ description = stringResource(R.string.conversation_boost_description)
+ )
+ }
+
+ // Only show transparency mode EQ section if SDP offset is available
+ if (isSdpOffsetAvailable.value) {
+ Text(
+ text = stringResource(R.string.equalizer).uppercase(),
+ style = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
+ modifier = Modifier.padding(8.dp, bottom = 2.dp)
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(backgroundColor, RoundedCornerShape(14.dp))
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ for (i in 0 until 8) {
+ val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(38.dp)
+ ) {
+ Text(
+ text = String.format("%.2f", eqValue.floatValue),
+ fontSize = 12.sp,
+ color = textColor,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+
+ Slider(
+ value = eqValue.floatValue,
+ onValueChange = { newVal ->
+ eqValue.floatValue = newVal
+ val newEQ = eq.value.copyOf()
+ newEQ[i] = eqValue.floatValue
+ eq.value = newEQ
+ },
+ valueRange = 0f..100f,
+ modifier = Modifier
+ .fillMaxWidth(0.9f)
+ .height(36.dp),
+ colors = SliderDefaults.colors(
+ thumbColor = thumbColor,
+ activeTrackColor = activeTrackColor,
+ inactiveTrackColor = trackColor
+ ),
+ thumb = {
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .shadow(4.dp, CircleShape)
+ .background(thumbColor, CircleShape)
+ )
+ },
+ track = {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(12.dp),
+ contentAlignment = Alignment.CenterStart
+ )
+ {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(4.dp)
+ .background(trackColor, RoundedCornerShape(4.dp))
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(eqValue.floatValue / 100f)
+ .height(4.dp)
+ .background(
+ activeTrackColor,
+ RoundedCornerShape(4.dp)
+ )
+ )
+ }
+ }
+ )
+
+ Text(
+ text = stringResource(R.string.band_label, i + 1),
+ fontSize = 12.sp,
+ color = textColor,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt
index 3a52634..15b6144 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt
@@ -178,7 +178,6 @@ class ATTManager(private val device: BluetoothDevice) {
throw IllegalStateException("End of stream reached")
}
val data = buffer.copyOfRange(0, len)
- Log.wtf(TAG, "Read ${data.size} bytes from ATT")
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
return data
}
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 415d5ce..94dfbc9 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -10,12 +10,12 @@ composeBom = "2025.04.00"
annotations = "26.0.2"
navigationCompose = "2.8.9"
constraintlayout = "2.2.1"
-haze = "1.5.3"
-hazeMaterials = "1.5.3"
-sliceBuilders = "1.1.0-alpha02"
-sliceCore = "1.1.0-alpha02"
-sliceView = "1.1.0-alpha02"
+haze = "1.6.10"
+hazeMaterials = "1.6.10"
dynamicanimation = "1.1.0"
+foundationLayout = "1.9.1"
+uiTooling = "1.9.1"
+mockk = "1.14.3"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -33,10 +33,10 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" }
haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "hazeMaterials" }
-androidx-slice-builders = { group = "androidx.slice", name = "slice-builders", version.ref = "sliceBuilders" }
-androidx-slice-core = { group = "androidx.slice", name = "slice-core", version.ref = "sliceCore" }
-androidx-slice-view = { group = "androidx.slice", name = "slice-view", version.ref = "sliceView" }
androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" }
+androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" }
+mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }