mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-25 02:05:40 +00:00
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:
+12
-15
@@ -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>
|
||||
|
||||
+64
-6
@@ -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()
|
||||
|
||||
+5
-4
@@ -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
|
||||
Reference in New Issue
Block a user