android: update usages for toggle

This commit is contained in:
Kavish Devar
2025-09-26 14:03:47 +05:30
parent d9795c4d28
commit 8dc7a97c43
19 changed files with 724 additions and 1381 deletions

View File

@@ -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"))
}

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -1,182 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
@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()
}

View File

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

View File

@@ -1,173 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
@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()
}

View File

@@ -1,169 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
@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
},
)
}
}

View File

@@ -1,163 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
@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()
}

View File

@@ -1,151 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
@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()
}

View File

@@ -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<Color>(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
}
}
}

View File

@@ -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<Boolean> = 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(

View File

@@ -1,150 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
@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()
}

View File

@@ -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<Boolean>,
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<Int?>(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))
)
)
}
}
}
}

View File

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

View File

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

View File

@@ -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}")
}

View File

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

View File

@@ -6,6 +6,7 @@
<string name="app_widget_description">See your AirPods battery status right from your home screen!</string>
<string name="accessibility">Accessibility</string>
<string name="tone_volume">Tone Volume</string>
<string name="tone_volume_description">Adjust the tone volume of sound effects played by AirPods.</string>
<string name="audio">Audio</string>
<string name="adaptive_audio">Adaptive Audio</string>
<string name="customize_adaptive_audio">Customize Adaptive Audio</string>
@@ -28,7 +29,7 @@
<string name="personalized_volume">Personalized Volume</string>
<string name="personalized_volume_description">Adjusts the volume of media in response to your environment.</string>
<string name="noise_cancellation_single_airpod">Noise Cancellation with Single AirPod</string>
<string name="noise_cancellation_single_airpod_description">Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.</string>
<string name="noise_cancellation_single_airpod_description">Allow AirPods to be put in noise cancellation mode when only one AirPod is in your ear.</string>
<string name="volume_control">Volume Control</string>
<string name="volume_control_description">Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.</string>
<string name="airpods_not_connected">AirPods not connected</string>
@@ -107,8 +108,11 @@
<string name="transparency_mode">Transparency Mode</string>
<string name="customize_transparency_mode">Customize Transparency Mode</string>
<string name="press_speed">Press Speed</string>
<string name="press_speed_description">Adjust the speed required to press two or three times on your AirPods.</string>
<string name="press_and_hold_duration">Press and Hold Duration</string>
<string name="press_and_hold_duration_description">Adjust the duration required to press and hold on your AirPods</string>
<string name="volume_swipe_speed">Volume Swipe Speed</string>
<string name="volume_swipe_speed_description">To prevent unintended volume adjustments, select preferred wait time between swipes.</string>
<string name="equalizer">Equalizer</string>
<string name="apply_eq_to">Apply EQ to</string>
<string name="phone">Phone</string>