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,
|
isListening = mediaLaunchers.isListening || state.isTranscribingVoiceNote,
|
||||||
isRecordingVoice = mediaLaunchers.isRecordingVoice,
|
isRecordingVoice = mediaLaunchers.isRecordingVoice,
|
||||||
isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked,
|
isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked,
|
||||||
|
amplitudeProvider = mediaLaunchers.amplitudeProvider,
|
||||||
onStopGeneration = { viewModel.interruptSession() },
|
onStopGeneration = { viewModel.interruptSession() },
|
||||||
isAgentTyping = state.isAgentTyping,
|
isAgentTyping = state.isAgentTyping,
|
||||||
canInterrupt = state.canInterrupt,
|
canInterrupt = state.canInterrupt,
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ fun ChatInputBar(
|
|||||||
onMicLock: () -> Unit = {},
|
onMicLock: () -> Unit = {},
|
||||||
isVoiceNoteLocked: Boolean = false,
|
isVoiceNoteLocked: Boolean = false,
|
||||||
isRecordingVoice: Boolean = false,
|
isRecordingVoice: Boolean = false,
|
||||||
|
amplitudeProvider: () -> Float = { 0f },
|
||||||
onStopGeneration: () -> Unit = {},
|
onStopGeneration: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
// Allow sending while the agent is mid-turn or awaiting approval: the
|
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||||
@@ -440,6 +441,7 @@ fun ChatInputBar(
|
|||||||
slideProgress = voiceSlideProgress,
|
slideProgress = voiceSlideProgress,
|
||||||
locked = isVoiceNoteLocked,
|
locked = isVoiceNoteLocked,
|
||||||
onCancel = onMicHoldCancel,
|
onCancel = onMicHoldCancel,
|
||||||
|
amplitude = amplitudeProvider,
|
||||||
modifier = Modifier.matchParentSize(),
|
modifier = Modifier.matchParentSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -490,20 +492,21 @@ fun ChatInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Zero-height anchor so the tooltip and lock icon float above the
|
// Anchor at the BOTTOM-end — directly above the mic button the thumb
|
||||||
// card, over the message list, like Telegram's lock control. It sits
|
// is holding. The earlier top-of-card anchor sat ~130 dp away from the
|
||||||
// outside the animated card because AnimatedVisibility clips its
|
// thumb and read as unrelated decoration, so the lock affordance went
|
||||||
// children to the animation bounds.
|
// 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)) {
|
Box(modifier = Modifier.fillMaxWidth().height(0.dp)) {
|
||||||
VoiceNoteLockHintOverlay(
|
VoiceNoteLockHintOverlay(
|
||||||
visible = isRecordingVoice && !isVoiceNoteLocked,
|
visible = isRecordingVoice && !isVoiceNoteLocked,
|
||||||
showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f,
|
showTextHint = !voiceLockHintDone && voiceSlideProgress.value >= 0.8f,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.wrapContentHeight(align = Alignment.Top, unbounded = true)
|
.wrapContentHeight(align = Alignment.Bottom, unbounded = true)
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.offset(y = (-42).dp)
|
.offset(y = (-54).dp)
|
||||||
.padding(end = 12.dp),
|
.padding(end = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -543,18 +546,30 @@ private fun VoiceNoteLockHintOverlay(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
Box(
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
modifier =
|
Box(
|
||||||
Modifier
|
modifier =
|
||||||
.size(36.dp)
|
Modifier
|
||||||
.background(palette.control, RoundedCornerShape(8.dp)),
|
.size(36.dp)
|
||||||
contentAlignment = Alignment.Center,
|
.background(palette.control, RoundedCornerShape(8.dp)),
|
||||||
) {
|
contentAlignment = Alignment.Center,
|
||||||
Icon(
|
) {
|
||||||
imageVector = Icons.Filled.Lock,
|
Icon(
|
||||||
contentDescription = null,
|
imageVector = Icons.Filled.Lock,
|
||||||
tint = palette.placeholder,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(18.dp),
|
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.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
class ChatMediaLaunchers(
|
class ChatMediaLaunchers(
|
||||||
val isListening: Boolean,
|
val isListening: Boolean,
|
||||||
@@ -48,6 +49,8 @@ class ChatMediaLaunchers(
|
|||||||
val onMicHoldEnd: () -> Unit = {},
|
val onMicHoldEnd: () -> Unit = {},
|
||||||
val onMicHoldCancel: () -> Unit = {},
|
val onMicHoldCancel: () -> Unit = {},
|
||||||
val onMicLock: () -> Unit = {},
|
val onMicLock: () -> Unit = {},
|
||||||
|
/** Normalized 0–1 mic level while recording; read fresh at draw time. */
|
||||||
|
val amplitudeProvider: () -> Float = { 0f },
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@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 = {
|
val onCameraTap: () -> Unit = {
|
||||||
try {
|
try {
|
||||||
val timeStamp =
|
val timeStamp =
|
||||||
@@ -380,6 +400,10 @@ fun rememberChatMediaLaunchers(
|
|||||||
onMicHoldEnd = onMicHoldEnd,
|
onMicHoldEnd = onMicHoldEnd,
|
||||||
onMicHoldCancel = onMicHoldCancel,
|
onMicHoldCancel = onMicHoldCancel,
|
||||||
onMicLock = onMicLock,
|
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.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.heightIn
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.requiredWidth
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
@@ -26,13 +32,15 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
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
|
* 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
|
* ([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
|
* 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
|
@Composable
|
||||||
internal fun VoiceNoteRecordingPanel(
|
internal fun VoiceNoteRecordingPanel(
|
||||||
@@ -70,6 +80,7 @@ internal fun VoiceNoteRecordingPanel(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
locked: Boolean = false,
|
locked: Boolean = false,
|
||||||
onCancel: () -> Unit = {},
|
onCancel: () -> Unit = {},
|
||||||
|
amplitude: () -> Float = { 0f },
|
||||||
) {
|
) {
|
||||||
val palette = composerPalette()
|
val palette = composerPalette()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -82,6 +93,19 @@ internal fun VoiceNoteRecordingPanel(
|
|||||||
delay(200)
|
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.
|
// Telegram blinks the record dot on a 1200 ms cycle.
|
||||||
val blink = rememberInfiniteTransition(label = "voice_dot")
|
val blink = rememberInfiniteTransition(label = "voice_dot")
|
||||||
val dotAlpha by
|
val dotAlpha by
|
||||||
@@ -126,11 +150,13 @@ internal fun VoiceNoteRecordingPanel(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = palette.placeholder,
|
color = palette.placeholder,
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
LevelMeter(levels = levels, tint = palette.placeholder)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f),
|
||||||
.clipToBounds(),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
val containerWidthPx = with(density) { maxWidth.toPx() }
|
val containerWidthPx = with(density) { maxWidth.toPx() }
|
||||||
@@ -149,15 +175,21 @@ internal fun VoiceNoteRecordingPanel(
|
|||||||
}.testTag("voice_note_cancel_button"),
|
}.testTag("voice_note_cancel_button"),
|
||||||
)
|
)
|
||||||
} else {
|
} 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(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
.requiredWidth(IntrinsicSize.Max)
|
||||||
.offset {
|
.offset {
|
||||||
val shift = -containerWidthPx * 0.25f * (1f - progress)
|
val shift = -containerWidthPx * 0.25f * (1f - progress)
|
||||||
val nudge = nudgeDp * density.density * progress
|
val nudge = nudgeDp * density.density * progress
|
||||||
IntOffset((shift + nudge).roundToInt(), 0)
|
IntOffset((shift + nudge).roundToInt(), 0)
|
||||||
}.alpha(progress),
|
}.alpha(progress),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
CancelChevron(tint = palette.placeholder)
|
CancelChevron(tint = palette.placeholder)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
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. */
|
/** "Slide to cancel" with the cancel word carrying the visual emphasis. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun slideToCancelText(palette: ComposerPalette) =
|
private fun slideToCancelText(palette: ComposerPalette) =
|
||||||
|
|||||||
Reference in new issue
Block a user