android: add EQ settings for phone and media

This commit is contained in:
Kavish Devar
2025-09-15 11:49:00 +05:30
parent c53356f77e
commit 9e6d97198b
3 changed files with 319 additions and 15 deletions

View File

@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.composables
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -62,7 +63,27 @@ fun LoudSoundReductionSwitch(attManager: ATTManager) {
while (attManager.socket?.isConnected != true) {
delay(100)
}
attManager.read(0x1b)
var parsed = false
for (attempt in 1..3) {
try {
val data = attManager.read(0x1b)
if (data.size == 2) {
loudSoundReductionEnabled = data[1].toInt() != 0
Log.d("LoudSoundReduction", "Read attempt $attempt: enabled=${loudSoundReductionEnabled}")
parsed = true
break
} else {
Log.d("LoudSoundReduction", "Read attempt $attempt returned empty data")
}
} catch (e: Exception) {
Log.w("LoudSoundReduction", "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (!parsed) {
Log.d("LoudSoundReduction", "Failed to read loud sound reduction state after 3 attempts")
}
}
LaunchedEffect(loudSoundReductionEnabled) {

View File

@@ -40,7 +40,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost
@@ -60,6 +63,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
@@ -95,6 +99,7 @@ import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
var debounceJob: Job? = null
var phoneMediaDebounceJob: Job? = null
const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@@ -108,6 +113,9 @@ fun AccessibilitySettingsScreen() {
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
val attManager = ATTManager(ServiceManager.getService()?.device?: throw IllegalStateException("No device connected"))
// get the AACP manager if available (used for EQ read/write)
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
DisposableEffect(attManager) {
onDispose {
Log.d(TAG, "Disconnecting from ATT...")
@@ -187,15 +195,15 @@ fun AccessibilitySettingsScreen() {
val conversationBoostEnabled = remember { mutableStateOf(false) }
val eq = remember { mutableStateOf(FloatArray(8)) }
// Flag to prevent sending default settings to device while we are loading device state
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val phoneEQEnabled = remember { mutableStateOf(false) }
val mediaEQEnabled = remember { mutableStateOf(false) }
val initialLoadComplete = remember { mutableStateOf(false) }
// Ensure we actually read device properties before allowing writes.
// Try up to 3 times silently; mark success only if parse succeeds.
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableStateOf(0) }
// Populate a single stored representation for convenience (kept for debug/logging)
val transparencySettings = remember {
mutableStateOf(
TransparencySettings(
@@ -217,13 +225,11 @@ fun AccessibilitySettingsScreen() {
}
LaunchedEffect(enabled.value, amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, eq.value, initialLoadComplete.value, initialReadSucceeded.value) {
// Do not send updates until we have populated UI from the device
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
// Do not send until we've successfully read the device properties at least once.
if (!initialReadSucceeded.value) {
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
return@LaunchedEffect
@@ -248,7 +254,6 @@ fun AccessibilitySettingsScreen() {
sendTransparencySettings(attManager, transparencySettings.value)
}
// Move initial connect / read here so we can populate the UI state variables above.
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
@@ -256,9 +261,28 @@ fun AccessibilitySettingsScreen() {
while (attManager.socket?.isConnected != true) {
delay(100)
}
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
try {
if (aacpManager != null) {
Log.d(TAG, "Found AACPManager, reading cached EQ data")
val aacpEQ = aacpManager.eqData
if (aacpEQ.isNotEmpty()) {
eq.value = aacpEQ.copyOf()
phoneMediaEQ.value = aacpEQ.copyOf()
phoneEQEnabled.value = aacpManager.eqOnPhone
mediaEQEnabled.value = aacpManager.eqOnMedia
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
Log.d(TAG, "AACPManager EQ data empty")
}
} else {
Log.d(TAG, "No AACPManager available")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
}
var parsedSettings: TransparencySettings? = null
// Try up to 3 read attempts silently
for (attempt in 1..3) {
initialReadAttempts.value = attempt
try {
@@ -278,7 +302,6 @@ fun AccessibilitySettingsScreen() {
if (parsedSettings != null) {
Log.d(TAG, "Initial transparency settings: $parsedSettings")
// Populate UI states from device values without triggering a send (initialReadSucceeded is set below)
enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
@@ -293,11 +316,31 @@ fun AccessibilitySettingsScreen() {
} catch (e: IOException) {
e.printStackTrace()
} finally {
// mark load complete (UI may be editable), but writes remain blocked until a successful read
initialLoadComplete.value = true
}
}
// Debounced write for phone/media EQ using AACP manager when values/toggles change
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150)
val manager = ServiceManager.getService()?.aacpManager
if (manager == null) {
Log.w(TAG, "Cannot write EQ: AACPManager not available")
return@launch
}
try {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
Log.d(TAG, "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})")
manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
}
}
}
AccessibilityToggle(
text = "Transparency Mode",
mutableState = enabled,
@@ -448,7 +491,168 @@ fun AccessibilitySettingsScreen() {
newEQ[i] = eqValue.floatValue
eq.value = newEQ
},
valueRange = 0f..1f,
valueRange = 0f..100f,
modifier = Modifier
.fillMaxWidth(0.9f)
)
Text(
text = "Band ${i + 1}",
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Apply EQ to".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(top = 0.dp, bottom = 12.dp)
) {
val darkModeLocal = isSystemInDarkTheme()
val phoneShape = RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
var phoneBackgroundColor by remember { mutableStateOf(if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val phoneAnimatedBackgroundColor by animateColorAsState(targetValue = phoneBackgroundColor, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(phoneAnimatedBackgroundColor, phoneShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
phoneBackgroundColor = if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
phoneBackgroundColor = if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
phoneEQEnabled.value = !phoneEQEnabled.value
}
)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Phone",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.weight(1f)
)
Checkbox(
checked = phoneEQEnabled.value,
onCheckedChange = { phoneEQEnabled.value = it },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent
),
modifier = Modifier
.height(24.dp)
.scale(1.5f)
)
}
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888)
)
val mediaShape = RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
var mediaBackgroundColor by remember { mutableStateOf(if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val mediaAnimatedBackgroundColor by animateColorAsState(targetValue = mediaBackgroundColor, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(mediaAnimatedBackgroundColor, mediaShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
mediaBackgroundColor = if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
mediaBackgroundColor = if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
mediaEQEnabled.value = !mediaEQEnabled.value
}
)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Media",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.weight(1f)
)
Checkbox(
checked = mediaEQEnabled.value,
onCheckedChange = { mediaEQEnabled.value = it },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent
),
modifier = Modifier
.height(24.dp)
.scale(1.5f)
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
for (i in 0 until 8) {
val eqPhoneValue = remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(32.dp)
) {
Text(
text = String.format("%.2f", eqPhoneValue.floatValue),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Slider(
value = eqPhoneValue.floatValue,
onValueChange = { newVal ->
eqPhoneValue.floatValue = newVal
val newEQ = phoneMediaEQ.value.copyOf()
newEQ[i] = eqPhoneValue.floatValue
phoneMediaEQ.value = newEQ
},
valueRange = 0f..100f,
modifier = Modifier
.fillMaxWidth(0.9f)
)
@@ -578,7 +782,6 @@ private fun parseTransparencySettingsResponse(data: ByteArray): TransparencySett
val enabled = buffer.float
Log.d(TAG, "Parsed enabled: $enabled")
// Left bud
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
@@ -642,7 +845,7 @@ private fun sendTransparencySettings(
debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val buffer = ByteBuffer.allocate(100).order(ByteOrder.LITTLE_ENDIAN) // 100 data bytes
val buffer = ByteBuffer.allocate(100).order(ByteOrder.LITTLE_ENDIAN)
Log.d(TAG,
"Sending settings: $transparencySettings"
@@ -676,3 +879,22 @@ private fun sendTransparencySettings(
}
}
}
// Debounced send helper for phone/media EQ (if needed elsewhere)
private fun sendPhoneMediaEQ(aacpManager: me.kavishdevar.librepods.utils.AACPManager?, eq: FloatArray, phoneEnabled: Boolean, mediaEnabled: Boolean) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
if (aacpManager == null) {
Log.w(TAG, "AACPManger is null; cannot send phone/media EQ")
return@launch
}
val phoneByte = if (phoneEnabled) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEnabled) 0x01.toByte() else 0x02.toByte()
aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(TAG, "Error in sendPhoneMediaEQ: ${e.message}")
}
}
}

View File

@@ -195,6 +195,15 @@ class AACPManager {
var audioSource: AudioSource? = null
private set
var eqData = FloatArray(8) { 0.0f }
private set
var eqOnPhone: Boolean = false
private set
var eqOnMedia: Boolean = false
private set
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
return controlCommandStatusList.find { it.identifier == identifier }
}
@@ -513,12 +522,60 @@ class AACPManager {
}
}
Opcodes.EQ_DATA -> {
if (packet.size != 140) {
Log.w(
TAG,
"Received EQ_DATA packet of unexpected size: ${packet.size}, expected 140"
)
return
}
if (packet[6] != 0x84.toByte()) {
Log.w(
TAG,
"Received EQ_DATA packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
)
return
}
// first 4 bytes AACP header, next two bytes opcode, next to bytes identifer
eqOnMedia = (packet[10] == 0x01.toByte())
eqOnPhone = (packet[11] == 0x01.toByte())
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media enabled. just directly the EQ... weird.
// the EQs are little endian floats
val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
// for now, just take the first EQ
eqData = FloatArray(8) { i -> eq1.get(i) }
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia")
}
else -> {
callback?.onUnknownPacketReceived(packet)
}
}
}
fun sendEqualizerData(eqData: FloatArray, eqOnPhone: Boolean, eqOnMedia: Boolean): Boolean {
if (eqData.size != 8) {
throw IllegalArgumentException("EQ data must be 8 floats")
}
return sendDataPacket(createEqualizerDataPacket(eqData, eqOnPhone, eqOnMedia))
}
fun createEqualizerDataPacket(eqData: FloatArray, eqOnPhone: Boolean, eqOnMedia: Boolean): ByteArray {
val opcode = byteArrayOf(Opcodes.EQ_DATA, 0x00)
val identifier = byteArrayOf(0x84.toByte(), 0x00)
val something = byteArrayOf(0x02, 0x02)
val phoneFlag = if (eqOnPhone) 0x01.toByte() else 0x00.toByte()
val mediaFlag = if (eqOnMedia) 0x01.toByte() else 0x00.toByte()
val buffer = ByteBuffer.allocate(32).order(ByteOrder.LITTLE_ENDIAN)
eqData.forEach { buffer.putFloat(it) }
return opcode + identifier + something + byteArrayOf(phoneFlag, mediaFlag) + buffer.array() + buffer.array() + buffer.array() + buffer.array()
}
fun sendNotificationRequest(): Boolean {
return sendDataPacket(createRequestNotificationPacket())
}
@@ -777,6 +834,10 @@ class AACPManager {
}
Log.d(TAG, "SELFMAC: $selfMacAddress")
val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac
if (targetMac == null) {
Log.w(TAG, "Cannot send Media Information packet: No connected device found")
return false
}
Log.d(TAG, "Sending Media Information packet to ${targetMac ?: "unknown device"}")
return sendDataPacket(
createMediaInformationPacket(
@@ -1108,7 +1169,7 @@ class AACPManager {
}
fun sendSomePacketIDontKnowWhatItIs() {
// 2900 00ff ffff ffff ffff
// 2900 00ff ffff ffff ffff -- enables setting EQ
sendDataPacket(
byteArrayOf(
0x29, 0x00,