diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt index 5e7cf63..25a0c4e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt @@ -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() } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt new file mode 100644 index 0000000..994da45 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt @@ -0,0 +1,182 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 LibrePods contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.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() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt new file mode 100644 index 0000000..56bd43f --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt @@ -0,0 +1,287 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 LibrePods contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.composables + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.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() +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt new file mode 100644 index 0000000..58ffa14 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt @@ -0,0 +1,76 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 LibrePods contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.composables + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.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() +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt index 46fa6f6..7492a62 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt @@ -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 ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt deleted file mode 100644 index a4d37b6..0000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * LibrePods - AirPods liberated from Apple’s ecosystem - * - * Copyright (C) 2025 LibrePods contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -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) { - 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") { } - ) - ) -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt new file mode 100644 index 0000000..37ef7c8 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt @@ -0,0 +1,176 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 LibrePods contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.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() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt new file mode 100644 index 0000000..14bb876 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt @@ -0,0 +1,163 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 LibrePods contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.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() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt index 9e587e3..d920461 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt @@ -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( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt index 545f6fb..e4c1e65 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt @@ -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) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt index 02e9993..6200348 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt @@ -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 } diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 8674767..8ebffb3 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -21,7 +21,6 @@ 头部手势 左耳 右耳 - 根据环境调整媒体音量 对话感知 当你开始与他人交谈时,会降低媒体音量并减少背景噪音。 个性化音量 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3d84861..fc1043d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -21,7 +21,6 @@ Head Gestures Left Right - Adjusts the volume of media in response to your environment Conversational Awareness Lowers media volume and reduces background noise when you start speaking to other people. Personalized Volume @@ -85,4 +84,10 @@ You can customize Transparency mode for your AirPods Pro to help you hear what\'s around you. AirPods Pro automatically reduce your exposure to loud environmental noises when in Transparency and Adaptive mode. Loud Sound Reduction + Call Controls + Connect to this device automatically + When enabled, AirPods will try to connect to this device automatically. Else, they will try to autoconnect only when last connected. + Pause media when falling asleep + Off Listening Mode + 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.