Skip to content

Commit 9e6d971

Browse files
committed
android: add EQ settings for phone and media
1 parent c53356f commit 9e6d971

File tree

3 files changed

+319
-15
lines changed

3 files changed

+319
-15
lines changed

android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
package me.kavishdevar.librepods.composables
2222

23+
import android.util.Log
2324
import androidx.compose.foundation.background
2425
import androidx.compose.foundation.clickable
2526
import androidx.compose.foundation.gestures.detectTapGestures
@@ -62,7 +63,27 @@ fun LoudSoundReductionSwitch(attManager: ATTManager) {
6263
while (attManager.socket?.isConnected != true) {
6364
delay(100)
6465
}
65-
attManager.read(0x1b)
66+
67+
var parsed = false
68+
for (attempt in 1..3) {
69+
try {
70+
val data = attManager.read(0x1b)
71+
if (data.size == 2) {
72+
loudSoundReductionEnabled = data[1].toInt() != 0
73+
Log.d("LoudSoundReduction", "Read attempt $attempt: enabled=${loudSoundReductionEnabled}")
74+
parsed = true
75+
break
76+
} else {
77+
Log.d("LoudSoundReduction", "Read attempt $attempt returned empty data")
78+
}
79+
} catch (e: Exception) {
80+
Log.w("LoudSoundReduction", "Read attempt $attempt failed: ${e.message}")
81+
}
82+
delay(200)
83+
}
84+
if (!parsed) {
85+
Log.d("LoudSoundReduction", "Failed to read loud sound reduction state after 3 attempts")
86+
}
6687
}
6788

6889
LaunchedEffect(loudSoundReductionEnabled) {

android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt

Lines changed: 235 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ import androidx.compose.foundation.rememberScrollState
4040
import androidx.compose.foundation.shape.RoundedCornerShape
4141
import androidx.compose.foundation.verticalScroll
4242
import androidx.compose.material3.CenterAlignedTopAppBar
43+
import androidx.compose.material3.Checkbox
44+
import androidx.compose.material3.CheckboxDefaults
4345
import androidx.compose.material3.ExperimentalMaterial3Api
46+
import androidx.compose.material3.HorizontalDivider
4447
import androidx.compose.material3.Scaffold
4548
import androidx.compose.material3.Slider
4649
import androidx.compose.material3.SnackbarHost
@@ -60,6 +63,7 @@ import androidx.compose.ui.Alignment
6063
import androidx.compose.ui.Modifier
6164
import androidx.compose.ui.draw.drawBehind
6265
import androidx.compose.ui.draw.rotate
66+
import androidx.compose.ui.draw.scale
6367
import androidx.compose.ui.geometry.Offset
6468
import androidx.compose.ui.graphics.Color
6569
import androidx.compose.ui.input.pointer.pointerInput
@@ -95,6 +99,7 @@ import java.nio.ByteOrder
9599
import kotlin.io.encoding.ExperimentalEncodingApi
96100

97101
var debounceJob: Job? = null
102+
var phoneMediaDebounceJob: Job? = null
98103
const 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

Comments
 (0)