fix(aap): Fix stem actions volume control, reset button, and Default/No Action semantics

Fix volume up/down doing nothing by using adjustSuggestedStreamVolume instead of dispatchMediaKeyEvent which ignores volume keycodes.

Add reset-to-defaults button with confirmation dialog in the stem actions TopAppBar.

Rename 'None' to 'Default' (firmware handles the press) and add 'No Action' (claimed but do nothing) to correctly model per-press-type claim mask semantics.

Remove disableNone lock; add cross-side auto-set logic so selecting Default resets both sides and selecting an action promotes the other side from Default to No Action.
This commit is contained in:
darken
2026-04-04 11:41:26 +02:00
committed by Matthias Urhahn
parent 1ae9ae587e
commit 7e73ac80e9
7 changed files with 137 additions and 33 deletions
@@ -53,6 +53,24 @@ class MediaControl @Inject constructor(
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime + 200, eventTime + 200, KeyEvent.ACTION_UP, keyCode, 0))
}
fun adjustVolumeUp() {
log(TAG, INFO) { "adjustVolumeUp()" }
audioManager.adjustSuggestedStreamVolume(
AudioManager.ADJUST_RAISE,
AudioManager.USE_DEFAULT_STREAM_TYPE,
AudioManager.FLAG_SHOW_UI,
)
}
fun adjustVolumeDown() {
log(TAG, INFO) { "adjustVolumeDown()" }
audioManager.adjustSuggestedStreamVolume(
AudioManager.ADJUST_LOWER,
AudioManager.USE_DEFAULT_STREAM_TYPE,
AudioManager.FLAG_SHOW_UI,
)
}
companion object {
private val TAG = logTag("MediaControl")
}
@@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.RestartAlt
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
@@ -21,6 +23,7 @@ import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -49,6 +52,7 @@ fun StemActionConfigScreenHost(
StemActionConfigScreen(
state = currentState,
onNavigateUp = { vm.navUp() },
onReset = { vm.resetAll() },
onLeftSingle = { vm.setLeftSingle(it) },
onLeftDouble = { vm.setLeftDouble(it) },
onLeftTriple = { vm.setLeftTriple(it) },
@@ -65,6 +69,7 @@ fun StemActionConfigScreenHost(
fun StemActionConfigScreen(
state: StemActionConfigViewModel.State,
onNavigateUp: () -> Unit,
onReset: () -> Unit = {},
onLeftSingle: (StemAction) -> Unit = {},
onLeftDouble: (StemAction) -> Unit = {},
onLeftTriple: (StemAction) -> Unit = {},
@@ -74,6 +79,28 @@ fun StemActionConfigScreen(
onRightTriple: (StemAction) -> Unit = {},
onRightLong: (StemAction) -> Unit = {},
) {
var showResetDialog by remember { mutableStateOf(false) }
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
confirmButton = {
TextButton(onClick = {
onReset()
showResetDialog = false
}) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = { showResetDialog = false }) {
Text(stringResource(android.R.string.cancel))
}
},
text = { Text(stringResource(R.string.stem_actions_reset_confirm_message)) },
)
}
Scaffold(
topBar = {
TopAppBar(
@@ -83,6 +110,14 @@ fun StemActionConfigScreen(
Icon(Icons.AutoMirrored.TwoTone.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = { showResetDialog = true }) {
Icon(
imageVector = Icons.TwoTone.RestartAlt,
contentDescription = stringResource(R.string.stem_actions_reset_label),
)
}
},
)
},
) { paddingValues ->
@@ -159,10 +194,6 @@ private fun StemActionRow(
onLeftChange: (StemAction) -> Unit,
onRightChange: (StemAction) -> Unit,
) {
// Enforce: if one side is non-NONE, the other can't be NONE
val leftIsLocked = rightAction != StemAction.NONE
val rightIsLocked = leftAction != StemAction.NONE
Row(
modifier = Modifier
.fillMaxWidth()
@@ -177,7 +208,7 @@ private fun StemActionRow(
StemActionDropdown(
selected = leftAction,
onSelected = onLeftChange,
disableNone = leftIsLocked,
otherSideAction = rightAction,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -191,7 +222,7 @@ private fun StemActionRow(
StemActionDropdown(
selected = rightAction,
onSelected = onRightChange,
disableNone = rightIsLocked,
otherSideAction = leftAction,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -203,11 +234,17 @@ private fun StemActionRow(
private fun StemActionDropdown(
selected: StemAction,
onSelected: (StemAction) -> Unit,
disableNone: Boolean,
otherSideAction: StemAction,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val options = if (otherSideAction == StemAction.NONE) {
StemAction.entries.filter { it != StemAction.NO_ACTION }
} else {
StemAction.entries.toList()
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
@@ -227,23 +264,13 @@ private fun StemActionDropdown(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
for (action in StemAction.entries) {
val enabled = !(action == StemAction.NONE && disableNone)
for (action in options) {
DropdownMenuItem(
text = {
Text(
text = action.label(),
color = if (enabled) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
)
},
text = { Text(action.label()) },
onClick = {
if (enabled) {
onSelected(action)
expanded = false
}
onSelected(action)
expanded = false
},
enabled = enabled,
)
}
}
@@ -253,6 +280,7 @@ private fun StemActionDropdown(
@Composable
private fun StemAction.label(): String = when (this) {
StemAction.NONE -> stringResource(R.string.stem_action_none)
StemAction.NO_ACTION -> stringResource(R.string.stem_action_no_action)
StemAction.PLAY_PAUSE -> stringResource(R.string.stem_action_play_pause)
StemAction.NEXT_TRACK -> stringResource(R.string.stem_action_next_track)
StemAction.PREVIOUS_TRACK -> stringResource(R.string.stem_action_previous_track)
@@ -6,6 +6,7 @@ import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionSettings
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import javax.inject.Inject
@HiltViewModel
@@ -47,12 +48,60 @@ class StemActionConfigViewModel @Inject constructor(
val rightLong: StemAction = StemAction.NONE,
)
fun setLeftSingle(action: StemAction) = launch { stemActionSettings.leftSingle.update { action } }
fun setLeftDouble(action: StemAction) = launch { stemActionSettings.leftDouble.update { action } }
fun setLeftTriple(action: StemAction) = launch { stemActionSettings.leftTriple.update { action } }
fun setLeftLong(action: StemAction) = launch { stemActionSettings.leftLong.update { action } }
fun setRightSingle(action: StemAction) = launch { stemActionSettings.rightSingle.update { action } }
fun setRightDouble(action: StemAction) = launch { stemActionSettings.rightDouble.update { action } }
fun setRightTriple(action: StemAction) = launch { stemActionSettings.rightTriple.update { action } }
fun setRightLong(action: StemAction) = launch { stemActionSettings.rightLong.update { action } }
fun setLeftSingle(action: StemAction) = launch {
stemActionSettings.leftSingle.update { action }
applyCrossSideEffect(action, stemActionSettings.rightSingle)
}
fun setLeftDouble(action: StemAction) = launch {
stemActionSettings.leftDouble.update { action }
applyCrossSideEffect(action, stemActionSettings.rightDouble)
}
fun setLeftTriple(action: StemAction) = launch {
stemActionSettings.leftTriple.update { action }
applyCrossSideEffect(action, stemActionSettings.rightTriple)
}
fun setLeftLong(action: StemAction) = launch {
stemActionSettings.leftLong.update { action }
applyCrossSideEffect(action, stemActionSettings.rightLong)
}
fun setRightSingle(action: StemAction) = launch {
stemActionSettings.rightSingle.update { action }
applyCrossSideEffect(action, stemActionSettings.leftSingle)
}
fun setRightDouble(action: StemAction) = launch {
stemActionSettings.rightDouble.update { action }
applyCrossSideEffect(action, stemActionSettings.leftDouble)
}
fun setRightTriple(action: StemAction) = launch {
stemActionSettings.rightTriple.update { action }
applyCrossSideEffect(action, stemActionSettings.leftTriple)
}
fun setRightLong(action: StemAction) = launch {
stemActionSettings.rightLong.update { action }
applyCrossSideEffect(action, stemActionSettings.leftLong)
}
private suspend fun applyCrossSideEffect(
selected: StemAction,
otherSide: eu.darken.capod.common.datastore.DataStoreValue<StemAction>,
) {
when (selected) {
StemAction.NONE -> otherSide.update { StemAction.NONE }
StemAction.NO_ACTION -> {} // No side-effect
else -> {
if (otherSide.flow.first() == StemAction.NONE) {
otherSide.update { StemAction.NO_ACTION }
}
}
}
}
fun resetAll() = launch { stemActionSettings.resetAll() }
}
@@ -50,12 +50,12 @@ class StemPressReaction @Inject constructor(
}
private suspend fun executeAction(action: StemAction) = when (action) {
StemAction.NONE -> Unit
StemAction.NONE, StemAction.NO_ACTION -> Unit
StemAction.PLAY_PAUSE -> mediaControl.sendPlayPause()
StemAction.NEXT_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_NEXT)
StemAction.PREVIOUS_TRACK -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS)
StemAction.VOLUME_UP -> mediaControl.sendKey(KeyEvent.KEYCODE_VOLUME_UP)
StemAction.VOLUME_DOWN -> mediaControl.sendKey(KeyEvent.KEYCODE_VOLUME_DOWN)
StemAction.VOLUME_UP -> mediaControl.adjustVolumeUp()
StemAction.VOLUME_DOWN -> mediaControl.adjustVolumeDown()
}
companion object {
@@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable
@Serializable
enum class StemAction {
NONE,
NO_ACTION,
PLAY_PAUSE,
NEXT_TRACK,
PREVIOUS_TRACK,
@@ -3,6 +3,7 @@ package eu.darken.capod.reaction.core.stem
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.datastore.createValue
@@ -29,4 +30,8 @@ class StemActionSettings @Inject constructor(
val rightDouble = dataStore.createValue("stem.right.double", StemAction.NONE, json, onErrorFallbackToDefault = true)
val rightTriple = dataStore.createValue("stem.right.triple", StemAction.NONE, json, onErrorFallbackToDefault = true)
val rightLong = dataStore.createValue("stem.right.long", StemAction.NONE, json, onErrorFallbackToDefault = true)
suspend fun resetAll() {
context.dataStore.edit { it.clear() }
}
}
+4 -1
View File
@@ -510,11 +510,14 @@
<string name="stem_actions_long_press">Long Press</string>
<string name="stem_actions_left">Left</string>
<string name="stem_actions_right">Right</string>
<string name="stem_action_none">None</string>
<string name="stem_action_none">Default</string>
<string name="stem_action_no_action">No Action</string>
<string name="stem_action_play_pause">Play/Pause</string>
<string name="stem_action_next_track">Next Track</string>
<string name="stem_action_previous_track">Previous Track</string>
<string name="stem_action_volume_up">Volume Up</string>
<string name="stem_action_volume_down">Volume Down</string>
<string name="stem_actions_reset_label">Reset to defaults</string>
<string name="stem_actions_reset_confirm_message">Reset all stem actions to defaults?</string>
</resources>