diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 9dba146..0395239 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -112,6 +112,8 @@ import me.kavishdevar.librepods.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.screens.AppSettingsScreen import me.kavishdevar.librepods.screens.DebugScreen import me.kavishdevar.librepods.screens.HeadTrackingScreen +import me.kavishdevar.librepods.screens.HearingAidScreen +import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.screens.Onboarding import me.kavishdevar.librepods.screens.RenameScreen @@ -380,6 +382,12 @@ fun Main() { composable("onboarding") { Onboarding(navController, context) } + composable("hearing_aid") { + HearingAidScreen(navController) + } + composable("hearing_aid_adjustments") { + HearingAidAdjustmentsScreen(navController) + } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt index abd8d14..2141a01 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt @@ -49,10 +49,11 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun AccessibilitySlider( - label: String, + label: String? = null, value: Float, onValueChange: (Float) -> Unit, - valueRange: ClosedFloatingPointRange + valueRange: ClosedFloatingPointRange, + widthFrac: Float = 1f ) { val isDarkTheme = isSystemInDarkTheme() @@ -62,18 +63,20 @@ fun AccessibilitySlider( val labelTextColor = if (isDarkTheme) Color.White else Color.Black Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(widthFrac), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = label, - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = labelTextColor, - fontFamily = androidx.compose.ui.text.font.FontFamily(Font(R.font.sf_pro)) + if (label != null) { + Text( + text = label, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = labelTextColor, + fontFamily = androidx.compose.ui.text.font.FontFamily(Font(R.font.sf_pro)) + ) ) - ) + } Slider( value = value, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt index f40364b..c83cc34 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt @@ -27,7 +27,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement 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 @@ -45,17 +48,22 @@ 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.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.R import me.kavishdevar.librepods.utils.AACPManager import kotlin.io.encoding.ExperimentalEncodingApi import androidx.core.content.edit import android.util.Log @Composable -fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null) { +fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null, description: String? = null) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black val snakeCasedName = @@ -109,39 +117,57 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam } } } - Box ( + Column ( modifier = Modifier - .padding(vertical = 8.dp) - .background(animatedBackgroundColor, RoundedCornerShape(14.dp)) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) - tryAwaitRelease() - backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - }, - onTap = { - checked = !checked - cb() - } - ) - }, - ) - { - Row( + .padding(vertical = 8.dp), + ) { + Box ( modifier = Modifier - .fillMaxWidth() - .height(55.dp) - .padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = name, modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor) - StyledSwitch( - checked = checked, - onCheckedChange = { - checked = it - cb() + .background(animatedBackgroundColor, RoundedCornerShape(14.dp)) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + checked = !checked + cb() + } + ) }, + ) + { + Row( + modifier = Modifier + .fillMaxWidth() + .height(55.dp) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = name, modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor) + StyledSwitch( + checked = checked, + onCheckedChange = { + checked = it + cb() + }, + ) + } + } + if (description != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier + .padding(horizontal = 8.dp) ) } } 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 9878f1f..193d162 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 @@ -111,9 +111,9 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.io.encoding.ExperimentalEncodingApi -var debounceJob: Job? = null -var phoneMediaDebounceJob: Job? = null -const val TAG = "AccessibilitySettings" +private var debounceJob: Job? = null +private var phoneMediaDebounceJob: Job? = null +private const val TAG = "AccessibilitySettings" @SuppressLint("DefaultLocale") @ExperimentalHazeMaterialsApi @@ -150,7 +150,7 @@ fun AccessibilitySettingsScreen() { CenterAlignedTopAppBar( title = { Text( - text = "Accessibility Settings", + text = stringResource(R.string.accessibility), style = TextStyle( fontSize = 20.sp, fontWeight = FontWeight.Medium, @@ -452,79 +452,232 @@ fun AccessibilitySettingsScreen() { // Only show transparency mode section if SDP offset is available if (isSdpOffsetAvailable.value) { AccessibilityToggle( - text = "Transparency Mode", + text = stringResource(R.string.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) + independent = true, + description = stringResource(R.string.customize_transparency_mode_description) ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Customize Transparency Mode".uppercase(), + text = stringResource(R.string.amplification).uppercase(), style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Light, color = textColor.copy(alpha = 0.6f), fontFamily = FontFamily(Font(R.font.sf_pro)) ), - modifier = Modifier.padding(8.dp, bottom = 2.dp) + modifier = Modifier.padding(8.dp, bottom = 0.dp) ) - Column( + Box( modifier = Modifier .fillMaxWidth() .background(backgroundColor, RoundedCornerShape(14.dp)) - .padding(8.dp) + .padding(horizontal = 8.dp, vertical = 0.dp) + .height(55.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 - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxSize() + ) { + Text( + text = "􀊥", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(start = 4.dp) + ) + AccessibilitySlider( + valueRange = -1f..1f, + value = amplificationSliderValue.floatValue, + onValueChange = { + amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f)) + }, + widthFrac = 0.90f, + ) + Text( + text = "􀊩", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(end = 4.dp) + ) + } } - Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = stringResource(R.string.balance).uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(8.dp, bottom = 0.dp) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + .padding(horizontal = 8.dp, vertical = 0.dp) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.left), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = stringResource(R.string.right), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + AccessibilitySlider( + valueRange = -1f..1f, + value = balanceSliderValue.floatValue, + onValueChange = { + balanceSliderValue.floatValue = snapIfClose(it, listOf(0f)) + }, + ) + } + } + + Text( + text = stringResource(R.string.tone).uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(8.dp, bottom = 0.dp) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + .padding(horizontal = 8.dp, vertical = 0.dp) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.darker), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = stringResource(R.string.brighter), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + AccessibilitySlider( + valueRange = -1f..1f, + value = toneSliderValue.floatValue, + onValueChange = { + toneSliderValue.floatValue = snapIfClose(it, listOf(0f)) + }, + ) + } + } + + Text( + text = stringResource(R.string.ambient_noise_reduction).uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(8.dp, bottom = 0.dp) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + .padding(horizontal = 8.dp, vertical = 0.dp) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.less), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = stringResource(R.string.more), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + AccessibilitySlider( + valueRange = 0f..1f, + value = ambientNoiseReductionSliderValue.floatValue, + onValueChange = { + ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f)) + }, + ) + } + } + + AccessibilityToggle( + text = stringResource(R.string.conversation_boost), + mutableState = conversationBoostEnabled, + independent = true, + description = stringResource(R.string.conversation_boost_description) + ) } Text( - text = "AUDIO", + text = stringResource(R.string.audio).uppercase(), style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Light, @@ -542,7 +695,7 @@ fun AccessibilitySettingsScreen() { verticalArrangement = Arrangement.SpaceBetween ) { Text( - text = "Tone Volume", + text = stringResource(R.string.tone_volume), style = TextStyle( fontSize = 16.sp, fontFamily = FontFamily(Font(R.font.sf_pro)), @@ -559,8 +712,8 @@ fun AccessibilitySettingsScreen() { LoudSoundReductionSwitch() DropdownMenuComponent( - label = "Press Speed", - options = pressSpeedOptions.values.toList(), + label = stringResource(R.string.press_speed), + options = listOf(stringResource(R.string.default_option), stringResource(R.string.slower), stringResource(R.string.slowest)), selectedOption = selectedPressSpeed.toString(), onOptionSelected = { newValue -> selectedPressSpeed = newValue @@ -572,8 +725,8 @@ fun AccessibilitySettingsScreen() { textColor = textColor ) DropdownMenuComponent( - label = "Press and Hold Duration", - options = pressAndHoldDurationOptions.values.toList(), + label = stringResource(R.string.press_and_hold_duration), + options = listOf(stringResource(R.string.default_option), stringResource(R.string.slower), stringResource(R.string.slowest)), selectedOption = selectedPressAndHoldDuration.toString(), onOptionSelected = { newValue -> selectedPressAndHoldDuration = newValue @@ -585,8 +738,8 @@ fun AccessibilitySettingsScreen() { textColor = textColor ) DropdownMenuComponent( - label = "Volume Swipe Speed", - options = volumeSwipeSpeedOptions.values.toList(), + label = stringResource(R.string.volume_swipe_speed), + options = listOf(stringResource(R.string.default_option), stringResource(R.string.longer), stringResource(R.string.longest)), selectedOption = selectedVolumeSwipeSpeed.toString(), onOptionSelected = { newValue -> selectedVolumeSwipeSpeed = newValue @@ -603,7 +756,7 @@ fun AccessibilitySettingsScreen() { // Only show transparency mode EQ section if SDP offset is available if (isSdpOffsetAvailable.value) { Text( - text = "Equalizer".uppercase(), + text = stringResource(R.string.equalizer).uppercase(), style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Light, @@ -687,7 +840,7 @@ fun AccessibilitySettingsScreen() { ) Text( - text = "Band ${i + 1}", + text = stringResource(R.string.band_label, i + 1), fontSize = 12.sp, color = textColor, modifier = Modifier.padding(top = 4.dp) @@ -700,7 +853,7 @@ fun AccessibilitySettingsScreen() { } Text( - text = "Apply EQ to".uppercase(), + text = stringResource(R.string.apply_eq_to).uppercase(), style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Light, @@ -740,7 +893,7 @@ fun AccessibilitySettingsScreen() { verticalAlignment = Alignment.CenterVertically ) { Text( - "Phone", + stringResource(R.string.phone), fontSize = 16.sp, color = textColor, fontFamily = FontFamily(Font(R.font.sf_pro)), @@ -791,7 +944,7 @@ fun AccessibilitySettingsScreen() { verticalAlignment = Alignment.CenterVertically ) { Text( - "Media", + stringResource(R.string.media), fontSize = 16.sp, color = textColor, fontFamily = FontFamily(Font(R.font.sf_pro)), @@ -888,7 +1041,7 @@ fun AccessibilitySettingsScreen() { ) Text( - text = "Band ${i + 1}", + text = stringResource(R.string.band_label, i + 1), fontSize = 12.sp, color = textColor, modifier = Modifier.padding(top = 4.dp) @@ -902,57 +1055,75 @@ fun AccessibilitySettingsScreen() { @Composable -fun AccessibilityToggle(text: String, mutableState: MutableState, independent: Boolean = false) { +fun AccessibilityToggle(text: String, mutableState: MutableState, independent: Boolean = false, description: String? = null) { val isDarkTheme = isSystemInDarkTheme() var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) val textColor = if (isDarkTheme) Color.White else Color.Black - val boxPaddings = if (independent) 2.dp else 4.dp val cornerShape = if (independent) RoundedCornerShape(14.dp) else RoundedCornerShape(0.dp) - Box ( + + Column( modifier = Modifier - .padding(vertical = boxPaddings) - .background(animatedBackgroundColor, cornerShape) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) - tryAwaitRelease() - backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - }, - onTap = { - mutableState.value = !mutableState.value - } - ) - }, - ) - { - val rowHeight = if (independent) 55.dp else 50.dp - val rowPadding = if (independent) 12.dp else 4.dp - Row( + .padding(vertical = 8.dp) + ) { + Box ( modifier = Modifier - .fillMaxWidth() - .height(rowHeight) - .padding(horizontal = rowPadding), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = text, - modifier = Modifier.weight(1f), - fontSize = 16.sp, - color = textColor - ) - StyledSwitch( - checked = mutableState.value, - onCheckedChange = { - mutableState.value = it + .background(animatedBackgroundColor, cornerShape) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + mutableState.value = !mutableState.value + } + ) }, + ) + { + val rowHeight = if (independent) 55.dp else 50.dp + val rowPadding = if (independent) 12.dp else 4.dp + Row( + modifier = Modifier + .fillMaxWidth() + .height(rowHeight) + .padding(horizontal = rowPadding), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + modifier = Modifier.weight(1f), + fontSize = 16.sp, + color = textColor + ) + StyledSwitch( + checked = mutableState.value, + onCheckedChange = { + mutableState.value = it + }, + ) + } + } + if (description != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier + .padding(horizontal = 8.dp) ) } } } -data class TransparencySettings ( +private data class TransparencySettings ( val enabled: Boolean, val leftEQ: FloatArray, val rightEQ: FloatArray, @@ -1134,7 +1305,7 @@ private fun snapIfClose(value: Float, points: List, threshold: Float = 0. } @Composable -fun DropdownMenuComponent( +private fun DropdownMenuComponent( label: String, options: List, selectedOption: String, 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 0f449a4..8a1419d 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 @@ -358,6 +358,9 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, if (!bleOnlyMode) { Spacer(modifier = Modifier.height(32.dp)) + NavigationButton(to = "hearing_aid", stringResource(R.string.hearing_aid), navController) + + Spacer(modifier = Modifier.height(16.dp)) NoiseControlSettings(service = service) Spacer(modifier = Modifier.height(16.dp)) @@ -401,17 +404,8 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, 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) + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION, + description = stringResource(R.string.off_listening_mode_description) ) // an about card- everything but the version number is unknown - will add later if i find out diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt new file mode 100644 index 0000000..287c8a8 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt @@ -0,0 +1,799 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 LibrePods contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import dev.chrisbanes.haze.HazeEffectScope +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.CupertinoMaterials +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.AccessibilitySlider +import me.kavishdevar.librepods.composables.IndependentToggle +import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch +import me.kavishdevar.librepods.composables.SinglePodANCSwitch +import me.kavishdevar.librepods.composables.StyledSwitch +import me.kavishdevar.librepods.composables.ToneVolumeSlider +import me.kavishdevar.librepods.composables.VolumeControlSwitch +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.ATTManager +import me.kavishdevar.librepods.utils.ATTHandles +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.RadareOffsetFinder +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.io.encoding.ExperimentalEncodingApi + +private var debounceJob: Job? = null +private var phoneMediaDebounceJob: Job? = null +private const val TAG = "AccessibilitySettings" + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun HearingAidAdjustmentsScreen(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val verticalScrollState = rememberScrollState() + val hazeState = remember { HazeState() } + val snackbarHostState = remember { SnackbarHostState() } + val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") + + val aacpManager = remember { ServiceManager.getService()?.aacpManager } + val context = LocalContext.current + val radareOffsetFinder = remember { RadareOffsetFinder(context) } + val isSdpOffsetAvailable = remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) } + val service = ServiceManager.getService() + + val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) + val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) + val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) + val labelTextColor = if (isDarkTheme) Color.White else Color.Black + + Scaffold( + containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), + topBar = { + val darkMode = isSystemInDarkTheme() + val mDensity = remember { mutableFloatStateOf(1f) } + + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(R.string.adjustments), + style = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + color = if (darkMode) Color.White else Color.Black, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + }, + modifier = Modifier + .hazeEffect( + state = hazeState, + style = CupertinoMaterials.thick(), + block = fun HazeEffectScope.() { + alpha = if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f + }) + .drawBehind { + mDensity.floatValue = density + val strokeWidth = 0.7.dp.value * density + val y = size.height - strokeWidth / 2 + if (verticalScrollState.value > 60.dp.value * density) { + drawLine( + if (darkMode) Color.DarkGray else Color.LightGray, + Offset(0f, y), + Offset(size.width, y), + strokeWidth + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .hazeSource(hazeState) + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp) + .verticalScroll(verticalScrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + val enabled = remember { mutableStateOf(false) } + val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) } + val balanceSliderValue = remember { mutableFloatStateOf(0.5f) } + val toneSliderValue = remember { mutableFloatStateOf(0.5f) } + val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) } + val conversationBoostEnabled = remember { mutableStateOf(false) } + val eq = remember { mutableStateOf(FloatArray(8)) } + + val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } + val phoneEQEnabled = remember { mutableStateOf(false) } + val mediaEQEnabled = remember { mutableStateOf(false) } + + val initialLoadComplete = remember { mutableStateOf(false) } + + val initialReadSucceeded = remember { mutableStateOf(false) } + val initialReadAttempts = remember { mutableStateOf(0) } + + val HearingAidSettings = remember { + mutableStateOf( + HearingAidSettings( + enabled = enabled.value, + leftEQ = eq.value, + rightEQ = eq.value, + leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2, + rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2, + leftTone = toneSliderValue.floatValue, + rightTone = toneSliderValue.floatValue, + leftConversationBoost = conversationBoostEnabled.value, + rightConversationBoost = conversationBoostEnabled.value, + leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + netAmplification = amplificationSliderValue.floatValue, + balance = balanceSliderValue.floatValue + ) + ) + } + + val hearingAidEnabled = remember { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) + } + + val hearingAidListener = remember { + object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value || + controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()) + } + } + } + } + + LaunchedEffect(Unit) { + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + } + + DisposableEffect(Unit) { + onDispose { + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + } + } + + LaunchedEffect(enabled.value, amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, eq.value, initialLoadComplete.value, initialReadSucceeded.value) { + if (!initialLoadComplete.value) { + Log.d(TAG, "Initial device load not complete - skipping send") + return@LaunchedEffect + } + + if (!initialReadSucceeded.value) { + Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds") + return@LaunchedEffect + } + + HearingAidSettings.value = HearingAidSettings( + enabled = enabled.value, + leftEQ = eq.value, + rightEQ = eq.value, + leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f, + rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f, + leftTone = toneSliderValue.floatValue, + rightTone = toneSliderValue.floatValue, + leftConversationBoost = conversationBoostEnabled.value, + rightConversationBoost = conversationBoostEnabled.value, + leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, + netAmplification = amplificationSliderValue.floatValue, + balance = balanceSliderValue.floatValue + ) + Log.d("HearingAidSettings", "Updated settings: ${HearingAidSettings.value}") + // sendHearingAidSettings(attManager, HearingAidSettings.value) + } + + DisposableEffect(Unit) { + onDispose { + // attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener) + } + } + + LaunchedEffect(Unit) { + Log.d(TAG, "Connecting to ATT...") + try { + // attManager.enableNotifications(ATTHandles.TRANSPARENCY) + // attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener) + + try { + if (aacpManager != null) { + Log.d(TAG, "Found AACPManager, reading cached EQ data") + val aacpEQ = aacpManager.eqData + if (aacpEQ.isNotEmpty()) { + eq.value = aacpEQ.copyOf() + phoneMediaEQ.value = aacpEQ.copyOf() + phoneEQEnabled.value = aacpManager.eqOnPhone + mediaEQEnabled.value = aacpManager.eqOnMedia + Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}") + } else { + Log.d(TAG, "AACPManager EQ data empty") + } + } else { + Log.d(TAG, "No AACPManager available") + } + } catch (e: Exception) { + Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}") + } + + /* + var parsedSettings: HearingAidSettings? = null + for (attempt in 1..3) { + initialReadAttempts.value = attempt + try { + val data = attManager.read(ATTHandles.TRANSPARENCY) + parsedSettings = parseHearingAidSettingsResponse(data = data) + if (parsedSettings != null) { + Log.d(TAG, "Parsed settings on attempt $attempt") + break + } else { + Log.d(TAG, "Parsing returned null on attempt $attempt") + } + } catch (e: Exception) { + Log.w(TAG, "Read attempt $attempt failed: ${e.message}") + } + delay(200) + } + + if (parsedSettings != null) { + Log.d(TAG, "Initial transparency settings: $parsedSettings") + enabled.value = parsedSettings.enabled + amplificationSliderValue.floatValue = parsedSettings.netAmplification + balanceSliderValue.floatValue = parsedSettings.balance + toneSliderValue.floatValue = parsedSettings.leftTone + ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction + conversationBoostEnabled.value = parsedSettings.leftConversationBoost + eq.value = parsedSettings.leftEQ.copyOf() + initialReadSucceeded.value = true + } else { + Log.d(TAG, "Failed to read/parse initial transparency settings after ${initialReadAttempts.value} attempts") + } + */ + } catch (e: IOException) { + e.printStackTrace() + } finally { + initialLoadComplete.value = true + } + } + + LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { + phoneMediaDebounceJob?.cancel() + phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { + delay(150) + val manager = ServiceManager.getService()?.aacpManager + if (manager == null) { + Log.w(TAG, "Cannot write EQ: AACPManager not available") + return@launch + } + try { + val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() + val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() + Log.d(TAG, "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})") + manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) + } catch (e: Exception) { + Log.w(TAG, "Error sending phone/media EQ: ${e.message}") + } + } + } + + val isDarkThemeLocal = isSystemInDarkTheme() + var backgroundColorHA by remember { mutableStateOf(if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } + val animatedBackgroundColorHA by animateColorAsState(targetValue = backgroundColorHA, animationSpec = tween(durationMillis = 500)) + + Text( + text = stringResource(R.string.amplification).uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(8.dp, bottom = 0.dp) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + .padding(horizontal = 8.dp, vertical = 0.dp) + .height(55.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxSize() + ) { + Text( + text = "􀊥", + style = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(start = 4.dp) + ) + AccessibilitySlider( + valueRange = -1f..1f, + value = amplificationSliderValue.floatValue, + onValueChange = { + amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f)) + }, + widthFrac = 0.90f + ) + Text( + text = "􀊩", + style = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(end = 4.dp) + ) + } + } + + val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + + IndependentToggle( + name = stringResource(R.string.swipe_to_control_amplification), + service = service, + sharedPreferences = sharedPreferences, + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, + description = stringResource(R.string.swipe_amplification_description) + ) + + Text( + text = stringResource(R.string.balance).uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(8.dp, bottom = 0.dp) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + .padding(horizontal = 8.dp, vertical = 0.dp) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.left), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = stringResource(R.string.right), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + AccessibilitySlider( + valueRange = -1f..1f, + value = balanceSliderValue.floatValue, + onValueChange = { + balanceSliderValue.floatValue = snapIfClose(it, listOf(0f)) + }, + ) + } + } + + Text( + text = stringResource(R.string.tone).uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(8.dp, bottom = 0.dp) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + .padding(horizontal = 8.dp, vertical = 0.dp) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.darker), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = stringResource(R.string.brighter), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + AccessibilitySlider( + valueRange = -1f..1f, + value = toneSliderValue.floatValue, + onValueChange = { + toneSliderValue.floatValue = snapIfClose(it, listOf(0f)) + }, + ) + } + } + + Text( + text = stringResource(R.string.ambient_noise_reduction).uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(8.dp, bottom = 0.dp) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + .padding(horizontal = 8.dp, vertical = 0.dp) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.less), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = stringResource(R.string.more), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + AccessibilitySlider( + valueRange = 0f..1f, + value = ambientNoiseReductionSliderValue.floatValue, + onValueChange = { + ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f)) + }, + ) + } + } + + AccessibilityToggle( + text = stringResource(R.string.conversation_boost), + mutableState = conversationBoostEnabled, + independent = true, + description = stringResource(R.string.conversation_boost_description) + ) + } + } +} + +private data class HearingAidSettings( + val enabled: Boolean, + val leftEQ: FloatArray, + val rightEQ: FloatArray, + val leftAmplification: Float, + val rightAmplification: Float, + val leftTone: Float, + val rightTone: Float, + val leftConversationBoost: Boolean, + val rightConversationBoost: Boolean, + val leftAmbientNoiseReduction: Float, + val rightAmbientNoiseReduction: Float, + val netAmplification: Float, + val balance: Float +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HearingAidSettings + + if (enabled != other.enabled) return false + if (leftAmplification != other.leftAmplification) return false + if (rightAmplification != other.rightAmplification) return false + if (leftTone != other.leftTone) return false + if (rightTone != other.rightTone) return false + if (leftConversationBoost != other.leftConversationBoost) return false + if (rightConversationBoost != other.rightConversationBoost) return false + if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false + if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false + if (!leftEQ.contentEquals(other.leftEQ)) return false + if (!rightEQ.contentEquals(other.rightEQ)) return false + + return true + } + + override fun hashCode(): Int { + var result = enabled.hashCode() + result = 31 * result + leftAmplification.hashCode() + result = 31 * result + rightAmplification.hashCode() + result = 31 * result + leftTone.hashCode() + result = 31 * result + rightTone.hashCode() + result = 31 * result + leftConversationBoost.hashCode() + result = 31 * result + rightConversationBoost.hashCode() + result = 31 * result + leftAmbientNoiseReduction.hashCode() + result = 31 * result + rightAmbientNoiseReduction.hashCode() + result = 31 * result + leftEQ.contentHashCode() + result = 31 * result + rightEQ.contentHashCode() + return result + } +} + +private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? { + val settingsData = data.copyOfRange(1, data.size) + val buffer = ByteBuffer.wrap(settingsData).order(ByteOrder.LITTLE_ENDIAN) + + val enabled = buffer.float + Log.d(TAG, "Parsed enabled: $enabled") + + val leftEQ = FloatArray(8) + for (i in 0..7) { + leftEQ[i] = buffer.float + Log.d(TAG, "Parsed left EQ${i+1}: ${leftEQ[i]}") + } + val leftAmplification = buffer.float + Log.d(TAG, "Parsed left amplification: $leftAmplification") + val leftTone = buffer.float + Log.d(TAG, "Parsed left tone: $leftTone") + val leftConvFloat = buffer.float + val leftConversationBoost = leftConvFloat > 0.5f + Log.d(TAG, "Parsed left conversation boost: $leftConvFloat ($leftConversationBoost)") + val leftAmbientNoiseReduction = buffer.float + Log.d(TAG, "Parsed left ambient noise reduction: $leftAmbientNoiseReduction") + + val rightEQ = FloatArray(8) + for (i in 0..7) { + rightEQ[i] = buffer.float + Log.d(TAG, "Parsed right EQ${i+1}: $rightEQ[i]") + } + + val rightAmplification = buffer.float + Log.d(TAG, "Parsed right amplification: $rightAmplification") + val rightTone = buffer.float + Log.d(TAG, "Parsed right tone: $rightTone") + val rightConvFloat = buffer.float + val rightConversationBoost = rightConvFloat > 0.5f + Log.d(TAG, "Parsed right conversation boost: $rightConvFloat ($rightConversationBoost)") + val rightAmbientNoiseReduction = buffer.float + Log.d(TAG, "Parsed right ambient noise reduction: $rightAmbientNoiseReduction") + + Log.d(TAG, "Settings parsed successfully") + + val avg = (leftAmplification + rightAmplification) / 2 + val amplification = avg.coerceIn(-1f, 1f) + val diff = rightAmplification - leftAmplification + val balance = diff.coerceIn(-1f, 1f) + + return HearingAidSettings( + enabled = enabled > 0.5f, + leftEQ = leftEQ, + rightEQ = rightEQ, + leftAmplification = leftAmplification, + rightAmplification = rightAmplification, + leftTone = leftTone, + rightTone = rightTone, + leftConversationBoost = leftConversationBoost, + rightConversationBoost = rightConversationBoost, + leftAmbientNoiseReduction = leftAmbientNoiseReduction, + rightAmbientNoiseReduction = rightAmbientNoiseReduction, + netAmplification = amplification, + balance = balance + ) +} + +private fun sendHearingAidSettings( + attManager: ATTManager, + HearingAidSettings: HearingAidSettings +) { + debounceJob?.cancel() + debounceJob = CoroutineScope(Dispatchers.IO).launch { + delay(100) + try { + val buffer = ByteBuffer.allocate(100).order(ByteOrder.LITTLE_ENDIAN) + + Log.d(TAG, + "Sending settings: $HearingAidSettings" + ) + + buffer.putFloat(if (HearingAidSettings.enabled) 1.0f else 0.0f) + + for (eq in HearingAidSettings.leftEQ) { + buffer.putFloat(eq) + } + buffer.putFloat(HearingAidSettings.leftAmplification) + buffer.putFloat(HearingAidSettings.leftTone) + buffer.putFloat(if (HearingAidSettings.leftConversationBoost) 1.0f else 0.0f) + buffer.putFloat(HearingAidSettings.leftAmbientNoiseReduction) + + for (eq in HearingAidSettings.rightEQ) { + buffer.putFloat(eq) + } + buffer.putFloat(HearingAidSettings.rightAmplification) + buffer.putFloat(HearingAidSettings.rightTone) + buffer.putFloat(if (HearingAidSettings.rightConversationBoost) 1.0f else 0.0f) + buffer.putFloat(HearingAidSettings.rightAmbientNoiseReduction) + + val data = buffer.array() + attManager.write( + ATTHandles.TRANSPARENCY, + value = data + ) + } catch (e: IOException) { + e.printStackTrace() + } + } +} + +private fun sendPhoneMediaEQ(aacpManager: me.kavishdevar.librepods.utils.AACPManager?, eq: FloatArray, phoneEnabled: Boolean, mediaEnabled: Boolean) { + phoneMediaDebounceJob?.cancel() + phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { + delay(100) + try { + if (aacpManager == null) { + Log.w(TAG, "AACPManger is null; cannot send phone/media EQ") + return@launch + } + val phoneByte = if (phoneEnabled) 0x01.toByte() else 0x02.toByte() + val mediaByte = if (mediaEnabled) 0x01.toByte() else 0x02.toByte() + aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte) + } catch (e: Exception) { + Log.w(TAG, "Error in sendPhoneMediaEQ: ${e.message}") + } + } +} + +private fun snapIfClose(value: Float, points: List, threshold: Float = 0.05f): Float { + val nearest = points.minByOrNull { kotlin.math.abs(it - value) } ?: value + return if (kotlin.math.abs(nearest - value) <= threshold) nearest else value +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt new file mode 100644 index 0000000..e7d6084 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt @@ -0,0 +1,341 @@ +/* + * LibrePods - AirPods liberated from Apple’s ecosystem + * + * Copyright (C) 2025 LibrePods contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.screens + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import dev.chrisbanes.haze.HazeEffectScope +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.CupertinoMaterials +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.AccessibilitySlider +import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch +import me.kavishdevar.librepods.composables.SinglePodANCSwitch +import me.kavishdevar.librepods.composables.StyledSwitch +import me.kavishdevar.librepods.composables.ToneVolumeSlider +import me.kavishdevar.librepods.composables.VolumeControlSwitch +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.ATTManager +import me.kavishdevar.librepods.utils.ATTHandles +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.RadareOffsetFinder +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.io.encoding.ExperimentalEncodingApi + +private var debounceJob: Job? = null +private var phoneMediaDebounceJob: Job? = null +private const val TAG = "AccessibilitySettings" + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun HearingAidScreen(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val verticalScrollState = rememberScrollState() + val hazeState = remember { HazeState() } + val snackbarHostState = remember { SnackbarHostState() } + val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") + + val aacpManager = remember { ServiceManager.getService()?.aacpManager } + val context = LocalContext.current + val radareOffsetFinder = remember { RadareOffsetFinder(context) } + val isSdpOffsetAvailable = remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) } + val service = ServiceManager.getService() + + val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) + val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) + val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) + val labelTextColor = if (isDarkTheme) Color.White else Color.Black + + Scaffold( + containerColor = if (isSystemInDarkTheme()) Color( + 0xFF000000 + ) else Color( + 0xFFF2F2F7 + ), + topBar = { + val darkMode = isSystemInDarkTheme() + val mDensity = remember { mutableFloatStateOf(1f) } + + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(R.string.hearing_aid), + style = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + color = if (darkMode) Color.White else Color.Black, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + }, + modifier = Modifier + .hazeEffect( + state = hazeState, + style = CupertinoMaterials.thick(), + block = fun HazeEffectScope.() { + alpha = + if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f + }) + .drawBehind { + mDensity.floatValue = density + val strokeWidth = 0.7.dp.value * density + val y = size.height - strokeWidth / 2 + if (verticalScrollState.value > 60.dp.value * density) { + drawLine( + if (darkMode) Color.DarkGray else Color.LightGray, + Offset(0f, y), + Offset(size.width, y), + strokeWidth + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .hazeSource(hazeState) + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp) + .verticalScroll(verticalScrollState), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val hearingAidEnabled = remember { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) + } + + val hearingAidListener = remember { + object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value || + controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()) + } + } + } + } + + LaunchedEffect(Unit) { + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + } + + DisposableEffect(Unit) { + onDispose { + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + } + } + + fun onChange(value: Boolean) { + if (value) { + // Enable and enroll if not enrolled + val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte() + if (!enrolled) { + aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) // Enroll and enable + } else { + aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) // Enable + } + aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte()) // Enable assist + } else { + // Disable both, keep enrolled + aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02)) // Disable + aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte()) // Disable assist + } + hearingAidEnabled.value = value + } + + Text( + text = stringResource(R.string.hearing_aid).uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f) + ), + modifier = Modifier.padding(8.dp, bottom = 2.dp) + ) + + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + ) { + val isDarkThemeLocal = isSystemInDarkTheme() + var backgroundColorHA by remember { mutableStateOf(if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } + val animatedBackgroundColorHA by animateColorAsState(targetValue = backgroundColorHA, animationSpec = tween(durationMillis = 500)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + backgroundColorHA = if (isDarkThemeLocal) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColorHA = if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + onChange(value = !hearingAidEnabled.value) + } + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(R.string.hearing_aid), modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor) + StyledSwitch( + checked = hearingAidEnabled.value, + onCheckedChange = { + onChange(value = it) + }, + ) + } + + HorizontalDivider( + thickness = 1.5.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(start = 12.dp, end = 0.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { navController.navigate("hearing_aid_adjustments") } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.adjustments), + fontSize = 16.sp, + color = textColor + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = textColor + ) + } + } + + Text( + text = stringResource(R.string.hearing_aid_description), + style = TextStyle( + fontSize = 12.sp, + fontWeight = FontWeight.Light, + color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(horizontal = 8.dp) + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e569933..a9825cb 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -100,4 +100,34 @@ Hang Up Press Once Press Twice + Hearing Aid + Adjustments + Swipe to control amplification + When in Transparency and no media is playing, swipe up and down on the Touch controls of your AirPods Pro to increase or decrease the amplification of environmental sounds. + Transparency Mode + Customize Transparency Mode + Press Speed + Press and Hold Duration + Volume Swipe Speed + Equalizer + Apply EQ to + Phone + Media + Band %d + Default + Slower + Slowest + Longer + Longest + Darker + Brighter + Less + More + Amplification + Balance + Tone + Ambient Noise Reduction + Conversation Boost + Conversation Boost focuses your AirPods Pro on the person talking in front of you, making it easier to hear in a face-to-face conversation. + AirPods can use the results of a hearing test to make adjustments that improve the clarity of voices and sounds around you. \n\n Hearing Aid is only intended for people with perceived mild to moderate hearing loss.