Add per-channel VU meters inside the mixer faders (v0.9.0)
The render loop now tracks each bus's post-fader/post-FX peak per block and folds it into a decaying meter value (instant attack, ~300 ms release) plus a clip latch (~0.8 s when the bus crosses 0 dBFS). Exposed via channelMeter / channelClipped and surfaced on the ViewModel. Each mixer fader paints, in one drawBehind, a VU meter inside its volume fill: rising from the bottom, coloured green -> amber by absolute level with a red tip, and solid red while the bus clips. A per-frame ticker writes into stable state objects read only in the fader's draw phase, so meter movement repaints just the four faders (no recomposition); an unchanged write is a no-op, so a silent mixer costs nothing but the frame tick. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,8 +22,8 @@ android {
|
|||||||
// (android.media.midi) and AAudio low-latency audio we rely on.
|
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 24
|
versionCode = 25
|
||||||
versionName = "0.8.1"
|
versionName = "0.9.0"
|
||||||
|
|
||||||
// We provide our own instrumentation runner if/when tests are added.
|
// We provide our own instrumentation runner if/when tests are added.
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|||||||
@@ -172,6 +172,17 @@ class AudioEngine(
|
|||||||
private var limOutL = 0f
|
private var limOutL = 0f
|
||||||
private var limOutR = 0f
|
private var limOutR = 0f
|
||||||
|
|
||||||
|
// ---- per-channel VU metering (for the mixer strips' fader VU) ----
|
||||||
|
// The audio thread tracks each bus's post-fader/post-FX peak within a block, then
|
||||||
|
// once per block folds it into a smoothly-decaying meter value (instant attack,
|
||||||
|
// exponential release) and a clip-hold counter (latched when the bus crosses 0 dBFS
|
||||||
|
// so a brief overload stays visible). The UI polls [channelMeter]/[channelClipped]
|
||||||
|
// at frame rate. Plain arrays: single writer (audio thread), display-only readers —
|
||||||
|
// a stale value for one frame is harmless, so no locking/volatility is needed.
|
||||||
|
private val chBlockPeak = FloatArray(Pattern.TRACK_COUNT) // scratch, reset each block
|
||||||
|
private val chMeter = FloatArray(Pattern.TRACK_COUNT) // published decaying peak
|
||||||
|
private val chClipHold = IntArray(Pattern.TRACK_COUNT) // blocks left to show "clipping"
|
||||||
|
|
||||||
// Per-block compact active-voice lists (audio-thread only). Rebuilt once per block:
|
// Per-block compact active-voice lists (audio-thread only). Rebuilt once per block:
|
||||||
// the per-sample mix loop iterates ONLY the voices that are actually sounding, so it
|
// the per-sample mix loop iterates ONLY the voices that are actually sounding, so it
|
||||||
// never calls render() on idle pool slots (the whole POLYPHONY*TRACK_COUNT pool was
|
// never calls render() on idle pool slots (the whole POLYPHONY*TRACK_COUNT pool was
|
||||||
@@ -577,6 +588,7 @@ class AudioEngine(
|
|||||||
// note it starts mid-block via [queueSeq]/[queueSmp] (see [advanceSequencer]).
|
// note it starts mid-block via [queueSeq]/[queueSmp] (see [advanceSequencer]).
|
||||||
blockGen++
|
blockGen++
|
||||||
actSeqN = 0; actSmpN = 0; actBusN = 0; actBSmpN = 0; actLiveN = 0; actASmpN = 0
|
actSeqN = 0; actSmpN = 0; actBusN = 0; actBSmpN = 0; actLiveN = 0; actASmpN = 0
|
||||||
|
for (ch in 0 until Pattern.TRACK_COUNT) chBlockPeak[ch] = 0f // reset per-channel VU accumulators
|
||||||
for (i in seqVoices.indices) if (seqVoices[i].isActive) { seqGen[i] = blockGen; actSeq[actSeqN++] = i }
|
for (i in seqVoices.indices) if (seqVoices[i].isActive) { seqGen[i] = blockGen; actSeq[actSeqN++] = i }
|
||||||
for (i in sampleVoices.indices) if (sampleVoices[i].isActive) { smpGen[i] = blockGen; actSmp[actSmpN++] = i }
|
for (i in sampleVoices.indices) if (sampleVoices[i].isActive) { smpGen[i] = blockGen; actSmp[actSmpN++] = i }
|
||||||
for (i in busVoices.indices) if (busVoices[i].isActive) actBus[actBusN++] = i
|
for (i in busVoices.indices) if (busVoices[i].isActive) actBus[actBusN++] = i
|
||||||
@@ -609,6 +621,9 @@ class AudioEngine(
|
|||||||
if (!s.isFinite()) s = 0f
|
if (!s.isFinite()) s = 0f
|
||||||
// Optional per-bus soft-clip limiter (toolbar toggle).
|
// Optional per-bus soft-clip limiter (toolbar toggle).
|
||||||
if (mc.limiter) s = softClip(s)
|
if (mc.limiter) s = softClip(s)
|
||||||
|
// Track this bus's peak for the mixer VU (post-fader/post-FX).
|
||||||
|
val a = if (s < 0f) -s else s
|
||||||
|
if (a > chBlockPeak[ch]) chBlockPeak[ch] = a
|
||||||
// Voices and inserts are mono; both master channels get the same
|
// Voices and inserts are mono; both master channels get the same
|
||||||
// sum. The master output/recording path stays stereo (2 channels).
|
// sum. The master output/recording path stays stereo (2 channels).
|
||||||
mixL += s; mixR += s
|
mixL += s; mixR += s
|
||||||
@@ -625,7 +640,24 @@ class AudioEngine(
|
|||||||
out[2 * i + 1] = oR
|
out[2 * i + 1] = oR
|
||||||
if (recCapturing) recordSample(proj, oL, oR)
|
if (recCapturing) recordSample(proj, oL, oR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fold this block's per-channel peaks into the VU meter values: instant attack
|
||||||
|
// (jump up to a new peak), exponential release (decay back down), plus a clip
|
||||||
|
// latch that holds when the bus crossed 0 dBFS so a momentary overload is seen.
|
||||||
|
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
val peak = chBlockPeak[ch]
|
||||||
|
val decayed = chMeter[ch] * METER_DECAY
|
||||||
|
chMeter[ch] = if (peak > decayed) peak else decayed
|
||||||
|
if (peak >= METER_CLIP_LEVEL) chClipHold[ch] = METER_CLIP_HOLD_BLOCKS
|
||||||
|
else if (chClipHold[ch] > 0) chClipHold[ch]--
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current VU level (peak amplitude, ~0..1+) for mixer bus [ch]; 0 if out of range. */
|
||||||
|
fun channelMeter(ch: Int): Float = if (ch in chMeter.indices) chMeter[ch] else 0f
|
||||||
|
|
||||||
|
/** True while bus [ch] is (or recently was) clipping past 0 dBFS — drives the red VU. */
|
||||||
|
fun channelClipped(ch: Int): Boolean = ch in chClipHold.indices && chClipHold[ch] > 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feed-forward, look-ahead brickwall limiter for the master bus. Results land in
|
* Feed-forward, look-ahead brickwall limiter for the master bus. Results land in
|
||||||
@@ -1307,6 +1339,11 @@ class AudioEngine(
|
|||||||
private const val LIM_HOLD_EXTRA = 8 // extra hold beyond the window so the peak fully clears
|
private const val LIM_HOLD_EXTRA = 8 // extra hold beyond the window so the peak fully clears
|
||||||
private const val LIM_RELEASE = 0.00025f // per-sample rise toward unity (~80 ms release @ 48 kHz)
|
private const val LIM_RELEASE = 0.00025f // per-sample rise toward unity (~80 ms release @ 48 kHz)
|
||||||
private const val LIVE_GAIN = 0.5f
|
private const val LIVE_GAIN = 0.5f
|
||||||
|
// Mixer VU ballistics (per ~4 ms block). Decay ≈ falls to ~5% over ~300 ms;
|
||||||
|
// clip latches red for ~0.8 s so a brief overload is noticeable.
|
||||||
|
private const val METER_DECAY = 0.96f
|
||||||
|
private const val METER_CLIP_LEVEL = 0.999f // bus peak at/above 0 dBFS = clipping
|
||||||
|
private const val METER_CLIP_HOLD_BLOCKS = 200 // ~0.8 s at 192-frame blocks / 48 kHz
|
||||||
private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above
|
private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -212,6 +212,12 @@ class AppViewModel(
|
|||||||
fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
|
fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
|
||||||
fun kbdNoteOff(midi: Int) { markNoteOff(midi); engine.busNoteOff(kbdChannel, midi) }
|
fun kbdNoteOff(midi: Int) { markNoteOff(midi); engine.busNoteOff(kbdChannel, midi) }
|
||||||
|
|
||||||
|
/** Live VU level (peak, ~0..1+) for mixer bus [ch] — polled at frame rate by the
|
||||||
|
* mixer's fader VU meters. */
|
||||||
|
fun channelMeter(ch: Int): Float = engine.channelMeter(ch)
|
||||||
|
/** True while mixer bus [ch] is (or just was) clipping past 0 dBFS. */
|
||||||
|
fun channelClipped(ch: Int): Boolean = engine.channelClipped(ch)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The core value-editing operation, shared by drag gestures and +/- input.
|
* The core value-editing operation, shared by drag gestures and +/- input.
|
||||||
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
|
* NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
|
||||||
|
|||||||
@@ -18,16 +18,25 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableFloatState
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -36,6 +45,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import space.rcmd.android.sizzle.model.Mixer
|
import space.rcmd.android.sizzle.model.Mixer
|
||||||
import space.rcmd.android.sizzle.model.MixerChannel
|
import space.rcmd.android.sizzle.model.MixerChannel
|
||||||
|
import space.rcmd.android.sizzle.model.Pattern
|
||||||
import space.rcmd.android.sizzle.model.ToolboxKind
|
import space.rcmd.android.sizzle.model.ToolboxKind
|
||||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||||
import space.rcmd.android.sizzle.ui.isLandscape
|
import space.rcmd.android.sizzle.ui.isLandscape
|
||||||
@@ -55,6 +65,23 @@ import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
|||||||
fun MixerScreen(vm: AppViewModel) {
|
fun MixerScreen(vm: AppViewModel) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
@Suppress("UNUSED_VARIABLE") val redraw = vm.revision
|
||||||
|
|
||||||
|
// Live per-channel VU state, polled from the audio engine once per frame. Held in
|
||||||
|
// stable state objects so only the fader's draw phase (which reads them) repaints —
|
||||||
|
// the strips themselves never recompose from meter movement. Writing an unchanged
|
||||||
|
// value is a no-op, so an idle (silent) mixer costs nothing but the frame tick.
|
||||||
|
val levels = remember { List(Pattern.TRACK_COUNT) { mutableFloatStateOf(0f) } }
|
||||||
|
val clips = remember { List(Pattern.TRACK_COUNT) { mutableStateOf(false) } }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
withFrameNanos { }
|
||||||
|
for (ch in 0 until Pattern.TRACK_COUNT) {
|
||||||
|
levels[ch].floatValue = vm.channelMeter(ch)
|
||||||
|
clips[ch].value = vm.channelClipped(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().background(c.background)) {
|
Column(Modifier.fillMaxSize().background(c.background)) {
|
||||||
RecordToolbar(vm)
|
RecordToolbar(vm)
|
||||||
Row(
|
Row(
|
||||||
@@ -62,7 +89,8 @@ fun MixerScreen(vm: AppViewModel) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
vm.project.mixer.channels.forEach { channel ->
|
vm.project.mixer.channels.forEach { channel ->
|
||||||
ChannelStrip(vm, channel, Modifier.weight(1f).fillMaxHeight())
|
ChannelStrip(vm, channel, levels[channel.index], clips[channel.index],
|
||||||
|
Modifier.weight(1f).fillMaxHeight())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +152,13 @@ private fun RecordToolbar(vm: AppViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modifier) {
|
private fun ChannelStrip(
|
||||||
|
vm: AppViewModel,
|
||||||
|
channel: MixerChannel,
|
||||||
|
level: MutableFloatState,
|
||||||
|
clipped: State<Boolean>,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so faders/mute/solo/routing refresh
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so faders/mute/solo/routing refresh
|
||||||
|
|
||||||
@@ -148,12 +182,12 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
|
|||||||
Column(
|
Column(
|
||||||
Modifier.weight(1f).fillMaxHeight(),
|
Modifier.weight(1f).fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) { ChannelLevels(vm, channel) }
|
) { ChannelLevels(vm, channel, level, clipped) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Portrait: one column, the fader filling the leftover height.
|
// Portrait: one column, the fader filling the leftover height.
|
||||||
ChannelRouting(vm, channel)
|
ChannelRouting(vm, channel)
|
||||||
ChannelLevels(vm, channel)
|
ChannelLevels(vm, channel, level, clipped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,13 +239,20 @@ private fun ColumnScope.ChannelRouting(vm: AppViewModel, channel: MixerChannel)
|
|||||||
/** Volume fader + limiter + mute/solo for a mixer [channel]. The fader fills the
|
/** Volume fader + limiter + mute/solo for a mixer [channel]. The fader fills the
|
||||||
* leftover height of its column. */
|
* leftover height of its column. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.ChannelLevels(vm: AppViewModel, channel: MixerChannel) {
|
private fun ColumnScope.ChannelLevels(
|
||||||
|
vm: AppViewModel,
|
||||||
|
channel: MixerChannel,
|
||||||
|
level: MutableFloatState,
|
||||||
|
clipped: State<Boolean>,
|
||||||
|
) {
|
||||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so LIM/mute/solo refresh
|
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // defeat strong-skipping so LIM/mute/solo refresh
|
||||||
SectionLabel("Volume")
|
SectionLabel("Volume")
|
||||||
VerticalFader(
|
VerticalFader(
|
||||||
value = channel.volume,
|
value = channel.volume,
|
||||||
default = Mixer.DEFAULT_VOLUME,
|
default = Mixer.DEFAULT_VOLUME,
|
||||||
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
onValueChange = { channel.volume = it; vm.bumpForToolbar() },
|
||||||
|
level = level,
|
||||||
|
clipped = clipped,
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
)
|
)
|
||||||
// Per-bus soft-clip limiter, sitting above mute/solo.
|
// Per-bus soft-clip limiter, sitting above mute/solo.
|
||||||
@@ -228,16 +269,67 @@ private fun ColumnScope.ChannelLevels(vm: AppViewModel, channel: MixerChannel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A vertical volume fader: fill grows from the bottom; tap or drag to set.
|
/** A vertical volume fader with a live VU meter drawn inside its filled area: fill
|
||||||
* Snaps to the [default] level within a small band so returning to the standard
|
* grows from the bottom to the set [value]; tap or drag to set. The VU ([level], a
|
||||||
* volume is easy to hit. */
|
* peak amplitude polled each frame) rises from the bottom within the fill, green →
|
||||||
|
* amber as it gets hotter, and the whole meter turns red while the bus is [clipped]
|
||||||
|
* (past 0 dBFS). Snaps to the [default] level within a small band so returning to the
|
||||||
|
* standard volume is easy to hit.
|
||||||
|
*
|
||||||
|
* Everything is painted in a single [drawBehind]; [level]/[clipped] are read in the
|
||||||
|
* draw phase, so meter movement repaints only this fader — no recomposition. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun VerticalFader(value: Float, default: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
|
private fun VerticalFader(
|
||||||
|
value: Float,
|
||||||
|
default: Float,
|
||||||
|
onValueChange: (Float) -> Unit,
|
||||||
|
level: MutableFloatState,
|
||||||
|
clipped: State<Boolean>,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
val c = LocalRetro.current
|
val c = LocalRetro.current
|
||||||
|
val volFill = c.accent.copy(alpha = 0.30f)
|
||||||
|
val vuLo = c.playhead // green — nominal level
|
||||||
|
val vuHi = c.accent // amber — getting hot
|
||||||
|
val vuClip = c.danger // red — clipping
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.border(1.dp, c.grid, RectangleShape)
|
.border(1.dp, c.grid, RectangleShape)
|
||||||
.background(c.background)
|
.background(c.background)
|
||||||
|
.drawBehind {
|
||||||
|
val h = size.height
|
||||||
|
val w = size.width
|
||||||
|
val vol = value.coerceIn(0f, 1f)
|
||||||
|
val volH = vol * h
|
||||||
|
val volTop = h - volH
|
||||||
|
// Volume fill (the set level), translucent.
|
||||||
|
if (volH > 0f) drawRect(volFill, topLeft = Offset(0f, volTop), size = Size(w, volH))
|
||||||
|
|
||||||
|
// VU meter, contained within the fill. Colour maps to ABSOLUTE level
|
||||||
|
// over the full track height (bottom green → top red), so a taller bar
|
||||||
|
// is visibly hotter; the gradient's red tip is what "hard clipping turns
|
||||||
|
// to a red hue" looks like as the signal climbs.
|
||||||
|
val lvl = level.floatValue.coerceIn(0f, 1f)
|
||||||
|
val meterFrac = if (lvl < vol) lvl else vol // stay inside the filled area
|
||||||
|
if (meterFrac > 0.001f) {
|
||||||
|
val mH = meterFrac * h
|
||||||
|
val top = h - mH
|
||||||
|
if (clipped.value) {
|
||||||
|
drawRect(vuClip, topLeft = Offset(0f, top), size = Size(w, mH))
|
||||||
|
} else {
|
||||||
|
val brush = Brush.verticalGradient(
|
||||||
|
0f to vuLo, 0.65f to vuLo, 0.85f to vuHi, 1f to vuClip,
|
||||||
|
startY = h, endY = 0f, // stop 0 = bottom, stop 1 = top of the track
|
||||||
|
)
|
||||||
|
drawRect(brush, topLeft = Offset(0f, top), size = Size(w, mH))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumb line at the set level.
|
||||||
|
val thumb = 3.dp.toPx()
|
||||||
|
drawRect(c.accent, topLeft = Offset(0f, (volTop - thumb / 2f).coerceIn(0f, h - thumb)),
|
||||||
|
size = Size(w, thumb))
|
||||||
|
}
|
||||||
.pointerInput(default) {
|
.pointerInput(default) {
|
||||||
detectTapGestures { off -> onValueChange(snapDefault(1f - off.y / size.height, default)) }
|
detectTapGestures { off -> onValueChange(snapDefault(1f - off.y / size.height, default)) }
|
||||||
}
|
}
|
||||||
@@ -247,18 +339,7 @@ private fun VerticalFader(value: Float, default: Float, onValueChange: (Float) -
|
|||||||
onValueChange(snapDefault(1f - change.position.y / size.height, default))
|
onValueChange(snapDefault(1f - change.position.y / size.height, default))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.BottomCenter,
|
)
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight(value.coerceIn(0.001f, 1f))
|
|
||||||
.background(c.accent.copy(alpha = 0.30f)),
|
|
||||||
contentAlignment = Alignment.TopCenter,
|
|
||||||
) {
|
|
||||||
Box(Modifier.fillMaxWidth().height(3.dp).background(c.accent)) // thumb
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Snap the fader to the [default] level when the raw value lands within a small
|
/** Snap the fader to the [default] level when the raw value lands within a small
|
||||||
|
|||||||
Reference in New Issue
Block a user