diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index efe1ee2e..f9924eae 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -130,6 +130,7 @@ import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen import me.kavishdevar.librepods.presentation.screens.CameraControlScreen import me.kavishdevar.librepods.presentation.screens.DebugScreen +import me.kavishdevar.librepods.presentation.screens.EqualizerScreen import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.presentation.screens.HearingAidScreen @@ -479,6 +480,9 @@ fun Main() { val purchaseViewModel: PurchaseViewModel = viewModel() PurchaseScreen(purchaseViewModel, navController) } + composable("equalizer_screen") { + if (airPodsViewModel != null) EqualizerScreen(airPodsViewModel) + } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index edaec253..0467ef84 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -21,6 +21,8 @@ package me.kavishdevar.librepods.bluetooth import android.util.Log +import me.kavishdevar.librepods.data.Capability +import me.kavishdevar.librepods.data.CustomEq import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.io.encoding.ExperimentalEncodingApi @@ -47,7 +49,7 @@ class AACPManager { const val PROXIMITY_KEYS_REQ: Byte = 0x30 const val PROXIMITY_KEYS_RSP: Byte = 0x31 const val STEM_PRESS: Byte = 0x19 - const val EQ_DATA: Byte = 0x53 + const val HEADPHONE_ACCOMMODATION: Byte = 0x53 const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1 const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2 const val SMART_ROUTING: Byte = 0x10 @@ -55,6 +57,7 @@ class AACPManager { const val SMART_ROUTING_RESP: Byte = 0x11 const val SEND_CONNECTED_MAC: Byte = 0x14 const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant? + const val CUSTOM_EQ: Byte = 0x63 } private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) @@ -199,6 +202,11 @@ class AACPManager { var eqOnMedia: Boolean = false private set + var customEq: CustomEq = CustomEq(state = 1, low = 50, mid = 50, high = 50) + private set + + var customEqCallback: ((CustomEq) -> Unit)? = null + fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? { return controlCommandStatusList.find { it.identifier == identifier } } @@ -235,7 +243,9 @@ class AACPManager { fun onConnectedDevicesReceived(connectedDevices: List) fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) fun onShowNearbyUI(sender: String) - fun onEQPacketReceived(eqData: FloatArray) + fun onHeadphoneAccommodationReceived(eqData: FloatArray) + fun onCustomEqReceived(customEq: CustomEq) + fun onCapabilitiesReceived(capabilities: List) } fun parseStemPressResponse(data: ByteArray): Pair { @@ -548,18 +558,18 @@ class AACPManager { } } - Opcodes.EQ_DATA -> { + Opcodes.HEADPHONE_ACCOMMODATION -> { if (packet.size != 140) { Log.w( TAG, - "Received EQ_DATA packet of unexpected size: ${packet.size}, expected 140" + "Received HEADPHONE_ACCOMMODATION 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" + "Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84" ) return } @@ -582,7 +592,7 @@ class AACPManager { "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia" ) - callback?.onEQPacketReceived(eqData) + callback?.onHeadphoneAccommodationReceived(eqData) } Opcodes.INFORMATION -> { @@ -591,6 +601,13 @@ class AACPManager { callback?.onDeviceInformationReceived(information) } + Opcodes.CUSTOM_EQ -> { + Log.d(TAG, "Parsing CUSTOM_EQ: ${packet.toHexString()}") + customEq = parseCustomEqPacket(packet) + customEqCallback?.invoke(customEq) + callback?.onCustomEqReceived(customEq) + } + else -> { Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}") callback?.onUnknownPacketReceived(packet) @@ -1296,4 +1313,38 @@ class AACPManager { version3 = strings.getOrNull(10) ?: "", ) } + + fun sendCustomEqPacket(customEq: CustomEq): Boolean { + return sendDataPacket(customEq.toPacket()) + } + + fun parseCustomEqPacket(packet: ByteArray): CustomEq { + val data = packet.sliceArray(6 until packet.size) + + if (data.size < 7) { + Log.e(TAG, "custom EQ packet length less than 7, returning default") + return CustomEq(1, 50, 50, 50) + } + + val lengthLow = data[0].toInt() and 0xFF + val lengthHigh = data[1].toInt() and 0xFF + + val length = (lengthHigh shl 8) or lengthLow + + if (length != 5) { + Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally") + } + + val state = data[3].toInt() + val low = data[4].toInt() + val mid = data[5].toInt() + val high = data[6].toInt() + + return CustomEq( + state, + low, + mid, + high + ) + } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/CustomEq.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/CustomEq.kt new file mode 100644 index 00000000..38fe3b79 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/CustomEq.kt @@ -0,0 +1,27 @@ +package me.kavishdevar.librepods.data + +import me.kavishdevar.librepods.bluetooth.AACPManager + +enum class CustomEqBand { LOW, MID, HIGH } + +data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) { + + fun isEnabled(): Boolean { + return state == 2 + } + + fun toPacket(): ByteArray { + return byteArrayOf( + AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00, + 0x05, 0x00, // length (LE) + 0x01, state.toByte(), + low.toByte(), mid.toByte(), high.toByte() + ) + } + + init { + require(low in 0..100) { "low must be between 0 and 100, was $low" } + require(mid in 0..100) { "mid must be between 0 and 100, was $mid" } + require(high in 0..100) { "high must be between 0 and 100, was $high" } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt index 84a30068..fe0232f3 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt @@ -22,8 +22,10 @@ import android.os.Parcelable import android.util.Log import kotlinx.parcelize.Parcelize +// TODO: Remove everything but Battery-related stuff + enum class Enums(val value: ByteArray) { - NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION), + NOISE_CANCELLATION(byteArrayOf(0x0d)), PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)), SETTINGS(byteArrayOf(0x09, 0x00)), NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value), @@ -81,12 +83,12 @@ class AirPodsNotifications { const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED" const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED" const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS" - const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA" + const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION" const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED" } class EarDetection { - private val notificationBit = Capabilities.EAR_DETECTION + private val notificationBit = 6.toByte() private val notificationPrefix = Enums.PREFIX.value + notificationBit var status: List = listOf(0x01, 0x01) @@ -243,13 +245,6 @@ class AirPodsNotifications { } } -class Capabilities { - companion object { - val NOISE_CANCELLATION = byteArrayOf(0x0d) - val EAR_DETECTION = byteArrayOf(0x06) - } -} - fun isHeadTrackingData(data: ByteArray): Boolean { if (data.size <= 60) return false diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt index c7836d05..6d8b91ac 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt @@ -53,6 +53,7 @@ fun AudioSettings( conversationalAwarenessCapability: Boolean, loudSoundReductionCapability: Boolean, adaptiveAudioCapability: Boolean, + customEqCapability: Boolean, adaptiveVolumeChecked: Boolean, onAdaptiveVolumeCheckedChange: (Boolean) -> Unit, @@ -157,6 +158,20 @@ fun AudioSettings( navController = navController, independent = false ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) + } + if (customEqCapability) { + NavigationButton( + to = "equalizer_screen", + name = stringResource(R.string.equalizer), + navController = navController, + independent = false + ) } } } @@ -170,6 +185,7 @@ fun AudioSettingsPreview() { conversationalAwarenessCapability = true, loudSoundReductionCapability = true, adaptiveAudioCapability = true, + customEqCapability = true, adaptiveVolumeChecked = true, onAdaptiveVolumeCheckedChange = { }, conversationalAwarenessChecked = true, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt index 6c60148f..14b30f3f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt @@ -140,7 +140,7 @@ half4 main(float2 coord) { } drawRect(color) } else { - if (isPressed) { + if (isPressed && enabled) { drawRect(Color.Black.copy(alpha = 0.4f)) drawRect(Color.White.copy(alpha = 0.2f)) } @@ -264,29 +264,38 @@ half4 main(float2 coord) { val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) val onDragStop: () -> Unit = { - scope.launch { - launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) } - launch { progressAnimation.animateTo(0f, progressAnimationSpec) } - launch { - offsetAnimation.animateTo( - Offset.Zero, - offsetAnimationSpec - ) + if (enabled) { + scope.launch { + launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) } + launch { + progressAnimation.animateTo( + 0f, + progressAnimationSpec + ) + } + launch { + offsetAnimation.animateTo( + Offset.Zero, + offsetAnimationSpec + ) + } } } } inspectDragGestures( onDragStart = { down -> pressStartPosition = down.position - scope.launch { - launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } - launch { - progressAnimation.animateTo( - 1f, - progressAnimationSpec - ) + if (enabled) { + scope.launch { + launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } + launch { + progressAnimation.animateTo( + 1f, + progressAnimationSpec + ) + } + launch { offsetAnimation.snapTo(Offset.Zero) } } - launch { offsetAnimation.snapTo(Offset.Zero) } } }, onDragEnd = { @@ -294,11 +303,13 @@ half4 main(float2 coord) { }, onDragCancel = onDragStop ) { _, dragAmount -> - scope.launch { - if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( - HapticFeedbackType.SegmentFrequentTick - ) - offsetAnimation.snapTo(offsetAnimation.value + dragAmount) + if (enabled) { + scope.launch { + if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( + HapticFeedbackType.SegmentFrequentTick + ) + offsetAnimation.snapTo(offsetAnimation.value + dragAmount) + } } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index 1b4fb5c2..aeb11e48 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -365,6 +365,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl conversationalAwarenessCapability = conversationalAwarenessCapability, loudSoundReductionCapability = loudSoundReductionCapability, adaptiveAudioCapability = adaptiveAudioCapability, + customEqCapability = true, adaptiveVolumeChecked = adaptiveVolumeChecked, onAdaptiveVolumeCheckedChange = { checked -> viewModel.setControlCommandBoolean( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt new file mode 100644 index 00000000..d6ecd76f --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt @@ -0,0 +1,658 @@ +/* + LibrePods - AirPods liberated from Appleโ€™s 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 General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.presentation.screens + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.visible +import androidx.compose.foundation.layout.width +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalDensity +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.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.highlight.Highlight +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.components.SelectItem +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.components.StyledSelectList +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(FlowPreview::class) +@Composable +fun EqualizerScreen(viewModel: AirPodsViewModel) { + val state by viewModel.uiState.collectAsState() + + val customEq = state.customEq + val enabled = customEq.isEnabled() + + val recommendedString = stringResource(R.string.recommended) + val customString = stringResource(R.string.custom) + + val eqStateOptions = remember(state.customEq) { + listOf( + SelectItem( + name = recommendedString, + selected = !enabled, + onClick = { viewModel.setCustomEqEnabled(false) } + ), + SelectItem( + name = customString, + selected = enabled, + onClick = { viewModel.setCustomEqEnabled(true) } + ), + ) + } + + StyledScaffold( + title = stringResource(R.string.equalizer) + ) { spacerHeight -> + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + val height = 200.dp + val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 + + val offsets = remember(state.customEq) { + listOf( + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)), + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)), + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100)) + ) + } + + Spacer(modifier = Modifier.height(spacerHeight)) + StyledSelectList(items = eqStateOptions) + Spacer(modifier = Modifier.height(12.dp)) + val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + Crossfade ( + customEq.isEnabled() + ) { visible -> + Column( + modifier = Modifier + .fillMaxWidth() + .visible(visible), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + ) { + val dashColor = + if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D) + // LaunchedEffect(offsets[0].floatValue, offsets[1].floatValue, offsets[2].floatValue) { + // val low = ((offsets[0].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt() + // val mid = ((offsets[1].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt() + // val high = ((offsets[2].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt() + // Log.d("EqualizerScreen", "$low, $mid, $high") + // viewModel.setCustomEq( + // low = low, + // mid = mid, + // high = high + // ) + // } + + LaunchedEffect(offsets) { + snapshotFlow { + Triple( + offsets[0].floatValue, + offsets[1].floatValue, + offsets[2].floatValue + ) + } + .debounce(100.milliseconds) // cool, should've been using this since the very beginning + .collect { (lowF, midF, highF) -> + val low = + 100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt() + val mid = + 100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt() + val high = + 100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt() + + viewModel.setCustomEq(low, mid, high) + } + } + + val backdrop = rememberLayerBackdrop() + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + ) { + Spacer(modifier = Modifier.height(42.dp)) + // Row( + // modifier = Modifier + // .fillMaxWidth() + // .padding(18.dp), + // verticalAlignment = Alignment.CenterVertically, + // horizontalArrangement = Arrangement.spacedBy(12.dp) + // ) { + // Box( + // modifier = Modifier + // .size(64.dp) + // .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp)) + // ) + // Column( + // modifier = Modifier + // .weight(1f), + // verticalArrangement = Arrangement.Center + // ) { + // Text( + // text = "Written into Changes", + // style = TextStyle( + // fontSize = 16.sp, + // fontFamily = FontFamily(Font(R.font.sf_pro)), + // fontWeight = FontWeight.Bold, + // color = if (isSystemInDarkTheme()) Color.White else Color.Black + // ) + // ) + // Spacer(modifier = Modifier.height(4.dp)) + // Text( + // text = "Avalon Emerson", + // style = TextStyle( + // fontSize = 14.sp, + // fontFamily = FontFamily(Font(R.font.sf_pro)), + // fontWeight = FontWeight.Normal, + // color = if (isSystemInDarkTheme()) Color.White else Color.Black + // ) + // ) + // } + // val paused = remember { mutableStateOf(false) } + // Box( + // modifier = Modifier + // .size(48.dp) + // .background(Color(0x600091FF), CircleShape) + // .clickable( + // interactionSource = remember { MutableInteractionSource() }, + // indication = null, + // ) { + // paused.value = !paused.value + // }, + // contentAlignment = Alignment.Center + // ) { + // Crossfade( + // targetState = paused.value, + // label = "media_icon" + // ) { p -> + // Text( + // text = if (p) "๔€Š„" else "๔€Š†", + // style = TextStyle( + // fontSize = 24.sp, + // fontFamily = FontFamily(Font(R.font.sf_pro)), + // fontWeight = FontWeight.Normal, + // color = Color(0xFF0091FF), + // textAlign = TextAlign.Center + // ) + // ) + // } + // } + // } + // + // HorizontalDivider( + // thickness = 1.dp, + // color = Color(0x40888888), + // modifier = Modifier + // .padding(horizontal = 20.dp) + // .padding(bottom = 16.dp) + // ) + + Box( + modifier = Modifier.fillMaxWidth() + ) { + fun colorFromY(y: Float): Color { + val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f) + val stops = listOf( + 0.0f to Color(0xFFFFA300), + 0.25f to Color(0xFFFCE600), + 0.5f to Color(0xFF00FAAF), + 0.75f to Color(0xFF00FAFF), + 1.0f to Color(0xFF00B5FF) + ) + val (start, end) = stops.zipWithNext() + .first { f <= it.second.first } + val c = (f - start.first) / (end.first - start.first) + return lerp(start.second, end.second, c) + } + + fun pathBrush( + startY: Float, + endY: Float, + ): Brush { + val stops = (0..20).map { i -> + val t = i / 20f + val y = lerp(startY, endY, t) + t to colorFromY(y) + } + + return Brush.linearGradient( + colorStops = stops.toTypedArray() + ) + } + + Column( + modifier = Modifier.fillMaxWidth().layerBackdrop(backdrop) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(height) + .padding(horizontal = 20.dp) + ) { + Row( + modifier = Modifier + .fillMaxSize() + ) { + val dashCount = (height / 10.dp).toInt() + repeat(3) { + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + for (i in 1..(dashCount)) { + val t = i.toFloat() / dashCount + val centerDistance = abs(0.5f - t) + val alpha = 1f - (centerDistance * 2f) + Box( + modifier = Modifier + .height(9.dp) + .width(0.75.dp) + .background( + dashColor.copy(alpha), + RoundedCornerShape(28.dp) + ) + ) + } + } + } + } + } + + Canvas( + modifier = Modifier + .fillMaxSize() + ) { + val canvasWidth = size.width + + drawLine( + color = backgroundColor, + start = Offset( + x = 0f, + y = offsets[0].floatValue + maxOffset + ), + end = Offset( + x = 1 / 6f * canvasWidth, + y = offsets[0].floatValue + maxOffset + ), + strokeWidth = 10f + ) + drawLine( + color = colorFromY(offsets[0].floatValue), + start = Offset( + x = 0f, + y = offsets[0].floatValue + maxOffset + ), + end = Offset( + x = 1 / 6f * canvasWidth, + y = offsets[0].floatValue + maxOffset + ), + strokeWidth = 8f + ) + + val lowToMidPath = Path() + lowToMidPath.moveTo( + x = 1 / 6f * canvasWidth, + y = offsets[0].floatValue + maxOffset + ) + lowToMidPath.cubicTo( + x1 = canvasWidth * 1 / 6f + 108.dp.value, + y1 = offsets[0].floatValue + maxOffset, + x2 = canvasWidth * 0.5f - 108.dp.value, + y2 = offsets[1].floatValue + maxOffset, + x3 = canvasWidth * 0.5f, + y3 = offsets[1].floatValue + maxOffset + ) + drawPath( + color = backgroundColor, + path = lowToMidPath, + style = Stroke(width = 10f) + ) + drawPath( + brush = pathBrush( + offsets[0].floatValue, + offsets[1].floatValue + ), + path = lowToMidPath, + style = Stroke(width = 8f) + ) + + val midToHighPath = Path() + midToHighPath.moveTo( + x = 0.5f * canvasWidth, + y = offsets[1].floatValue + maxOffset + ) + midToHighPath.cubicTo( + x1 = canvasWidth * 0.5f + 108.dp.value, + y1 = offsets[1].floatValue + maxOffset, + x2 = canvasWidth * 5 / 6f - 108.dp.value, + y2 = offsets[2].floatValue + maxOffset, + x3 = canvasWidth * 5 / 6f, + y3 = offsets[2].floatValue + maxOffset + ) + drawPath( + color = backgroundColor, + path = midToHighPath, + style = Stroke(width = 10f) + ) + drawPath( + brush = pathBrush( + offsets[1].floatValue, + offsets[2].floatValue + ), + path = midToHighPath, + style = Stroke(width = 8f) + ) + drawLine( + color = backgroundColor, + start = Offset( + x = 5 / 6f * canvasWidth, + y = offsets[2].floatValue + maxOffset + ), + end = Offset( + x = 1f * canvasWidth, + y = offsets[2].floatValue + maxOffset + ), + strokeWidth = 10f + ) + drawLine( + color = colorFromY(offsets[2].floatValue), + start = Offset( + x = 5 / 6f * canvasWidth, + y = offsets[2].floatValue + maxOffset + ), + end = Offset( + x = 1f * canvasWidth, + y = offsets[2].floatValue + maxOffset + ), + strokeWidth = 8f + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f) + ) { + Text( + text = "Low".uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Bold, + color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( + 0.2f + ), + textAlign = TextAlign.Center + ), + modifier = Modifier.fillMaxWidth() + ) + } + Box( + modifier = Modifier.weight(1f) + ) { + Text( + text = "Mid".uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Bold, + color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( + 0.2f + ), + textAlign = TextAlign.Center + ), + modifier = Modifier.fillMaxWidth() + ) + } + Box( + modifier = Modifier.weight(1f) + ) { + Text( + text = "High".uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Bold, + color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( + 0.2f + ), + textAlign = TextAlign.Center + ), + modifier = Modifier.fillMaxWidth() + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(height) + .padding(horizontal = 20.dp), + + verticalAlignment = Alignment.CenterVertically + ) { + for (i in 0..2) { + Row( + modifier = Modifier + .weight(1f), + horizontalArrangement = Arrangement.Center + ) { + val pressed = remember { mutableStateOf(false) } + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = offsets[i].floatValue.roundToInt() + ) + }, + contentAlignment = Alignment.Center + ) { + Crossfade( + pressed.value + ) { + Box( + modifier = Modifier + .size(96.dp) + .then( + if (it) { + Modifier.drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + highlight = { + Highlight.Ambient + }, + onDrawSurface = { + drawCircle( + color = Color.White.copy( + 0.2f + ), + radius = size.height + ) + drawCircle( + color = colorFromY( + offsets[i].floatValue + ), + style = Stroke(2.dp.value), + radius = size.height / 2 + ) + }, + effects = { + lens( + refractionHeight = 32f.dp.value, + refractionAmount = size.height + ) + } + ) + } else Modifier + ) + ) + } + Box( + modifier = Modifier + .size(18.dp) + .background( + colorFromY(offsets[i].floatValue), + CircleShape + ) + .border( + 2.5.dp, + backgroundColor, + CircleShape + ) + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + offsets[i].floatValue = + (offsets[i].floatValue + delta).coerceIn( + -maxOffset, + maxOffset + ) + }, + onDragStarted = { + pressed.value = true + }, + onDragStopped = { + pressed.value = false + } + ) + ) + } + } + } + } + } + } + } + + val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } } + + StyledButton( + onClick = { + offsets[0].floatValue = 0f + offsets[1].floatValue = 0f + offsets[2].floatValue = 0f + }, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + isInteractive = false, + surfaceColor = backgroundColor, + enabled = resetButtonEnabled.value + ) { + Text( + text = stringResource(R.string.reset), + style = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Normal, + color = if (!offsets.all { it.floatValue == 0f }) Color(0xFF0093FF) else Color.Gray + ) + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 1114859f..939588e9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -50,6 +50,7 @@ import me.kavishdevar.librepods.data.BatteryComponent import me.kavishdevar.librepods.data.BatteryStatus import me.kavishdevar.librepods.data.Capability import me.kavishdevar.librepods.data.ControlCommandRepository +import me.kavishdevar.librepods.data.CustomEq import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.XposedRemotePrefProvider import me.kavishdevar.librepods.services.AirPodsService @@ -97,7 +98,9 @@ data class AirPodsUiState( val dynamicEndOfCharge: Boolean = false, val connectionSuccessful: Boolean = false, - val timeUntilFOSSPremiumExpiry: Long = 0L + val timeUntilFOSSPremiumExpiry: Long = 0L, + + val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled ) class AirPodsViewModel( @@ -140,13 +143,36 @@ class AirPodsViewModel( _cameraAction.value = action } + fun setCustomEq(low: Int, mid: Int, high: Int) { + require(low in 0..100) + require(mid in 0..100) + require(high in 0..100) + val updatedEq = _uiState.value.customEq.copy(low = low, mid = mid, high = high) + service.aacpManager.sendCustomEqPacket(updatedEq) + _uiState.update { + it.copy( + customEq = updatedEq + ) + } + } + + fun setCustomEqEnabled(enabled: Boolean) { + service.aacpManager.sendCustomEqPacket(_uiState.value.customEq.copy(state = if (enabled) 2 else 1)) + _uiState.update { + it.copy( + customEq = it.customEq.copy(state = if (enabled) 2 else 1) + ) + } + } + init { observeBroadcasts() loadName() loadInstance() loadSharedPreferences() - setupControlObservers() + observeAACP() loadControlList() + loadEq() loadATT() observeATT() observeSharedPreferences() @@ -158,7 +184,7 @@ class AirPodsViewModel( listeners.forEach { (id, listener) -> controlRepo.remove(id, listener) } - + service.aacpManager.customEqCallback = null appContext.unregisterReceiver(broadcastReceiver) super.onCleared() @@ -315,7 +341,7 @@ class AirPodsViewModel( } // I'm lazy, sorry. - fun setupControlObservers() { + fun observeAACP() { val identifiersList = listOf( ControlCommandIdentifiers.MIC_MODE, ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, @@ -347,6 +373,9 @@ class AirPodsViewModel( for (identifier in identifiersList) { observeControl(identifier) } + service.aacpManager.customEqCallback = { customEq -> + _uiState.update { it.copy(customEq = customEq) } + } } fun refreshInitialData() { @@ -479,6 +508,14 @@ class AirPodsViewModel( } } + private fun loadEq() { + _uiState.update { + it.copy( + customEq = service.aacpManager.customEq + ) + } + } + private fun loadInstance() { val instance = service.airpodsInstance ?: AirPodsInstance( name = "AirPods", diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 9d990588..7dff6f69 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -96,6 +96,8 @@ import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.Battery import me.kavishdevar.librepods.data.BatteryComponent import me.kavishdevar.librepods.data.BatteryStatus +import me.kavishdevar.librepods.data.Capability +import me.kavishdevar.librepods.data.CustomEq import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.XposedRemotePrefProvider import me.kavishdevar.librepods.data.isHeadTrackingData @@ -1159,13 +1161,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } - override fun onEQPacketReceived(eqData: FloatArray) { + override fun onHeadphoneAccommodationReceived(eqData: FloatArray) { sendBroadcast( Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply { setPackage(packageName) }) } + override fun onCustomEqReceived(customEq: CustomEq) { + // TODO + } + + override fun onCapabilitiesReceived(capabilities: List) { + // TODO + } + override fun onUnknownPacketReceived(packet: ByteArray) { Log.d( "AACPManager", @@ -2839,7 +2849,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } else if (bytesRead == -1) { - Log.d("AirPods Service", "BluetoothConnectionManager.getAACPSocket()? closed (bytesRead = -1)") + Log.d("AirPodsService", "socket closed (bytesRead = -1)") sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { setPackage(packageName) }) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9328137c..f866a274 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -277,4 +277,6 @@ AirPods can learn from your daily usage and determine when to charge to an optmized limit and when to allow or full charge. This limit adapts to your daily usage and preserves your battery lifespan over time.\nThis setting may not affect unsupported AirPods, or AirPods on an older firmware version. Enable LibrePods in Xposed or update your device to proceed. Due to an error in billing, premium access will expire in %1$d days. If you already upgraded the app, please click on this message to email billing@kavish.xyz to restore or verify access. Apologies for the inconvenience. + Custom + Recommended