1 parent
5145a3354b
commit
9529a7e32c
4 files changed
+129
-24
No files matched your search
@@ -869,6 +869,7 @@ fun ChatScreen(
|
||||
isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote,
|
||||
isRecordingVoice = mediaLaunchers.isRecordingVoice,
|
||||
isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked,
|
||||
amplitudeProvider = mediaLaunchers.amplitudeProvider,
|
||||
onStopGeneration = { viewModel.interruptSession() },
|
||||
isAgentTyping = state.isAgentTyping,
|
||||
canInterrupt = state.canInterrupt,
|
||||
|
||||
@@ -125,6 +125,7 @@ fun ChatInputBar(
|
||||
onMicLock: () -> Unit = {},
|
||||
isVoiceNoteLocked: Boolean = false,
|
||||
isRecordingVoice: Boolean = false,
|
||||
amplitudeProvider: () -> Float = { 0f },
|
||||
onStopGeneration: () -> Unit = {},
|
||||
) {
|
||||
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||
@@ -440,6 +441,7 @@ fun ChatInputBar(
|
||||
slideProgress = voiceSlideProgress,
|
||||
locked = isVoiceNoteLocked,
|
||||
onCancel = onMicHoldCancel,
|
||||
amplitude = amplitudeProvider,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
}
|
||||
@@ -490,20 +492,21 @@ fun ChatInputBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Zero-height anchor so the tooltip and lock icon float above the
|
||||
// card, over the message list, like Telegram's lock control. It sits
|
||||
// outside the animated card because AnimatedVisibility clips its
|
||||
// children to the animation bounds.
|
||||
// Anchor at the BOTTOM-end — directly above the mic button the thumb
|
||||
// is holding. The earlier top-of-card anchor sat ~130 dp away from the
|
||||
// thumb and read as unrelated decoration, so the lock affordance went
|
||||
// unnoticed. The content grows upward (wrapContentHeight(Bottom,
|
||||
// unbounded)) and the offset lifts it clear of the button top.
|
||||
Box(modifier = Modifier.fillMaxWidth().height(0.dp)) {
|
||||
VoiceNoteLockHintOverlay(
|
||||
visible = isRecordingVoice && !isVoiceNoteLocked,
|
||||
showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f,
|
||||
modifier =
|
||||
Modifier
|
||||
.wrapContentHeight(align = Alignment.Top, unbounded = true)
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(y = (-42).dp)
|
||||
.padding(end = 12.dp),
|
||||
.wrapContentHeight(align = Alignment.Bottom, unbounded = true)
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(y = (-54).dp)
|
||||
.padding(end = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -543,18 +546,30 @@ private fun VoiceNoteLockHintOverlay(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.background(palette.control, RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = palette.placeholder,
|
||||
modifier = Modifier.size(18.dp),
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.background(palette.control, RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = palette.placeholder,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
// The track: a short line from the lock circle down toward the mic
|
||||
// button — the affordance reads as "the thing above your thumb",
|
||||
// and the direction of the gesture is implied by where it sits.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(1.5.dp)
|
||||
.height(12.dp)
|
||||
.background(palette.placeholder.copy(alpha = 0.6f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class ChatMediaLaunchers(
|
||||
val isListening: Boolean,
|
||||
@@ -48,6 +49,8 @@ class ChatMediaLaunchers(
|
||||
val onMicHoldEnd: () -> Unit = {},
|
||||
val onMicHoldCancel: () -> Unit = {},
|
||||
val onMicLock: () -> Unit = {},
|
||||
/** Normalized 0–1 mic level while recording; read fresh at draw time. */
|
||||
val amplitudeProvider: () -> Float = { 0f },
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -334,6 +337,23 @@ fun rememberChatMediaLaunchers(
|
||||
}
|
||||
}
|
||||
|
||||
// Mic-level feed for the recording blob (VoiceMicBlob). MediaRecorder's
|
||||
// maxAmplitude is the PEAK since the last read, so this poller must stay
|
||||
// the ONLY caller while recording. sqrt compression spreads speech
|
||||
// dynamics the way Telegram's RMS/1800 normalization does.
|
||||
val recordingAmplitude = remember { mutableStateOf(0f) }
|
||||
LaunchedEffect(isRecordingVoice) {
|
||||
if (isRecordingVoice) {
|
||||
while (isRecordingVoice) {
|
||||
recordingAmplitude.value =
|
||||
sqrt(voiceNoteRecorder.currentAmplitude().coerceAtLeast(0) / 32767f)
|
||||
delay(AMPLITUDE_POLL_MS)
|
||||
}
|
||||
} else {
|
||||
recordingAmplitude.value = 0f
|
||||
}
|
||||
}
|
||||
|
||||
val onCameraTap: () -> Unit = {
|
||||
try {
|
||||
val timeStamp =
|
||||
@@ -380,6 +400,10 @@ fun rememberChatMediaLaunchers(
|
||||
onMicHoldEnd = onMicHoldEnd,
|
||||
onMicHoldCancel = onMicHoldCancel,
|
||||
onMicLock = onMicLock,
|
||||
amplitudeProvider = { recordingAmplitude.value },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Telegram samples their amplitude pipeline roughly this fast. */
|
||||
private const val AMPLITUDE_POLL_MS = 60L
|
||||
+69
-4
@@ -10,15 +10,21 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.requiredWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
@@ -26,13 +32,15 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
@@ -62,7 +70,9 @@ import kotlin.math.roundToInt
|
||||
* and forth, and slides left and fades as the gesture drags toward cancel
|
||||
* ([slideProgress] runs 1 → 0 across that drag). Once the gesture slides up to
|
||||
* lock, the hint is replaced by a bold tappable "CANCEL" and the action button
|
||||
* submits the note.
|
||||
* submits the note. Behind the hint, a live mic-level meter (bars from the
|
||||
* original #1250 build, restored) shows the recording level — louder speech
|
||||
* makes the bars visibly climb; flat bars mean the mic hears silence.
|
||||
*/
|
||||
@Composable
|
||||
internal fun VoiceNoteRecordingPanel(
|
||||
@@ -70,6 +80,7 @@ internal fun VoiceNoteRecordingPanel(
|
||||
modifier: Modifier = Modifier,
|
||||
locked: Boolean = false,
|
||||
onCancel: () -> Unit = {},
|
||||
amplitude: () -> Float = { 0f },
|
||||
) {
|
||||
val palette = composerPalette()
|
||||
val density = LocalDensity.current
|
||||
@@ -82,6 +93,19 @@ internal fun VoiceNoteRecordingPanel(
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
// Level meter: rolling window of the last [VOICE_NOTE_BARS] samples. The
|
||||
// provider emits at the 60 ms poll cadence with Telegram's smoothing
|
||||
// already applied; equal values do not re-emit, so silence writes nothing
|
||||
// (Compose stays idle for instrumented tests).
|
||||
val levels = remember { mutableStateListOf<Float>() }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { amplitude() }.collect { level ->
|
||||
levels.add(level)
|
||||
while (levels.size > VOICE_NOTE_BARS) {
|
||||
levels.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Telegram blinks the record dot on a 1200 ms cycle.
|
||||
val blink = rememberInfiniteTransition(label = "voice_dot")
|
||||
val dotAlpha by
|
||||
@@ -126,11 +150,13 @@ internal fun VoiceNoteRecordingPanel(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = palette.placeholder,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
LevelMeter(levels = levels, tint = palette.placeholder)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
BoxWithConstraints(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.clipToBounds(),
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val containerWidthPx = with(density) { maxWidth.toPx() }
|
||||
@@ -149,15 +175,21 @@ internal fun VoiceNoteRecordingPanel(
|
||||
}.testTag("voice_note_cancel_button"),
|
||||
)
|
||||
} else {
|
||||
// requiredWidth(intrinsic): the hint lays out at its natural
|
||||
// width even when the meter leaves it tight, and the removed
|
||||
// border clip lets the nudge and the cancel drag overflow the
|
||||
// box instead of being cut at its edges — the fade carries it.
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.requiredWidth(IntrinsicSize.Max)
|
||||
.offset {
|
||||
val shift = -containerWidthPx * 0.25f * (1f - progress)
|
||||
val nudge = nudgeDp * density.density * progress
|
||||
IntOffset((shift + nudge).roundToInt(), 0)
|
||||
}.alpha(progress),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CancelChevron(tint = palette.placeholder)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
@@ -172,6 +204,39 @@ internal fun VoiceNoteRecordingPanel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live mic-level meter from the original #1250 build: a row of small
|
||||
* vertical bars, oldest left, newest right; each bar's height tracks its
|
||||
* sample (4–22 dp). Silences stay flat, loud speech climbs — the immediate
|
||||
* level feedback the blob could not give.
|
||||
*/
|
||||
@Composable
|
||||
private fun LevelMeter(
|
||||
levels: SnapshotStateList<Float>,
|
||||
tint: Color,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.height(22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
val filler = VOICE_NOTE_BARS - levels.size
|
||||
repeat(VOICE_NOTE_BARS) { index ->
|
||||
val level = if (index < filler) 0f else levels[index - filler]
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(3.dp)
|
||||
.height((4f + 18f * level).dp)
|
||||
.background(tint, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of level-meter bars in the recording panel. */
|
||||
private const val VOICE_NOTE_BARS = 14
|
||||
|
||||
/** "Slide to cancel" with the cancel word carrying the visual emphasis. */
|
||||
@Composable
|
||||
private fun slideToCancelText(palette: ComposerPalette) =
|
||||
|
||||
Reference in new issue
Block a user