@@ -40,7 +40,10 @@ import androidx.compose.foundation.rememberScrollState
4040import androidx.compose.foundation.shape.RoundedCornerShape
4141import androidx.compose.foundation.verticalScroll
4242import androidx.compose.material3.CenterAlignedTopAppBar
43+ import androidx.compose.material3.Checkbox
44+ import androidx.compose.material3.CheckboxDefaults
4345import androidx.compose.material3.ExperimentalMaterial3Api
46+ import androidx.compose.material3.HorizontalDivider
4447import androidx.compose.material3.Scaffold
4548import androidx.compose.material3.Slider
4649import androidx.compose.material3.SnackbarHost
@@ -60,6 +63,7 @@ import androidx.compose.ui.Alignment
6063import androidx.compose.ui.Modifier
6164import androidx.compose.ui.draw.drawBehind
6265import androidx.compose.ui.draw.rotate
66+ import androidx.compose.ui.draw.scale
6367import androidx.compose.ui.geometry.Offset
6468import androidx.compose.ui.graphics.Color
6569import androidx.compose.ui.input.pointer.pointerInput
@@ -95,6 +99,7 @@ import java.nio.ByteOrder
9599import kotlin.io.encoding.ExperimentalEncodingApi
96100
97101var debounceJob: Job ? = null
102+ var phoneMediaDebounceJob: Job ? = null
98103const val TAG = " AccessibilitySettings"
99104
100105@SuppressLint(" DefaultLocale" )
@@ -108,6 +113,9 @@ fun AccessibilitySettingsScreen() {
108113 val hazeState = remember { HazeState () }
109114 val snackbarHostState = remember { SnackbarHostState () }
110115 val attManager = ATTManager (ServiceManager .getService()?.device? : throw IllegalStateException (" No device connected" ))
116+ // get the AACP manager if available (used for EQ read/write)
117+ val aacpManager = remember { ServiceManager .getService()?.aacpManager }
118+
111119 DisposableEffect (attManager) {
112120 onDispose {
113121 Log .d(TAG , " Disconnecting from ATT..." )
@@ -187,15 +195,15 @@ fun AccessibilitySettingsScreen() {
187195 val conversationBoostEnabled = remember { mutableStateOf(false ) }
188196 val eq = remember { mutableStateOf(FloatArray (8 )) }
189197
190- // Flag to prevent sending default settings to device while we are loading device state
198+ val phoneMediaEQ = remember { mutableStateOf(FloatArray (8 ) { 0.5f }) }
199+ val phoneEQEnabled = remember { mutableStateOf(false ) }
200+ val mediaEQEnabled = remember { mutableStateOf(false ) }
201+
191202 val initialLoadComplete = remember { mutableStateOf(false ) }
192203
193- // Ensure we actually read device properties before allowing writes.
194- // Try up to 3 times silently; mark success only if parse succeeds.
195204 val initialReadSucceeded = remember { mutableStateOf(false ) }
196205 val initialReadAttempts = remember { mutableStateOf(0 ) }
197206
198- // Populate a single stored representation for convenience (kept for debug/logging)
199207 val transparencySettings = remember {
200208 mutableStateOf(
201209 TransparencySettings (
@@ -217,13 +225,11 @@ fun AccessibilitySettingsScreen() {
217225 }
218226
219227 LaunchedEffect (enabled.value, amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, eq.value, initialLoadComplete.value, initialReadSucceeded.value) {
220- // Do not send updates until we have populated UI from the device
221228 if (! initialLoadComplete.value) {
222229 Log .d(TAG , " Initial device load not complete - skipping send" )
223230 return @LaunchedEffect
224231 }
225232
226- // Do not send until we've successfully read the device properties at least once.
227233 if (! initialReadSucceeded.value) {
228234 Log .d(TAG , " Initial device read not successful yet - skipping send until read succeeds" )
229235 return @LaunchedEffect
@@ -248,17 +254,35 @@ fun AccessibilitySettingsScreen() {
248254 sendTransparencySettings(attManager, transparencySettings.value)
249255 }
250256
251- // Move initial connect / read here so we can populate the UI state variables above.
252257 LaunchedEffect (Unit ) {
253258 Log .d(TAG , " Connecting to ATT..." )
254259 try {
255260 attManager.connect()
256261 while (attManager.socket?.isConnected != true ) {
257262 delay(100 )
258263 }
264+ // If we have an AACP manager, prefer its EQ data to populate EQ controls first
265+ try {
266+ if (aacpManager != null ) {
267+ Log .d(TAG , " Found AACPManager, reading cached EQ data" )
268+ val aacpEQ = aacpManager.eqData
269+ if (aacpEQ.isNotEmpty()) {
270+ eq.value = aacpEQ.copyOf()
271+ phoneMediaEQ.value = aacpEQ.copyOf()
272+ phoneEQEnabled.value = aacpManager.eqOnPhone
273+ mediaEQEnabled.value = aacpManager.eqOnMedia
274+ Log .d(TAG , " Populated EQ from AACPManager: ${aacpEQ.toList()} " )
275+ } else {
276+ Log .d(TAG , " AACPManager EQ data empty" )
277+ }
278+ } else {
279+ Log .d(TAG , " No AACPManager available" )
280+ }
281+ } catch (e: Exception ) {
282+ Log .w(TAG , " Error reading EQ from AACPManager: ${e.message} " )
283+ }
259284
260285 var parsedSettings: TransparencySettings ? = null
261- // Try up to 3 read attempts silently
262286 for (attempt in 1 .. 3 ) {
263287 initialReadAttempts.value = attempt
264288 try {
@@ -278,7 +302,6 @@ fun AccessibilitySettingsScreen() {
278302
279303 if (parsedSettings != null ) {
280304 Log .d(TAG , " Initial transparency settings: $parsedSettings " )
281- // Populate UI states from device values without triggering a send (initialReadSucceeded is set below)
282305 enabled.value = parsedSettings.enabled
283306 amplificationSliderValue.floatValue = parsedSettings.netAmplification
284307 balanceSliderValue.floatValue = parsedSettings.balance
@@ -293,11 +316,31 @@ fun AccessibilitySettingsScreen() {
293316 } catch (e: IOException ) {
294317 e.printStackTrace()
295318 } finally {
296- // mark load complete (UI may be editable), but writes remain blocked until a successful read
297319 initialLoadComplete.value = true
298320 }
299321 }
300322
323+ // Debounced write for phone/media EQ using AACP manager when values/toggles change
324+ LaunchedEffect (phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
325+ phoneMediaDebounceJob?.cancel()
326+ phoneMediaDebounceJob = CoroutineScope (Dispatchers .IO ).launch {
327+ delay(150 )
328+ val manager = ServiceManager .getService()?.aacpManager
329+ if (manager == null ) {
330+ Log .w(TAG , " Cannot write EQ: AACPManager not available" )
331+ return @launch
332+ }
333+ try {
334+ val phoneByte = if (phoneEQEnabled.value) 0x01 .toByte() else 0x02 .toByte()
335+ val mediaByte = if (mediaEQEnabled.value) 0x01 .toByte() else 0x02 .toByte()
336+ Log .d(TAG , " Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value} , mediaEnabled=${mediaEQEnabled.value} )" )
337+ manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
338+ } catch (e: Exception ) {
339+ Log .w(TAG , " Error sending phone/media EQ: ${e.message} " )
340+ }
341+ }
342+ }
343+
301344 AccessibilityToggle (
302345 text = " Transparency Mode" ,
303346 mutableState = enabled,
@@ -448,7 +491,168 @@ fun AccessibilitySettingsScreen() {
448491 newEQ[i] = eqValue.floatValue
449492 eq.value = newEQ
450493 },
451- valueRange = 0f .. 1f ,
494+ valueRange = 0f .. 100f ,
495+ modifier = Modifier
496+ .fillMaxWidth(0.9f )
497+ )
498+
499+ Text (
500+ text = " Band ${i + 1 } " ,
501+ fontSize = 12 .sp,
502+ color = textColor,
503+ modifier = Modifier .padding(top = 4 .dp)
504+ )
505+ }
506+ }
507+ }
508+
509+ Spacer (modifier = Modifier .height(16 .dp))
510+ Text (
511+ text = " Apply EQ to" .uppercase(),
512+ style = TextStyle (
513+ fontSize = 14 .sp,
514+ fontWeight = FontWeight .Light ,
515+ color = textColor.copy(alpha = 0.6f ),
516+ fontFamily = FontFamily (Font (R .font.sf_pro))
517+ ),
518+ modifier = Modifier .padding(8 .dp, bottom = 2 .dp)
519+ )
520+ Column (
521+ modifier = Modifier
522+ .fillMaxWidth()
523+ .background(backgroundColor, RoundedCornerShape (14 .dp))
524+ .padding(top = 0 .dp, bottom = 12 .dp)
525+ ) {
526+ val darkModeLocal = isSystemInDarkTheme()
527+
528+ val phoneShape = RoundedCornerShape (topStart = 14 .dp, topEnd = 14 .dp)
529+ var phoneBackgroundColor by remember { mutableStateOf(if (darkModeLocal) Color (0xFF1C1C1E ) else Color (0xFFFFFFFF )) }
530+ val phoneAnimatedBackgroundColor by animateColorAsState(targetValue = phoneBackgroundColor, animationSpec = tween(durationMillis = 500 ))
531+
532+ Row (
533+ modifier = Modifier
534+ .height(48 .dp)
535+ .fillMaxWidth()
536+ .background(phoneAnimatedBackgroundColor, phoneShape)
537+ .pointerInput(Unit ) {
538+ detectTapGestures(
539+ onPress = {
540+ phoneBackgroundColor = if (darkModeLocal) Color (0x40888888 ) else Color (0x40D9D9D9 )
541+ tryAwaitRelease()
542+ phoneBackgroundColor = if (darkModeLocal) Color (0xFF1C1C1E ) else Color (0xFFFFFFFF )
543+ phoneEQEnabled.value = ! phoneEQEnabled.value
544+ }
545+ )
546+ }
547+ .padding(horizontal = 16 .dp),
548+ verticalAlignment = Alignment .CenterVertically
549+ ) {
550+ Text (
551+ " Phone" ,
552+ fontSize = 16 .sp,
553+ fontFamily = FontFamily (Font (R .font.sf_pro)),
554+ modifier = Modifier .weight(1f )
555+ )
556+ Checkbox (
557+ checked = phoneEQEnabled.value,
558+ onCheckedChange = { phoneEQEnabled.value = it },
559+ colors = CheckboxDefaults .colors().copy(
560+ checkedCheckmarkColor = Color (0xFF007AFF ),
561+ uncheckedCheckmarkColor = Color .Transparent ,
562+ checkedBoxColor = Color .Transparent ,
563+ uncheckedBoxColor = Color .Transparent ,
564+ checkedBorderColor = Color .Transparent ,
565+ uncheckedBorderColor = Color .Transparent
566+ ),
567+ modifier = Modifier
568+ .height(24 .dp)
569+ .scale(1.5f )
570+ )
571+ }
572+
573+ HorizontalDivider (
574+ thickness = 1.5 .dp,
575+ color = Color (0x40888888 )
576+ )
577+
578+ val mediaShape = RoundedCornerShape (bottomStart = 14 .dp, bottomEnd = 14 .dp)
579+ var mediaBackgroundColor by remember { mutableStateOf(if (darkModeLocal) Color (0xFF1C1C1E ) else Color (0xFFFFFFFF )) }
580+ val mediaAnimatedBackgroundColor by animateColorAsState(targetValue = mediaBackgroundColor, animationSpec = tween(durationMillis = 500 ))
581+
582+ Row (
583+ modifier = Modifier
584+ .height(48 .dp)
585+ .fillMaxWidth()
586+ .background(mediaAnimatedBackgroundColor, mediaShape)
587+ .pointerInput(Unit ) {
588+ detectTapGestures(
589+ onPress = {
590+ mediaBackgroundColor = if (darkModeLocal) Color (0x40888888 ) else Color (0x40D9D9D9 )
591+ tryAwaitRelease()
592+ mediaBackgroundColor = if (darkModeLocal) Color (0xFF1C1C1E ) else Color (0xFFFFFFFF )
593+ mediaEQEnabled.value = ! mediaEQEnabled.value
594+ }
595+ )
596+ }
597+ .padding(horizontal = 16 .dp),
598+ verticalAlignment = Alignment .CenterVertically
599+ ) {
600+ Text (
601+ " Media" ,
602+ fontSize = 16 .sp,
603+ fontFamily = FontFamily (Font (R .font.sf_pro)),
604+ modifier = Modifier .weight(1f )
605+ )
606+ Checkbox (
607+ checked = mediaEQEnabled.value,
608+ onCheckedChange = { mediaEQEnabled.value = it },
609+ colors = CheckboxDefaults .colors().copy(
610+ checkedCheckmarkColor = Color (0xFF007AFF ),
611+ uncheckedCheckmarkColor = Color .Transparent ,
612+ checkedBoxColor = Color .Transparent ,
613+ uncheckedBoxColor = Color .Transparent ,
614+ checkedBorderColor = Color .Transparent ,
615+ uncheckedBorderColor = Color .Transparent
616+ ),
617+ modifier = Modifier
618+ .height(24 .dp)
619+ .scale(1.5f )
620+ )
621+ }
622+ }
623+
624+ Column (
625+ modifier = Modifier
626+ .fillMaxWidth()
627+ .background(backgroundColor, RoundedCornerShape (14 .dp))
628+ .padding(12 .dp),
629+ horizontalAlignment = Alignment .CenterHorizontally
630+ ) {
631+ for (i in 0 until 8 ) {
632+ val eqPhoneValue = remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
633+ Row (
634+ horizontalArrangement = Arrangement .SpaceBetween ,
635+ verticalAlignment = Alignment .CenterVertically ,
636+ modifier = Modifier
637+ .fillMaxWidth()
638+ .height(32 .dp)
639+ ) {
640+ Text (
641+ text = String .format(" %.2f" , eqPhoneValue.floatValue),
642+ fontSize = 12 .sp,
643+ color = textColor,
644+ modifier = Modifier .padding(bottom = 4 .dp)
645+ )
646+
647+ Slider (
648+ value = eqPhoneValue.floatValue,
649+ onValueChange = { newVal ->
650+ eqPhoneValue.floatValue = newVal
651+ val newEQ = phoneMediaEQ.value.copyOf()
652+ newEQ[i] = eqPhoneValue.floatValue
653+ phoneMediaEQ.value = newEQ
654+ },
655+ valueRange = 0f .. 100f ,
452656 modifier = Modifier
453657 .fillMaxWidth(0.9f )
454658 )
@@ -578,7 +782,6 @@ private fun parseTransparencySettingsResponse(data: ByteArray): TransparencySett
578782 val enabled = buffer.float
579783 Log .d(TAG , " Parsed enabled: $enabled " )
580784
581- // Left bud
582785 val leftEQ = FloatArray (8 )
583786 for (i in 0 .. 7 ) {
584787 leftEQ[i] = buffer.float
@@ -642,7 +845,7 @@ private fun sendTransparencySettings(
642845 debounceJob = CoroutineScope (Dispatchers .IO ).launch {
643846 delay(100 )
644847 try {
645- val buffer = ByteBuffer .allocate(100 ).order(ByteOrder .LITTLE_ENDIAN ) // 100 data bytes
848+ val buffer = ByteBuffer .allocate(100 ).order(ByteOrder .LITTLE_ENDIAN )
646849
647850 Log .d(TAG ,
648851 " Sending settings: $transparencySettings "
@@ -676,3 +879,22 @@ private fun sendTransparencySettings(
676879 }
677880 }
678881}
882+
883+ // Debounced send helper for phone/media EQ (if needed elsewhere)
884+ private fun sendPhoneMediaEQ (aacpManager : me.kavishdevar.librepods.utils.AACPManager ? , eq : FloatArray , phoneEnabled : Boolean , mediaEnabled : Boolean ) {
885+ phoneMediaDebounceJob?.cancel()
886+ phoneMediaDebounceJob = CoroutineScope (Dispatchers .IO ).launch {
887+ delay(100 )
888+ try {
889+ if (aacpManager == null ) {
890+ Log .w(TAG , " AACPManger is null; cannot send phone/media EQ" )
891+ return @launch
892+ }
893+ val phoneByte = if (phoneEnabled) 0x01 .toByte() else 0x02 .toByte()
894+ val mediaByte = if (mediaEnabled) 0x01 .toByte() else 0x02 .toByte()
895+ aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
896+ } catch (e: Exception ) {
897+ Log .w(TAG , " Error in sendPhoneMediaEQ: ${e.message} " )
898+ }
899+ }
900+ }
0 commit comments