diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 2e7067e..07a253a 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -62,8 +62,11 @@ 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)
+ 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"))))
+ compileOnly(files("libs/libxposed-api-100.aar"))
+ debugImplementation(files("libs/backdrop-debug.aar"))
+ releaseImplementation(files("libs/backdrop-release.aar"))
}
diff --git a/android/app/libs/backdrop-debug.aar b/android/app/libs/backdrop-debug.aar
new file mode 100644
index 0000000..d8d5bff
Binary files /dev/null and b/android/app/libs/backdrop-debug.aar differ
diff --git a/android/app/libs/backdrop-release.aar b/android/app/libs/backdrop-release.aar
new file mode 100644
index 0000000..306297b
Binary files /dev/null and b/android/app/libs/backdrop-release.aar differ
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
index 0a355d9..ddf746b 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
@@ -42,6 +42,7 @@ import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.AACPManager
+import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
@@ -95,7 +96,12 @@ fun AudioSettings(navController: NavController) {
.padding(start = 12.dp, end = 0.dp)
)
- LoudSoundReductionSwitch()
+ StyledToggle(
+ label = stringResource(R.string.loud_sound_reduction),
+ description = stringResource(R.string.loud_sound_reduction_description),
+ attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
+ independent = false
+ )
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt
deleted file mode 100644
index 04a9adb..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt
+++ /dev/null
@@ -1,182 +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.content.Context.MODE_PRIVATE
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-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.graphics.Color
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-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
-
-@Composable
-fun AutomaticConnectionSwitch() {
- val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
- val service = ServiceManager.getService()!!
-
- val sharedPreferenceKey = "automatic_connection_ctrl_cmd"
-
- val automaticConnectionEnabledValue = service.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)
-
- var automaticConnectionEnabled by remember {
- mutableStateOf(
- if (automaticConnectionEnabledValue != null) {
- automaticConnectionEnabledValue == 1.toByte()
- } else {
- sharedPreferences.getBoolean(sharedPreferenceKey, false)
- }
- )
- }
-
- fun updateAutomaticConnection(enabled: Boolean) {
- automaticConnectionEnabled = enabled
- service.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG.value,
- enabled
- )
- // todo: send other connected devices smartAudioRoutingDisabled or something, check packets again.
-
- sharedPreferences.edit()
- .putBoolean(sharedPreferenceKey, enabled)
- .apply()
- }
-
- val automaticConnectionListener = object: AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG.value) {
- val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
- val enabled = newValue == 1.toByte()
- automaticConnectionEnabled = enabled
-
- sharedPreferences.edit()
- .putBoolean(sharedPreferenceKey, enabled)
- .apply()
- }
- }
- }
-
- LaunchedEffect(Unit) {
- service.aacpManager.registerControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
- automaticConnectionListener
- )
- }
- DisposableEffect(Unit) {
- onDispose {
- service.aacpManager.unregisterControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
- automaticConnectionListener
- )
- }
- }
-
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
- val isPressed = remember { mutableStateOf(false) }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- shape = RoundedCornerShape(14.dp),
- color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
- )
- .padding(horizontal = 12.dp, vertical = 12.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed.value = true
- tryAwaitRelease()
- isPressed.value = false
- }
- )
- }
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ) {
- updateAutomaticConnection(!automaticConnectionEnabled)
- },
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(end = 4.dp)
- ) {
- Text(
- text = stringResource(R.string.automatically_connect),
- fontSize = 16.sp,
- color = textColor
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = stringResource(R.string.automatically_connect_description),
- fontSize = 12.sp,
- color = textColor.copy(0.6f),
- lineHeight = 14.sp,
- )
- }
- StyledSwitch(
- checked = automaticConnectionEnabled,
- onCheckedChange = {
- updateAutomaticConnection(it)
- },
- )
- }
-}
-
-@Preview
-@Composable
-fun AutomaticConnectionSwitchPreview() {
- AutomaticConnectionSwitch()
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt
index d0e386c..90106e4 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt
@@ -30,9 +30,15 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlin.io.encoding.ExperimentalEncodingApi
+import android.content.Context.MODE_PRIVATE
+import me.kavishdevar.librepods.composables.StyledToggle
+import me.kavishdevar.librepods.utils.AACPManager
+import me.kavishdevar.librepods.R
@Composable
fun ConnectionSettings() {
@@ -45,7 +51,13 @@ fun ConnectionSettings() {
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(top = 2.dp)
) {
- EarDetectionSwitch()
+ StyledToggle(
+ label = stringResource(R.string.ear_detection),
+ controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
+ sharedPreferenceKey = "automatic_ear_detection",
+ sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
+ independent = false
+ )
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
@@ -53,7 +65,14 @@ fun ConnectionSettings() {
.padding(start = 12.dp, end = 0.dp)
)
- AutomaticConnectionSwitch()
+ StyledToggle(
+ label = stringResource(R.string.automatically_connect),
+ description = stringResource(R.string.automatically_connect_description),
+ controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
+ sharedPreferenceKey = "automatic_connection_ctrl_cmd",
+ sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
+ independent = false
+ )
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt
deleted file mode 100644
index 52eafc1..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt
+++ /dev/null
@@ -1,173 +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.content.Context.MODE_PRIVATE
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-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.graphics.Color
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-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
-
-@Composable
-fun EarDetectionSwitch() {
- val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
- val service = ServiceManager.getService()!!
-
- val sharedPreferenceKey = "automatic_ear_detection"
-
- val earDetectionEnabledValue = service.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)
-
- var earDetectionEnabled by remember {
- mutableStateOf(
- if (earDetectionEnabledValue != null) {
- earDetectionEnabledValue == 1.toByte()
- } else {
- sharedPreferences.getBoolean(sharedPreferenceKey, false)
- }
- )
- }
-
- fun updateEarDetection(enabled: Boolean) {
- earDetectionEnabled = enabled
- service.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG.value,
- enabled
- )
- service.setEarDetection(enabled)
-
- sharedPreferences.edit()
- .putBoolean(sharedPreferenceKey, enabled)
- .apply()
- }
-
- val earDetectionListener = object: AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG.value) {
- val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
- val enabled = newValue == 1.toByte()
- earDetectionEnabled = enabled
-
- sharedPreferences.edit()
- .putBoolean(sharedPreferenceKey, enabled)
- .apply()
- }
- }
- }
-
- LaunchedEffect(Unit) {
- service.aacpManager.registerControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
- earDetectionListener
- )
- }
- DisposableEffect(Unit) {
- onDispose {
- service.aacpManager.unregisterControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
- earDetectionListener
- )
- }
- }
-
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
- val isPressed = remember { mutableStateOf(false) }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- shape = RoundedCornerShape(14.dp),
- color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
- )
- .padding(horizontal = 12.dp, vertical = 12.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed.value = true
- tryAwaitRelease()
- isPressed.value = false
- }
- )
- }
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ) {
- updateEarDetection(!earDetectionEnabled)
- },
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(end = 4.dp)
- ) {
- Text(
- text = stringResource(R.string.ear_detection),
- fontSize = 16.sp,
- color = textColor
- )
- }
- StyledSwitch(
- checked = earDetectionEnabled,
- onCheckedChange = {
- updateEarDetection(it)
- }
- )
- }
-}
-
-@Preview
-@Composable
-fun EarDetectionSwitchPreview() {
- EarDetectionSwitch()
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt
deleted file mode 100644
index 5d31963..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt
+++ /dev/null
@@ -1,169 +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.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-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.graphics.Color
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import kotlinx.coroutines.delay
-import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.ATTHandles
-import kotlin.io.encoding.ExperimentalEncodingApi
-
-@Composable
-fun LoudSoundReductionSwitch() {
- var loudSoundReductionEnabled by remember {
- mutableStateOf(
- false
- )
- }
- val attManager = ServiceManager.getService()?.attManager ?: return
- LaunchedEffect(Unit) {
- attManager.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
-
- var parsed = false
- for (attempt in 1..3) {
- try {
- val data = attManager.read(ATTHandles.LOUD_SOUND_REDUCTION)
- loudSoundReductionEnabled = data[0].toInt() != 0
- Log.d("LoudSoundReduction", "Read attempt $attempt: enabled=${loudSoundReductionEnabled}")
- parsed = true
- break
- } catch (e: Exception) {
- Log.w("LoudSoundReduction", "Read attempt $attempt failed: ${e.message}")
- }
- delay(200)
- }
- if (!parsed) {
- Log.d("LoudSoundReduction", "Failed to read loud sound reduction state after 3 attempts")
- }
- }
-
- LaunchedEffect(loudSoundReductionEnabled) {
- if (attManager.socket?.isConnected != true) return@LaunchedEffect
- attManager.write(ATTHandles.LOUD_SOUND_REDUCTION, if (loudSoundReductionEnabled) byteArrayOf(1) else byteArrayOf(0))
- }
-
- val loudSoundListener = remember {
- object : (ByteArray) -> Unit {
- override fun invoke(value: ByteArray) {
- if (value.isNotEmpty()) {
- loudSoundReductionEnabled = value[0].toInt() != 0
- Log.d("LoudSoundReduction", "Updated from notification: enabled=$loudSoundReductionEnabled")
- } else {
- Log.w("LoudSoundReduction", "Empty value in notification")
- }
- }
- }
- }
-
- LaunchedEffect(Unit) {
- attManager.registerListener(ATTHandles.LOUD_SOUND_REDUCTION, loudSoundListener)
- }
-
- DisposableEffect(Unit) {
- onDispose {
- attManager.unregisterListener(ATTHandles.LOUD_SOUND_REDUCTION, loudSoundListener)
- }
- }
-
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
- val isPressed = remember { mutableStateOf(false) }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- shape = RoundedCornerShape(14.dp),
- color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
- )
- .padding(horizontal = 12.dp, vertical = 12.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed.value = true
- tryAwaitRelease()
- isPressed.value = false
- }
- )
- }
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ) {
- loudSoundReductionEnabled = !loudSoundReductionEnabled
- },
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(end = 4.dp)
- ) {
- Text(
- text = stringResource(R.string.loud_sound_reduction),
- fontSize = 16.sp,
- color = textColor
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = stringResource(R.string.loud_sound_reduction_description),
- fontSize = 12.sp,
- color = textColor.copy(0.6f),
- lineHeight = 14.sp,
- )
- }
- StyledSwitch(
- checked = loudSoundReductionEnabled,
- onCheckedChange = {
- loudSoundReductionEnabled = it
- },
- )
- }
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt
deleted file mode 100644
index ef72c92..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt
+++ /dev/null
@@ -1,163 +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 androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-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.graphics.Color
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.res.stringResource
-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
-
- @Composable
-fun PersonalizedVolumeSwitch() {
- val service = ServiceManager.getService()!!
-
- val adaptiveVolumeEnabledValue = service.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)
-
- var adaptiveVolumeEnabled by remember {
- mutableStateOf(
- adaptiveVolumeEnabledValue == 1.toByte()
- )
- }
-
- fun updatePersonalizedVolume(enabled: Boolean) {
- adaptiveVolumeEnabled = enabled
- service.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG.value,
- enabled
- )
- }
-
- val adaptiveVolumeListener = object: AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG.value) {
- val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
- adaptiveVolumeEnabled = newValue == 1.toByte()
- }
- }
- }
-
- LaunchedEffect(Unit) {
- service.aacpManager.registerControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
- adaptiveVolumeListener
- )
- }
- DisposableEffect(Unit) {
- onDispose {
- service.aacpManager.unregisterControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
- adaptiveVolumeListener
- )
- }
- }
-
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
- val isPressed = remember { mutableStateOf(false) }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- shape = RoundedCornerShape(14.dp),
- color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
- )
- .padding(horizontal = 12.dp, vertical = 12.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed.value = true
- tryAwaitRelease()
- isPressed.value = false
- }
- )
- }
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ) {
- updatePersonalizedVolume(!adaptiveVolumeEnabled)
- },
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(end = 4.dp)
- ) {
- Text(
- text = stringResource(R.string.personalized_volume),
- fontSize = 16.sp,
- color = textColor
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = stringResource(R.string.personalized_volume_description),
- fontSize = 12.sp,
- color = textColor.copy(0.6f),
- lineHeight = 14.sp,
- )
- }
- StyledSwitch(
- checked = adaptiveVolumeEnabled,
- onCheckedChange = {
- updatePersonalizedVolume(it)
- },
- )
- }
-}
-
-@Preview
-@Composable
-fun PersonalizedVolumeSwitchPreview() {
- PersonalizedVolumeSwitch()
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt
deleted file mode 100644
index 4818bf8..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt
+++ /dev/null
@@ -1,151 +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 androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-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.graphics.Color
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.AACPManager
-import kotlin.io.encoding.ExperimentalEncodingApi
-
-@Composable
-fun SinglePodANCSwitch() {
- val service = ServiceManager.getService()!!
- val singleANCEnabledValue = service.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)
- var singleANCEnabled by remember {
- mutableStateOf(
- singleANCEnabledValue == 1.toByte()
- )
- }
- val listener = object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE.value) {
- val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
- singleANCEnabled = newValue == 1.toByte()
- }
- }
- }
- LaunchedEffect(Unit) {
- service.aacpManager.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, listener)
- }
- DisposableEffect(Unit) {
- onDispose {
- service.aacpManager.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, listener)
- }
- }
-
- fun updateSingleEnabled(enabled: Boolean) {
- singleANCEnabled = enabled
- service.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE.value,
- enabled
- )
- }
-
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
- val isPressed = remember { mutableStateOf(false) }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- shape = RoundedCornerShape(14.dp),
- color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
- )
- .padding(horizontal = 12.dp, vertical = 12.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed.value = true
- tryAwaitRelease()
- isPressed.value = false
- }
- )
- }
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ) {
- updateSingleEnabled(!singleANCEnabled)
- },
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(end = 4.dp)
- ) {
- Text(
- text = "Noise Cancellation with Single AirPod",
- fontSize = 16.sp,
- color = textColor
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = "Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.",
- fontSize = 12.sp,
- color = textColor.copy(0.6f),
- lineHeight = 14.sp,
- )
- }
- StyledSwitch(
- checked = singleANCEnabled,
- onCheckedChange = {
- updateSingleEnabled(it)
- },
- )
- }
-}
-
-@Preview
-@Composable
-fun SinglePodANCSwitchPreview() {
- SinglePodANCSwitch()
-}
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 5b01cc5..4786c0f 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
@@ -18,26 +18,58 @@
package me.kavishdevar.librepods.composables
-import androidx.compose.animation.core.animateDpAsState
+import android.content.res.Configuration
+import androidx.compose.animation.Animatable
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
+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.Box
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.offset
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.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+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.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastCoerceIn
+import androidx.compose.ui.util.lerp
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
+import com.kyant.backdrop.drawBackdrop
+import com.kyant.backdrop.effects.refractionWithDispersion
+import com.kyant.backdrop.highlight.Highlight
+import com.kyant.backdrop.shadow.Shadow
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
@Composable
fun StyledSwitch(
@@ -47,42 +79,172 @@ fun StyledSwitch(
) {
val isDarkTheme = isSystemInDarkTheme()
- val thumbColor = Color.White
- val trackColor = if (enabled) (
- if (isDarkTheme) {
- if (checked) Color(0xFF34C759) else Color(0xFF5B5B5E)
- } else {
- if (checked) Color(0xFF34C759) else Color(0xFFD1D1D6)
- }
- ) else {
- if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
+ val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
+ val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
+
+ val trackWidth = 70.dp
+ val trackHeight = 31.dp
+ val thumbHeight = 27.dp
+ val thumbWidth = 36.dp
+
+ val backdrop = rememberLayerBackdrop()
+ val switchBackdrop = rememberLayerBackdrop()
+ val fraction by remember {
+ derivedStateOf { if (checked) 1f else 0f }
}
+ val animatedFraction = remember { Animatable(fraction) }
+ val trackWidthPx = remember { mutableFloatStateOf(0f) }
+ val density = LocalDensity.current
+ val animationScope = rememberCoroutineScope()
+ val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
+ val colorAnimationSpec = tween(300, easing = FastOutSlowInEasing)
+ val progressAnimation = remember { Animatable(0f) }
+ val innerShadowLayer = rememberGraphicsLayer().apply {
+ compositingStrategy = CompositingStrategy.Offscreen
+ }
+ val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) }
-
- val thumbOffsetX by animateDpAsState(targetValue = if (checked) 20.dp else 0.dp, label = "Test")
+ LaunchedEffect(checked) {
+ val targetColor = if (checked) onColor else offColor
+ animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
+ val targetFrac = if (checked) 1f else 0f
+ animatedFraction.animateTo(targetFrac, progressAnimationSpec)
+ }
Box(
modifier = Modifier
- .width(51.dp)
- .height(31.dp)
- .clip(RoundedCornerShape(15.dp))
- .background(trackColor) // Dynamic track background
- .padding(horizontal = 3.dp),
+ .width(trackWidth)
+ .height(trackHeight),
contentAlignment = Alignment.CenterStart
) {
Box(
modifier = Modifier
- .offset(x = thumbOffsetX)
- .size(27.dp)
- .clip(CircleShape)
- .background(thumbColor)
- .clickable { if (enabled) onCheckedChange(!checked) }
+ .layerBackdrop(switchBackdrop)
+ .clip(RoundedCornerShape(trackHeight / 2))
+ .background(animatedTrackColor.value)
+ .width(trackWidth)
+ .height(trackHeight)
+ .onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
+ )
+
+ Box(
+ modifier = Modifier
+ .padding(horizontal = 2.dp)
+ .graphicsLayer {
+ translationX = animatedFraction.value * (trackWidthPx.floatValue - with(density) { thumbWidth.toPx() + 4.dp.toPx() })
+ }
+ .then(if (enabled) Modifier.draggable(
+ rememberDraggableState { delta ->
+ if (trackWidthPx.floatValue > 0f) {
+ val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(0f, 1f)
+ animationScope.launch {
+ animatedFraction.snapTo(newFraction)
+ }
+ val newChecked = newFraction >= 0.5f
+ if (newChecked != checked) {
+ onCheckedChange(newChecked)
+ }
+ }
+ },
+ Orientation.Horizontal,
+ startDragImmediately = true,
+ onDragStarted = {
+ animationScope.launch {
+ progressAnimation.animateTo(1f, progressAnimationSpec)
+ }
+ },
+ onDragStopped = {
+ animationScope.launch {
+ progressAnimation.animateTo(0f, progressAnimationSpec)
+ val snappedFraction = if (animatedFraction.value >= 0.5f) 1f else 0f
+ animatedFraction.animateTo(snappedFraction, progressAnimationSpec)
+ onCheckedChange(snappedFraction >= 0.5f)
+ }
+ }
+ ) else Modifier)
+ .drawBackdrop(
+ rememberCombinedBackdrop(backdrop, switchBackdrop),
+ { RoundedCornerShape(thumbHeight / 2) },
+ highlight = {
+ val progress = progressAnimation.value
+ Highlight.AmbientDefault.copy(alpha = progress)
+ },
+ shadow = {
+ Shadow(
+ radius = 4f.dp,
+ color = Color.Black.copy(0.05f)
+ )
+ },
+ layer = {
+ val progress = progressAnimation.value
+ val scale = lerp(1f, 2f, progress)
+ scaleX = scale
+ scaleY = scale
+ },
+ onDrawSurface = {
+ val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
+
+ val shape = RoundedCornerShape(thumbHeight / 2)
+ 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))
+ },
+ effects = {
+ refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
+ }
+ )
+ .width(thumbWidth)
+ .height(thumbHeight)
)
}
}
-@Preview
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun StyledSwitchPreview() {
- StyledSwitch(checked = true, onCheckedChange = {})
+ val isDarkTheme = isSystemInDarkTheme()
+ val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
+ Box(
+ modifier = Modifier
+ .background(backgroundColor)
+ .width(100.dp)
+ .height(100.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ val checked = remember { mutableStateOf(true) }
+ StyledSwitch(
+ checked = checked.value,
+ onCheckedChange = {
+ checked.value = it
+ },
+ enabled = true
+ )
+ LaunchedEffect(Unit) {
+ delay(1000)
+ checked.value = false
+ delay(1000)
+ checked.value = true
+ }
+ }
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt
index 896c4a3..99567b9 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt
@@ -59,9 +59,11 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
+import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
+import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
@@ -72,7 +74,8 @@ fun StyledToggle(
checkedState: MutableState = remember { mutableStateOf(false) } ,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
- independent: Boolean = true
+ independent: Boolean = true,
+ onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -88,6 +91,7 @@ fun StyledToggle(
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
+ onCheckedChange?.invoke(checked)
}
if (independent) {
@@ -100,7 +104,7 @@ fun StyledToggle(
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f)
),
- modifier = Modifier.padding(8.dp, bottom = 2.dp)
+ modifier = Modifier.padding(8.dp, bottom = 4.dp)
)
}
Box(
@@ -232,7 +236,10 @@ fun StyledToggle(
label: String,
description: String? = null,
controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
- independent: Boolean = true
+ independent: Boolean = true,
+ sharedPreferenceKey: String? = null,
+ sharedPreferences: SharedPreferences? = null,
+ onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val service = ServiceManager.getService() ?: return
val isDarkTheme = isSystemInDarkTheme()
@@ -243,9 +250,19 @@ fun StyledToggle(
var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
-
+ if (sharedPreferenceKey != null && sharedPreferences != null) {
+ checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
+ }
fun cb() {
service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
+ if (sharedPreferences != null) {
+ if (sharedPreferenceKey == null) {
+ Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
+ return
+ }
+ sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
+ }
+ onCheckedChange?.invoke(checked)
}
val listener = remember {
@@ -277,7 +294,225 @@ fun StyledToggle(
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f)
),
- modifier = Modifier.padding(8.dp, bottom = 2.dp)
+ modifier = Modifier.padding(8.dp, bottom = 4.dp)
+ )
+ }
+ Box(
+ modifier = Modifier
+ .background(animatedBackgroundColor, RoundedCornerShape(14.dp))
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onPress = {
+ backgroundColor =
+ if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
+ tryAwaitRelease()
+ backgroundColor =
+ if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ },
+ onTap = {
+ checked = !checked
+ cb()
+ }
+ )
+ }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(55.dp)
+ .padding(horizontal = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = label,
+ modifier = Modifier.weight(1f),
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontFamily = FontFamily(Font(R.font.sf_pro)),
+ fontWeight = FontWeight.Normal,
+ color = textColor
+ )
+ )
+ StyledSwitch(
+ checked = checked,
+ onCheckedChange = {
+ checked = it
+ cb()
+ }
+ )
+ }
+ }
+ if (description != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Box(
+ modifier = Modifier
+ .padding(horizontal = 8.dp)
+ .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
+ ) {
+ Text(
+ text = description,
+ style = TextStyle(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ )
+ )
+ }
+ }
+ }
+ } else {
+ val isPressed = remember { mutableStateOf(false) }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ shape = RoundedCornerShape(14.dp),
+ color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
+ )
+ .padding(horizontal = 12.dp, vertical = 12.dp)
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onPress = {
+ isPressed.value = true
+ tryAwaitRelease()
+ isPressed.value = false
+ }
+ )
+ }
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) {
+ checked = !checked
+ cb()
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 4.dp)
+ ) {
+ Text(
+ text = label,
+ fontSize = 16.sp,
+ color = textColor
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ if (description != null) {
+ Text(
+ text = description,
+ fontSize = 12.sp,
+ color = textColor.copy(0.6f),
+ lineHeight = 14.sp,
+ )
+ }
+ }
+ StyledSwitch(
+ checked = checked,
+ onCheckedChange = {
+ checked = it
+ cb()
+ }
+ )
+ }
+ }
+}
+
+@Composable
+fun StyledToggle(
+ title: String? = null,
+ label: String,
+ description: String? = null,
+ attHandle: ATTHandles,
+ independent: Boolean = true,
+ sharedPreferenceKey: String? = null,
+ sharedPreferences: SharedPreferences? = null,
+ onCheckedChange: ((Boolean) -> Unit)? = null,
+) {
+ val attManager = ServiceManager.getService()?.attManager ?: return
+ val isDarkTheme = isSystemInDarkTheme()
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+ var checked by remember { mutableStateOf(false) }
+ var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
+ val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
+
+ LaunchedEffect(Unit) {
+ attManager.enableNotifications(attHandle)
+
+ var parsed = false
+ for (attempt in 1..3) {
+ try {
+ val data = attManager.read(attHandle)
+ checked = data[0].toInt() != 0
+ Log.d("StyledToggle", "Read attempt $attempt for $label: enabled=$checked")
+ parsed = true
+ break
+ } catch (e: Exception) {
+ Log.w("StyledToggle", "Read attempt $attempt for $label failed: ${e.message}")
+ }
+ delay(200)
+ }
+ if (!parsed) {
+ Log.d("StyledToggle", "Failed to read state for $label after 3 attempts")
+ }
+ }
+
+ if (sharedPreferenceKey != null && sharedPreferences != null) {
+ checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
+ }
+
+ fun cb() {
+ if (sharedPreferences != null) {
+ if (sharedPreferenceKey == null) {
+ Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
+ return
+ }
+ sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
+ }
+ onCheckedChange?.invoke(checked)
+ }
+
+ LaunchedEffect(checked) {
+ if (attManager.socket?.isConnected != true) return@LaunchedEffect
+ attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0))
+ }
+
+ val listener = remember {
+ object : (ByteArray) -> Unit {
+ override fun invoke(value: ByteArray) {
+ if (value.isNotEmpty()) {
+ checked = value[0].toInt() != 0
+ Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked")
+ } else {
+ Log.w("StyledToggle", "Empty value in notification for $label")
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ attManager.registerListener(attHandle, listener)
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ attManager.unregisterListener(attHandle, listener)
+ }
+ }
+
+ if (independent) {
+ Column(modifier = Modifier.padding(vertical = 8.dp)) {
+ if (title != null) {
+ Text(
+ text = title,
+ style = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f)
+ ),
+ modifier = Modifier.padding(8.dp, bottom = 4.dp)
)
}
Box(
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt
deleted file mode 100644
index 1c8b622..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt
+++ /dev/null
@@ -1,150 +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 androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-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.graphics.Color
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.AACPManager
-import kotlin.io.encoding.ExperimentalEncodingApi
-
-@Composable
-fun VolumeControlSwitch() {
- val service = ServiceManager.getService()!!
- val volumeControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)
- var volumeControlEnabled by remember {
- mutableStateOf(
- volumeControlEnabledValue == 1.toByte()
- )
- }
- val listener = object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE.value) {
- val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
- volumeControlEnabled = newValue == 1.toByte()
- }
- }
- }
- LaunchedEffect(Unit) {
- service.aacpManager.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, listener)
- }
- DisposableEffect(Unit) {
- onDispose {
- service.aacpManager.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, listener)
- }
- }
- fun updateVolumeControlEnabled(enabled: Boolean) {
- volumeControlEnabled = enabled
- service.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE.value,
- enabled
- )
- }
-
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
- val isPressed = remember { mutableStateOf(false) }
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- shape = RoundedCornerShape(14.dp),
- color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
- )
- .padding(horizontal = 12.dp, vertical = 12.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed.value = true
- tryAwaitRelease()
- isPressed.value = false
- }
- )
- }
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ) {
- updateVolumeControlEnabled(!volumeControlEnabled)
- },
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(end = 4.dp)
- ) {
- Text(
- text = "Volume Control",
- fontSize = 16.sp,
- color = textColor
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = "Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.",
- fontSize = 12.sp,
- color = textColor.copy(0.6f),
- lineHeight = 14.sp,
- )
- }
- StyledSwitch(
- checked = volumeControlEnabled,
- onCheckedChange = {
- updateVolumeControlEnabled(it)
- },
- )
- }
-}
-
-@Preview
-@Composable
-fun VolumeControlSwitchPreview() {
- VolumeControlSwitch()
-}
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 d5615c3..1e8a1c0 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
@@ -62,6 +62,7 @@ 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.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
@@ -87,17 +88,15 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch
import me.kavishdevar.librepods.composables.NavigationButton
-import me.kavishdevar.librepods.composables.SinglePodANCSwitch
import me.kavishdevar.librepods.composables.StyledDropdown
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
-import me.kavishdevar.librepods.composables.StyledSwitch
-import me.kavishdevar.librepods.composables.VolumeControlSwitch
+import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
+import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -334,8 +333,67 @@ fun AccessibilitySettingsScreen(navController: NavController) {
}
}
+ DropdownMenuComponent(
+ label = stringResource(R.string.press_speed),
+ description = stringResource(R.string.press_speed_description),
+ options = pressSpeedOptions.values.toList(),
+ selectedOption = selectedPressSpeed?: "Default",
+ onOptionSelected = { newValue ->
+ selectedPressSpeed = newValue
+ aacpManager?.sendControlCommand(
+ identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
+ value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
+ ?: 0.toByte()
+ )
+ },
+ textColor = textColor,
+ hazeState = hazeState,
+ independent = true
+ )
+
+ DropdownMenuComponent(
+ label = stringResource(R.string.press_and_hold_duration),
+ description = stringResource(R.string.press_and_hold_duration_description),
+ options = pressAndHoldDurationOptions.values.toList(),
+ selectedOption = selectedPressAndHoldDuration?: "Default",
+ onOptionSelected = { newValue ->
+ selectedPressAndHoldDuration = newValue
+ aacpManager?.sendControlCommand(
+ identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
+ value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
+ ?: 0.toByte()
+ )
+ },
+ textColor = textColor,
+ hazeState = hazeState,
+ independent = true
+ )
+
+ StyledToggle(
+ title = stringResource(R.string.noise_control).uppercase(),
+ label = stringResource(R.string.noise_cancellation_single_airpod),
+ description = stringResource(R.string.noise_cancellation_single_airpod_description),
+ controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
+ independent = true,
+ )
+
+ StyledToggle(
+ label = stringResource(R.string.loud_sound_reduction),
+ description = stringResource(R.string.loud_sound_reduction_description),
+ attHandle = ATTHandles.LOUD_SOUND_REDUCTION
+ )
+
+ if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
+ NavigationButton(
+ to = "transparency_customization",
+ name = stringResource(R.string.customize_transparency_mode),
+ navController = navController
+ )
+ }
+
StyledSlider(
label = stringResource(R.string.tone_volume).uppercase(),
+ description = stringResource(R.string.tone_volume_description),
mutableFloatState = toneVolumeValue,
onValueChange = {
toneVolumeValue.floatValue = it
@@ -347,114 +405,25 @@ fun AccessibilitySettingsScreen(navController: NavController) {
independent = true
)
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(top = 2.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.SpaceBetween
- ) {
- SinglePodANCSwitch()
- HorizontalDivider(
- thickness = 1.5.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(start = 12.dp, end = 0.dp)
- )
-
- VolumeControlSwitch()
- HorizontalDivider(
- thickness = 1.5.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(start = 12.dp, end = 0.dp)
- )
-
- LoudSoundReductionSwitch()
- HorizontalDivider(
- thickness = 1.5.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(start = 12.dp, end = 0.dp)
- )
-
- DropdownMenuComponent(
- label = stringResource(R.string.press_speed),
- 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()
- )
- },
- textColor = textColor,
- hazeState = hazeState
- )
- HorizontalDivider(
- thickness = 1.5.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(start = 12.dp, end = 0.dp)
- )
-
- DropdownMenuComponent(
- label = stringResource(R.string.press_and_hold_duration),
- 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()
- )
- },
- textColor = textColor,
- hazeState = hazeState
- )
- HorizontalDivider(
- thickness = 1.5.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(start = 12.dp, end = 0.dp)
- )
-
- DropdownMenuComponent(
- label = stringResource(R.string.volume_swipe_speed),
- 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()
- )
- },
- textColor = textColor,
- hazeState = hazeState
- )
- }
+ DropdownMenuComponent(
+ label = stringResource(R.string.volume_swipe_speed),
+ description = stringResource(R.string.volume_swipe_speed_description),
+ options = volumeSwipeSpeedOptions.values.toList(),
+ selectedOption = selectedVolumeSwipeSpeed?: "Default",
+ onOptionSelected = { newValue ->
+ selectedVolumeSwipeSpeed = newValue
+ aacpManager?.sendControlCommand(
+ identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
+ value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
+ ?: 1.toByte()
+ )
+ },
+ textColor = textColor,
+ hazeState = hazeState,
+ independent = true
+ )
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
- NavigationButton(
- to = "transparency_customization",
- name = stringResource(R.string.customize_transparency_mode),
- navController = navController
- )
-
- Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.apply_eq_to).uppercase(),
style = TextStyle(
@@ -680,113 +649,6 @@ fun AccessibilitySettingsScreen(navController: NavController) {
}
}
-
-@Composable
-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)
- )
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val cornerShape = if (independent) RoundedCornerShape(14.dp) else RoundedCornerShape(0.dp)
-
- Column(
- modifier = Modifier
- .padding(vertical = 8.dp)
- ) {
- if (title != null) {
- Text(
- text = title,
- 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)
- )
- Spacer(modifier = Modifier.height(4.dp))
- }
- Box(
- modifier = Modifier
- .background(animatedBackgroundColor, cornerShape)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- backgroundColor =
- if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
- tryAwaitRelease()
- backgroundColor =
- if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- },
- onTap = {
- mutableState.value = !mutableState.value
- }
- )
- },
- )
- {
- val rowHeight = if (independent) 55.dp else 50.dp
- val rowPadding = if (independent) 12.dp else 4.dp
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(rowHeight)
- .padding(horizontal = rowPadding),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = text,
- modifier = Modifier.weight(1f),
- fontSize = 16.sp,
- color = textColor
- )
- StyledSwitch(
- checked = mutableState.value,
- onCheckedChange = {
- mutableState.value = it
- },
- )
- }
- }
- if (description != null) {
- Spacer(modifier = Modifier.height(8.dp))
- Box ( // for some reason, haze and backdrop don't work for uncontained text
- modifier = Modifier
- .fillMaxWidth()
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7), cornerShape)
- ) {
- Text(
- text = description,
- style = TextStyle(
- fontSize = 12.sp,
- fontWeight = FontWeight.Light,
- color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- // modifier = Modifier
- // .padding(horizontal = 8.dp)
- )
- }
- }
- }
-}
-
-
@ExperimentalHazeMaterialsApi
@Composable
private fun DropdownMenuComponent(
@@ -797,6 +659,7 @@ private fun DropdownMenuComponent(
textColor: Color,
hazeState: HazeState,
description: String? = null,
+ independent: Boolean = true
) {
val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() }
@@ -808,125 +671,164 @@ private fun DropdownMenuComponent(
var parentHoveredIndex by remember { mutableStateOf(null) }
var parentDragActive by remember { mutableStateOf(false) }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 12.dp, end = 12.dp)
- .height(55.dp)
- .pointerInput(Unit) {
- detectTapGestures { offset ->
- val now = System.currentTimeMillis()
- if (expanded) {
- expanded = false
- lastDismissTime = now
- } else {
- if (now - lastDismissTime > 250L) {
- touchOffset = offset
- expanded = true
- }
- }
- }
- }
- .pointerInput(Unit) {
- detectDragGesturesAfterLongPress(
- onDragStart = { offset ->
- val now = System.currentTimeMillis()
- touchOffset = offset
- if (!expanded && now - lastDismissTime > 250L) {
- expanded = true
- }
- lastDismissTime = now
- parentDragActive = true
- parentHoveredIndex = 0
- },
- onDrag = { change, _ ->
- val current = change.position
- val touch = touchOffset ?: current
- val posInPopupY = current.y - touch.y
- val idx = (posInPopupY / itemHeightPx).toInt()
- parentHoveredIndex = idx
- },
- onDragEnd = {
- parentDragActive = false
- parentHoveredIndex?.let { idx ->
- if (idx in options.indices) {
- onOptionSelected(options[idx])
- expanded = false
- lastDismissTime = System.currentTimeMillis()
- }
- }
- parentHoveredIndex = null
- },
- onDragCancel = {
- parentDragActive = false
- parentHoveredIndex = null
- }
- )
- },
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = label,
- fontSize = 16.sp,
- color = textColor,
- modifier = Modifier.padding(bottom = 4.dp)
- )
- Box(
- modifier = Modifier.onGloballyPositioned { coordinates ->
- boxPosition = coordinates.positionInParent()
- }
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = selectedOption,
- fontSize = 16.sp,
- color = textColor.copy(alpha = 0.8f)
- )
- Icon(
- Icons.Default.KeyboardArrowDown,
- contentDescription = null,
- modifier = Modifier.size(18.dp),
- tint = textColor.copy(alpha = 0.6f)
- )
- }
-
- StyledDropdown(
- expanded = expanded,
- onDismissRequest = {
- expanded = false
- lastDismissTime = System.currentTimeMillis()
- },
- options = options,
- selectedOption = selectedOption,
- touchOffset = touchOffset,
- boxPosition = boxPosition,
- externalHoveredIndex = parentHoveredIndex,
- externalDragActive = parentDragActive,
- onOptionSelected = { option ->
- onOptionSelected(option)
- expanded = false
- },
- hazeState = hazeState
- )
- }
- Box(
+ Column(modifier = Modifier.fillMaxWidth()){
+ Column(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 8.dp)
- .background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7))
- ){
- Text(
- text = description ?: "",
- style = TextStyle(
- fontSize = 12.sp,
- fontWeight = FontWeight.Light,
- color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ .then(
+ if (independent) {
+ if (description != null) {
+ Modifier.padding(top = 8.dp, bottom = 4.dp)
+ } else {
+ Modifier.padding(vertical = 8.dp)
+ }
+ } else Modifier
)
- )
+ .background(
+ if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) else Color.Transparent,
+ if (independent) RoundedCornerShape(14.dp) else RoundedCornerShape(0.dp)
+ )
+ .clip(if (independent) RoundedCornerShape(14.dp) else RoundedCornerShape(0.dp))
+ ){
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 12.dp, end = 12.dp)
+ .height(55.dp)
+ .pointerInput(Unit) {
+ detectTapGestures { offset ->
+ val now = System.currentTimeMillis()
+ if (expanded) {
+ expanded = false
+ lastDismissTime = now
+ } else {
+ if (now - lastDismissTime > 250L) {
+ touchOffset = offset
+ expanded = true
+ }
+ }
+ }
+ }
+ .pointerInput(Unit) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = { offset ->
+ val now = System.currentTimeMillis()
+ touchOffset = offset
+ if (!expanded && now - lastDismissTime > 250L) {
+ expanded = true
+ }
+ lastDismissTime = now
+ parentDragActive = true
+ parentHoveredIndex = 0
+ },
+ onDrag = { change, _ ->
+ val current = change.position
+ val touch = touchOffset ?: current
+ val posInPopupY = current.y - touch.y
+ val idx = (posInPopupY / itemHeightPx).toInt()
+ parentHoveredIndex = idx
+ },
+ onDragEnd = {
+ parentDragActive = false
+ parentHoveredIndex?.let { idx ->
+ if (idx in options.indices) {
+ onOptionSelected(options[idx])
+ expanded = false
+ lastDismissTime = System.currentTimeMillis()
+ }
+ }
+ parentHoveredIndex = null
+ },
+ onDragCancel = {
+ parentDragActive = false
+ parentHoveredIndex = null
+ }
+ )
+ },
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier.weight(1f)
+ ){
+ Text(
+ text = label,
+ fontSize = 16.sp,
+ color = textColor,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ if (!independent && description != null){
+ Text(
+ text = description,
+ style = TextStyle(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
+ modifier = Modifier.padding(bottom = 2.dp)
+ )
+ }
+ }
+ Box(
+ modifier = Modifier.onGloballyPositioned { coordinates ->
+ boxPosition = coordinates.positionInParent()
+ }
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = selectedOption,
+ fontSize = 16.sp,
+ color = textColor.copy(alpha = 0.8f)
+ )
+ Icon(
+ Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = textColor.copy(alpha = 0.6f)
+ )
+ }
+
+ StyledDropdown(
+ expanded = expanded,
+ onDismissRequest = {
+ expanded = false
+ lastDismissTime = System.currentTimeMillis()
+ },
+ options = options,
+ selectedOption = selectedOption,
+ touchOffset = touchOffset,
+ boxPosition = boxPosition,
+ externalHoveredIndex = parentHoveredIndex,
+ externalDragActive = parentDragActive,
+ onOptionSelected = { option ->
+ onOptionSelected(option)
+ expanded = false
+ },
+ hazeState = hazeState
+ )
+ }
+ }
+ }
+ if (independent && description != null){
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp)
+ .background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7))
+ ){
+ Text(
+ text = description,
+ style = TextStyle(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Light,
+ color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ )
+ )
+ }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
index f4621d8..ad71bb4 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
@@ -168,7 +168,8 @@ fun HeadTrackingScreen(navController: NavController) {
label = "Head Gestures",
sharedPreferences = sharedPreferences,
sharedPreferenceKey = "head_gestures",
- )
+ )
+
Spacer(modifier = Modifier.height(2.dp))
Text(
stringResource(R.string.head_gestures_details),
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 1b26564..6c642a5 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
@@ -337,9 +337,9 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
independent = true,
)
- AccessibilityToggle(
- text = stringResource(R.string.conversation_boost),
- mutableState = conversationBoostEnabled,
+ StyledToggle(
+ label = stringResource(R.string.conversation_boost),
+ checkedState = conversationBoostEnabled,
independent = true,
description = stringResource(R.string.conversation_boost_description)
)
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 d14fc94..106ea48 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
@@ -246,13 +246,13 @@ fun HearingAidScreen(navController: NavController) {
modifier = Modifier.padding(horizontal = 8.dp)
)
Spacer(modifier = Modifier.height(16.dp))
-
- AccessibilityToggle(
- text = stringResource(R.string.media_assist),
- mutableState = mediaAssistEnabled,
+
+ StyledToggle(
+ title = stringResource(R.string.media_assist).uppercase(),
+ label = stringResource(R.string.media_assist),
+ checkedState = mediaAssistEnabled,
independent = true,
- description = stringResource(R.string.media_assist_description),
- title = stringResource(R.string.media_assist).uppercase()
+ description = stringResource(R.string.media_assist_description)
)
Spacer(modifier = Modifier.height(8.dp))
@@ -377,10 +377,8 @@ fun HearingAidScreen(navController: NavController) {
try {
val data = attManager.read(ATTHandles.TRANSPARENCY)
val parsed = parseTransparencySettingsResponse(data)
- if (parsed != null) {
- val disabledSettings = parsed.copy(enabled = false)
- sendTransparencySettings(attManager, disabledSettings)
- }
+ val disabledSettings = parsed.copy(enabled = false)
+ sendTransparencySettings(attManager, disabledSettings)
} catch (e: Exception) {
Log.e(TAG, "Error disabling transparency: ${e.message}")
}
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
index 565039e..a0a8855 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt
@@ -66,6 +66,7 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
+import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.RadareOffsetFinder
@@ -288,9 +289,9 @@ fun TransparencySettingsScreen(navController: NavController) {
// Only show transparency mode section if SDP offset is available
if (isSdpOffsetAvailable.value) {
- AccessibilityToggle(
- text = stringResource(R.string.transparency_mode),
- mutableState = enabled,
+ StyledToggle(
+ label = stringResource(R.string.transparency_mode),
+ checkedState = enabled,
independent = true,
description = stringResource(R.string.customize_transparency_mode_description)
)
@@ -344,9 +345,9 @@ fun TransparencySettingsScreen(navController: NavController) {
independent = true,
)
- AccessibilityToggle(
- text = stringResource(R.string.conversation_boost),
- mutableState = conversationBoostEnabled,
+ StyledToggle(
+ label = stringResource(R.string.conversation_boost),
+ checkedState = conversationBoostEnabled,
independent = true,
description = stringResource(R.string.conversation_boost_description)
)
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index b89fbf9..2c49987 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -6,6 +6,7 @@
See your AirPods battery status right from your home screen!
Accessibility
Tone Volume
+ Adjust the tone volume of sound effects played by AirPods.
Audio
Adaptive Audio
Customize Adaptive Audio
@@ -28,7 +29,7 @@
Personalized Volume
Adjusts the volume of media in response to your environment.
Noise Cancellation with Single AirPod
- Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.
+ Allow AirPods to be put in noise cancellation mode when only one AirPod is in your ear.
Volume Control
Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.
AirPods not connected
@@ -107,8 +108,11 @@
Transparency Mode
Customize Transparency Mode
Press Speed
+ Adjust the speed required to press two or three times on your AirPods.
Press and Hold Duration
+ Adjust the duration required to press and hold on your AirPods
Volume Swipe Speed
+ To prevent unintended volume adjustments, select preferred wait time between swipes.
Equalizer
Apply EQ to
Phone