android: add a few options

ik not the right branch/pr but, eh, i am not merging this hook until i test further, and if i don't merge, conflicts, a lot of 'em
This commit is contained in:
Kavish Devar
2025-09-20 01:43:24 +05:30
parent 71a1f834cb
commit bb69a74a8e
13 changed files with 1154 additions and 365 deletions

View File

@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.composables
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
@@ -27,7 +28,10 @@ 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.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
@@ -37,12 +41,32 @@ 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.ATTManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AudioSettings() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val attManager = ATTManager(ServiceManager.getService()?.device?: throw IllegalStateException("No device connected"))
DisposableEffect(attManager) {
onDispose {
try {
attManager.disconnect()
} catch (e: Exception) {
Log.w("AirPodsAudioSettings", "Error while disconnecting ATTManager: ${e.message}")
}
}
}
LaunchedEffect(Unit) {
Log.d("AirPodsAudioSettings", "Connecting to ATT...")
try {
attManager.connect()
} catch (e: Exception) {
Log.w("AirPodsAudioSettings", "Error while connecting ATTManager: ${e.message}")
}
}
Text(
text = stringResource(R.string.audio).uppercase(),
@@ -63,7 +87,29 @@ fun AudioSettings() {
.padding(top = 2.dp)
) {
PersonalizedVolumeSwitch()
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 12.dp, end = 0.dp)
)
ConversationalAwarenessSwitch()
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 12.dp, end = 0.dp)
)
LoudSoundReductionSwitch(attManager)
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 12.dp, end = 0.dp)
)
Column(
modifier = Modifier
@@ -91,7 +137,6 @@ fun AudioSettings() {
color = textColor.copy(alpha = 0.6f)
)
)
AdaptiveStrengthSlider()
}
}

View File

@@ -0,0 +1,182 @@
/*
* 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.getValue
import androidx.compose.runtime.LaunchedEffect
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 android.content.Context.MODE_PRIVATE
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 shared_preference_key = "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(shared_preference_key, 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(shared_preference_key, 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(shared_preference_key, 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

@@ -0,0 +1,287 @@
/*
* 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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.HorizontalDivider
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.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun CallControlSettings() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Text(
text = stringResource(R.string.call_controls).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(top = 2.dp)
) {
val service = ServiceManager.getService()!!
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
}?.value ?: byteArrayOf(0x00, 0x03)
var flipped by remember { mutableStateOf(callControlEnabledValue.contentEquals(byteArrayOf(0x00, 0x02))) }
var singlePressAction by remember { mutableStateOf(if (flipped) "Double Press" else "Single Press") }
var doublePressAction by remember { mutableStateOf(if (flipped) "Single Press" else "Double Press") }
var showSinglePressDropdown by remember { mutableStateOf(false) }
var showDoublePressDropdown by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG) {
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
flipped = newFlipped
singlePressAction = if (newFlipped) "Double Press" else "Single Press"
doublePressAction = if (newFlipped) "Single Press" else "Double Press"
Log.d("CallControlSettings", "Control command received, flipped: $newFlipped")
}
}
}
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
listener
)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
}
}
LaunchedEffect(flipped) {
Log.d("CallControlSettings", "Call control flipped: $flipped")
}
Column(
modifier = Modifier
.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp)
.height(55.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Answer call",
fontSize = 18.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(
text = "Single Press",
fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f)
)
}
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 12.dp, end = 0.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp)
.height(55.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Mute/Unmute",
fontSize = 18.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Box {
Row(
modifier = Modifier.clickable { showSinglePressDropdown = true },
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = singlePressAction,
fontSize = 18.sp,
color = textColor.copy(alpha = 0.8f)
)
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = textColor.copy(alpha = 0.6f)
)
}
DropdownMenu(
expanded = showSinglePressDropdown,
onDismissRequest = { showSinglePressDropdown = false }
) {
DropdownMenuItem(
text = { Text("Single Press") },
onClick = {
singlePressAction = "Single Press"
doublePressAction = "Double Press"
showSinglePressDropdown = false
service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x03))
}
)
DropdownMenuItem(
text = { Text("Double Press") },
onClick = {
singlePressAction = "Double Press"
doublePressAction = "Single Press"
showSinglePressDropdown = false
service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x02))
}
)
}
}
}
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 12.dp, end = 0.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp)
.height(55.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Hang Up",
fontSize = 18.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Box {
Row(
modifier = Modifier.clickable { showDoublePressDropdown = true },
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = doublePressAction,
fontSize = 18.sp,
color = textColor.copy(alpha = 0.8f)
)
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = textColor.copy(alpha = 0.6f)
)
}
DropdownMenu(
expanded = showDoublePressDropdown,
onDismissRequest = { showDoublePressDropdown = false }
) {
DropdownMenuItem(
text = { Text("Single Press") },
onClick = {
doublePressAction = "Single Press"
singlePressAction = "Double Press"
showDoublePressDropdown = false
service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x02))
}
)
DropdownMenuItem(
text = { Text("Double Press") },
onClick = {
doublePressAction = "Double Press"
singlePressAction = "Single Press"
showDoublePressDropdown = false
service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x03))
}
)
}
}
}
}
}
}
@Preview
@Composable
fun CallControlSettingsPreview() {
CallControlSettings()
}

View File

@@ -0,0 +1,76 @@
/*
* 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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
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.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun ConnectionSettings() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(top = 2.dp)
) {
EarDetectionSwitch()
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 12.dp, end = 0.dp)
)
AutomaticConnectionSwitch()
}
}
@Preview
@Composable
fun ConnectionSettingsPreview() {
ConnectionSettings()
}

View File

@@ -133,7 +133,7 @@ fun ConversationalAwarenessSwitch() {
.padding(end = 4.dp)
) {
Text(
text = "Conversational Awareness",
text = stringResource(R.string.conversational_awareness),
fontSize = 16.sp,
color = textColor
)

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/>.
*/
package me.kavishdevar.librepods.composables
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import me.kavishdevar.librepods.R
class DropdownItem(val name: String, val onSelect: () -> Unit) {
fun select() {
onSelect()
}
}
@Composable
fun CustomDropdown(name: String, description: String = "", items: List<DropdownItem>) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
var expanded by remember { mutableStateOf(false) }
var offset by remember { mutableStateOf(IntOffset.Zero) }
var popupHeight by remember { mutableStateOf(0.dp) }
val animatedHeight by animateDpAsState(
targetValue = if (expanded) popupHeight else 0.dp,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
)
val animatedScale by animateFloatAsState(
targetValue = if (expanded) 1f else 0f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
)
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(14.dp),
color = Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
expanded = true
}
.onGloballyPositioned { coordinates ->
val windowPosition = coordinates.localToWindow(Offset.Zero)
offset = IntOffset(windowPosition.x.toInt(), windowPosition.y.toInt() + coordinates.size.height)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = name,
fontSize = 16.sp,
color = textColor,
maxLines = 1
)
if (description.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
maxLines = 1
)
}
}
Text(
text = "\uDBC0\uDD8F",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
}
if (expanded) {
Popup(
alignment = Alignment.TopStart,
offset = offset ,
properties = PopupProperties(focusable = true),
onDismissRequest = { expanded = false }
) {
val density = LocalDensity.current
Column(
modifier = Modifier
.background(backgroundColor, RoundedCornerShape(8.dp))
.padding(8.dp)
.widthIn(max = 50.dp)
.height(animatedHeight)
.scale(animatedScale)
.onGloballyPositioned { coordinates ->
popupHeight = with(density) { coordinates.size.height.toDp() }
}
) {
items.forEach { item ->
Text(
text = item.name,
modifier = Modifier
.fillMaxWidth()
.clickable {
item.select()
expanded = false
}
.padding(8.dp),
color = textColor
)
}
}
}
}
}
@Preview
@Composable
fun CustomDropdownPreview() {
CustomDropdown(
name = "Volume Swipe Speed",
items = listOf(
DropdownItem("Always On") { },
DropdownItem("Off") { },
DropdownItem("Only when speaking") { }
)
)
}

View File

@@ -0,0 +1,176 @@
/*
* 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.getValue
import androidx.compose.runtime.LaunchedEffect
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 android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
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 shared_preference_key = "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(shared_preference_key, false)
}
)
}
fun updateEarDetection(enabled: Boolean) {
earDetectionEnabled = enabled
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG.value,
enabled
)
service.setEarDetection(enabled)
sharedPreferences.edit()
.putBoolean(shared_preference_key, 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(shared_preference_key, 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

@@ -0,0 +1,163 @@
/*
* 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.getValue
import androidx.compose.runtime.LaunchedEffect
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

@@ -75,6 +75,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -102,6 +103,7 @@ import me.kavishdevar.librepods.composables.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -124,6 +126,9 @@ fun AccessibilitySettingsScreen() {
val attManager = ATTManager(ServiceManager.getService()?.device?: throw IllegalStateException("No device connected"))
// get the AACP manager if available (used for EQ read/write)
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val context = LocalContext.current
val radareOffsetFinder = remember { RadareOffsetFinder(context) }
val isSdpOffsetAvailable = remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
@@ -457,76 +462,80 @@ fun AccessibilitySettingsScreen() {
}
}
AccessibilityToggle(
text = "Transparency Mode",
mutableState = enabled,
independent = true
)
Text(
text = stringResource(R.string.customize_transparency_mode_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
),
modifier = Modifier
.padding(horizontal = 2.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Customize Transparency Mode".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(8.dp)
) {
AccessibilitySlider(
label = "Amplification",
valueRange = -1f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f))
},
)
AccessibilitySlider(
label = "Balance",
valueRange = -1f..1f,
value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = snapIfClose(it, listOf(0f))
},
)
AccessibilitySlider(
label = "Tone",
valueRange = -1f..1f,
value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = snapIfClose(it, listOf(0f))
},
)
AccessibilitySlider(
label = "Ambient Noise Reduction",
valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f))
},
)
// Only show transparency mode section if SDP offset is available
if (isSdpOffsetAvailable.value) {
AccessibilityToggle(
text = "Conversation Boost",
mutableState = conversationBoostEnabled
text = "Transparency Mode",
mutableState = enabled,
independent = true
)
Text(
text = stringResource(R.string.customize_transparency_mode_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
),
modifier = Modifier
.padding(horizontal = 2.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Customize Transparency Mode".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(8.dp)
) {
AccessibilitySlider(
label = "Amplification",
valueRange = -1f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f))
},
)
AccessibilitySlider(
label = "Balance",
valueRange = -1f..1f,
value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = snapIfClose(it, listOf(0f))
},
)
AccessibilitySlider(
label = "Tone",
valueRange = -1f..1f,
value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = snapIfClose(it, listOf(0f))
},
)
AccessibilitySlider(
label = "Ambient Noise Reduction",
valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f))
},
)
AccessibilityToggle(
text = "Conversation Boost",
mutableState = conversationBoostEnabled
)
}
Spacer(modifier = Modifier.height(2.dp))
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "AUDIO",
style = TextStyle(
@@ -604,101 +613,105 @@ fun AccessibilitySettingsScreen() {
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "Equalizer".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
// Only show transparency mode EQ section if SDP offset is available
if (isSdpOffsetAvailable.value) {
Text(
text = "Equalizer".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
for (i in 0 until 8) {
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
) {
Text(
text = String.format("%.2f", eqValue.floatValue),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Slider(
value = eqValue.floatValue,
onValueChange = { newVal ->
eqValue.floatValue = newVal
val newEQ = eq.value.copyOf()
newEQ[i] = eqValue.floatValue
eq.value = newEQ
},
valueRange = 0f..100f,
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
for (i in 0 until 8) {
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth(0.9f)
.height(36.dp),
colors = SliderDefaults.colors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
inactiveTrackColor = trackColor
),
thumb = {
Box(
modifier = Modifier
.size(24.dp)
.shadow(4.dp, CircleShape)
.background(thumbColor, CircleShape)
)
},
track = {
Box (
modifier = Modifier
.fillMaxWidth()
.height(12.dp),
contentAlignment = Alignment.CenterStart
)
{
.fillMaxWidth()
.height(38.dp)
) {
Text(
text = String.format("%.2f", eqValue.floatValue),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Slider(
value = eqValue.floatValue,
onValueChange = { newVal ->
eqValue.floatValue = newVal
val newEQ = eq.value.copyOf()
newEQ[i] = eqValue.floatValue
eq.value = newEQ
},
valueRange = 0f..100f,
modifier = Modifier
.fillMaxWidth(0.9f)
.height(36.dp),
colors = SliderDefaults.colors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
inactiveTrackColor = trackColor
),
thumb = {
Box(
modifier = Modifier
.size(24.dp)
.shadow(4.dp, CircleShape)
.background(thumbColor, CircleShape)
)
},
track = {
Box (
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.background(trackColor, RoundedCornerShape(4.dp))
)
Box(
modifier = Modifier
.fillMaxWidth(eqValue.floatValue / 100f)
.height(4.dp)
.background(activeTrackColor, RoundedCornerShape(4.dp))
.height(12.dp),
contentAlignment = Alignment.CenterStart
)
{
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.background(trackColor, RoundedCornerShape(4.dp))
)
Box(
modifier = Modifier
.fillMaxWidth(eqValue.floatValue / 100f)
.height(4.dp)
.background(activeTrackColor, RoundedCornerShape(4.dp))
)
}
}
}
)
)
Text(
text = "Band ${i + 1}",
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(top = 4.dp)
)
Text(
text = "Band ${i + 1}",
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Apply EQ to".uppercase(),
style = TextStyle(

View File

@@ -94,6 +94,8 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.CustomDevice
import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings
import me.kavishdevar.librepods.composables.ConnectionSettings
import me.kavishdevar.librepods.composables.IndependentToggle
import me.kavishdevar.librepods.composables.NameField
import me.kavishdevar.librepods.composables.NavigationButton
@@ -353,11 +355,35 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
)
}
// Only show L2CAP-dependent features when not in BLE-only mode
if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(32.dp))
NoiseControlSettings(service = service)
Spacer(modifier = Modifier.height(16.dp))
CallControlSettings()
// camera control goes here, airpods side is done, i just need to figure out how to listen to app open/close events
Spacer(modifier = Modifier.height(16.dp))
PressAndHoldSettings(navController = navController)
Spacer(modifier = Modifier.height(16.dp))
AudioSettings()
Spacer(modifier = Modifier.height(16.dp))
ConnectionSettings()
// microphone settings
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = stringResource(R.string.sleep_detection),
service = service,
sharedPreferences = sharedPreferences,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.head_gestures).uppercase(),
@@ -369,43 +395,36 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Spacer(modifier = Modifier.height(2.dp))
NavigationButton(to = "head_tracking", "Head Tracking", navController)
Spacer(modifier = Modifier.height(16.dp))
PressAndHoldSettings(navController = navController)
Spacer(modifier = Modifier.height(16.dp))
AudioSettings()
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = "Off Listening Mode",
service = service,
sharedPreferences = sharedPreferences,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
)
}
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = "Automatic Ear Detection",
service = service,
functionName = "setEarDetection",
sharedPreferences = sharedPreferences,
default = true,
)
// Only show debug when not in BLE-only mode
if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(to = "", "Accessibility", navController = navController, onClick = {
val intent = Intent(context, CustomDevice::class.java)
context.startActivity(intent)
})
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = stringResource(R.string.off_listening_mode),
service = service,
sharedPreferences = sharedPreferences,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
)
Text(
text = stringResource(R.string.off_listening_mode_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(8.dp, top = 0.dp)
)
// an about card- everything but the version number is unknown - will add later if i find out
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)
}

View File

@@ -114,6 +114,10 @@ class AACPManager {
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34),
STEM_CONFIG(0x39),
SLEEP_DETECTION_CONFIG(0x35),
ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
EAR_DETECTION_CONFIG(0x0A),
AUTOMATIC_CONNECTION_CONFIG(0x20),
OWNS_CONNECTION(0x06);
companion object {
@@ -594,6 +598,8 @@ class AACPManager {
fun createRequestNotificationPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00)
val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
// note to self #1: third byte is 0xfd when ear detection is disabled
// note to self #2: this can be sent any time, not just at the start of the aacp connection
return opcode + data
}

View File

@@ -21,7 +21,6 @@
<string name="head_gestures">头部手势</string>
<string name="left">左耳</string>
<string name="right">右耳</string>
<string name="adjusts_volume">根据环境调整媒体音量</string>
<string name="conversational_awareness">对话感知</string>
<string name="conversational_awareness_description">当你开始与他人交谈时,会降低媒体音量并减少背景噪音。</string>
<string name="personalized_volume">个性化音量</string>

View File

@@ -21,7 +21,6 @@
<string name="head_gestures">Head Gestures</string>
<string name="left">Left</string>
<string name="right">Right</string>
<string name="adjusts_volume">Adjusts the volume of media in response to your environment</string>
<string name="conversational_awareness">Conversational Awareness</string>
<string name="conversational_awareness_description">Lowers media volume and reduces background noise when you start speaking to other people.</string>
<string name="personalized_volume">Personalized Volume</string>
@@ -85,4 +84,10 @@
<string name="customize_transparency_mode_description">You can customize Transparency mode for your AirPods Pro to help you hear what\'s around you.</string>
<string name="loud_sound_reduction_description">AirPods Pro automatically reduce your exposure to loud environmental noises when in Transparency and Adaptive mode.</string>
<string name="loud_sound_reduction">Loud Sound Reduction</string>
<string name="call_controls">Call Controls</string>
<string name="automatically_connect">Connect to this device automatically</string>
<string name="automatically_connect_description">When enabled, AirPods will try to connect to this device automatically. Else, they will try to autoconnect only when last connected.</string>
<string name="sleep_detection">Pause media when falling asleep</string>
<string name="off_listening_mode">Off Listening Mode</string>
<string name="off_listening_mode_description">When this is on, AirPods listening modes will include an Off option. Loud sound levels are not reduced when the listening mode is set to Off.</string>
</resources>