feat(swipe to queue): left swipe + configurable actions + play next

This commit is contained in:
Se7en-dev committed 2026-08-31 15:18:29 +02:00
1 parent 47fe539e76
commit 973580e230
42 files changed
+1426 -175

No files matched your search

@@ -476,26 +476,75 @@
"advancedOptions": [
{
"type": "choice",
"key": "swipe_direction",
"title": "Swipe Direction",
"description": "Choose direction of the swipe gesture",
"key": "left_action",
"title": "Left Swipe Action",
"description": "Choose the action for a left swipe",
"entries": [
"Left",
"Right"
"Disabled",
"Play next",
"Add to queue"
],
"values": [
"0x0",
"0x1",
"0x2"
],
"defaultIndex": 2,
"distinctFrom": "right_action",
"token": "RL_SWIPE_TO_QUEUE_LEFT_ACTION"
},
{
"type": "choice",
"key": "right_action",
"title": "Right Swipe Action",
"description": "Choose the action for a right swipe",
"entries": [
"Disabled",
"Play next",
"Add to queue"
],
"values": [
"0x0",
"0x1",
"0x2"
],
"defaultIndex": 0,
"distinctFrom": "left_action",
"token": "RL_SWIPE_TO_QUEUE_RIGHT_ACTION"
},
{
"type": "choice",
"key": "remove_direction",
"title": "Remove Track from Queue",
"description": "Choose the swipe direction used to remove items from the play queue",
"entries": [
"Left swipe",
"Right swipe",
"Both"
],
"values": [
"0x4",
"0x8"
"0x8",
"0xc"
],
"defaultIndex": 0,
"token": "RL_SWIPE_TO_QUEUE_DIRECTION"
"token": "RL_SWIPE_TO_QUEUE_REMOVE_DIRECTION"
},
{
"type": "color",
"key": "background_color",
"title": "Background Color",
"title": "Add to Queue Color",
"default": -14748953,
"defaultLabel": "Cyan",
"token": "RL_SWIPE_TO_QUEUE_ADD_COLOR"
},
{
"type": "color",
"key": "play_next_color",
"title": "Play Next Color",
"default": -14749031,
"token": "RL_SWIPE_TO_QUEUE_COLOR"
"defaultLabel": "Green",
"token": "RL_SWIPE_TO_QUEUE_PLAY_NEXT_COLOR"
}
]
},
@@ -161,6 +161,7 @@ fun RadiantSegmented(
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
disabledIndices: Set<Int> = emptySet(),
) {
if (radiantStyle.native) {
SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) {
@@ -168,7 +169,7 @@ fun RadiantSegmented(
SegmentedButton(
selected = index == selectedIndex,
onClick = { onSelect(index) },
enabled = enabled,
enabled = enabled && index !in disabledIndices,
shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size),
icon = {},
label = { Text(text = label, maxLines = 1) },
@@ -196,6 +197,7 @@ fun RadiantSegmented(
) {
options.forEachIndexed { index, label ->
val selected = index == selectedIndex
val segmentEnabled = enabled && index !in disabledIndices
val pillShape = RoundedCornerShape(13.dp)
val alpha by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
@@ -218,7 +220,7 @@ fun RadiantSegmented(
} else Modifier
)
.clickable(
enabled = enabled,
enabled = segmentEnabled,
role = Role.RadioButton,
onClick = { onSelect(index) },
),
@@ -226,7 +228,11 @@ fun RadiantSegmented(
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = if (selected) scheme.onPrimary else scheme.onSurfaceVariant,
color = when {
selected -> scheme.onPrimary
segmentEnabled -> scheme.onSurfaceVariant
else -> scheme.onSurface.copy(alpha = 0.38f)
},
maxLines = 1,
)
}
@@ -166,6 +166,11 @@ fun PatchAdvancedOptionsSheet(
title = choice.title,
entries = choice.entries,
selectedIndex = state.choice(patch, choice),
forceDropdown = choice.forceDropdown,
disabledIndices = patch.conflictingChoices(choice)
.map { state.choice(patch, it) }
.filter { it != 0 }
.toSet(),
onSelect = { state.setChoice(patch, choice, it) },
)
}
@@ -205,6 +210,11 @@ fun PatchAdvancedOptionsSheet(
title = option.title,
entries = option.entries,
selectedIndex = state.choice(patch, option),
forceDropdown = option.forceDropdown,
disabledIndices = patch.conflictingChoices(option)
.map { state.choice(patch, it) }
.filter { it != 0 }
.toSet(),
onSelect = { state.setChoice(patch, option, it) },
)
}
@@ -213,6 +223,7 @@ fun PatchAdvancedOptionsSheet(
title = option.title,
color = state.color(patch, option),
defaultColor = option.default,
manifestDefaultLabel = option.defaultLabel,
onColorChange = { state.setColor(patch, option, it) },
)
}
@@ -382,6 +393,8 @@ private fun ChoiceOptionRow(
selectedIndex: Int,
onSelect: (Int) -> Unit,
title: String = "",
forceDropdown: Boolean = false,
disabledIndices: Set<Int> = emptySet(),
modifier: Modifier = Modifier,
) {
Column(
@@ -397,7 +410,7 @@ private fun ChoiceOptionRow(
textAlign = TextAlign.Center,
)
}
if (entries.size > 3) {
if (forceDropdown || entries.size > 3) {
var expanded by rememberSaveable { mutableStateOf(false) }
val selectedLabel = entries.getOrNull(selectedIndex).orEmpty()
@@ -420,6 +433,7 @@ private fun ChoiceOptionRow(
entries.forEachIndexed { index, entry ->
DropdownMenuItem(
text = { Text(entry) },
enabled = index !in disabledIndices,
onClick = {
onSelect(index)
expanded = false
@@ -435,6 +449,7 @@ private fun ChoiceOptionRow(
SegmentedButton(
selected = index == selectedIndex,
onClick = { onSelect(index) },
enabled = index !in disabledIndices,
shape = SegmentedButtonDefaults.itemShape(index = index, count = entries.size),
icon = {},
label = {
@@ -457,17 +472,16 @@ private fun ColorOptionRow(
title: String,
color: Int,
defaultColor: Int,
manifestDefaultLabel: String?,
onColorChange: (Int) -> Unit,
) {
var showPicker by rememberSaveable { mutableStateOf(false) }
val selectedColor = color.withOpaqueAlpha()
val materialYouColor = MaterialTheme.colorScheme.primary.toArgb().withOpaqueAlpha()
val opaqueDefault = defaultColor.withOpaqueAlpha()
val defaultLabel = if (opaqueDefault == TIDAL_GREEN) {
val defaultLabel = manifestDefaultLabel ?: if (opaqueDefault == TIDAL_GREEN) {
stringResource(R.string.patch_color_green)
} else {
stringResource(R.string.patch_waze_color_waze_default)
}
} else stringResource(R.string.patch_waze_color_waze_default)
val presets = listOf(
defaultLabel,
stringResource(R.string.patch_waze_color_tidal_cyan),
@@ -179,7 +179,12 @@ class PatchOptionsModel(
fun setChoiceValue(spec: PatchSpec, option: OptionSpec.Choice, index: Int) {
if (index !in option.entries.indices) return
optionInts = optionInts + (keyOf(spec, option) to index)
val selectedKey = keyOf(spec, option)
optionInts = normalizeChoiceSelections(
specs = listOf(spec),
values = optionInts + (selectedKey to index),
preferredKey = selectedKey,
)
}
fun colorValue(spec: PatchSpec, option: OptionSpec.Color): Int =
@@ -204,7 +209,14 @@ class PatchOptionsModel(
val prefix = "${spec.id}/"
optionBools = optionBools.filterKeys { !it.startsWith(prefix) }
optionFloats = optionFloats.filterKeys { !it.startsWith(prefix) }
optionInts = optionInts.filterKeys { !it.startsWith(prefix) }
optionInts = normalizeChoiceSelections(
specs = listOf(spec),
values = optionInts.filterKeys { !it.startsWith(prefix) },
)
}
private fun normalizeChoiceSelections(specs: List<PatchSpec>) {
optionInts = normalizeChoiceSelections(specs, optionInts)
}
val optionState: PatchOptionState = PatchOptionState(
@@ -282,6 +294,7 @@ class PatchOptionsModel(
val loaded = if (component == null) builtinSpecs else loadManifestSpecs(component) ?: builtinSpecs
mainThread {
specs = loaded
normalizeChoiceSelections(loaded)
validatePatchSelection()
specsLoading = false
}
@@ -351,6 +364,7 @@ class PatchOptionsModel(
?: loadCachedReleaseManifestSpecs() ?: builtinSpecs
mainThread {
specs = loaded
normalizeChoiceSelections(loaded)
validatePatchSelection()
specsLoading = false
}
@@ -415,6 +429,7 @@ class PatchOptionsModel(
}
init {
normalizeChoiceSelections(specs)
validatePatchSelection()
screenModelScope.launchBlock { fetchPkgNameState() }
// Default source is the latest release's manifest; custom selections drive themselves.
@@ -125,6 +125,8 @@ sealed interface OptionSpec {
val defaultIndex: Int = 0,
val values: List<String> = emptyList(),
val requiresOption: String? = null,
val forceDropdown: Boolean = false,
val distinctFrom: String? = null,
val token: String? = null,
) : OptionSpec
@@ -136,6 +138,7 @@ sealed interface OptionSpec {
override val title: String = "",
override val description: String = "",
val default: Int = 0,
val defaultLabel: String? = null,
/** Placeholder name (without the surrounding `__`) baked into the `.patch`/extension files. */
val token: String? = null,
) : OptionSpec
@@ -231,6 +234,41 @@ val OptionSpec.variantGate: Int?
fun OptionSpec.hiddenForVariant(selectedVariant: Int): Boolean =
variantGate.let { it != null && it != selectedVariant }
fun PatchSpec.conflictingChoices(option: OptionSpec.Choice): List<OptionSpec.Choice> =
advancedOptions.filterIsInstance<OptionSpec.Choice>().filter { other ->
other.key != option.key &&
(option.distinctFrom == other.key || other.distinctFrom == option.key)
}
internal fun normalizeChoiceSelections(
specs: List<PatchSpec>,
values: Map<String, Int>,
preferredKey: String? = null,
): Map<String, Int> {
val normalized = values.toMutableMap()
for (spec in specs) {
val choices = spec.advancedOptions.filterIsInstance<OptionSpec.Choice>()
fun fullKey(option: OptionSpec.Choice) = "${spec.id}/${option.key}"
fun selected(option: OptionSpec.Choice): Int =
(normalized[fullKey(option)] ?: option.defaultIndex)
.coerceIn(0, option.entries.lastIndex.coerceAtLeast(0))
for (leftIndex in choices.indices) {
val left = choices[leftIndex]
for (rightIndex in leftIndex + 1 until choices.size) {
val right = choices[rightIndex]
if (left.distinctFrom != right.key && right.distinctFrom != left.key) continue
val selected = selected(left)
if (selected == 0 || selected != selected(right)) continue
val loser = if (preferredKey == fullKey(right)) left else right
normalized[fullKey(loser)] = 0
}
}
}
return if (normalized == values) values else normalized
}
sealed interface OptionLock {
data object Free : OptionLock
data class NeedsVariant(val title: String) : OptionLock
@@ -43,6 +43,7 @@ import com.meowarex.rlmobile.ui.components.ResetToDefaultButton
import com.meowarex.rlmobile.ui.screens.patchopts.OptionLock
import com.meowarex.rlmobile.ui.screens.patchopts.OptionSpec
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionState
import com.meowarex.rlmobile.ui.screens.patchopts.conflictingChoices
import com.meowarex.rlmobile.ui.screens.patchopts.hiddenForVariant
import com.meowarex.rlmobile.ui.screens.patchopts.optionLock
import com.meowarex.rlmobile.ui.screens.patchopts.PatchSpec
@@ -172,6 +173,11 @@ fun PatchAdvancedOptionsSheet(
title = choice.title,
entries = choice.entries,
selectedIndex = state.choice(patch, choice),
forceDropdown = choice.forceDropdown,
disabledIndices = patch.conflictingChoices(choice)
.map { state.choice(patch, it) }
.filter { it != 0 }
.toSet(),
onSelect = { state.setChoice(patch, choice, it) },
)
}
@@ -211,6 +217,11 @@ fun PatchAdvancedOptionsSheet(
title = option.title,
entries = option.entries,
selectedIndex = state.choice(patch, option),
forceDropdown = option.forceDropdown,
disabledIndices = patch.conflictingChoices(option)
.map { state.choice(patch, it) }
.filter { it != 0 }
.toSet(),
onSelect = { state.setChoice(patch, option, it) },
)
}
@@ -219,6 +230,7 @@ fun PatchAdvancedOptionsSheet(
title = option.title,
color = state.color(patch, option),
defaultColor = option.default,
manifestDefaultLabel = option.defaultLabel,
onColorChange = { state.setColor(patch, option, it) },
)
}
@@ -382,6 +394,8 @@ private fun ChoiceOptionRow(
selectedIndex: Int,
onSelect: (Int) -> Unit,
title: String = "",
forceDropdown: Boolean = false,
disabledIndices: Set<Int> = emptySet(),
modifier: Modifier = Modifier,
) {
Column(
@@ -397,7 +411,7 @@ private fun ChoiceOptionRow(
textAlign = TextAlign.Center,
)
}
if (entries.size > 3) {
if (forceDropdown || entries.size > 3) {
var expanded by rememberSaveable { mutableStateOf(false) }
val selectedLabel = entries.getOrNull(selectedIndex).orEmpty()
@@ -416,6 +430,7 @@ private fun ChoiceOptionRow(
entries.forEachIndexed { index, entry ->
DropdownMenuItem(
text = { Text(entry) },
enabled = index !in disabledIndices,
onClick = {
onSelect(index)
expanded = false
@@ -430,6 +445,7 @@ private fun ChoiceOptionRow(
options = entries,
selectedIndex = selectedIndex,
onSelect = onSelect,
disabledIndices = disabledIndices,
)
}
}
@@ -439,17 +455,16 @@ private fun ColorOptionRow(
title: String,
color: Int,
defaultColor: Int,
manifestDefaultLabel: String?,
onColorChange: (Int) -> Unit,
) {
var showPicker by rememberSaveable { mutableStateOf(false) }
val selectedColor = color.withOpaqueAlpha()
val materialYouColor = MaterialTheme.colorScheme.primary.toArgb().withOpaqueAlpha()
val opaqueDefault = defaultColor.withOpaqueAlpha()
val defaultLabel = if (opaqueDefault == TIDAL_GREEN) {
val defaultLabel = manifestDefaultLabel ?: if (opaqueDefault == TIDAL_GREEN) {
stringResource(R.string.patch_color_green)
} else {
stringResource(R.string.patch_waze_color_waze_default)
}
} else stringResource(R.string.patch_waze_color_waze_default)
val presets = listOf(
defaultLabel,
stringResource(R.string.patch_waze_color_tidal_cyan),