@ -29,11 +29,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.delay
@Composable
fun MessageBubble ( message : Message , onPlayVoice : ( String , ( ) -> Unit , ( ) -> Unit ) -> Unit = { _ , _ , _ -> } ,
onSeekVoice : ( String , Int , ( ) -> Unit ) -> Unit = { _ , _ , _ -> } ) {
fun MessageBubble ( message : Message , playbackState : ChatViewModel . PlaybackState = ChatViewModel . PlaybackState ( ) ,
onPlayVoice : ( String , Int ) -> Unit = { _ , _ -> } ,
onSeekVoice : ( String , Int , Int ) -> Unit = { _ , _ , _ -> } ) {
val bgColor = if ( message . isOutgoing ) Color ( 0xFF4A90D9 ) else Color ( 0xFFE8E8E8 )
val textColor = if ( message . isOutgoing ) Color . White else Color . Black
val alignment = if ( message . isOutgoing ) Arrangement . End else Arrangement . Start
@ -42,6 +44,7 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit, () -> Unit
val isVoice = message . contentType == " audio/opus "
val isMedia = message . contentType . isNotEmpty ( ) && !is Voice && message . contentType != " text/plain "
val isActive = isVoice && playbackState . filePath == message . filePath
Row (
modifier = Modifier . fillMaxWidth ( ) . padding ( horizontal = 8. dp , vertical = 2. dp ) ,
@ -58,7 +61,7 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit, () -> Unit
Spacer ( Modifier . height ( 2. dp ) )
when {
isVoice -> VoiceBubble ( message , textColor , onPlayVoice , onSeekVoice )
isVoice -> VoiceBubble ( message , textColor , isActive , playbackState , onPlayVoice , onSeekVoice )
isMedia -> Text ( " \uD83D \uDCC4 ${message.text} " , fontSize = 15. sp , color = textColor )
else -> {
Text ( message . author , fontSize = 13. sp , fontWeight = FontWeight . Bold , color = textColor . copy ( alpha = 0.7f ) )
@ -79,58 +82,30 @@ fun MessageBubble(message: Message, onPlayVoice: (String, () -> Unit, () -> Unit
}
}
}
private data class PlayState ( val playId : Int , val elapsedMs : Int , val progress : Float )
@Composable
private fun VoiceBubble ( message : Message , textColor : Color ,
onPlay : ( String , ( ) -> Unit , ( ) -> Unit ) -> Unit ,
onSeek : ( String , Int , ( ) -> Unit ) -> Unit ) {
private fun VoiceBubble ( message : Message , textColor : Color , isActive : Boolean ,
ps : ChatViewModel . PlaybackState ,
onPlay : ( String , Int ) -> Unit ,
onSeek : ( String , Int , Int ) -> Unit ) {
val wf = message . waveform
val wfCount = wf . size
val durMs = message . voiceDurationMs
var playing by remember { mutableStateOf ( false ) }
var paused by remember { mutableStateOf ( false ) }
var state by remember { mutableStateOf ( PlayState ( 0 , 0 , 0f ) ) }
val onDone = remember { { playing = false ; paused = false ; state = PlayState ( 0 , 0 , 0f ) } }
val onPaused = remember { { paused = true ; playing = false } }
/* elapsed timer while playing — keyed on atomic playId */
LaunchedEffect ( state . playId ) {
if ( playing && ! paused ) {
val start = System . currentTimeMillis ( ) - state . elapsedMs
while ( playing && ! paused ) {
val ms = ( System . currentTimeMillis ( ) - start ) . toInt ( ) . coerceIn ( 0 , durMs )
val frac = if ( durMs > 0 ) ms . toFloat ( ) / durMs . toFloat ( ) else 0f
state = state . copy ( elapsedMs = ms , progress = frac . coerceIn ( 0f , 1f ) )
delay ( 80 )
}
}
}
val playing = isActive && ps . playing
val paused = isActive && ps . paused
val elapsedMs = ps . elapsedMs
val progress = ps . progress
Column {
Row ( verticalAlignment = Alignment . CenterVertically ) {
/* Play/Pause button */
Box (
modifier = Modifier . size ( 32. dp ) . background ( Color . White , CircleShape )
. clickable {
if ( message . filePath . isNotEmpty ( ) ) {
if ( playing && ! paused ) {
onPlay ( message . filePath , onDone , onPaused )
} else {
state = state . copy ( playId = state . playId + 1 )
playing = true ; paused = false
onPlay ( message . filePath , onDone , onPaused )
}
}
} ,
. clickable { if ( message . filePath . isNotEmpty ( ) ) onPlay ( message . filePath , durMs ) } ,
contentAlignment = Alignment . Center
) {
Text ( if ( playing && ! paused ) " \u23F8 " else " \u25B6 " , fontSize = 14. sp , color = Color . Black )
}
Spacer ( Modifier . width ( 8. dp ) )
/* Waveform with seek on tap */
if ( wfCount >= 2 ) {
Box (
modifier = Modifier . weight ( 1f ) . height ( 28. dp )
@ -139,9 +114,7 @@ private fun VoiceBubble(message: Message, textColor: Color,
if ( message . filePath . isEmpty ( ) ) return @detectTapGestures
val seekFrac = ( offset . x / size . width ) . coerceIn ( 0f , 1f )
val seekMs = ( seekFrac * durMs ) . toInt ( )
state = PlayState ( state . playId + 1 , seekMs , seekFrac )
playing = true ; paused = false
onSeek ( message . filePath , seekMs , onDone )
onSeek ( message . filePath , seekMs , durMs )
}
}
) {
@ -149,7 +122,7 @@ private fun VoiceBubble(message: Message, textColor: Color,
val barW = size . width / wfCount
val ph = size . height
val showCursor = playing || paused
val playedIdx = if ( showCursor && state . progress > 0f ) ( state . progress * wfCount ) . toInt ( ) else 0
val playedIdx = if ( showCursor && progress > 0f ) ( progress * wfCount ) . toInt ( ) else 0
for ( i in 0 until wfCount ) {
val level = wf [ i ] . coerceIn ( 0f , 1f )
val barH = ( 2f + level * ( ph - 2f ) ) . coerceAtMost ( ph )
@ -162,8 +135,8 @@ private fun VoiceBubble(message: Message, textColor: Color,
size = Size ( barW * 0.75f , barH )
)
}
if ( showCursor && state . progress > 0f && state . progress < 1f ) {
val cursorX = state . progress * size . width
if ( showCursor && progress > 0f && progress < 1f ) {
val cursorX = progress * size . width
drawLine ( Color ( 0xFFFFD600 ) , Offset ( cursorX , 0f ) , Offset ( cursorX , ph ) , strokeWidth = 2.5f )
}
}
@ -173,14 +146,13 @@ private fun VoiceBubble(message: Message, textColor: Color,
Spacer ( Modifier . height ( 4. dp ) )
/* Duration / elapsed + timestamp */
Row (
modifier = Modifier . fillMaxWidth ( ) ,
horizontalArrangement = Arrangement . SpaceBetween ,
verticalAlignment = Alignment . Bottom
) {
Text (
if ( playing || paused ) " ${formatDurationMs(state. elapsedMs)} / ${formatDurationMs(durMs)} "
if ( playing || paused ) " ${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)} "
else formatDurationMs ( durMs ) ,
fontSize = 12. sp ,
color = textColor . copy ( alpha = 0.55f )