mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-10-15 07:53:12 +11:00
feat(swipe to queue): left swipe + configurable actions + play next
This commit is contained in:
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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+9
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+19
-5
@@ -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),
|
||||
|
||||
+17
-2
@@ -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
|
||||
|
||||
+20
-5
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user