From 4bc76de750ba2690d017f957d8d73f4c6a9e7bb7 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Tue, 23 Sep 2025 00:03:03 +0530 Subject: [PATCH] android: liquidglass sliders --- android/app/build.gradle.kts | 3 + android/app/src/main/AndroidManifest.xml | 5 +- .../librepods/CustomDeviceActivity.kt | 55 -- .../me/kavishdevar/librepods/MainActivity.kt | 8 + .../composables/AccessibilitySlider.kt | 139 ---- .../composables/CallControlSettings.kt | 14 +- .../composables/ConfirmationDialog.kt | 25 +- .../composables/MicrophoneSettings.kt | 54 +- .../librepods/composables/StyledSlider.kt | 364 +++++++++ .../librepods/composables/StyledSwitch.kt | 10 +- .../librepods/composables/ToneVolumeSlider.kt | 190 ----- .../screens/AccessibilitySettingsScreen.kt | 737 +++++------------- .../screens/AirPodsSettingsScreen.kt | 19 +- .../screens/HearingAidAdjustmentsScreen.kt | 312 ++------ .../librepods/screens/HearingAidScreen.kt | 65 +- .../screens/TransparencySettingsScreen.kt | 563 +++++++++++++ .../kavishdevar/librepods/utils/ATTManager.kt | 1 - android/gradle/libs.versions.toml | 16 +- 18 files changed, 1320 insertions(+), 1260 deletions(-) delete mode 100644 android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt delete mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt delete mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c8c0f9d..2e7067e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -62,5 +62,8 @@ dependencies { implementation(libs.haze) implementation(libs.haze.materials) implementation(libs.androidx.dynamicanimation) + implementation(libs.androidx.compose.foundation.layout) compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) + implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar")))) + debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c960427..08024b0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,7 +7,8 @@ android:required="false" /> - + @@ -32,6 +33,8 @@ tools:ignore="ScopedStorage" /> + diff --git a/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt deleted file mode 100644 index bb9c9d8..0000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt +++ /dev/null @@ -1,55 +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 - -import android.annotation.SuppressLint -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen -import me.kavishdevar.librepods.ui.theme.LibrePodsTheme - -@ExperimentalHazeMaterialsApi -class CustomDevice : ComponentActivity() { - @SuppressLint("MissingPermission") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - LibrePodsTheme { - val navController = rememberNavController() - - NavHost(navController = navController, startDestination = "main") { - composable("main") { - AccessibilitySettingsScreen() - } - } - } - } - } - - override fun onDestroy() { - super.onDestroy() - } -} 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 0395239..9ffc793 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -108,12 +108,14 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberMultiplePermissionsState import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.constants.AirPodsNotifications +import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen 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.TransparencySettingsScreen import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.screens.Onboarding import me.kavishdevar.librepods.screens.RenameScreen @@ -382,6 +384,12 @@ fun Main() { composable("onboarding") { Onboarding(navController, context) } + composable("accessibility") { + AccessibilitySettingsScreen(navController) + } + composable("transparency_customization") { + TransparencySettingsScreen(navController) + } composable("hearing_aid") { HearingAidScreen(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 deleted file mode 100644 index 2141a01..0000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt +++ /dev/null @@ -1,139 +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.foundation.background -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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -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 kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AccessibilitySlider( - label: String? = null, - value: Float, - onValueChange: (Float) -> Unit, - valueRange: ClosedFloatingPointRange, - widthFrac: Float = 1f -) { - val isDarkTheme = isSystemInDarkTheme() - - 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 - - Column( - modifier = Modifier.fillMaxWidth(widthFrac), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - 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, - onValueChange = onValueChange, - valueRange = valueRange, - onValueChangeFinished = { - // Round to 2 decimal places - onValueChange((value * 100).roundToInt() / 100f) - }, - modifier = Modifier - .fillMaxWidth() - .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 - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .background(trackColor, RoundedCornerShape(4.dp)) - ) - Box( - modifier = Modifier - .fillMaxWidth((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)) - .height(4.dp) - .background(activeTrackColor, RoundedCornerShape(4.dp)) - ) - } - } - ) - } -} - -@Preview -@Composable -fun AccessibilitySliderPreview() { - AccessibilitySlider( - label = "Test Slider", - value = 1.0f, - onValueChange = {}, - valueRange = 0f..2f - ) -} 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 index 76d6070..c485c3a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt @@ -61,13 +61,16 @@ 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 dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.utils.AACPManager import kotlin.io.encoding.ExperimentalEncodingApi +@ExperimentalHazeMaterialsApi @Composable -fun CallControlSettings() { +fun CallControlSettings(hazeState: HazeState) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) @@ -306,7 +309,8 @@ fun CallControlSettings() { 0x03 ) else byteArrayOf(0x00, 0x02) service.aacpManager.sendControlCommand(0x24, bytes) - } + }, + hazeState = hazeState ) } } @@ -433,7 +437,8 @@ fun CallControlSettings() { 0x02 ) else byteArrayOf(0x00, 0x03) service.aacpManager.sendControlCommand(0x24, bytes) - } + }, + hazeState = hazeState ) } } @@ -441,8 +446,9 @@ fun CallControlSettings() { } } +@ExperimentalHazeMaterialsApi @Preview @Composable fun CallControlSettingsPreview() { - CallControlSettings() + CallControlSettings(HazeState()) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt index 5587501..1378c33 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt @@ -1,6 +1,7 @@ package me.kavishdevar.librepods.composables import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidthIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider @@ -25,7 +27,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType 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 @@ -51,18 +52,24 @@ fun ConfirmationDialog( onConfirm: () -> Unit, onDismiss: () -> Unit = { showDialog.value = false }, hazeState: HazeState, - isDarkTheme: Boolean, - textColor: Color, - activeTrackColor: Color ) { + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) if (showDialog.value) { Dialog(onDismissRequest = { showDialog.value = false }) { Box( modifier = Modifier - .fillMaxWidth() - .background(if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f), RoundedCornerShape(14.dp)) + .fillMaxWidth(0.75f) + .requiredWidthIn(min = 200.dp, max = 360.dp) + .background(Color.Transparent, RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp)) - .hazeEffect(hazeState, CupertinoMaterials.regular()) + .hazeEffect( + hazeState, + style = CupertinoMaterials.regular( + containerColor = if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f) + ) + ) ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp)) @@ -155,7 +162,7 @@ fun ConfirmationDialog( .background(if (leftPressed) pressedColor else Color.Transparent), contentAlignment = Alignment.Center ) { - Text(dismissText, color = activeTrackColor) + Text(dismissText, color = accentColor) } Box( modifier = Modifier @@ -170,7 +177,7 @@ fun ConfirmationDialog( .background(if (rightPressed) pressedColor else Color.Transparent), contentAlignment = Alignment.Center ) { - Text(confirmText, color = activeTrackColor) + Text(confirmText, color = accentColor) } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt index 5ff5f38..8e10609 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt @@ -20,6 +20,7 @@ package me.kavishdevar.librepods.composables +import android.annotation.SuppressLint import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -70,19 +71,29 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity 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.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup +import dev.chrisbanes.haze.HazeEffectScope +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.CupertinoMaterials +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.utils.AACPManager import kotlin.io.encoding.ExperimentalEncodingApi +@ExperimentalHazeMaterialsApi @Composable -fun MicrophoneSettings() { +fun MicrophoneSettings(hazeState: HazeState) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) @@ -287,19 +298,22 @@ fun MicrophoneSettings() { AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value, byteArrayOf(byteValue.toByte()) ) - } + }, + hazeState = hazeState ) } } } } +@ExperimentalHazeMaterialsApi @Preview @Composable fun MicrophoneSettingsPreview() { - MicrophoneSettings() + MicrophoneSettings(HazeState()) } +@ExperimentalHazeMaterialsApi @Composable fun DragSelectableDropdown( expanded: Boolean, @@ -311,7 +325,8 @@ fun DragSelectableDropdown( onOptionSelected: (String) -> Unit, externalHoveredIndex: Int? = null, externalDragActive: Boolean = false, - modifier: Modifier = Modifier + hazeState: HazeState, + @SuppressLint("ModifierParameter") modifier: Modifier = Modifier ) { if (expanded) { val relativeOffset = touchOffset?.let { it - boxPosition } ?: Offset.Zero @@ -328,9 +343,7 @@ fun DragSelectableDropdown( modifier = modifier .padding(8.dp) .width(300.dp) - .background( - if (isSystemInDarkTheme()) Color(0xFF2C2C2E) else Color(0xFFFFFFFF) - ) + .background(Color.Transparent) .clip(RoundedCornerShape(8.dp)), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { @@ -389,14 +402,13 @@ fun DragSelectableDropdown( } else { index == hoveredIndex } + val isSystemInDarkTheme = isSystemInDarkTheme() Box( modifier = Modifier .fillMaxWidth() .height(itemHeight) .background( - if (isHovered) (if (isSystemInDarkTheme()) Color(0xFF3A3A3C) else Color( - 0xFFD1D1D6 - )) else Color.Transparent + Color.Transparent ) .clickable( interactionSource = remember { MutableInteractionSource() }, @@ -405,6 +417,22 @@ fun DragSelectableDropdown( onOptionSelected(text) onDismissRequest() } + .hazeEffect( + state = hazeState, + style = CupertinoMaterials.regular(), + block = fun HazeEffectScope.() { + alpha = 1f + backgroundColor = if (isSystemInDarkTheme) { + Color(0xB02C2C2E) + } else { + Color(0xB0FFFFFF) + } + tints = if (isHovered) listOf( + HazeTint( + color = if (isSystemInDarkTheme) Color(0x338A8A8A) else Color(0x40D9D9D9) + ) + ) else listOf() + }) .padding(horizontal = 12.dp), contentAlignment = Alignment.CenterStart ) { @@ -415,7 +443,11 @@ fun DragSelectableDropdown( ) { Text( text, - color = if (isSystemInDarkTheme()) Color.White else Color.Black + style = TextStyle( + fontSize = 16.sp, + color = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.75f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) ) Checkbox( checked = text == selectedOption, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt new file mode 100644 index 0000000..a86e401 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt @@ -0,0 +1,364 @@ +/* + * 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 android.annotation.SuppressLint +import android.content.res.Configuration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.BlurEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.layer.CompositingStrategy +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +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 androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.fastRoundToInt +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.refractionWithDispersion +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.rememberBackdrop +import com.kyant.backdrop.rememberCombinedBackdropDrawer +import com.kyant.backdrop.shadow.Shadow +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import kotlin.math.roundToInt + +@Composable +fun StyledSlider( + label: String? = null, + mutableFloatState: MutableFloatState, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + backdrop: Backdrop = rememberBackdrop(), + snapPoints: List = emptyList(), + snapThreshold: Float = 0.05f, + startIcon: String? = null, + endIcon: String? = null, + startLabel: String? = null, + endLabel: String? = null, + independent: Boolean = false, + @SuppressLint("ModifierParameter") modifier: Modifier = Modifier +) { + val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val isLightTheme = !isSystemInDarkTheme() + val accentColor = + if (isLightTheme) Color(0xFF0088FF) + else Color(0xFF0091FF) + val trackColor = + if (isLightTheme) Color(0xFF787878).copy(0.2f) + else Color(0xFF787880).copy(0.36f) + val labelTextColor = if (isLightTheme) Color.Black else Color.White + + val fraction by remember { + derivedStateOf { + ((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start)) + .fastCoerceIn(0f, 1f) + } + } + + val animationScope = rememberCoroutineScope() + val progressAnimationSpec = spring(0.5f, 300f, 0.001f) + val progressAnimation = remember { Animatable(0f) } + + val trackBackdrop = rememberBackdrop() + val innerShadowLayer = + rememberGraphicsLayer().apply { + compositingStrategy = CompositingStrategy.Offscreen + } + + val content = @Composable { + Column( + modifier = modifier.fillMaxWidth(1f).padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (label != null) { + Text( + text = label, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + + if (startLabel != null || endLabel != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = startLabel ?: "", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = endLabel ?: "", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp) + ) { + if (startIcon != null) { + Text( + text = startIcon, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + BoxWithConstraints( + Modifier + .weight(1f), + contentAlignment = Alignment.CenterStart + ) { + val density = LocalDensity.current + val trackWidth = constraints.maxWidth + + Box(Modifier.backdrop(trackBackdrop)) { + Box( + Modifier + .clip(RoundedCornerShape(28.dp)) + .background(trackColor) + .height(6f.dp) + .fillMaxWidth() + ) + + Box( + Modifier + .clip(RoundedCornerShape(28.dp)) + .background(accentColor) + .height(6f.dp) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val fraction = fraction + val width = (fraction * constraints.maxWidth).fastRoundToInt() + layout(width, placeable.height) { + placeable.place(0, 0) + } + } + ) + } + + Box( + Modifier + .graphicsLayer { + val fraction = fraction + translationX = + (-size.width / 2f + fraction * trackWidth) + .fastCoerceIn( + -size.width / 4f, + trackWidth - size.width * 3f / 4f + ) + } + .draggable( + rememberDraggableState { delta -> + val trackWidth = trackWidth - with(density) { 40f.dp.toPx() } + val targetFraction = fraction + delta / trackWidth + val targetValue = + lerp(valueRange.start, valueRange.endInclusive, targetFraction) + .fastCoerceIn(valueRange.start, valueRange.endInclusive) + val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose( + targetValue, + snapPoints, + snapThreshold + ) else targetValue + onValueChange(snappedValue) + }, + Orientation.Horizontal, + startDragImmediately = true, + onDragStarted = { + animationScope.launch { + progressAnimation.animateTo(1f, progressAnimationSpec) + } + }, + onDragStopped = { + animationScope.launch { + progressAnimation.animateTo(0f, progressAnimationSpec) + onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f) + } + } + ) + .drawBackdrop( + rememberCombinedBackdropDrawer(backdrop, trackBackdrop), + { RoundedCornerShape(28.dp) }, + highlight = { + val progress = progressAnimation.value + Highlight.AmbientDefault.copy(alpha = progress) + }, + shadow = { + Shadow( + elevation = 4f.dp, + color = Color.Black.copy(0.08f) + ) + }, + layer = { + val progress = progressAnimation.value + val scale = lerp(1f, 1.5f, progress) + scaleX = scale + scaleY = scale + }, + onDrawSurface = { + val progress = progressAnimation.value.fastCoerceIn(0f, 1f) + + val shape = RoundedCornerShape(28.dp) + val outline = shape.createOutline(size, layoutDirection, this) + val innerShadowOffset = 4f.dp.toPx() + val innerShadowBlurRadius = 4f.dp.toPx() + + innerShadowLayer.alpha = progress + innerShadowLayer.renderEffect = + BlurEffect( + innerShadowBlurRadius, + innerShadowBlurRadius, + TileMode.Decal + ) + innerShadowLayer.record { + drawOutline(outline, Color.Black.copy(0.2f)) + translate(0f, innerShadowOffset) { + drawOutline( + outline, + Color.Transparent, + blendMode = BlendMode.Clear + ) + } + } + drawLayer(innerShadowLayer) + + drawRect(Color.White.copy(1f - progress)) + } + ) { + refractionWithDispersion(6f.dp.toPx(), size.height / 2f) + } + .size(40f.dp, 24f.dp) + ) + } + if (endIcon != null) { + Text( + text = endIcon, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = labelTextColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(horizontal = 12.dp) + ) + } + } + } + } + + if (independent) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + .padding(horizontal = 8.dp, vertical = 0.dp) + .heightIn(min = 55.dp), + contentAlignment = Alignment.Center + ) { + content() + } + } else { + content() + } +} + +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 +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun StyledSliderPreview() { + StyledSlider( + mutableFloatState = remember {mutableFloatStateOf(1f)}, + onValueChange = {}, + valueRange = 0f..2f, + independent = true, + startIcon = "A", + endIcon = "B" + ) +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt index ba7a67c..5b01cc5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt @@ -1,17 +1,17 @@ /* * 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 . */ @@ -85,4 +85,4 @@ fun StyledSwitch( @Composable fun StyledSwitchPreview() { StyledSwitch(checked = true, onCheckedChange = {}) -} \ No newline at end of file +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt deleted file mode 100644 index 98ef02a..0000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt +++ /dev/null @@ -1,190 +0,0 @@ -/* - * LibrePods - AirPods liberated from Apple’s ecosystem - * - * Copyright (C) 2025 LibrePods contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -@file:OptIn(ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.composables - -import android.util.Log -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -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.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -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.R -import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.AACPManager -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ToneVolumeSlider() { - val service = ServiceManager.getService()!! - val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find { - it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME - }?.value?.takeIf { it.isNotEmpty() }?.get(0) - val sliderValue = remember { mutableFloatStateOf( - sliderValueFromAACP?.toFloat() ?: -1f - ) } - val listener = object : AACPManager.ControlCommandListener { - override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { - if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value) { - val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() - if (newValue != null) { - sliderValue.floatValue = newValue - } - } - } - } - LaunchedEffect(Unit) { - service.aacpManager.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, listener) - } - DisposableEffect(Unit) { - onDispose { - service.aacpManager.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, listener) - } - } - Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}") - - val isDarkTheme = isSystemInDarkTheme() - - 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 - - Row( - modifier = Modifier - .fillMaxWidth(0.95f), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "\uDBC0\uDEA1", - style = TextStyle( - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Light, - color = labelTextColor - ), - modifier = Modifier.padding(start = 4.dp) - ) - Slider( - value = sliderValue.floatValue, - onValueChange = { - sliderValue.floatValue = snapIfClose(it, listOf(100f)) - }, - valueRange = 0f..125f, - onValueChangeFinished = { - sliderValue.floatValue = snapIfClose(sliderValue.floatValue.roundToInt().toFloat(), listOf(100f)) - service.aacpManager.sendControlCommand( - identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value, - value = byteArrayOf(sliderValue.floatValue.toInt().toByte(), - 0x50.toByte() - ) - ) - }, - modifier = Modifier - .weight(1f) - .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 - ) - { - Box( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .background(trackColor, RoundedCornerShape(4.dp)) - ) - Box( - modifier = Modifier - .fillMaxWidth(sliderValue.floatValue / 125) - .height(4.dp) - .background(activeTrackColor, RoundedCornerShape(4.dp)) - ) - } - } - ) - Text( - text = "\uDBC0\uDEA9", - style = TextStyle( - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Light, - color = labelTextColor - ), - modifier = Modifier.padding(end = 4.dp) - ) - } -} - -@Preview -@Composable -fun ToneVolumeSliderPreview() { - ToneVolumeSlider() -} - -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 -} \ No newline at end of file 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 56021a5..b6fc44f 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 @@ -19,6 +19,7 @@ package me.kavishdevar.librepods.screens import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice import android.util.Log import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween @@ -31,15 +32,13 @@ 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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CenterAlignedTopAppBar @@ -62,27 +61,27 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf 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.tooling.preview.Preview 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 @@ -95,50 +94,43 @@ 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.StyledSlider import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch +import me.kavishdevar.librepods.composables.NavigationButton 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.ATTHandles import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.TransparencySettings import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse import me.kavishdevar.librepods.utils.sendTransparencySettings 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 var toneVolumeDebounceJob: Job? = null private const val TAG = "AccessibilitySettings" @SuppressLint("DefaultLocale") @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @Composable -fun AccessibilitySettingsScreen() { +fun AccessibilitySettingsScreen(navController: NavController) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black - val verticalScrollState = rememberScrollState() + val verticalScrollState = rememberScrollState() val hazeState = remember { HazeState() } val snackbarHostState = remember { SnackbarHostState() } - val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") - // 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 isSdpOffsetAvailable = + remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) } 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( @@ -216,7 +208,7 @@ fun AccessibilitySettingsScreen() { val initialLoadComplete = remember { mutableStateOf(false) } val initialReadSucceeded = remember { mutableStateOf(false) } - val initialReadAttempts = remember { mutableStateOf(0) } + val initialReadAttempts = remember { mutableIntStateOf(0) } val transparencySettings = remember { mutableStateOf( @@ -247,7 +239,8 @@ fun AccessibilitySettingsScreen() { amplificationSliderValue.floatValue = parsed.netAmplification balanceSliderValue.floatValue = parsed.balance toneSliderValue.floatValue = parsed.leftTone - ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction + ambientNoiseReductionSliderValue.floatValue = + parsed.leftAmbientNoiseReduction conversationBoostEnabled.value = parsed.leftConversationBoost eq.value = parsed.leftEQ.copyOf() Log.d(TAG, "Updated transparency settings from notification") @@ -263,22 +256,34 @@ fun AccessibilitySettingsScreen() { 1.toByte() to "Slower", 2.toByte() to "Slowest" ) - val selectedPressSpeedValue = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0) - var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]) } + val selectedPressSpeedValue = + aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() } + ?.get(0) + var selectedPressSpeed by remember { + mutableStateOf( + pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] + ) + } val selectedPressSpeedListener = object : AACPManager.ControlCommandListener { - override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { - if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) { - val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) - selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0] - } + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) { + val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) + selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0] } } + } LaunchedEffect(Unit) { - aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, selectedPressSpeedListener) + aacpManager?.registerControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, + selectedPressSpeedListener + ) } DisposableEffect(Unit) { onDispose { - aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, selectedPressSpeedListener) + aacpManager?.unregisterControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, + selectedPressSpeedListener + ) } } @@ -287,22 +292,36 @@ fun AccessibilitySettingsScreen() { 1.toByte() to "Slower", 2.toByte() to "Slowest" ) - val selectedPressAndHoldDurationValue = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0) - var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] ?: pressAndHoldDurationOptions[0]) } + val selectedPressAndHoldDurationValue = + aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() } + ?.get(0) + var selectedPressAndHoldDuration by remember { + mutableStateOf( + pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] + ?: pressAndHoldDurationOptions[0] + ) + } val selectedPressAndHoldDurationListener = object : AACPManager.ControlCommandListener { - override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { - if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) { - val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) - selectedPressAndHoldDuration = pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0] - } + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) { + val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) + selectedPressAndHoldDuration = + pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0] } } + } LaunchedEffect(Unit) { - aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, selectedPressAndHoldDurationListener) + aacpManager?.registerControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, + selectedPressAndHoldDurationListener + ) } DisposableEffect(Unit) { onDispose { - aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, selectedPressAndHoldDurationListener) + aacpManager?.unregisterControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, + selectedPressAndHoldDurationListener + ) } } @@ -311,123 +330,36 @@ fun AccessibilitySettingsScreen() { 2.toByte() to "Longer", 3.toByte() to "Longest" ) - val selectedVolumeSwipeSpeedValue = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0) - var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] ?: volumeSwipeSpeedOptions[1]) } + val selectedVolumeSwipeSpeedValue = + aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() } + ?.get(0) + var selectedVolumeSwipeSpeed by remember { + mutableStateOf( + volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] + ?: volumeSwipeSpeedOptions[1] + ) + } val selectedVolumeSwipeSpeedListener = object : AACPManager.ControlCommandListener { override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) { val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) - selectedVolumeSwipeSpeed = volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1] + selectedVolumeSwipeSpeed = + volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1] } } } LaunchedEffect(Unit) { - aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, selectedVolumeSwipeSpeedListener) - } - DisposableEffect(Unit) { - onDispose { - aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, selectedVolumeSwipeSpeedListener) - } - } - - 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 - } - - transparencySettings.value = TransparencySettings( - 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 + aacpManager?.registerControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, + selectedVolumeSwipeSpeedListener ) - Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}") - sendTransparencySettings(attManager, transparencySettings.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) - - // If we have an AACP manager, prefer its EQ data to populate EQ controls first - 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: TransparencySettings? = null - for (attempt in 1..3) { - initialReadAttempts.value = attempt - try { - val data = attManager.read(ATTHandles.TRANSPARENCY) - parsedSettings = parseTransparencySettingsResponse(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 + aacpManager?.unregisterControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, + selectedVolumeSwipeSpeedListener + ) } } @@ -444,251 +376,63 @@ fun AccessibilitySettingsScreen() { 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})") + 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}") } } } - - // Only show transparency mode section if SDP offset is available - if (isSdpOffsetAvailable.value) { - AccessibilityToggle( - text = stringResource(R.string.transparency_mode), - mutableState = enabled, - independent = true, - description = stringResource(R.string.customize_transparency_mode_description) - ) - Spacer(modifier = Modifier.height(4.dp)) - 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 = 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) + val toneVolumeValue = remember { mutableFloatStateOf( + aacpManager?.controlCommandStatusList?.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME + }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() ?: 75f + ) } + LaunchedEffect(toneVolumeValue.floatValue) { + toneVolumeDebounceJob?.cancel() + toneVolumeDebounceJob = CoroutineScope(Dispatchers.IO).launch { + delay(150) + val manager = ServiceManager.getService()?.aacpManager + if (manager == null) { + Log.w(TAG, "Cannot write tone volume: AACPManager not available") + return@launch + } + try { + manager.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value, + value = byteArrayOf(toneVolumeValue.floatValue.toInt().toByte(), 0x50.toByte()) ) + } catch (e: Exception) { + Log.w(TAG, "Error sending tone volume: ${e.message}") } } - - 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 = stringResource(R.string.audio).uppercase(), + text = stringResource(R.string.tone_volume).uppercase(), style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Light, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), + 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) ) + StyledSlider( + mutableFloatState = toneVolumeValue, + onValueChange = { + toneVolumeValue.floatValue = it + }, + valueRange = 0f..125f, + snapPoints = listOf(100f), + startIcon = "\uDBC0\uDEA1", + endIcon = "\uDBC0\uDEA9", + independent = true + ) + Column( modifier = Modifier .fillMaxWidth() @@ -697,164 +441,73 @@ fun AccessibilitySettingsScreen() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween ) { - Text( - text = stringResource(R.string.tone_volume), - style = TextStyle( - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Light, - color = textColor - ), - modifier = Modifier - .padding(horizontal = 8.dp, vertical = 4.dp) - .fillMaxWidth() - ) - ToneVolumeSlider() SinglePodANCSwitch() VolumeControlSwitch() LoudSoundReductionSwitch() DropdownMenuComponent( label = stringResource(R.string.press_speed), - options = listOf(stringResource(R.string.default_option), stringResource(R.string.slower), stringResource(R.string.slowest)), + options = listOf( + stringResource(R.string.default_option), + stringResource(R.string.slower), + stringResource(R.string.slowest) + ), selectedOption = selectedPressSpeed.toString(), onOptionSelected = { newValue -> selectedPressSpeed = newValue aacpManager?.sendControlCommand( identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value, - value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte() + value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() + ?: 0.toByte() ) }, textColor = textColor ) DropdownMenuComponent( label = stringResource(R.string.press_and_hold_duration), - options = listOf(stringResource(R.string.default_option), stringResource(R.string.slower), stringResource(R.string.slowest)), + options = listOf( + stringResource(R.string.default_option), + stringResource(R.string.slower), + stringResource(R.string.slowest) + ), selectedOption = selectedPressAndHoldDuration.toString(), onOptionSelected = { newValue -> selectedPressAndHoldDuration = newValue aacpManager?.sendControlCommand( identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value, - value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte() + value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() + ?: 0.toByte() ) }, textColor = textColor ) DropdownMenuComponent( label = stringResource(R.string.volume_swipe_speed), - options = listOf(stringResource(R.string.default_option), stringResource(R.string.longer), stringResource(R.string.longest)), + options = listOf( + stringResource(R.string.default_option), + stringResource(R.string.longer), + stringResource(R.string.longest) + ), selectedOption = selectedVolumeSwipeSpeed.toString(), onOptionSelected = { newValue -> selectedVolumeSwipeSpeed = newValue aacpManager?.sendControlCommand( identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value, - value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte() + value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() + ?: 1.toByte() ) }, textColor = textColor ) } + + NavigationButton( + to = "transparency_customization", + name = stringResource(R.string.customize_transparency_mode), + navController = navController + ) + Spacer(modifier = Modifier.height(2.dp)) - - // Only show transparency mode EQ section if SDP offset is available - if (isSdpOffsetAvailable.value) { - Text( - text = stringResource(R.string.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, - 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 - ) - { - 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 = stringResource(R.string.band_label, i + 1), - fontSize = 12.sp, - color = textColor, - modifier = Modifier.padding(top = 4.dp) - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - } - Text( text = stringResource(R.string.apply_eq_to).uppercase(), style = TextStyle( @@ -874,8 +527,17 @@ fun AccessibilitySettingsScreen() { val darkModeLocal = isSystemInDarkTheme() val phoneShape = RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp) - var phoneBackgroundColor by remember { mutableStateOf(if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } - val phoneAnimatedBackgroundColor by animateColorAsState(targetValue = phoneBackgroundColor, animationSpec = tween(durationMillis = 500)) + var phoneBackgroundColor by remember { + mutableStateOf( + if (darkModeLocal) Color( + 0xFF1C1C1E + ) else Color(0xFFFFFFFF) + ) + } + val phoneAnimatedBackgroundColor by animateColorAsState( + targetValue = phoneBackgroundColor, + animationSpec = tween(durationMillis = 500) + ) Row( modifier = Modifier @@ -885,9 +547,11 @@ fun AccessibilitySettingsScreen() { .pointerInput(Unit) { detectTapGestures( onPress = { - phoneBackgroundColor = if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) + phoneBackgroundColor = + if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) tryAwaitRelease() - phoneBackgroundColor = if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + phoneBackgroundColor = + if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) phoneEQEnabled.value = !phoneEQEnabled.value } ) @@ -925,8 +589,17 @@ fun AccessibilitySettingsScreen() { ) val mediaShape = RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp) - var mediaBackgroundColor by remember { mutableStateOf(if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } - val mediaAnimatedBackgroundColor by animateColorAsState(targetValue = mediaBackgroundColor, animationSpec = tween(durationMillis = 500)) + var mediaBackgroundColor by remember { + mutableStateOf( + if (darkModeLocal) Color( + 0xFF1C1C1E + ) else Color(0xFFFFFFFF) + ) + } + val mediaAnimatedBackgroundColor by animateColorAsState( + targetValue = mediaBackgroundColor, + animationSpec = tween(durationMillis = 500) + ) Row( modifier = Modifier @@ -936,9 +609,11 @@ fun AccessibilitySettingsScreen() { .pointerInput(Unit) { detectTapGestures( onPress = { - mediaBackgroundColor = if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) + mediaBackgroundColor = + if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) tryAwaitRelease() - mediaBackgroundColor = if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + mediaBackgroundColor = + if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) mediaEQEnabled.value = !mediaEQEnabled.value } ) @@ -979,7 +654,8 @@ fun AccessibilitySettingsScreen() { horizontalAlignment = Alignment.CenterHorizontally ) { for (i in 0 until 8) { - val eqPhoneValue = remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) } + val eqPhoneValue = + remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) } Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -1020,7 +696,7 @@ fun AccessibilitySettingsScreen() { ) }, track = { - Box ( + Box( modifier = Modifier .fillMaxWidth() .height(12.dp), @@ -1058,10 +734,25 @@ fun AccessibilitySettingsScreen() { @Composable -fun AccessibilityToggle(text: String, mutableState: MutableState, independent: Boolean = false, description: String? = null, title: String? = null) { +fun AccessibilityToggle( + text: String, + mutableState: MutableState, + independent: Boolean = false, + description: String? = null, + title: String? = null +) { val isDarkTheme = isSystemInDarkTheme() - var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } - val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) + var backgroundColor by remember { + mutableStateOf( + if (isDarkTheme) Color(0xFF1C1C1E) else Color( + 0xFFFFFFFF + ) + ) + } + val animatedBackgroundColor by animateColorAsState( + targetValue = backgroundColor, + animationSpec = tween(durationMillis = 500) + ) val textColor = if (isDarkTheme) Color.White else Color.Black val cornerShape = if (independent) RoundedCornerShape(14.dp) else RoundedCornerShape(0.dp) @@ -1082,15 +773,17 @@ fun AccessibilityToggle(text: String, mutableState: MutableState, indep ) Spacer(modifier = Modifier.height(4.dp)) } - Box ( + Box( modifier = Modifier .background(animatedBackgroundColor, cornerShape) .pointerInput(Unit) { detectTapGestures( onPress = { - backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + backgroundColor = + if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) tryAwaitRelease() - backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + backgroundColor = + if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) }, onTap = { mutableState.value = !mutableState.value @@ -1139,11 +832,6 @@ fun AccessibilityToggle(text: String, mutableState: MutableState, indep } } -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 -} - @Composable private fun DropdownMenuComponent( label: String, @@ -1154,7 +842,7 @@ private fun DropdownMenuComponent( ) { var expanded by remember { mutableStateOf(false) } - Column ( + Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp) @@ -1195,23 +883,4 @@ private fun DropdownMenuComponent( } } } -} - -// Debounced send helper for phone/media EQ (if needed elsewhere) -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}") - } - } -} +} \ No newline at end of file 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 8a1419d..56ec24f 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 @@ -84,14 +84,13 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController 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 dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.launch 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 @@ -146,7 +145,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, } val verticalScrollState = rememberScrollState() - val hazeState = remember { HazeState() } + val hazeState = rememberHazeState( blurEnabled = true ) val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() @@ -250,7 +249,8 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, 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 @@ -364,7 +364,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, NoiseControlSettings(service = service) Spacer(modifier = Modifier.height(16.dp)) - CallControlSettings() + CallControlSettings(hazeState = hazeState) // camera control goes here, airpods side is done, i just need to figure out how to listen to app open/close events @@ -378,7 +378,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, ConnectionSettings() Spacer(modifier = Modifier.height(16.dp)) - MicrophoneSettings() + MicrophoneSettings(hazeState) Spacer(modifier = Modifier.height(16.dp)) IndependentToggle( @@ -388,15 +388,12 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, default = false, controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG ) - + Spacer(modifier = Modifier.height(16.dp)) NavigationButton(to = "head_tracking", stringResource(R.string.head_gestures), navController) Spacer(modifier = Modifier.height(16.dp)) - NavigationButton(to = "", "Accessibility", navController = navController, onClick = { - val intent = Intent(context, CustomDevice::class.java) - context.startActivity(intent) - }) + NavigationButton(to = "accessibility", "Accessibility", navController = navController) Spacer(modifier = Modifier.height(16.dp)) IndependentToggle( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt index 25b2eba..695965f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt @@ -21,37 +21,16 @@ 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 @@ -59,22 +38,14 @@ 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.mutableIntStateOf 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 @@ -96,17 +67,12 @@ 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.StyledSlider 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.ATTHandles +import me.kavishdevar.librepods.utils.ATTManager import me.kavishdevar.librepods.utils.RadareOffsetFinder import java.io.IOException import java.nio.ByteBuffer @@ -114,14 +80,13 @@ import java.nio.ByteOrder import kotlin.io.encoding.ExperimentalEncodingApi private var debounceJob: Job? = null -private var phoneMediaDebounceJob: Job? = null private const val TAG = "HearingAidAdjustments" @SuppressLint("DefaultLocale") @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @Composable -fun HearingAidAdjustmentsScreen(navController: NavController) { +fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black val verticalScrollState = rememberScrollState() @@ -131,14 +96,14 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { val aacpManager = remember { ServiceManager.getService()?.aacpManager } val context = LocalContext.current - val radareOffsetFinder = remember { RadareOffsetFinder(context) } - val isSdpOffsetAvailable = remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) } + remember { RadareOffsetFinder(context) } + 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 + if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) + if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) + if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) + if (isDarkTheme) Color.White else Color.Black Scaffold( containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), @@ -192,9 +157,9 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { .verticalScroll(verticalScrollState), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val enabled = remember { mutableStateOf(false) } + remember { mutableStateOf(false) } val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) } val balanceSliderValue = remember { mutableFloatStateOf(0.5f) } val toneSliderValue = remember { mutableFloatStateOf(0.5f) } @@ -210,9 +175,9 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { val initialLoadComplete = remember { mutableStateOf(false) } val initialReadSucceeded = remember { mutableStateOf(false) } - val initialReadAttempts = remember { mutableStateOf(0) } + val initialReadAttempts = remember { mutableIntStateOf(0) } - val HearingAidSettings = remember { + val hearingAidSettings = remember { mutableStateOf( HearingAidSettings( leftEQ = eq.value, @@ -295,7 +260,7 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { return@LaunchedEffect } - HearingAidSettings.value = HearingAidSettings( + hearingAidSettings.value = HearingAidSettings( leftEQ = eq.value, rightEQ = eq.value, leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f, @@ -310,8 +275,8 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { balance = balanceSliderValue.floatValue, ownVoiceAmplification = ownVoiceAmplification.floatValue ) - Log.d(TAG, "Updated settings: ${HearingAidSettings.value}") - sendHearingAidSettings(attManager, HearingAidSettings.value) + Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") + sendHearingAidSettings(attManager, hearingAidSettings.value) } LaunchedEffect(Unit) { @@ -342,7 +307,7 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { var parsedSettings: HearingAidSettings? = null for (attempt in 1..3) { - initialReadAttempts.value = attempt + initialReadAttempts.intValue = attempt try { val data = attManager.read(ATTHandles.HEARING_AID) parsedSettings = parseHearingAidSettingsResponse(data = data) @@ -369,7 +334,7 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification initialReadSucceeded.value = true } else { - Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.value} attempts") + Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts") } } catch (e: IOException) { e.printStackTrace() @@ -378,10 +343,6 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { } } - 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( @@ -392,48 +353,16 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { ), 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) - ) - } - } + StyledSlider( + valueRange = -1f..1f, + mutableFloatState = amplificationSliderValue, + onValueChange = { + amplificationSliderValue.floatValue = it + }, + startIcon = "􀊥", + endIcon = "􀊩", + independent = true, + ) val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) @@ -455,47 +384,17 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { ), 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)) - }, - ) - } - } + StyledSlider( + valueRange = -1f..1f, + mutableFloatState = balanceSliderValue, + onValueChange = { + balanceSliderValue.floatValue = it + }, + snapPoints = listOf(0f), + startLabel = stringResource(R.string.left), + endLabel = stringResource(R.string.right), + independent = true, + ) Text( text = stringResource(R.string.tone).uppercase(), @@ -507,47 +406,16 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { ), 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)) - }, - ) - } - } + StyledSlider( + valueRange = -1f..1f, + mutableFloatState = toneSliderValue, + onValueChange = { + toneSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.darker), + endLabel = stringResource(R.string.brighter), + independent = true, + ) Text( text = stringResource(R.string.ambient_noise_reduction).uppercase(), @@ -560,47 +428,16 @@ fun HearingAidAdjustmentsScreen(navController: NavController) { 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)) - }, - ) - } - } + StyledSlider( + valueRange = 0f..1f, + mutableFloatState = ambientNoiseReductionSliderValue, + onValueChange = { + ambientNoiseReductionSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.less), + endLabel = stringResource(R.string.more), + independent = true, + ) AccessibilityToggle( text = stringResource(R.string.conversation_boost), @@ -668,8 +505,6 @@ private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings if (data.size < 104) return null val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) - val phoneEnabled = buffer.get() == 0x01.toByte() - val mediaEnabled = buffer.get() == 0x01.toByte() buffer.getShort() // skip 0x60 0x00 val leftEQ = FloatArray(8) @@ -718,7 +553,7 @@ private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings private fun sendHearingAidSettings( attManager: ATTManager, - HearingAidSettings: HearingAidSettings + hearingAidSettings: HearingAidSettings ) { debounceJob?.cancel() debounceJob = CoroutineScope(Dispatchers.IO).launch { @@ -736,19 +571,19 @@ private fun sendHearingAidSettings( buffer.put(2, 0x64) // Left ear adjustments - buffer.putFloat(36, HearingAidSettings.leftAmplification) - buffer.putFloat(40, HearingAidSettings.leftTone) - buffer.putFloat(44, if (HearingAidSettings.leftConversationBoost) 1.0f else 0.0f) - buffer.putFloat(48, HearingAidSettings.leftAmbientNoiseReduction) + buffer.putFloat(36, hearingAidSettings.leftAmplification) + buffer.putFloat(40, hearingAidSettings.leftTone) + buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f) + buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction) // Right ear adjustments - buffer.putFloat(84, HearingAidSettings.rightAmplification) - buffer.putFloat(88, HearingAidSettings.rightTone) - buffer.putFloat(92, if (HearingAidSettings.rightConversationBoost) 1.0f else 0.0f) - buffer.putFloat(96, HearingAidSettings.rightAmbientNoiseReduction) + buffer.putFloat(84, hearingAidSettings.rightAmplification) + buffer.putFloat(88, hearingAidSettings.rightTone) + buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f) + buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction) // Own voice amplification - buffer.putFloat(100, HearingAidSettings.ownVoiceAmplification) + buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification) Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}") @@ -758,8 +593,3 @@ private fun sendHearingAidSettings( } } } - -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 index bdd1cc5..27752ec 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt @@ -19,7 +19,6 @@ 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 @@ -28,43 +27,30 @@ 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.TextButton 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 @@ -72,16 +58,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.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.PointerEventType import androidx.compose.ui.input.pointer.pointerInput -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 @@ -99,27 +79,15 @@ 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.ConfirmationDialog -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 me.kavishdevar.librepods.utils.TransparencySettings +import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse import me.kavishdevar.librepods.utils.sendTransparencySettings -import java.io.IOException -import java.nio.ByteBuffer -import java.nio.ByteOrder import kotlin.io.encoding.ExperimentalEncodingApi private var debounceJob: Job? = null @@ -139,15 +107,6 @@ fun HearingAidScreen(navController: NavController) { 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 val showDialog = remember { mutableStateOf(false) } @@ -385,7 +344,11 @@ fun HearingAidScreen(navController: NavController) { onAdjustMediaChange(!adjustMediaEnabled.value) } ) - }, + } + .background( + animatedBackgroundColorAdjustMedia, + RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp) + ), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -430,7 +393,11 @@ fun HearingAidScreen(navController: NavController) { onAdjustPhoneChange(!adjustPhoneEnabled.value) } ) - }, + } + .background( + animatedBackgroundColorAdjustPhone, + RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp) + ), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -466,11 +433,10 @@ fun HearingAidScreen(navController: NavController) { if (!enrolled) { aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) } else { - aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) + aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) } aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte()) hearingAidEnabled.value = true - // Disable transparency mode CoroutineScope(Dispatchers.IO).launch { try { val data = attManager.read(ATTHandles.TRANSPARENCY) @@ -484,9 +450,6 @@ fun HearingAidScreen(navController: NavController) { } } }, - hazeState = hazeState, - isDarkTheme = isDarkTheme, - textColor = textColor, - activeTrackColor = activeTrackColor + hazeState = hazeState ) -} \ No newline at end of file +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt new file mode 100644 index 0000000..dc2353a --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt @@ -0,0 +1,563 @@ +/* + * 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.util.Log +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +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.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +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.mutableIntStateOf +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.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.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.tooling.preview.Preview +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.StyledSlider +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.ATTHandles +import me.kavishdevar.librepods.utils.ATTManager +import me.kavishdevar.librepods.utils.RadareOffsetFinder +import me.kavishdevar.librepods.utils.TransparencySettings +import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse +import me.kavishdevar.librepods.utils.sendTransparencySettings +import java.io.IOException +import kotlin.io.encoding.ExperimentalEncodingApi + +private const val TAG = "TransparencySettings" + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun TransparencySettingsScreen(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 ?: return + val aacpManager = remember { ServiceManager.getService()?.aacpManager } + val isSdpOffsetAvailable = + remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) } + + 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) + + 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.customize_transparency_mode), + 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 initialLoadComplete = remember { mutableStateOf(false) } + + val initialReadSucceeded = remember { mutableStateOf(false) } + val initialReadAttempts = remember { mutableIntStateOf(0) } + + val transparencySettings = remember { + mutableStateOf( + TransparencySettings( + 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 transparencyListener = remember { + object : (ByteArray) -> Unit { + override fun invoke(value: ByteArray) { + val parsed = parseTransparencySettingsResponse(value) + if (parsed != null) { + enabled.value = parsed.enabled + amplificationSliderValue.floatValue = parsed.netAmplification + balanceSliderValue.floatValue = parsed.balance + toneSliderValue.floatValue = parsed.leftTone + ambientNoiseReductionSliderValue.floatValue = + parsed.leftAmbientNoiseReduction + conversationBoostEnabled.value = parsed.leftConversationBoost + eq.value = parsed.leftEQ.copyOf() + Log.d(TAG, "Updated transparency settings from notification") + } else { + Log.w(TAG, "Failed to parse transparency settings from notification") + } + } + } + } + + 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 + } + + transparencySettings.value = TransparencySettings( + 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("TransparencySettings", "Updated settings: ${transparencySettings.value}") + sendTransparencySettings(attManager, transparencySettings.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) + + // If we have an AACP manager, prefer its EQ data to populate EQ controls first + 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() + 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: TransparencySettings? = null + for (attempt in 1..3) { + initialReadAttempts.intValue = attempt + try { + val data = attManager.read(ATTHandles.TRANSPARENCY) + parsedSettings = parseTransparencySettingsResponse(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.intValue} attempts" + ) + } + } catch (e: IOException) { + e.printStackTrace() + } finally { + initialLoadComplete.value = true + } + } + + // Only show transparency mode section if SDP offset is available + if (isSdpOffsetAvailable.value) { + AccessibilityToggle( + text = stringResource(R.string.transparency_mode), + mutableState = enabled, + independent = true, + description = stringResource(R.string.customize_transparency_mode_description) + ) + Spacer(modifier = Modifier.height(4.dp)) + 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) + ) + StyledSlider( + valueRange = -1f..1f, + mutableFloatState = amplificationSliderValue, + onValueChange = { + amplificationSliderValue.floatValue = it + }, + startIcon = "􀊥", + endIcon = "􀊩", + independent = true + ) + + 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) + ) + StyledSlider( + valueRange = -1f..1f, + mutableFloatState = balanceSliderValue, + onValueChange = { + balanceSliderValue.floatValue = it + }, + snapPoints = listOf(0f), + startLabel = stringResource(R.string.left), + endLabel = stringResource(R.string.right), + independent = true, + ) + + 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) + ) + StyledSlider( + valueRange = -1f..1f, + mutableFloatState = toneSliderValue, + onValueChange = { + toneSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.darker), + endLabel = stringResource(R.string.brighter), + independent = true, + ) + + 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) + ) + + StyledSlider( + valueRange = 0f..1f, + mutableFloatState = ambientNoiseReductionSliderValue, + onValueChange = { + ambientNoiseReductionSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.less), + endLabel = stringResource(R.string.more), + independent = true, + ) + + AccessibilityToggle( + text = stringResource(R.string.conversation_boost), + mutableState = conversationBoostEnabled, + independent = true, + description = stringResource(R.string.conversation_boost_description) + ) + } + + // Only show transparency mode EQ section if SDP offset is available + if (isSdpOffsetAvailable.value) { + Text( + text = stringResource(R.string.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, + 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 + ) + { + 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 = stringResource(R.string.band_label, i + 1), + fontSize = 12.sp, + color = textColor, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt index 3a52634..15b6144 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt @@ -178,7 +178,6 @@ class ATTManager(private val device: BluetoothDevice) { throw IllegalStateException("End of stream reached") } val data = buffer.copyOfRange(0, len) - Log.wtf(TAG, "Read ${data.size} bytes from ATT") Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}") return data } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 415d5ce..94dfbc9 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -10,12 +10,12 @@ composeBom = "2025.04.00" annotations = "26.0.2" navigationCompose = "2.8.9" constraintlayout = "2.2.1" -haze = "1.5.3" -hazeMaterials = "1.5.3" -sliceBuilders = "1.1.0-alpha02" -sliceCore = "1.1.0-alpha02" -sliceView = "1.1.0-alpha02" +haze = "1.6.10" +hazeMaterials = "1.6.10" dynamicanimation = "1.1.0" +foundationLayout = "1.9.1" +uiTooling = "1.9.1" +mockk = "1.14.3" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } @@ -33,10 +33,10 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" } haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "hazeMaterials" } -androidx-slice-builders = { group = "androidx.slice", name = "slice-builders", version.ref = "sliceBuilders" } -androidx-slice-core = { group = "androidx.slice", name = "slice-core", version.ref = "sliceCore" } -androidx-slice-view = { group = "androidx.slice", name = "slice-view", version.ref = "sliceView" } androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" } +androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }