desktop: support sending voice messages, use shared VLC media-player factory (#6739)

* desktop: support sending voice messages

* alert for unsupported platforms

* dont record on error

* better initialization

* desktop: use shared VLC media-player factory (#6741)

* desktop: use shared VLC media-player factory

* fix factory

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-04-03 21:24:08 +01:00
committed by GitHub
parent bcdc8effe5
commit 4545fdd0a9
6 changed files with 127 additions and 25 deletions
@@ -24,7 +24,6 @@ import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatItem
import chat.simplex.common.platform.*
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import dev.icerock.moko.resources.compose.painterResource
@@ -313,21 +312,19 @@ private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNex
LockToCurrentOrientationUntilDispose()
StopRecordButton(stopRecordingAndAddAudio)
} else {
val startRecording: () -> Unit = out@ {
if (appPlatform.isDesktop) {
return@out showInDevelopingAlert()
val startRecording: () -> Unit = {
val filePath = rec.start { progress: Int?, finished: Boolean ->
val state = recState.value
if (state is RecordingState.Started && progress != null) {
recState.value = if (!finished)
RecordingState.Started(state.filePath, progress)
else
RecordingState.Finished(state.filePath, progress)
}
}
if (filePath.isNotEmpty()) {
recState.value = RecordingState.Started(filePath = filePath)
}
recState.value = RecordingState.Started(
filePath = rec.start { progress: Int?, finished: Boolean ->
val state = recState.value
if (state is RecordingState.Started && progress != null) {
recState.value = if (!finished)
RecordingState.Started(state.filePath, progress)
else
RecordingState.Finished(state.filePath, progress)
}
},
)
}
val interactionSource = interactionSourceWithTapDetection(
onPress = { if (recState.value is RecordingState.NotStarted) startRecording() },
@@ -54,6 +54,7 @@
<string name="blocked_by_admin_items_description">%d messages blocked by admin</string>
<string name="sending_files_not_yet_supported">sending files is not supported yet</string>
<string name="receiving_files_not_yet_supported">receiving files is not supported yet</string>
<string name="voice_recording_not_supported">Voice recording is not supported on your platform</string>
<string name="sender_you_pronoun">you</string>
<string name="unknown_message_format">unknown message format</string>
<string name="invalid_message_format">invalid message format</string>
@@ -5,6 +5,7 @@ import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.base.State
import uk.co.caprica.vlcj.player.component.AudioPlayerComponent
@@ -12,20 +13,77 @@ import java.io.File
import java.util.*
import kotlin.math.max
internal val vlcFactory: MediaPlayerFactory by lazy { MediaPlayerFactory() }
actual class RecorderNative: RecorderInterface {
private var player: MediaPlayer? = null
private var progressJob: Job? = null
private var filePath: String? = null
private var recStartedAt: Long? = null
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
/*LALAL*/
return ""
VideoPlayerHolder.stopAll()
AudioPlayer.stop()
val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_", tmpDir), ".tmp", tmpDir)
fileToSave.deleteOnExit()
val path = fileToSave.absolutePath
filePath = path
val mrl = when {
desktopPlatform.isMac() -> "qtsound://"
desktopPlatform.isLinux() -> "pulse://"
desktopPlatform.isWindows() -> "dshow://"
else -> {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.voice_recording_not_supported))
return ""
}
}
val sout = ":sout=#transcode{vcodec=none,acodec=mp4a,ab=32,channels=1,samplerate=16000}:std{access=file,mux=mp4,dst=$path}"
val options = mutableListOf(sout, ":sout-avcodec-strict=-2")
if (desktopPlatform.isWindows()) {
options.add(":dshow-vdev=none")
options.add(":dshow-adev=")
}
RecorderInterface.stopRecording = { stop() }
progressJob = CoroutineScope(Dispatchers.Default).launch {
// Shared factory init may take a few seconds on first VLC use — progress shows 0 until recording starts
val p = vlcFactory.mediaPlayers().newMediaPlayer()
player = p
p.media().play(mrl, *options.toTypedArray())
recStartedAt = System.currentTimeMillis()
while (isActive) {
val ms = progress()
onProgressUpdate(ms, false)
if (ms != null && ms >= MAX_VOICE_MILLIS_FOR_SENDING) {
stop()
break
}
delay(50)
}
}.apply {
invokeOnCompletion { onProgressUpdate(realDuration(path), true) }
}
return path
}
override fun stop(): Int {
/*LALAL*/
return 0
val path = filePath ?: return 0
RecorderInterface.stopRecording = null
runCatching { player?.controls()?.stop() }
runCatching { player?.release() }
runBlocking { progressJob?.cancelAndJoin() }
progressJob = null
filePath = null
player = null
return (realDuration(path) ?: 0).also { recStartedAt = null }
}
private fun progress(): Int? = recStartedAt?.let { (System.currentTimeMillis() - it).toInt() }
private fun realDuration(path: String): Int? = AudioPlayer.duration(path) ?: progress()
}
actual object AudioPlayer: AudioPlayerInterface {
private val player by lazy { AudioPlayerComponent().mediaPlayer() }
private val player by lazy { AudioPlayerComponent(vlcFactory).mediaPlayer() }
override val currentlyPlaying: MutableState<CurrentlyPlayingState?> = mutableStateOf(null)
private var progressJob: Job? = null
@@ -170,7 +228,7 @@ actual object AudioPlayer: AudioPlayerInterface {
override fun duration(unencryptedFilePath: String): Int? {
var res: Int? = null
try {
val helperPlayer = AudioPlayerComponent().mediaPlayer()
val helperPlayer = AudioPlayerComponent(vlcFactory).mediaPlayer()
helperPlayer.media().startPaused(unencryptedFilePath)
res = helperPlayer.duration
helperPlayer.stop()
@@ -10,6 +10,7 @@ import uk.co.caprica.vlcj.media.VideoOrientation
import uk.co.caprica.vlcj.player.base.*
import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent
import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent
import uk.co.caprica.vlcj.player.component.MediaPlayerSpecs
import java.awt.Component
import java.awt.image.BufferedImage
import java.io.File
@@ -32,7 +33,7 @@ actual class VideoPlayer actual constructor(
override val duration: MutableState<Long> = mutableStateOf(defaultDuration)
override val preview: MutableState<ImageBitmap> = mutableStateOf(defaultPreview)
val mediaPlayerComponent by lazy { runBlocking(playerThread.asCoroutineDispatcher()) { getOrCreatePlayer() } }
val mediaPlayerComponent by lazy { getOrCreatePlayer() }
val player by lazy { mediaPlayerComponent.mediaPlayer() }
init {
@@ -207,9 +208,9 @@ actual class VideoPlayer actual constructor(
private fun initializeMediaPlayerComponent(): Component {
return if (desktopPlatform.isMac()) {
CallbackMediaPlayerComponent()
CallbackMediaPlayerComponent(MediaPlayerSpecs.callbackMediaPlayerSpec().apply { withFactory(vlcFactory) })
} else {
EmbeddedMediaPlayerComponent()
EmbeddedMediaPlayerComponent(MediaPlayerSpecs.embeddedMediaPlayerSpec().apply { withFactory(vlcFactory) })
}
}
@@ -277,7 +278,7 @@ actual class VideoPlayer actual constructor(
private fun putPlayer(player: Component) = playersPool.add(player)
private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent()
private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent(MediaPlayerSpecs.callbackMediaPlayerSpec().apply { withFactory(vlcFactory) })
private fun putHelperPlayer(player: CallbackMediaPlayerComponent) = helperPlayersPool.add(player)
}
}
@@ -73,6 +73,12 @@ compose {
iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.icns"))
appCategory = "public.app-category.social-networking"
bundleID = "chat.simplex.app"
infoPlist {
extraKeysRawXml = """
<key>NSMicrophoneUsageDescription</key>
<string>SimpleX needs microphone access to record voice messages</string>
"""
}
val identity = rootProject.extra["desktop.mac.signing.identity"] as String?
val keychain = rootProject.extra["desktop.mac.signing.keychain"] as String?
val appleId = rootProject.extra["desktop.mac.notarization.apple_id"] as String?
@@ -0,0 +1,39 @@
# Desktop Voice Recording
## Overview
Implement voice recording on desktop using vlcj (already a dependency). The `RecorderNative` class is currently a stub. All UI is already in common code.
## Files to modify
1. `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt` — implement `RecorderNative`
2. `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt` — remove desktop "in development" guard (line 317-318)
3. `apps/multiplatform/desktop/build.gradle.kts` — add `NSMicrophoneUsageDescription` to macOS Info.plist
## RecorderNative implementation
Uses `MediaPlayerFactory` + `MediaPlayer` to capture from default microphone and transcode to AAC/m4a via VLC's sout chain.
Platform-specific capture MRLs:
- macOS: `qtsound://`
- Linux: `pulse://`
- Windows: `dshow://` with `:dshow-vdev=none :dshow-adev=`
Transcode options: `vcodec=none,acodec=mp4a,ab=32,channels=1,samplerate=16000` — matches Android (mono, 16kHz, 32kbps AAC).
Factory requires `--sout-avcodec-strict=-2` to enable FFmpeg's native AAC encoder.
Progress tracked via elapsed time (VLC capture has no position API). Duration read via `AudioPlayer.duration()` after stop.
Max duration: enforced by stopping recording after `MAX_VOICE_MILLIS_FOR_SENDING` (300,000 ms) in the progress coroutine.
## macOS permission
Add `NSMicrophoneUsageDescription` to Info.plist via Gradle `infoPlist` block.
## What does NOT change
- `RecorderInterface` (common)
- `ComposeView.kt`, `ComposeVoiceView` — already handle voice preview/sending
- Audio format — `.m4a` (matches Android)
- All voice recording UI — already in common code