New Patch: Mini-Player Redesign

This commit is contained in:
2026-05-26 22:49:21 +10:00
parent 193d5ad90d
commit c6b18d1aa9
15 changed files with 1151 additions and 25 deletions
@@ -37,6 +37,9 @@ private fun PatchOptionsScreenPreview(
enabledPatchCount = KnownPatch.All.size,
isPatchEnabled = { true },
onTogglePatch = { _, _ -> },
patchLockState = { PatchLock.Free },
variantIndex = { 0 },
onSelectVariant = { _, _ -> },
isConfigValid = parameters.isConfigValid,
onInstall = {},
)
@@ -3,6 +3,11 @@ package com.meowarex.rlmobile.ui.screens.patchopts
import androidx.annotation.StringRes
import com.meowarex.rlmobile.R
data class PatchVariant(
@StringRes val titleRes: Int,
val fileNames: List<String>,
)
enum class KnownPatch(
/**
* Numeric display order in the patch options list. Lower = higher up.
@@ -18,6 +23,8 @@ enum class KnownPatch(
@StringRes val descRes: Int,
val requires: List<KnownPatch> = emptyList(),
val disables: List<KnownPatch> = emptyList(),
val variants: List<PatchVariant> = emptyList(),
val defaultVariantIndex: Int = 0,
) {
// Dependency-first order (later refs need backward resolution).
// The `order` field controls display order; declaration order doesn't matter.
@@ -89,6 +96,27 @@ enum class KnownPatch(
descRes = R.string.patch_lyrics_progress_pill_desc,
requires = listOf(LyricsDisableCover, LyricsReplaceLyricsButton, LyricsReplaceShareButton),
),
MiniPlayerRedesign(
order = 50,
fileNames = emptyList(),
titleRes = R.string.patch_mini_player_redesign_title,
descRes = R.string.patch_mini_player_redesign_desc,
defaultVariantIndex = 2,
variants = listOf(
PatchVariant(
titleRes = R.string.patch_mini_player_variant_floating_title,
fileNames = listOf("mini-player-floating.patch"),
),
PatchVariant(
titleRes = R.string.patch_mini_player_variant_square_grey_title,
fileNames = listOf("mini-player-grey.patch"),
),
PatchVariant(
titleRes = R.string.patch_mini_player_variant_square_black_title,
fileNames = listOf("mini-player-black.patch"),
),
),
),
EnableLegacyUi(
order = 10,
fileNames = listOf("enable-legacy-ui.patch"),
@@ -107,13 +135,16 @@ enum class KnownPatch(
),
);
val allVariantFileNames: Set<String>
get() = variants.flatMapTo(mutableSetOf()) { it.fileNames }
companion object {
/**
* Sorted by `order` ascending. Tie-breaks fall back to the first filename
* (alphabetical) so the order is always deterministic.
*/
val All: List<KnownPatch> = entries.sortedWith(
compareBy({ it.order }, { it.fileNames.first() })
compareBy({ it.order }, { it.fileNames.firstOrNull() ?: it.name })
)
}
}
@@ -33,15 +33,26 @@ data class PatchOptions(
val customPatches: PatchComponent? = null,
val disabledPatches: Set<String> = emptySet(),
val selectedVariants: Map<String, Int> = emptyMap(),
) : Parcelable {
companion object {
val Default = PatchOptions(
appName = "TIDAL",
packageName = "com.aspiro.tidal",
debuggable = false,
customTidalApk = null,
customPatches = null,
disabledPatches = (KnownPatch.DebugMenuUnlock.fileNames + KnownPatch.EnableLegacyUi.fileNames).toSet(),
)
val Default: PatchOptions = run {
val miniPlayerFiles = KnownPatch.MiniPlayerRedesign.allVariantFileNames
val disabled = (
KnownPatch.DebugMenuUnlock.fileNames +
KnownPatch.EnableLegacyUi.fileNames +
miniPlayerFiles
).toSet()
PatchOptions(
appName = "TIDAL",
packageName = "com.aspiro.tidal",
debuggable = false,
customTidalApk = null,
customPatches = null,
disabledPatches = disabled,
)
}
}
}
@@ -50,10 +50,32 @@ class PatchOptionsModel(
var disabledPatches by mutableStateOf(prefilledOptions.disabledPatches)
private set
fun isPatchEnabled(patch: KnownPatch): Boolean =
patch.fileNames.none { it in disabledPatches }
var selectedVariants by mutableStateOf(prefilledOptions.selectedVariants)
private set
fun variantIndex(patch: KnownPatch): Int = selectedVariants[patch.name]
?.coerceIn(0, patch.variants.lastIndex.coerceAtLeast(0))
?: patch.defaultVariantIndex.coerceIn(0, patch.variants.lastIndex.coerceAtLeast(0))
fun isPatchEnabled(patch: KnownPatch): Boolean = if (patch.variants.isNotEmpty()) {
val v = patch.variants[variantIndex(patch)]
v.fileNames.isNotEmpty() && v.fileNames.none { it in disabledPatches }
} else {
patch.fileNames.isNotEmpty() && patch.fileNames.none { it in disabledPatches }
}
fun setPatchEnabled(patch: KnownPatch, enabled: Boolean) {
if (patch.variants.isNotEmpty()) {
val all = patch.allVariantFileNames
val selected = patch.variants[variantIndex(patch)].fileNames.toSet()
disabledPatches = if (enabled) {
(disabledPatches + all) - selected
} else {
disabledPatches + all
}
return
}
fun closure(seed: KnownPatch, step: (KnownPatch) -> List<KnownPatch>): Set<KnownPatch> =
buildSet {
fun walk(p: KnownPatch) { if (add(p)) step(p).forEach(::walk) }
@@ -78,6 +100,37 @@ class PatchOptionsModel(
disabledPatches = (disabledPatches - enableFiles) + disableFiles
}
fun selectVariant(patch: KnownPatch, index: Int) {
if (patch.variants.isEmpty() || index !in patch.variants.indices) return
val wasOn = isPatchEnabled(patch)
selectedVariants = selectedVariants + (patch.name to index)
if (wasOn) setPatchEnabled(patch, true)
}
fun lockState(patch: KnownPatch): PatchLock {
if (patch.variants.isNotEmpty()) return PatchLock.Free
fun closure(seed: KnownPatch, step: (KnownPatch) -> List<KnownPatch>): Set<KnownPatch> =
buildSet {
fun walk(p: KnownPatch) { if (add(p)) step(p).forEach(::walk) }
walk(seed)
}
for (other in KnownPatch.All) {
if (other == patch || !isPatchEnabled(other)) continue
val requiresClosure = closure(other) { it.requires }
if (patch in requiresClosure - other) return PatchLock.LockedOn(other)
val disablesClosure = requiresClosure.flatMap { it.disables }
.flatMapTo(mutableSetOf()) { d ->
closure(d) { dep -> KnownPatch.All.filter { dep in it.requires } }
}
if (patch in disablesClosure) return PatchLock.LockedOff(other)
}
return PatchLock.Free
}
val enabledPatchCount: Int
get() = KnownPatch.All.count { isPatchEnabled(it) }
@@ -123,6 +176,7 @@ class PatchOptionsModel(
customTidalApk = customTidalApk,
customPatches = customPatches,
disabledPatches = disabledPatches,
selectedVariants = selectedVariants,
)
}
@@ -147,7 +201,16 @@ class PatchOptionsModel(
mainThread { packageNameState = state }
}
private fun validatePatchSelection() {
for (patch in KnownPatch.All) {
if (isPatchEnabled(patch)) {
setPatchEnabled(patch, true)
}
}
}
init {
validatePatchSelection()
screenModelScope.launchBlock { fetchPkgNameState() }
}
@@ -162,3 +225,9 @@ enum class PackageNameState {
Invalid,
Taken,
}
sealed class PatchLock {
object Free : PatchLock()
data class LockedOn(val by: KnownPatch) : PatchLock()
data class LockedOff(val by: KnownPatch) : PatchLock()
}
@@ -63,6 +63,9 @@ class PatchOptionsScreen(
enabledPatchCount = model.enabledPatchCount,
isPatchEnabled = model::isPatchEnabled,
onTogglePatch = model::setPatchEnabled,
patchLockState = model::lockState,
variantIndex = model::variantIndex,
onSelectVariant = model::selectVariant,
isConfigValid = model.isConfigValid,
onInstall = {
@@ -96,6 +99,9 @@ fun PatchOptionsScreenContent(
enabledPatchCount: Int,
isPatchEnabled: (KnownPatch) -> Boolean,
onTogglePatch: (KnownPatch, Boolean) -> Unit,
patchLockState: (KnownPatch) -> PatchLock,
variantIndex: (KnownPatch) -> Int,
onSelectVariant: (KnownPatch, Int) -> Unit,
isConfigValid: Boolean,
onInstall: () -> Unit,
@@ -162,6 +168,9 @@ fun PatchOptionsScreenContent(
totalCount = KnownPatch.All.size,
isEnabled = isPatchEnabled,
onToggle = onTogglePatch,
lockState = patchLockState,
variantIndex = variantIndex,
onSelectVariant = onSelectVariant,
modifier = Modifier.padding(top = 4.dp),
)
@@ -18,9 +18,14 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.patchopts.KnownPatch
import com.meowarex.rlmobile.ui.screens.patchopts.PatchLock
private data class LockInfo(val patch: KnownPatch, val lock: PatchLock)
@Composable
fun PatchSelectionAccordion(
@@ -28,6 +33,9 @@ fun PatchSelectionAccordion(
totalCount: Int,
isEnabled: (KnownPatch) -> Boolean,
onToggle: (KnownPatch, Boolean) -> Unit,
lockState: (KnownPatch) -> PatchLock,
variantIndex: (KnownPatch) -> Int,
onSelectVariant: (KnownPatch, Int) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
@@ -36,6 +44,8 @@ fun PatchSelectionAccordion(
label = "patch-accordion-arrow",
)
var lockInfo by remember { mutableStateOf<LockInfo?>(null) }
Column(
modifier = modifier
.fillMaxWidth()
@@ -80,6 +90,7 @@ fun PatchSelectionAccordion(
AnimatedVisibility(visible = expanded) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background.copy(0.4f))
@@ -94,45 +105,73 @@ fun PatchSelectionAccordion(
)
for (patch in KnownPatch.All) key(patch) {
PatchCheckboxRow(
val checked = isEnabled(patch)
val lock = lockState(patch)
PatchSwitchRow(
title = stringResource(patch.titleRes),
description = stringResource(patch.descRes),
checked = isEnabled(patch),
checked = checked,
lock = lock,
onCheckedChange = { onToggle(patch, it) },
onLockedTap = { lockInfo = LockInfo(patch, lock) },
)
if (patch.variants.isNotEmpty()) {
AnimatedVisibility(visible = checked) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 4.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
) {
PatchVariantSelector(
variants = patch.variants,
selectedIndex = variantIndex(patch),
onSelect = { idx -> onSelectVariant(patch, idx) },
)
}
}
}
}
}
}
}
lockInfo?.let { info ->
PatchLockDialog(
thisPatch = info.patch,
lock = info.lock,
onDismiss = { lockInfo = null },
)
}
}
@Composable
private fun PatchCheckboxRow(
private fun PatchSwitchRow(
title: String,
description: String,
checked: Boolean,
lock: PatchLock,
onCheckedChange: (Boolean) -> Unit,
onLockedTap: () -> Unit,
) {
val interactionSource = remember(::MutableInteractionSource)
val isLocked = lock !is PatchLock.Free
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = interactionSource,
indication = null,
role = Role.Checkbox,
) { onCheckedChange(!checked) }
.padding(vertical = 4.dp),
role = Role.Switch,
) {
if (isLocked) onLockedTap() else onCheckedChange(!checked)
}
.alpha(if (isLocked) 0.45f else 1f)
.padding(vertical = 6.dp),
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
interactionSource = interactionSource,
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.weight(1f),
@@ -147,5 +186,77 @@ private fun PatchCheckboxRow(
modifier = Modifier.alpha(.7f),
)
}
Box {
Switch(
checked = checked,
enabled = !isLocked,
onCheckedChange = onCheckedChange,
interactionSource = interactionSource,
)
if (isLocked) {
Box(
modifier = Modifier
.matchParentSize()
.clickable(
interactionSource = remember(::MutableInteractionSource),
indication = null,
role = Role.Switch,
) { onLockedTap() }
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PatchLockDialog(
thisPatch: KnownPatch,
lock: PatchLock,
onDismiss: () -> Unit,
) {
val (titleRes, msgRes, blockerTitle) = when (lock) {
is PatchLock.LockedOn -> Triple(
R.string.patch_lock_required_title,
R.string.patch_lock_required_msg,
stringResource(lock.by.titleRes),
)
is PatchLock.LockedOff -> Triple(
R.string.patch_lock_blocked_title,
R.string.patch_lock_blocked_msg,
stringResource(lock.by.titleRes),
)
PatchLock.Free -> return
}
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp,
) {
Column(
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 20.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = AnnotatedString.fromHtml(stringResource(msgRes, blockerTitle)),
style = MaterialTheme.typography.titleMedium,
)
Box(modifier = Modifier.fillMaxWidth()) {
TextButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.CenterEnd),
) {
Text(stringResource(R.string.action_got_it))
}
}
}
}
}
}
@@ -0,0 +1,44 @@
package com.meowarex.rlmobile.ui.screens.patchopts.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import com.meowarex.rlmobile.ui.screens.patchopts.PatchVariant
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PatchVariantSelector(
variants: List<PatchVariant>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
SingleChoiceSegmentedButtonRow(
modifier = modifier.fillMaxWidth(),
) {
variants.forEachIndexed { index, variant ->
SegmentedButton(
selected = index == selectedIndex,
onClick = { onSelect(index) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = variants.size,
),
icon = {},
label = {
Text(
text = stringResource(variant.titleRes),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
},
)
}
}
}
@@ -283,6 +283,18 @@
name="patch_enable_legacy_ui_desc"
>[This patch will stop working soon] - Replaces the New Compose based UI with the Legacy UI (disables player-market-ui feature flag)</string>
<string name="patch_mini_player_redesign_title">Redesigned Mini-Player</string>
<string name="patch_mini_player_redesign_desc">Integrates the Seekbar into the control panel at the bottom of the screen.</string>
<string name="patch_mini_player_variant_floating_title">Floating</string>
<string name="patch_mini_player_variant_square_grey_title">Grey</string>
<string name="patch_mini_player_variant_square_black_title">Black</string>
<string name="patch_lock_required_title">Patch Required!</string>
<string name="patch_lock_blocked_title">Patch Blocked!</string>
<string name="patch_lock_required_msg">Patch Required by &lt;b>%s&lt;/b>.</string>
<string name="patch_lock_blocked_msg">Patch Blocked by &lt;b>%s&lt;/b>.</string>
<string name="action_got_it">Got it</string>
<string name="componentopts_screen_title">Custom Component (%s)</string>
<string name="componentopts_screen_desc">Select a custom build that was imported by Manager.</string>
<string name="componentopts_selected_none">Latest</string>