android: liquidglass sliders

This commit is contained in:
Kavish Devar
2025-09-23 00:03:03 +05:30
parent 4751f70579
commit 4bc76de750
18 changed files with 1320 additions and 1260 deletions

View File

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

View File

@@ -7,7 +7,8 @@
android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
@@ -32,6 +33,8 @@
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.LOCAL_MAC_ADDRESS"
tools:ignore="ProtectedPermissions" />

View File

@@ -1,55 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods
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()
}
}

View File

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

View File

@@ -1,139 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.composables
import androidx.compose.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<Float>,
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
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,364 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.composables
import 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<Float>,
backdrop: Backdrop = rememberBackdrop(),
snapPoints: List<Float> = 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<Float>, 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"
)
}

View File

@@ -1,17 +1,17 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
*
* Copyright (C) 2025 LibrePods contributors
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@@ -85,4 +85,4 @@ fun StyledSwitch(
@Composable
fun StyledSwitchPreview() {
StyledSwitch(checked = true, onCheckedChange = {})
}
}

View File

@@ -1,190 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.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<Float>, 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
}

View File

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

View File

@@ -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<Float>, 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
}

View File

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

View File

@@ -0,0 +1,563 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.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))
}
}
}
}

View File

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

View File

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