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)
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(libs.androidx.dynamicanimation) implementation(libs.androidx.dynamicanimation)
implementation(libs.androidx.compose.foundation.layout)
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) 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" /> android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <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.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS" <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
@@ -32,6 +33,8 @@
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" /> android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.LOCAL_MAC_ADDRESS" <uses-permission android:name="android.permission.LOCAL_MAC_ADDRESS"
tools:ignore="ProtectedPermissions" /> 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 com.google.accompanist.permissions.rememberMultiplePermissionsState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.DebugScreen import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingAidScreen import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.screens.Onboarding import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.RenameScreen import me.kavishdevar.librepods.screens.RenameScreen
@@ -382,6 +384,12 @@ fun Main() {
composable("onboarding") { composable("onboarding") {
Onboarding(navController, context) Onboarding(navController, context)
} }
composable("accessibility") {
AccessibilitySettingsScreen(navController)
}
composable("transparency_customization") {
TransparencySettingsScreen(navController)
}
composable("hearing_aid") { composable("hearing_aid") {
HearingAidScreen(navController) 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.R
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun CallControlSettings() { fun CallControlSettings(hazeState: HazeState) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -306,7 +309,8 @@ fun CallControlSettings() {
0x03 0x03
) else byteArrayOf(0x00, 0x02) ) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes) service.aacpManager.sendControlCommand(0x24, bytes)
} },
hazeState = hazeState
) )
} }
} }
@@ -433,7 +437,8 @@ fun CallControlSettings() {
0x02 0x02
) else byteArrayOf(0x00, 0x03) ) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes) service.aacpManager.sendControlCommand(0x24, bytes)
} },
hazeState = hazeState
) )
} }
} }
@@ -441,8 +446,9 @@ fun CallControlSettings() {
} }
} }
@ExperimentalHazeMaterialsApi
@Preview @Preview
@Composable @Composable
fun CallControlSettingsPreview() { fun CallControlSettingsPreview() {
CallControlSettings() CallControlSettings(HazeState())
} }

View File

@@ -1,6 +1,7 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider 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.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -51,18 +52,24 @@ fun ConfirmationDialog(
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false }, onDismiss: () -> Unit = { showDialog.value = false },
hazeState: HazeState, 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) { if (showDialog.value) {
Dialog(onDismissRequest = { showDialog.value = false }) { Dialog(onDismissRequest = { showDialog.value = false }) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(0.75f)
.background(if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f), RoundedCornerShape(14.dp)) .requiredWidthIn(min = 200.dp, max = 360.dp)
.background(Color.Transparent, RoundedCornerShape(14.dp))
.clip(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) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp)) androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp))
@@ -155,7 +162,7 @@ fun ConfirmationDialog(
.background(if (leftPressed) pressedColor else Color.Transparent), .background(if (leftPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(dismissText, color = activeTrackColor) Text(dismissText, color = accentColor)
} }
Box( Box(
modifier = Modifier modifier = Modifier
@@ -170,7 +177,7 @@ fun ConfirmationDialog(
.background(if (rightPressed) pressedColor else Color.Transparent), .background(if (rightPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(confirmText, color = activeTrackColor) Text(confirmText, color = accentColor)
} }
} }
} }

View File

@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn 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.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource 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.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup 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.R
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun MicrophoneSettings() { fun MicrophoneSettings(hazeState: HazeState) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -287,19 +298,22 @@ fun MicrophoneSettings() {
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value, AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte()) byteArrayOf(byteValue.toByte())
) )
} },
hazeState = hazeState
) )
} }
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Preview @Preview
@Composable @Composable
fun MicrophoneSettingsPreview() { fun MicrophoneSettingsPreview() {
MicrophoneSettings() MicrophoneSettings(HazeState())
} }
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun DragSelectableDropdown( fun DragSelectableDropdown(
expanded: Boolean, expanded: Boolean,
@@ -311,7 +325,8 @@ fun DragSelectableDropdown(
onOptionSelected: (String) -> Unit, onOptionSelected: (String) -> Unit,
externalHoveredIndex: Int? = null, externalHoveredIndex: Int? = null,
externalDragActive: Boolean = false, externalDragActive: Boolean = false,
modifier: Modifier = Modifier hazeState: HazeState,
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier
) { ) {
if (expanded) { if (expanded) {
val relativeOffset = touchOffset?.let { it - boxPosition } ?: Offset.Zero val relativeOffset = touchOffset?.let { it - boxPosition } ?: Offset.Zero
@@ -328,9 +343,7 @@ fun DragSelectableDropdown(
modifier = modifier modifier = modifier
.padding(8.dp) .padding(8.dp)
.width(300.dp) .width(300.dp)
.background( .background(Color.Transparent)
if (isSystemInDarkTheme()) Color(0xFF2C2C2E) else Color(0xFFFFFFFF)
)
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp)),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
@@ -389,14 +402,13 @@ fun DragSelectableDropdown(
} else { } else {
index == hoveredIndex index == hoveredIndex
} }
val isSystemInDarkTheme = isSystemInDarkTheme()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(itemHeight) .height(itemHeight)
.background( .background(
if (isHovered) (if (isSystemInDarkTheme()) Color(0xFF3A3A3C) else Color( Color.Transparent
0xFFD1D1D6
)) else Color.Transparent
) )
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@@ -405,6 +417,22 @@ fun DragSelectableDropdown(
onOptionSelected(text) onOptionSelected(text)
onDismissRequest() 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), .padding(horizontal = 12.dp),
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
@@ -415,7 +443,11 @@ fun DragSelectableDropdown(
) { ) {
Text( Text(
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( Checkbox(
checked = text == selectedOption, 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 * LibrePods - AirPods liberated from Apples ecosystem
* *
* Copyright (C) 2025 LibrePods contributors * Copyright (C) 2025 LibrePods contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License. * by the Free Software Foundation, either version 3 of the License.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@@ -85,4 +85,4 @@ fun StyledSwitch(
@Composable @Composable
fun StyledSwitchPreview() { fun StyledSwitchPreview() {
StyledSwitch(checked = true, onCheckedChange = {}) 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.NavController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import dev.chrisbanes.haze.HazeEffectScope import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.CustomDevice
import me.kavishdevar.librepods.composables.AudioSettings import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings import me.kavishdevar.librepods.composables.CallControlSettings
@@ -146,7 +145,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() } val hazeState = rememberHazeState( blurEnabled = true )
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -250,7 +249,8 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
block = fun HazeEffectScope.() { block = fun HazeEffectScope.() {
alpha = alpha =
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
}) }
)
.drawBehind { .drawBehind {
mDensity.floatValue = density mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density val strokeWidth = 0.7.dp.value * density
@@ -364,7 +364,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
NoiseControlSettings(service = service) NoiseControlSettings(service = service)
Spacer(modifier = Modifier.height(16.dp)) 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 // 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() ConnectionSettings()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
MicrophoneSettings() MicrophoneSettings(hazeState)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
IndependentToggle( IndependentToggle(
@@ -388,15 +388,12 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
default = false, default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
NavigationButton(to = "head_tracking", stringResource(R.string.head_gestures), navController) NavigationButton(to = "head_tracking", stringResource(R.string.head_gestures), navController)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
NavigationButton(to = "", "Accessibility", navController = navController, onClick = { NavigationButton(to = "accessibility", "Accessibility", navController = navController)
val intent = Intent(context, CustomDevice::class.java)
context.startActivity(intent)
})
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
IndependentToggle( IndependentToggle(

View File

@@ -21,37 +21,16 @@ package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.util.Log 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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CenterAlignedTopAppBar 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.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -59,22 +38,14 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind 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.geometry.Offset
import androidx.compose.ui.graphics.Color 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -96,17 +67,12 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R 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.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.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.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
@@ -114,14 +80,13 @@ import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null private var debounceJob: Job? = null
private var phoneMediaDebounceJob: Job? = null
private const val TAG = "HearingAidAdjustments" private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidAdjustmentsScreen(navController: NavController) { fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
@@ -131,14 +96,14 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
val aacpManager = remember { ServiceManager.getService()?.aacpManager } val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val context = LocalContext.current val context = LocalContext.current
val radareOffsetFinder = remember { RadareOffsetFinder(context) } remember { RadareOffsetFinder(context) }
val isSdpOffsetAvailable = remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) } remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val service = ServiceManager.getService() val service = ServiceManager.getService()
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black if (isDarkTheme) Color.White else Color.Black
Scaffold( Scaffold(
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
@@ -192,9 +157,9 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
.verticalScroll(verticalScrollState), .verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(16.dp) 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 amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) } val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) } val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
@@ -210,9 +175,9 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
val initialLoadComplete = remember { mutableStateOf(false) } val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = 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( mutableStateOf(
HearingAidSettings( HearingAidSettings(
leftEQ = eq.value, leftEQ = eq.value,
@@ -295,7 +260,7 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
return@LaunchedEffect return@LaunchedEffect
} }
HearingAidSettings.value = HearingAidSettings( hearingAidSettings.value = HearingAidSettings(
leftEQ = eq.value, leftEQ = eq.value,
rightEQ = eq.value, rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f, leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
@@ -310,8 +275,8 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
balance = balanceSliderValue.floatValue, balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue ownVoiceAmplification = ownVoiceAmplification.floatValue
) )
Log.d(TAG, "Updated settings: ${HearingAidSettings.value}") Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, HearingAidSettings.value) sendHearingAidSettings(attManager, hearingAidSettings.value)
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -342,7 +307,7 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
var parsedSettings: HearingAidSettings? = null var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) { for (attempt in 1..3) {
initialReadAttempts.value = attempt initialReadAttempts.intValue = attempt
try { try {
val data = attManager.read(ATTHandles.HEARING_AID) val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data) parsedSettings = parseHearingAidSettingsResponse(data = data)
@@ -369,7 +334,7 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
initialReadSucceeded.value = true initialReadSucceeded.value = true
} else { } 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) { } catch (e: IOException) {
e.printStackTrace() 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(
text = stringResource(R.string.amplification).uppercase(), text = stringResource(R.string.amplification).uppercase(),
style = TextStyle( style = TextStyle(
@@ -392,48 +353,16 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
), ),
modifier = Modifier.padding(8.dp, bottom = 0.dp) modifier = Modifier.padding(8.dp, bottom = 0.dp)
) )
Box( StyledSlider(
modifier = Modifier valueRange = -1f..1f,
.fillMaxWidth() mutableFloatState = amplificationSliderValue,
.background(backgroundColor, RoundedCornerShape(14.dp)) onValueChange = {
.padding(horizontal = 8.dp, vertical = 0.dp) amplificationSliderValue.floatValue = it
.height(55.dp) },
) { startIcon = "􀊥",
Row( endIcon = "􀊩",
verticalAlignment = Alignment.CenterVertically, independent = true,
horizontalArrangement = Arrangement.SpaceBetween, )
modifier = Modifier.fillMaxSize()
) {
Text(
text = "􀊥",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(start = 4.dp)
)
AccessibilitySlider(
valueRange = -1f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f))
},
widthFrac = 0.90f
)
Text(
text = "􀊩",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(end = 4.dp)
)
}
}
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
@@ -455,47 +384,17 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
), ),
modifier = Modifier.padding(8.dp, bottom = 0.dp) modifier = Modifier.padding(8.dp, bottom = 0.dp)
) )
Box( StyledSlider(
modifier = Modifier valueRange = -1f..1f,
.fillMaxWidth() mutableFloatState = balanceSliderValue,
.background(backgroundColor, RoundedCornerShape(14.dp)) onValueChange = {
.padding(horizontal = 8.dp, vertical = 0.dp) balanceSliderValue.floatValue = it
) { },
Column { snapPoints = listOf(0f),
Row( startLabel = stringResource(R.string.left),
modifier = Modifier endLabel = stringResource(R.string.right),
.fillMaxWidth() independent = true,
.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(
text = stringResource(R.string.tone).uppercase(), text = stringResource(R.string.tone).uppercase(),
@@ -507,47 +406,16 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
), ),
modifier = Modifier.padding(8.dp, bottom = 0.dp) modifier = Modifier.padding(8.dp, bottom = 0.dp)
) )
Box( StyledSlider(
modifier = Modifier valueRange = -1f..1f,
.fillMaxWidth() mutableFloatState = toneSliderValue,
.background(backgroundColor, RoundedCornerShape(14.dp)) onValueChange = {
.padding(horizontal = 8.dp, vertical = 0.dp) toneSliderValue.floatValue = it
) { },
Column { startLabel = stringResource(R.string.darker),
Row( endLabel = stringResource(R.string.brighter),
modifier = Modifier independent = true,
.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(
text = stringResource(R.string.ambient_noise_reduction).uppercase(), text = stringResource(R.string.ambient_noise_reduction).uppercase(),
@@ -560,47 +428,16 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
modifier = Modifier.padding(8.dp, bottom = 0.dp) modifier = Modifier.padding(8.dp, bottom = 0.dp)
) )
Box( StyledSlider(
modifier = Modifier valueRange = 0f..1f,
.fillMaxWidth() mutableFloatState = ambientNoiseReductionSliderValue,
.background(backgroundColor, RoundedCornerShape(14.dp)) onValueChange = {
.padding(horizontal = 8.dp, vertical = 0.dp) ambientNoiseReductionSliderValue.floatValue = it
) { },
Column { startLabel = stringResource(R.string.less),
Row( endLabel = stringResource(R.string.more),
modifier = Modifier independent = true,
.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( AccessibilityToggle(
text = stringResource(R.string.conversation_boost), text = stringResource(R.string.conversation_boost),
@@ -668,8 +505,6 @@ private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings
if (data.size < 104) return null if (data.size < 104) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) 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 buffer.getShort() // skip 0x60 0x00
val leftEQ = FloatArray(8) val leftEQ = FloatArray(8)
@@ -718,7 +553,7 @@ private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings
private fun sendHearingAidSettings( private fun sendHearingAidSettings(
attManager: ATTManager, attManager: ATTManager,
HearingAidSettings: HearingAidSettings hearingAidSettings: HearingAidSettings
) { ) {
debounceJob?.cancel() debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch { debounceJob = CoroutineScope(Dispatchers.IO).launch {
@@ -736,19 +571,19 @@ private fun sendHearingAidSettings(
buffer.put(2, 0x64) buffer.put(2, 0x64)
// Left ear adjustments // Left ear adjustments
buffer.putFloat(36, HearingAidSettings.leftAmplification) buffer.putFloat(36, hearingAidSettings.leftAmplification)
buffer.putFloat(40, HearingAidSettings.leftTone) buffer.putFloat(40, hearingAidSettings.leftTone)
buffer.putFloat(44, if (HearingAidSettings.leftConversationBoost) 1.0f else 0.0f) buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(48, HearingAidSettings.leftAmbientNoiseReduction) buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction)
// Right ear adjustments // Right ear adjustments
buffer.putFloat(84, HearingAidSettings.rightAmplification) buffer.putFloat(84, hearingAidSettings.rightAmplification)
buffer.putFloat(88, HearingAidSettings.rightTone) buffer.putFloat(88, hearingAidSettings.rightTone)
buffer.putFloat(92, if (HearingAidSettings.rightConversationBoost) 1.0f else 0.0f) buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(96, HearingAidSettings.rightAmbientNoiseReduction) buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction)
// Own voice amplification // 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) }}") 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 package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.util.Log import android.util.Log
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween 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.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.CenterAlignedTopAppBar 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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -72,16 +58,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind 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.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -99,27 +79,15 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AccessibilitySlider
import me.kavishdevar.librepods.composables.ConfirmationDialog 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.StyledSwitch
import me.kavishdevar.librepods.composables.ToneVolumeSlider
import me.kavishdevar.librepods.composables.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager 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.AACPManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings import me.kavishdevar.librepods.utils.sendTransparencySettings
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null private var debounceJob: Job? = null
@@ -139,15 +107,6 @@ fun HearingAidScreen(navController: NavController) {
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val aacpManager = remember { ServiceManager.getService()?.aacpManager } 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) } val showDialog = remember { mutableStateOf(false) }
@@ -385,7 +344,11 @@ fun HearingAidScreen(navController: NavController) {
onAdjustMediaChange(!adjustMediaEnabled.value) onAdjustMediaChange(!adjustMediaEnabled.value)
} }
) )
}, }
.background(
animatedBackgroundColorAdjustMedia,
RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
@@ -430,7 +393,11 @@ fun HearingAidScreen(navController: NavController) {
onAdjustPhoneChange(!adjustPhoneEnabled.value) onAdjustPhoneChange(!adjustPhoneEnabled.value)
} }
) )
}, }
.background(
animatedBackgroundColorAdjustPhone,
RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
@@ -466,11 +433,10 @@ fun HearingAidScreen(navController: NavController) {
if (!enrolled) { if (!enrolled) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
} else { } 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()) aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte())
hearingAidEnabled.value = true hearingAidEnabled.value = true
// Disable transparency mode
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val data = attManager.read(ATTHandles.TRANSPARENCY) val data = attManager.read(ATTHandles.TRANSPARENCY)
@@ -484,9 +450,6 @@ fun HearingAidScreen(navController: NavController) {
} }
} }
}, },
hazeState = hazeState, hazeState = hazeState
isDarkTheme = isDarkTheme,
textColor = textColor,
activeTrackColor = activeTrackColor
) )
} }

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") throw IllegalStateException("End of stream reached")
} }
val data = buffer.copyOfRange(0, len) 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) }}") Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
return data return data
} }

View File

@@ -10,12 +10,12 @@ composeBom = "2025.04.00"
annotations = "26.0.2" annotations = "26.0.2"
navigationCompose = "2.8.9" navigationCompose = "2.8.9"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"
haze = "1.5.3" haze = "1.6.10"
hazeMaterials = "1.5.3" hazeMaterials = "1.6.10"
sliceBuilders = "1.1.0-alpha02"
sliceCore = "1.1.0-alpha02"
sliceView = "1.1.0-alpha02"
dynamicanimation = "1.1.0" dynamicanimation = "1.1.0"
foundationLayout = "1.9.1"
uiTooling = "1.9.1"
mockk = "1.14.3"
[libraries] [libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } 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" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" } haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" }
haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "hazeMaterials" } 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-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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }