diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt index 1eecf4d..c1cec37 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt @@ -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) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt index fb94720..438f5a9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt @@ -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}") + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt index 9141285..764e368 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt @@ -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,