diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 4de0175457..9184071c07 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -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, 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() }, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 08d4adea70..95c3311a3d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -54,6 +54,7 @@ %d messages blocked by admin sending files is not supported yet receiving files is not supported yet + Voice recording is not supported on your platform you unknown message format invalid message format diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 9f34891b37..59d71a83f1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -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 = 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() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index 50eeaee604..c5a38ec4a1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -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 = mutableStateOf(defaultDuration) override val preview: MutableState = 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) } } diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 1e7bda37c4..8f072539e8 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -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 = """ + NSMicrophoneUsageDescription + SimpleX needs microphone access to record voice messages + """ + } 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? diff --git a/plans/2026-04-02-desktop-voice-recording.md b/plans/2026-04-02-desktop-voice-recording.md new file mode 100644 index 0000000000..e29af72f34 --- /dev/null +++ b/plans/2026-04-02-desktop-voice-recording.md @@ -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